Skip to content

Commit

Permalink
Add the f-template component which can interpret a text binding and c…
Browse files Browse the repository at this point in the history
…onvert it to a ViewTemplate (#7073)

# Pull Request

## 📖 Description

This is some initial work to convert declarative HTML into a `ViewTemplate`.

## 👩‍💻 Reviewer Notes

Some of this is preliminary work to get a workflow established for creation of aspect bindings and other requirements for the declarative HTML. This work also exposes the `fastElementRegistry` as well as adding an override for controller updates after a new template has been applied.

## ✅ Checklist

### General

<!--- Review the list and put an x in the boxes that apply. -->

- [ ] I have included a change request file using `$ npm run change`
- [x] I have added tests for my changes.
- [x] I have tested my changes.
- [x] I have updated the project documentation to reflect my changes.
- [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project.

## ⏭ Next Steps

- Adding attribute bindings
- Adding logic for common directives such as `when`
  • Loading branch information
janechu authored Feb 21, 2025
1 parent 1b762f2 commit ed313e4
Show file tree
Hide file tree
Showing 20 changed files with 582 additions and 161 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Expose the fast registry and allow triggering definition updates",
"packageName": "@microsoft/fast-element",
"email": "[email protected]",
"dependentChangeType": "none"
}
429 changes: 297 additions & 132 deletions package-lock.json

Large diffs are not rendered by default.

22 changes: 15 additions & 7 deletions packages/web-components/fast-btr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ This approach should focus on flexibility for webapp/website developers and give

## Usage

In your JS bundle you will need to include the `@microsoft/fast-btr` package:

```typescript
import "@microsoft/fast-btr";
```

This will include the `<f-template>` custom element and all logic for interpreting the declarative HTML template into a `ViewTemplate`.

### Initial Rendering

The rendering using the Rust script could look as follows:
Expand Down Expand Up @@ -50,21 +58,21 @@ At some point before the prehydration script and the definition of the custom el
```html
<f-template name="my-custom-element">
<template>
<button @click="x.handleClick()" ?disabled="x.disabled">
${x.greeting}
<button @click="{{ handleClick() }}" ?disabled="{{ disabled }}">
{{ greeting }}
</button>
<input
:value="x.value"
:value="{{ value }}"
>
<f-when condition="x.hasFriends()">
<f-when condition="{{ hasFriends }}">
<ul>
<f-repeat items="x.friends">
<li>${x.friend}</li>
<f-repeat items="{{ friend in friends }}">
<li>{{ friend }}</li>
</f-repeat>
</ul>
</f-when>
<slot>
<f-slotted :items="x.items"></f-slotted>
<f-slotted :items="{{ items }}"></f-slotted>
</slot>
</template>
</f-template>
Expand Down
9 changes: 9 additions & 0 deletions packages/web-components/fast-btr/docs/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
```ts

import { FASTElement } from '@microsoft/fast-element';

// @public
export class TemplateElement extends FASTElement {
// (undocumented)
connectedCallback(): void;
name?: string;
}

// (No @packageDocumentation comment for this package)

```
17 changes: 12 additions & 5 deletions packages/web-components/fast-btr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@
"clean": "tsc -b --clean src",
"clean:dist": "node ../../../build/clean.js dist",
"build": "tsc -b src && npm run doc",
"build-app": "webpack",
"build-server": "tsc -b server",
"doc": "api-extractor run --local",
"doc:ci": "api-extractor run",
"prepublishOnly": "npm run clean && npm run build",
"build-server": "tsc -b server",
"eslint": "eslint . --ext .ts",
"eslint:fix": "eslint . --ext .ts --fix",
"prepublishOnly": "npm run clean && npm run build",
"pretest": "npm run build-server && npm run build",
"prettier:diff": "prettier --config ../../../.prettierrc \"**/*.{ts,html}\" --list-different",
"prettier": "prettier --config ../../../.prettierrc --write \"**/*.{ts,html}\"",
"test": "playwright test --config=playwright.config.cjs",
"test": "npm run build-app && playwright test --config=playwright.config.cjs",
"test-server": "node server/dist/server.js",
"install-playwright-browsers": "npm run playwright install"
"install-playwright-browsers": "npm run playwright install",
"dev": "npm run build && npm run build-app && npm run build-server && npm run test-server"
},
"description": "A package for facilitating rendering FAST Web Components in a non-browser environment.",
"exports": {
Expand All @@ -42,6 +44,9 @@
},
"./package.json": "./package.json"
},
"sideEffects": [
"./dist/esm/index.js"
],
"peerDependencies": {
"@microsoft/fast-element": "^2.0.1"
},
Expand All @@ -52,7 +57,9 @@
"@types/express": "^4.17.21",
"@types/node": "^17.0.17",
"express": "^4.19.2",
"typescript": "~5.3.0"
"typescript": "~5.3.0",
"webpack": "^5.97.1",
"webpack-cli": "^6.0.1"
},
"beachball": {
"disallowedChangeTypes": [
Expand Down
14 changes: 14 additions & 0 deletions packages/web-components/fast-btr/server/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TemplateElement } from "@microsoft/fast-btr";
import { attr, FASTElement } from "@microsoft/fast-element";

class CustomElement extends FASTElement {
@attr
text: string = "Hello";
}
CustomElement.define({
name: "custom-element",
});

TemplateElement.define({
name: "f-template",
});
3 changes: 3 additions & 0 deletions packages/web-components/fast-btr/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@ const app = express();
app.get("/binding", (req: Request, res: Response) =>
handlePathRequest("./src/fixtures/binding.fixture.html", "text/html", req, res)
);
app.get("/main.js", (req: Request, res: Response) =>
handlePathRequest("./server/dist/main.js", "text/javascript", req, res)
);
app.listen(PORT);
2 changes: 1 addition & 1 deletion packages/web-components/fast-btr/server/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"rootDir": ".",
"outDir": "dist"
},
"references": [{ "path": "../src"}]
"include": ["./server.ts"]
}
1 change: 1 addition & 0 deletions packages/web-components/fast-btr/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TemplateElement } from "./template.js";
109 changes: 109 additions & 0 deletions packages/web-components/fast-btr/src/components/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
attr,
FAST,
FASTElement,
FASTElementDefinition,
fastElementRegistry,
ViewTemplate,
} from "@microsoft/fast-element";
import { DOMPolicy } from "@microsoft/fast-element/dom-policy.js";
import { Message } from "../interfaces.js";

/**
* The <f-template> custom element that will provide view logic to the element
*/
class TemplateElement extends FASTElement {
/**
* The name of the custom element this template will be applied to
*/
@attr
public name?: string;

private bindingRegex: RegExp = /{{(?:.*?)}}/g;

private openBinding: string = "{{";

private closeBinding: string = "}}";

connectedCallback(): void {
super.connectedCallback();

if (this.name) {
this.$fastController.definition.registry
.whenDefined(this.name)
.then(value => {
const registeredFastElement: FASTElementDefinition | undefined =
fastElementRegistry.getByType(value);
const template = this.getElementsByTagName("template").item(0);

if (template) {
const childNodes = template.content.childNodes;
const strings: any[] = [];
const values: any[] = []; // these can be bindings, directives, etc.

childNodes.forEach(childNode => {
switch (childNode.nodeType) {
case 1: // HTMLElement
break;
case 3: // text
this.resolveTextBindings(childNode, strings, values);
break;
default:
break;
}
});

strings.push("");

(strings as any).raw = strings.map(value =>
String.raw({ raw: value })
);

if (registeredFastElement) {
// all new elements will get the updated template
registeredFastElement.template = ViewTemplate.create(
strings,
values,
DOMPolicy.create()
);
}
} else {
throw FAST.error(Message.noTemplateProvided, { name: this.name });
}
});
}
}

/**
* Resolve a text binding
* @param childNode The child node to interpret.
* @param strings The strings array.
* @param values The interpreted values.
*/
private resolveTextBindings(
childNode: ChildNode,
strings: Array<string>,
values: Array<any>
) {
const textContent = childNode.textContent || "";
const bindingArray = textContent.match(this.bindingRegex);
const stringArray = textContent.split(this.bindingRegex);

if (bindingArray) {
bindingArray.forEach((htmlBindingItem, index) => {
// create a binding
const sansBindingStrings = htmlBindingItem
.replace(this.openBinding, "")
.replace(this.closeBinding, "")
.trim();
const bindingItem = (x: any) => x[sansBindingStrings];
strings.push(stringArray[index]);
values.push(bindingItem);
});
} else {
strings.push(textContent);
}
}
}

export { TemplateElement };
3 changes: 3 additions & 0 deletions packages/web-components/fast-btr/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const debugMessages = {
[2000 /* noTemplateProvided */]: `The first child of the <f-template> must be a <template>, this is missing from ${name}.`,
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@
</head>
<body>
<custom-element text="Hello world">
<template shadowrootmode="open">
Hello world
</template>
<template shadowrootmode="open">Hello world</template>
</custom-element>
<f-template name="custom-element">
<template>
{{text}}
</template>
<template>{{text}}</template>
</f-template>
<script type="module" src="/main.js"></script>
</body>
</html>
20 changes: 17 additions & 3 deletions packages/web-components/fast-btr/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { expect, test } from "@playwright/test";

test("placeholder", async () => {
// TODO: update this with tests against fixtures
expect(1+1).toEqual(2);
test.describe("f-template", async () => {
test("create a binding", async ({ page }) => {
await page.goto("/binding");

const customElement = await page.locator("custom-element");

await expect(await customElement.getAttribute("text")).toEqual("Hello world");
await expect((await customElement.textContent()) || "".includes("Hello world")).toBeTruthy();

await page.evaluate(() => {
const customElement = document.getElementsByTagName("custom-element");
customElement.item(0)?.setAttribute("text", "Hello pluto");
});

await expect(await customElement.getAttribute("text")).toEqual("Hello pluto");
await expect((await customElement.textContent()) || "".includes("Hello pluto")).toBeTruthy();
});
});
6 changes: 6 additions & 0 deletions packages/web-components/fast-btr/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FAST } from "@microsoft/fast-element";
import { debugMessages } from "./debug.js";

FAST.addMessages(debugMessages);

export { TemplateElement } from "./components/index.js";
7 changes: 7 additions & 0 deletions packages/web-components/fast-btr/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Warning and error messages.
* @internal
*/
export const enum Message {
noTemplateProvided = 2000,
}
29 changes: 29 additions & 0 deletions packages/web-components/fast-btr/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.resolve(path.dirname(__filename), "./");

export default {
entry: "./server/main.ts",
mode: "production",
output: {
filename: "main.js",
path: path.resolve(__dirname, "./server/dist"),
},
devServer: {
static: "./public",
port: 3001
},
resolve: {
extensions: [".ts", ".js"],
extensionAlias: {
".js": [".js", ".ts"],
}
},
module: {
rules: [
{ test: /\.([cm]?ts)$/, loader: "ts-loader" }
]
},
};
22 changes: 20 additions & 2 deletions packages/web-components/fast-element/docs/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export class ElementController<TElement extends HTMLElement = HTMLElement> exten
// (undocumented)
protected disconnectBehaviors(): void;
emit(type: string, detail?: any, options?: Omit<CustomEventInit, "detail">): void | boolean;
static forCustomElement(element: HTMLElement): ElementController;
static forCustomElement(element: HTMLElement, override?: boolean): ElementController;
get isBound(): boolean;
get isConnected(): boolean;
get mainStyles(): ElementStyles | null;
Expand Down Expand Up @@ -458,10 +458,15 @@ export class FASTElementDefinition<TType extends Constructable<HTMLElement> = Co
readonly registry: CustomElementRegistry;
readonly shadowOptions?: ShadowRootOptions;
readonly styles?: ElementStyles;
readonly template?: ElementViewTemplate;
template?: ElementViewTemplate;
readonly type: TType;
}

// Warning: (ae-internal-missing-underscore) The name "fastElementRegistry" should be prefixed with an underscore because the declaration is marked as @internal
//
// @internal
export const fastElementRegistry: TypeRegistry<FASTElementDefinition>;

// @public
export interface FASTGlobal {
addMessages(messages: Record<number, string>): void;
Expand Down Expand Up @@ -958,6 +963,19 @@ export type TrustedTypesPolicy = {
createHTML(html: string): string;
};

// Warning: (ae-forgotten-export) The symbol "TypeDefinition" needs to be exported by the entry point index.d.ts
// Warning: (ae-internal-missing-underscore) The name "TypeRegistry" should be prefixed with an underscore because the declaration is marked as @internal
//
// @internal
export interface TypeRegistry<TDefinition extends TypeDefinition> {
// (undocumented)
getByType(key: Function): TDefinition | undefined;
// (undocumented)
getForInstance(object: any): TDefinition | undefined;
// (undocumented)
register(definition: TDefinition): boolean;
}

// @public
export interface UpdateQueue {
enqueue(callable: Callable): void;
Expand Down
Loading

0 comments on commit ed313e4

Please sign in to comment.