├── src
├── demo
│ └── index.ts
├── stampino-base-element.ts
├── test
│ ├── stampino-base-element_test.ts
│ └── stampino-element_test.ts
└── stampino-element.ts
├── .prettierrc.json
├── web-test-runner.config.js
├── .gitignore
├── web-dev-server.config.js
├── demo
├── simple-greeting.html
└── index.html
├── tsconfig.json
├── LICENSE
├── package.json
└── README.md
/src/demo/index.ts:
--------------------------------------------------------------------------------
1 | import '../stampino-element.js';
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "bracketSpacing": false
4 | }
--------------------------------------------------------------------------------
/web-test-runner.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | testFramework: {
3 | config: {
4 | ui: 'tdd',
5 | timeout: '2000',
6 | },
7 | },
8 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /test
3 | /.wireit
4 |
5 | /demo/*.d.ts
6 | /demo/*.d.ts.map
7 | /demo/*.js
8 | /demo/*.js.map
9 |
10 | *.tsbuildinfo
11 |
12 | /stampino-element.*
13 | /stampino-base-element.*
14 |
--------------------------------------------------------------------------------
/web-dev-server.config.js:
--------------------------------------------------------------------------------
1 | // import { fromRollup } from '@web/dev-server-rollup';
2 | // import {htmlModules} from 'rollup-plugin-html-modules';
3 |
4 | export default {
5 | open: true,
6 | nodeResolve: true,
7 | appIndex: 'demo/index.html',
8 | // plugins: [fromRollup(htmlModules)]
9 | };
10 |
--------------------------------------------------------------------------------
/demo/simple-greeting.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 | Hello {{ name }}!
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2022",
5 | "module": "NodeNext",
6 | "moduleResolution": "NodeNext",
7 | "lib": ["DOM", "es2020", "DOM.Iterable"],
8 | "declaration": true,
9 | "declarationMap": true,
10 | "sourceMap": true,
11 | "outDir": "./",
12 | "rootDir": "./src",
13 | "strict": true,
14 | "noUnusedLocals": true,
15 | "noUnusedParameters": true,
16 | "noImplicitReturns": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "noUncheckedIndexedAccess": true,
19 | "noPropertyAccessFromIndexSignature": true,
20 | "skipLibCheck": true,
21 | "forceConsistentCasingInFileNames": true
22 | },
23 | "include": ["src/**/*.ts"],
24 | "exclude": []
25 | }
26 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
16 |
17 |
18 |
19 |
20 | This is a sub-template
21 | Foo is {{ foo ?? 'undefined' }}
22 |
23 |
24 |
25 |
30 |
31 | Hello {{ name }}!
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Justin Fagnani
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/stampino-base-element.ts:
--------------------------------------------------------------------------------
1 | import {LitElement} from 'lit';
2 | import {Renderers, prepareTemplate} from 'stampino';
3 |
4 | /**
5 | * The base class for elements declared with ``.
6 | */
7 | export class StampinoBaseElement extends LitElement {
8 | static superTemplate?: HTMLTemplateElement;
9 | static template?: HTMLTemplateElement;
10 | static renderers?: Renderers;
11 | static _preparedTemplate?: (model: object) => unknown;
12 |
13 | connectedCallback() {
14 | const ctor = this.constructor as typeof StampinoBaseElement;
15 | ctor._preparedTemplate =
16 | ctor.template === undefined
17 | ? undefined
18 | : prepareTemplate(
19 | ctor.template,
20 | undefined,
21 | ctor.renderers,
22 | ctor.superTemplate,
23 | );
24 | super.connectedCallback();
25 | }
26 |
27 | render() {
28 | return (this.constructor as typeof StampinoBaseElement)._preparedTemplate?.(
29 | this,
30 | );
31 | }
32 |
33 | /**
34 | * Returns a function that can be used in event handlers to update a property.
35 | *
36 | * This is experimental and may change or be removed in the future!
37 | */
38 | protected _getPropertySetter(name: keyof this, value: this[keyof this]) {
39 | return () => this[name] = value;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/test/stampino-base-element_test.ts:
--------------------------------------------------------------------------------
1 | import {assert} from '@esm-bundle/chai';
2 | import {StampinoBaseElement} from '../stampino-base-element.js';
3 | import {render, html} from 'lit/html.js';
4 | import {customElement, property} from 'lit/decorators.js';
5 |
6 | suite('StampinoBaseElement', () => {
7 | let container: HTMLDivElement;
8 |
9 | setup(() => {
10 | container = document.createElement('div');
11 | document.body.append(container);
12 | });
13 |
14 | teardown(() => {
15 | container.remove();
16 | });
17 |
18 | test('basic', async () => {
19 | const template = document.createElement('template');
20 | template.innerHTML = `
21 | Hello {{ name }}!
22 | `;
23 | @customElement('test-element-1')
24 | class TestElement extends StampinoBaseElement {
25 | static template = template;
26 |
27 | @property()
28 | accessor name: string | undefined;
29 | }
30 | render(html``, container);
31 | const el = container.firstElementChild as TestElement;
32 | assert.instanceOf(el, TestElement);
33 | await el.updateComplete;
34 | assert.equal(
35 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
36 | `Hello World!
`,
37 | );
38 | });
39 | });
40 |
41 | const stripExpressionMarkers = (html: string) =>
42 | html.replace(/||lit\$[0-9]+\$/g, '');
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stampino-element",
3 | "version": "0.2.0",
4 | "type": "module",
5 | "main": "./stampino-element.js",
6 | "exports": {
7 | ".": {
8 | "types": "./stampino-element.d.ts",
9 | "default": "./stampino-element.js"
10 | }
11 | },
12 | "files": [
13 | "stampino-element.{d.ts,d.ts.map,js,js.map}",
14 | "stampino-base-element.{d.ts,d.ts.map,js,js.map}",
15 | "src/*",
16 | "!src/demo/*",
17 | "!src/test/*"
18 | ],
19 | "dependencies": {
20 | "lit": "^3.1.2",
21 | "stampino": "^0.8.2"
22 | },
23 | "scripts": {
24 | "build": "wireit",
25 | "test": "wireit",
26 | "format": "prettier \"src/**/*.ts\" --write",
27 | "demo": "wireit"
28 | },
29 | "devDependencies": {
30 | "@esm-bundle/chai": "^4.3.4-fix.0",
31 | "@types/mocha": "^10.0.6",
32 | "@web/dev-server": "^0.4.3",
33 | "@web/test-runner": "^0.18.1",
34 | "@web/test-runner-mocha": "^0.9.0",
35 | "@web/test-runner-playwright": "^0.11.0",
36 | "prettier": "^3.2.5",
37 | "typescript": "^5.4.2",
38 | "wireit": "^0.14.4"
39 | },
40 | "author": "Justin Fagnani ",
41 | "license": "MIT",
42 | "repository": "justinfagnani/stampino-element",
43 | "bugs": {
44 | "url": "https://github.com/justinfagnani/stampino-element/issues"
45 | },
46 | "homepage": "https://github.com/justinfagnani/stampino-element#readme",
47 | "wireit": {
48 | "build": {
49 | "command": "tsc --pretty",
50 | "files": [
51 | "src/**/*.ts",
52 | "tsconfig.json"
53 | ],
54 | "output": [
55 | "stampino-element.{d.ts,d.ts.map,js,js.map}",
56 | "stampino-base-element.{d.ts,d.ts.map,js,js.map}",
57 | "test"
58 | ],
59 | "clean": "if-file-deleted"
60 | },
61 | "demo": {
62 | "command": "wds",
63 | "service": true,
64 | "dependencies": [
65 | {
66 | "script": "build",
67 | "cascade": false
68 | }
69 | ],
70 | "files": [
71 | "web-dev-server.config.js"
72 | ]
73 | },
74 | "test": {
75 | "command": "wtr test/**/*_test.js --node-resolve --playwright --browsers chromium",
76 | "dependencies": [
77 | "build"
78 | ],
79 | "files": [
80 | "web-test-runner.config.js"
81 | ],
82 | "output": []
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/stampino-element.ts:
--------------------------------------------------------------------------------
1 | import {evaluateTemplate} from 'stampino';
2 | import {StampinoBaseElement} from './stampino-base-element.js';
3 | import {css, PropertyDeclaration, unsafeCSS} from 'lit';
4 |
5 | const typeHints = {
6 | String: String,
7 | Number: Number,
8 | Boolean: Boolean,
9 | Object: Object,
10 | Array: Array,
11 | } as const;
12 |
13 | /**
14 | * Declares a custom element with Stampino templating.
15 | */
16 | export class StampinoElement extends HTMLElement {
17 | static observedAttributes = ['name', 'properties'];
18 |
19 | private declare _initialized: boolean;
20 |
21 | class?: typeof StampinoBaseElement;
22 | template?: HTMLTemplateElement;
23 |
24 | constructor() {
25 | super();
26 | Object.defineProperty(this, '_initialized', {value: false, writable: true});
27 | }
28 |
29 | connectedCallback() {
30 | if (!this._initialized) {
31 | this._initialized = true;
32 | const extendsName = this.getAttribute('extends');
33 | const elementName = this.getAttribute('name');
34 | const propertiesAttr = this.getAttribute('properties');
35 | const propertyChildren = this.querySelectorAll('st-prop');
36 | this.template =
37 | this.querySelector('template') ?? undefined;
38 | const style = this.querySelector(
39 | "style[type='adopted-css']",
40 | );
41 |
42 | let superclass = StampinoBaseElement;
43 | let superTemplate = undefined;
44 |
45 | if (extendsName !== null) {
46 | const superDefinition = (
47 | this.getRootNode() as unknown as ParentNode
48 | ).querySelector(`stampino-element[name=${extendsName}]`);
49 | if (superDefinition === null) {
50 | console.warn(
51 | `Could not find superclass definition for ${extendsName}`,
52 | );
53 | return;
54 | }
55 | const foundSuperclass = (superDefinition as StampinoElement).class;
56 | if (foundSuperclass) {
57 | superclass = foundSuperclass;
58 | }
59 | superTemplate = (superDefinition as StampinoElement).template;
60 | }
61 |
62 | const C = (this.class = class extends superclass {});
63 | if (this.template) {
64 | C.template = this.template;
65 | }
66 | if (superTemplate) {
67 | C.superTemplate = superTemplate;
68 | }
69 |
70 | if (style) {
71 | C.styles = css`
72 | ${unsafeCSS(style.textContent)}
73 | `;
74 | }
75 |
76 | for (const p of propertiesAttr?.split(' ') ?? []) {
77 | C.createProperty(p);
78 | }
79 |
80 | for (const p of propertyChildren) {
81 | const name = p.getAttribute('name');
82 | const reflect = p.hasAttribute('reflect');
83 | const noAttribute = p.hasAttribute('noattribute');
84 | const attribute =
85 | !noAttribute && (p.getAttribute('attribute') ?? undefined);
86 | const typeHint = p.getAttribute('type');
87 | const type =
88 | typeHint === null
89 | ? undefined
90 | : typeHints[typeHint as keyof typeof typeHints];
91 | const options: PropertyDeclaration = {
92 | reflect,
93 | attribute,
94 | type,
95 | };
96 | if (name !== null) {
97 | C.createProperty(name, options);
98 | }
99 | }
100 |
101 | // Find all callable templates in the same scope
102 | const root = this.getRootNode() as Document | ShadowRoot | Element;
103 | const templates = root.querySelectorAll('template[id]');
104 | C.renderers = Object.fromEntries(
105 | [...templates].map((t) => [
106 | t.id,
107 | (model, handlers, renderers) =>
108 | evaluateTemplate(
109 | t as HTMLTemplateElement,
110 | model,
111 | handlers,
112 | renderers,
113 | ),
114 | ]),
115 | );
116 |
117 |
118 |
119 | if (elementName) {
120 | customElements.define(elementName, C);
121 | }
122 | }
123 | }
124 | }
125 | customElements.define('stampino-element', StampinoElement);
126 |
127 | class StampinoProperty extends HTMLElement {
128 | static observedAttributes = [
129 | 'name',
130 | 'type',
131 | 'noattribute',
132 | 'attribute',
133 | 'reflect',
134 | ];
135 | }
136 | customElements.define('st-prop', StampinoProperty);
137 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # stampino-element
2 |
3 | `` is a web component for creating _declarative web components_. That is, defining web components entirely in HTML.
4 |
5 | `` uses [Stampino](https://www.npmjs.com/package/stampino) (which in turn uses [lit-html](https://lit-html.polymer-project.org/)) for template rendering, and [LitElement](https://lit-element.polymer-project.org/) for reactive properties.
6 |
7 | Templates can have expressions and control flow like `` and ``.
8 |
9 | The custom element name is specified in the `name` attribute, and a space-separated list of properties/attributes in `properties`.
10 |
11 | Scoped styles can be added with a `
22 |
23 | Hello {{ name }}!
24 |
25 |
26 |
27 |
28 | ```
29 |
30 | ### Element names
31 |
32 | The custom element name is declared with the required attribute `name`. The name must include a dash (`-`) as per custom elements in the HTML specification.
33 |
34 | ### Properties
35 |
36 | Properties are specified with either the `properties` attribute or child `` elements.
37 |
38 | The `properties` attribute takes a space-separated list of property names, all properties listed get an associated attribute of the same name, using the String type converter, with no reflection.
39 |
40 | If you need to set property options like `type`, `reflect`, `attribute`
41 |
42 | | Attribute | Meaning |
43 | | ----------|---------|
44 | | `name` | The name of the property. Case-sensitive |
45 | | `type` | The "type hint" for the property. Valid values are "String", "Number", "Boolean", "Object", or "Array" |
46 | | `reflect` | A boolean attribute. If present the property reflects to an attribute. |
47 | | `attribute` | The attribute name associated with the property |
48 | | `noattribute` | If present the property is not read from an attribute |
49 |
50 | Example:
51 | ```html
52 |
53 |
54 |
55 |
56 | ```
57 |
58 | ### Styling
59 |
60 | Styles are added with a `
26 |
27 | Hello {{ name }}!
28 |
29 |
30 | `;
31 | container.insertAdjacentHTML('beforeend', ``);
32 | const el = container.querySelector('test-1') as StampinoBaseElement;
33 | assert.instanceOf(el, StampinoBaseElement);
34 | await el.updateComplete;
35 | assert.equal(
36 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
37 | `Hello World!
`,
38 | );
39 |
40 | const h1 = el.shadowRoot?.firstElementChild!;
41 | const computedStyles = getComputedStyle(h1);
42 | assert.equal(computedStyles.color, 'rgb(0, 0, 255)');
43 | });
44 |
45 | test('reconnecting', async () => {
46 | container.innerHTML = `
47 |
48 |
49 | Hello {{ name }}!
50 |
51 |
52 | `;
53 | const definitionEl = container.querySelector(
54 | 'stampino-element',
55 | ) as StampinoElement;
56 |
57 | // Remove and reattach the element to check that it doesn't redo definition
58 | // time work:
59 | definitionEl.remove();
60 | container.append(definitionEl);
61 |
62 | // Make sure an element still works
63 | container.insertAdjacentHTML('beforeend', ``);
64 | const el = container.querySelector('test-1') as StampinoBaseElement;
65 | assert.instanceOf(el, StampinoBaseElement);
66 | await el.updateComplete;
67 | assert.equal(
68 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
69 | `Hello World!
`,
70 | );
71 | });
72 |
73 | suite('properties', () => {
74 | test('single property', async () => {
75 | container.innerHTML = `
76 |
77 | Hello {{ name }}!
78 |
79 |
80 | `;
81 | interface TestProp1Element extends StampinoBaseElement {
82 | name?: string;
83 | }
84 | const el = container.querySelector('test-prop-1') as TestProp1Element;
85 | assert.equal(el.name, undefined);
86 | el.name = 'World';
87 | await el.updateComplete;
88 | assert.equal(
89 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
90 | `Hello World!
`,
91 | );
92 | });
93 |
94 | test('multiple properties', async () => {
95 | container.innerHTML = `
96 |
97 | {{ a }}
{{ b }}
98 |
99 |
100 | `;
101 | interface TestProp1Element extends StampinoBaseElement {
102 | a?: string;
103 | b?: string;
104 | }
105 | const el = container.querySelector('test-prop-2') as TestProp1Element;
106 | assert.equal(el.a, undefined);
107 | assert.equal(el.b, undefined);
108 | el.b = 'BBB';
109 | await el.updateComplete;
110 | assert.equal(
111 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
112 | `BBB
`,
113 | );
114 | });
115 |
116 | test('property child declaration - type', async () => {
117 | container.innerHTML = `
118 |
119 |
120 | {{ a }}
121 |
122 |
123 | `;
124 | interface TestProp1Element extends StampinoBaseElement {
125 | a?: number;
126 | }
127 | const el = container.querySelector('test-prop-3') as TestProp1Element;
128 | assert.equal(el.a, 123);
129 | await el.updateComplete;
130 | assert.equal(
131 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
132 | `123
`,
133 | );
134 | });
135 |
136 | test('property child declaration - noattribute', async () => {
137 | container.innerHTML = `
138 |
139 |
140 | {{ a }}
141 |
142 |
143 | `;
144 | interface TestProp1Element extends StampinoBaseElement {
145 | a?: string;
146 | }
147 | const el = container.querySelector('test-prop-4') as TestProp1Element;
148 | assert.equal(el.a, undefined);
149 | await el.updateComplete;
150 | assert.equal(
151 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
152 | ``,
153 | );
154 | });
155 |
156 | test('property child declaration - reflect', async () => {
157 | container.innerHTML = `
158 |
159 |
160 | {{ a }}
161 |
162 |
163 | `;
164 | interface TestProp1Element extends StampinoBaseElement {
165 | a?: string;
166 | }
167 | const el = container.querySelector('test-prop-5') as TestProp1Element;
168 | assert.equal(el.a, undefined);
169 | el.a = 'AAA';
170 | await el.updateComplete;
171 | assert.equal(
172 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
173 | `AAA
`,
174 | );
175 | assert.equal(el.getAttribute('a'), 'AAA');
176 | });
177 | });
178 |
179 | suite('inheritance', () => {
180 | test('trivial subclass', async () => {
181 | container.innerHTML = `
182 |
183 |
188 |
189 | Hello {{ name }}!
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 | `;
199 | const el = container.querySelector('test-3-b') as StampinoBaseElement;
200 | assert.instanceOf(el, StampinoBaseElement);
201 | await el.updateComplete;
202 | assert.equal(
203 | stripExpressionMarkers(el.shadowRoot!.innerHTML).trim(),
204 | `Hello World!
`,
205 | );
206 |
207 | const h1 = el.shadowRoot?.firstElementChild!;
208 | const computedStyles = getComputedStyle(h1);
209 | assert.equal(computedStyles.color, 'rgb(0, 0, 255)');
210 | });
211 |
212 | test('subclass with implicit super template', async () => {
213 | container.innerHTML = `
214 |
215 |
220 |
221 | {{ a }}
222 | {{ b }}
223 |
224 |
225 |
226 |
227 |
228 | {{ b }}
229 |
230 |
231 |
232 |
233 | `;
234 | const el = container.querySelector('test-4-b') as StampinoBaseElement;
235 | assert.instanceOf(el, StampinoBaseElement);
236 | await el.updateComplete;
237 | assert.match(
238 | stripExpressionMarkers(el.shadowRoot!.innerHTML),
239 | /^\s*AAA<\/h1>\s*BBB<\/h3>\s*/,
240 | );
241 | });
242 | });
243 | });
244 |
245 | const stripExpressionMarkers = (html: string) =>
246 | html.replace(/||lit\$[0-9]+\$/g, '');
247 |
--------------------------------------------------------------------------------