├── 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 | 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 | 23 | 24 | 25 | 30 | 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 `