define-element 0.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +248 -0
  2. package/define-element.js +271 -0
  3. package/package.json +26 -14
package/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # define-element [![npm](https://img.shields.io/npm/v/define-element?color=tomato)](https://npmjs.org/define-element) [![size](https://img.shields.io/bundlephobia/minzip/define-element?label=size&color=brightgreen)](https://bundlephobia.com/package/define-element) [![ci](https://github.com/dy/define-element/actions/workflows/test.yml/badge.svg)](https://github.com/dy/define-element/actions/workflows/test.yml)
2
+
3
+ A custom element to define custom elements.
4
+
5
+ * [Declarative Custom Elements](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Declarative-Custom-Elements-Strawman.md) reference implemetation
6
+ * Typed props, scoped styles, shadow DOM, slots, lifecycle — one `<script>` tag
7
+ * Native web components for [sprae](https://github.com/dy/sprae), [alpine](https://alpinejs.dev), [petite-vue](https://github.com/vuejs/petite-vue), [template-parts](https://github.com/nicegist/template-parts) and others
8
+
9
+
10
+ ```html
11
+ <define-element>
12
+ <x-greeting name:string="world">
13
+ <template>
14
+ <p part="msg"></p>
15
+ </template>
16
+ <script>
17
+ const update = () => this.part.msg.textContent = `Hello, ${this.name}!`
18
+ update()
19
+ this.onattributechanged = update
20
+ </script>
21
+ <style>:host { font-style: italic }</style>
22
+ </x-greeting>
23
+ </define-element>
24
+
25
+ <x-greeting></x-greeting>
26
+ <x-greeting name="Arjuna"></x-greeting>
27
+ ```
28
+
29
+ ```html
30
+ <script src="https://unpkg.com/define-element"></script>
31
+ ```
32
+
33
+ or `$ npm i define-element` → `import 'define-element'`
34
+
35
+
36
+ ## Definition
37
+
38
+ Elements are defined by-example inside `<define-element>`. Each child becomes a custom element. A definition can contain `<template>`, `<script>`, and `<style>`.
39
+
40
+ ```html
41
+ <define-element>
42
+ <my-element greeting:string="hello">
43
+ <template>...</template>
44
+ <style>...</style>
45
+ <script>...</script>
46
+ </my-element>
47
+ </define-element>
48
+ ```
49
+
50
+ Multiple definitions per block. After processing, `<define-element>` removes itself. Without `<template>`, instance content is preserved as-is.
51
+
52
+
53
+ ## Props
54
+
55
+ Declared as attributes with optional types:
56
+
57
+ ```html
58
+ <x-widget count:number="0" label:string="Click me" active:boolean>
59
+ ```
60
+
61
+ | Type | Coercion | Default |
62
+ |------|----------|---------|
63
+ | `:string` | `String(v)` | `""` |
64
+ | `:number` | `Number(v)` | `0` |
65
+ | `:boolean` | `true` unless `"false"` or `null` | `false` |
66
+ | `:date` | `new Date(v)` | `null` |
67
+ | `:array` | `JSON.parse(v)` | `[]` |
68
+ | `:object` | `JSON.parse(v)` | `{}` |
69
+ | (none) | auto-detect | as-is |
70
+
71
+ Properties reflect to attributes and vice versa. Instance attributes override definition defaults.
72
+
73
+
74
+ ## Template, Parts & Script
75
+
76
+ `<template>` is cloned into each instance on first connect. Elements with `part` are collected into `this.part`. `<script>` runs once per instance with `this` as the element, via script injection (no `eval`).
77
+
78
+ ```html
79
+ <define-element>
80
+ <x-clock>
81
+ <template>
82
+ <time part="display"></time>
83
+ </template>
84
+ <script>
85
+ let id
86
+ const tick = () => this.part.display.textContent = new Date().toLocaleTimeString()
87
+ tick()
88
+ this.onconnected = () => id = setInterval(tick, 1000)
89
+ this.ondisconnected = () => clearInterval(id)
90
+ </script>
91
+ <style>:host { font-family: monospace; }</style>
92
+ </x-clock>
93
+ </define-element>
94
+ ```
95
+
96
+ | Access | Description |
97
+ |--------|-------------|
98
+ | `this` | The element instance |
99
+ | `this.count` | Prop value |
100
+ | `this.state` | Template state (from processor or plain object) |
101
+ | `this.part.x` | DOM ref via `part="x"` |
102
+ | `this.onconnected` | Connected callback |
103
+ | `this.ondisconnected` | Disconnected callback |
104
+ | `this.onadopted` | Adopted callback |
105
+ | `this.onattributechanged` | Attribute changed callback |
106
+
107
+ Script runs once on first connect. `onconnected` fires after script, including on first connect. On re-insertion, only `onconnected` fires. Async `await` is auto-detected.
108
+
109
+
110
+ ## Style
111
+
112
+ `<style>` is scoped automatically. With shadow DOM, styles use [adoptedStyleSheets](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/adoptedStyleSheets) (shared across instances). Without shadow DOM, styles are scoped via [CSS nesting](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting) under the element tag, and `:host` is rewritten to the tag name.
113
+
114
+
115
+ ## Shadow DOM & Slots
116
+
117
+ Add `shadowrootmode` to the template for encapsulation. Slots work natively:
118
+
119
+ ```html
120
+ <define-element>
121
+ <x-dialog open:boolean>
122
+ <template shadowrootmode="open">
123
+ <dialog part="dialog">
124
+ <header><slot name="title">Notice</slot></header>
125
+ <slot></slot>
126
+ <footer><button part="close">Close</button></footer>
127
+ </dialog>
128
+ </template>
129
+ <script>
130
+ const sync = () => this.open ? this.part.dialog.showModal() : this.part.dialog.close()
131
+ this.part.close.onclick = () => this.open = false
132
+ this.onattributechanged = sync
133
+ sync()
134
+ </script>
135
+ <style>
136
+ dialog::backdrop { background: rgba(0,0,0,.5); }
137
+ header { font-weight: bold; margin-bottom: .5em; }
138
+ footer { margin-top: 1em; text-align: right; }
139
+ </style>
140
+ </x-dialog>
141
+ </define-element>
142
+
143
+ <x-dialog open>
144
+ <span slot="title">Confirm</span>
145
+ <p>Are you sure?</p>
146
+ </x-dialog>
147
+ ```
148
+
149
+
150
+ ## Processor
151
+
152
+ Pluggable template engine. Without a processor, templates are static HTML (cloned automatically). With a processor, the processor owns template mounting — it receives an empty `root` with `root.template` pointing to the original `<template>` element, and is responsible for cloning/rendering content.
153
+
154
+ ```js
155
+ processor(root, state) => state
156
+ ```
157
+
158
+ - `root` — element (light DOM) or shadowRoot (shadow DOM), empty
159
+ - `root.template` — original `<template>` element (shared across instances)
160
+ - `state` — `{ propName: value }` from prop defaults + instance attributes
161
+ - Returns reactive state object (stored as `el.state`)
162
+
163
+ ```js
164
+ let DE = customElements.get('define-element')
165
+
166
+ // sprae — clone template, then sprae processes content reactively
167
+ import sprae from 'sprae'
168
+ DE.processor = (root, state) => {
169
+ root.appendChild(root.template.content.cloneNode(true))
170
+ return sprae(root, state)
171
+ }
172
+ ```
173
+
174
+ ```html
175
+ <define-element>
176
+ <x-counter count:number="0">
177
+ <template>
178
+ <button :onclick="count++">
179
+ Count: <span :text="count"></span>
180
+ </button>
181
+ </template>
182
+ </x-counter>
183
+ </define-element>
184
+ ```
185
+
186
+ No `<script>` needed — [sprae](https://github.com/dy/sprae) updates the template automatically when state changes. Other processors:
187
+
188
+ ```js
189
+ // @github/template-parts — renders directly from template, no pre-clone needed
190
+ import { TemplateInstance } from '@github/template-parts'
191
+ DE.processor = (root, state) => {
192
+ root.replaceChildren(new TemplateInstance(root.template, state))
193
+ return state
194
+ }
195
+
196
+ // petite-vue
197
+ import { createApp, reactive } from 'petite-vue'
198
+ DE.processor = (root, state) => {
199
+ root.appendChild(root.template.content.cloneNode(true))
200
+ let r = reactive(state)
201
+ createApp(r).mount(root)
202
+ return r
203
+ }
204
+
205
+ // Alpine.js
206
+ import Alpine from 'alpinejs'
207
+ DE.processor = (root, state) => {
208
+ root.appendChild(root.template.content.cloneNode(true))
209
+ let r = Alpine.reactive(state)
210
+ Alpine.addScopeToNode(root, r)
211
+ Alpine.initTree(root)
212
+ return r
213
+ }
214
+ ```
215
+
216
+ Frameworks with their own component models (Lit, Vue, Stencil) are better used directly.
217
+
218
+
219
+ ## Progressive Enhancement
220
+
221
+ Definitions are plain HTML — they render as inert content before JS loads. No flash of unstyled content, no blank page. The component markup is the fallback.
222
+
223
+ For shadow DOM components, server-rendered HTML can include [Declarative Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM#declaratively_with_html) (`<template shadowrootmode>`) for instant first paint. `define-element` then hydrates behavior on top.
224
+
225
+ This is not server-side rendering in the framework sense — there is no server runtime. It is HTML that works without JavaScript and gets enhanced when JavaScript arrives.
226
+
227
+
228
+ ## Why
229
+
230
+ The [W3C Declarative Custom Elements proposal](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Declarative-Custom-Elements-Strawman.md) has stalled for years. JS-side solutions (Lit, FAST, Stencil) require build tools and class boilerplate. The declarative CE space is a [graveyard](./docs/alternatives.md).
231
+
232
+ The gap: no lightweight way to define a custom element as HTML — components as content, not as code. Paste a `<define-element>` block into any page, CMS, or markdown file and it works. No npm, no import maps, no build step. One `<script>` tag.
233
+
234
+ This ~270-line reference implementation is evidence that the W3C proposal is implementable and useful. Ship it natively.
235
+
236
+
237
+ ## Alternatives
238
+
239
+ <sup>[EPA-WG custom-element](https://github.com/EPA-WG/custom-element) · [tram-deco](https://github.com/Tram-One/tram-deco) · [tram-lite](https://github.com/Tram-One/tram-lite) · [uce-template](https://github.com/WebReflection/uce-template) · [Ponys](https://github.com/jhuddle/ponys) · [snuggsi](https://github.com/devpunks/snuggsi) · [element-modules](https://github.com/trusktr/element-modules) · [Lit](https://lit.dev) · [Stencil](https://stenciljs.com) · [FAST](https://www.fast.design) · [Catalyst](https://github.github.io/catalyst/) · [Atomico](https://atomicojs.dev) · [Minze](https://minze.dev) · [haunted](https://github.com/matthewp/haunted) · [W3C DCE Proposal](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Declarative-Custom-Elements-Strawman.md)</sup>
240
+
241
+ [Detailed comparison →](./docs/alternatives.md)
242
+
243
+
244
+ ### License
245
+
246
+ [Krishnized](https://github.com/krishnized/license) ISC
247
+
248
+ <p align="center"><a href="https://github.com/krishnized/license">ॐ</a></p>
@@ -0,0 +1,271 @@
1
+ /**
2
+ * <define-element> — a custom element to define custom elements.
3
+ * Processor signature: (root, state) => state
4
+ *
5
+ * @example
6
+ * <define-element>
7
+ * <x-counter count:number="0">
8
+ * <template><output part="out">0</output><button part="inc">+</button></template>
9
+ * <script>this.part.inc.onclick = () => this.count++</script>
10
+ * </x-counter>
11
+ * </define-element>
12
+ */
13
+
14
+ // Type coercions for prop values
15
+ const types = {
16
+ string: v => v == null ? '' : String(v),
17
+ number: v => v == null ? 0 : Number(v),
18
+ boolean: v => v != null && v !== 'false' && v !== false,
19
+ date: v => v == null ? null : new Date(v),
20
+ array: v => v == null ? [] : typeof v === 'string' ? JSON.parse(v) : Array.from(v),
21
+ object: v => v == null ? {} : typeof v === 'string' ? JSON.parse(v) : v,
22
+ }
23
+
24
+ // Serialize value to attribute string
25
+ const serialize = (v, type) =>
26
+ v == null ? null :
27
+ type === 'boolean' ? (v ? '' : null) :
28
+ type === 'array' || type === 'object' ? JSON.stringify(v) :
29
+ type === 'date' ? v.toISOString?.() ?? String(v) :
30
+ String(v)
31
+
32
+ // Auto-detect type from string value
33
+ const auto = v => v == null ? v : !isNaN(v) && v !== '' ? Number(v) : v === 'true' ? true : v === 'false' ? false : v
34
+
35
+ // Track which light-DOM styles have been injected
36
+ let injectedStyles = new Set()
37
+
38
+ /**
39
+ * Parse prop declarations from element attributes.
40
+ * Format: name:type="default" or name="default" (auto-detect type)
41
+ */
42
+ function parseProps(el) {
43
+ let props = []
44
+ for (let { name, value } of [...el.attributes]) {
45
+ if (name === 'is') continue
46
+ let [prop, type] = name.split(':')
47
+ props.push({ name: prop, type: type || null, default: value })
48
+ }
49
+ return props
50
+ }
51
+
52
+ /**
53
+ * Define a custom element from a definition element.
54
+ * @param {Element} el - The definition child (e.g., <x-counter count:number="0">)
55
+ */
56
+ function define(el) {
57
+ let tag = el.localName
58
+ let ext = el.getAttribute('is')
59
+
60
+ let propDefs = parseProps(el)
61
+ let propNames = propDefs.map(p => p.name)
62
+ let propMap = Object.fromEntries(propDefs.map(p => [p.name, p]))
63
+
64
+ // extract sections
65
+ let tpl = el.querySelector('template')
66
+ let shadowMode = tpl?.getAttribute('shadowrootmode') || null
67
+ if (shadowMode) tpl.removeAttribute('shadowrootmode')
68
+
69
+ let scriptEl = el.querySelector('script')
70
+ let scriptText = scriptEl?.textContent || null
71
+
72
+ let styleEl = el.querySelector('style')
73
+ let styleText = styleEl?.textContent || null
74
+
75
+ // shared adopted stylesheet for shadow DOM (one per definition)
76
+ let adoptedSheet = null
77
+ if (styleText && shadowMode && typeof CSSStyleSheet !== 'undefined') {
78
+ adoptedSheet = new CSSStyleSheet()
79
+ adoptedSheet.replaceSync(styleText)
80
+ }
81
+
82
+ // determine base class
83
+ let Base = ext ? document.createElement(tag).constructor : HTMLElement
84
+
85
+ let C = class extends Base {
86
+ static observedAttributes = propNames
87
+
88
+ constructor() {
89
+ super()
90
+ this._de_init = false
91
+ this._de_props = {}
92
+
93
+ for (let p of propDefs) {
94
+ let coerce = p.type ? types[p.type] : auto
95
+ this._de_props[p.name] = coerce(p.default || null)
96
+ }
97
+ }
98
+
99
+ connectedCallback() {
100
+ if (!this._de_init) {
101
+ this._de_init = true
102
+
103
+ let root = this
104
+ if (shadowMode) {
105
+ root = this.shadowRoot || this.attachShadow({ mode: shadowMode })
106
+ }
107
+
108
+ // inject style
109
+ if (styleText) {
110
+ if (shadowMode) {
111
+ if (adoptedSheet) {
112
+ root.adoptedStyleSheets = [adoptedSheet]
113
+ } else {
114
+ let s = document.createElement('style')
115
+ s.textContent = styleText
116
+ root.prepend(s)
117
+ }
118
+ } else {
119
+ // light DOM: @scope
120
+ let s = document.createElement('style')
121
+ s.textContent = scopeCSS(styleText, tag)
122
+ s.setAttribute('data-de', tag)
123
+ if (!injectedStyles.has(tag)) {
124
+ injectedStyles.add(tag)
125
+ document.head.appendChild(s)
126
+ }
127
+ }
128
+ }
129
+
130
+ // expose original template for processors
131
+ if (tpl) root.template = tpl
132
+
133
+ // build initial state from prop defaults + current attributes
134
+ let state = {}
135
+ for (let p of propDefs) {
136
+ let coerce = p.type ? types[p.type] : auto
137
+ let attrVal = this.getAttribute(p.name)
138
+ state[p.name] = attrVal != null ? coerce(attrVal) : this._de_props[p.name]
139
+ }
140
+
141
+ // run processor or clone template
142
+ let p = DefineElement.processor
143
+ if (p) {
144
+ // processor owns template mounting — no pre-clone
145
+ let result = p(root, state)
146
+ this.state = result || state
147
+ } else {
148
+ if (tpl && !root.firstChild) {
149
+ root.appendChild(tpl.content.cloneNode(true))
150
+ }
151
+ this.state = state
152
+ }
153
+
154
+ // collect parts (after processor, since it populates root)
155
+ this.part = {}
156
+ root.querySelectorAll('[part]').forEach(p =>
157
+ this.part[p.getAttribute('part')] = p
158
+ )
159
+
160
+ // run script (errors in injected scripts don't throw — browser reports via onerror)
161
+ if (scriptText) runScript(scriptText, this)
162
+ }
163
+
164
+ this.onconnected?.()
165
+ }
166
+
167
+ disconnectedCallback() {
168
+ this.ondisconnected?.()
169
+ }
170
+
171
+ adoptedCallback() {
172
+ this.onadopted?.()
173
+ }
174
+
175
+ attributeChangedCallback(name, oldVal, newVal) {
176
+ if (this._de_reflecting) return
177
+ let def = propMap[name]
178
+ if (!def) return
179
+
180
+ let coerce = def.type ? types[def.type] : auto
181
+ let val = coerce(newVal)
182
+ this._de_props[name] = val
183
+
184
+ if (this._de_init && this.state) {
185
+ this.state[name] = val
186
+ }
187
+
188
+ this.onattributechanged?.({ attributeName: name, oldValue: oldVal, newValue: newVal })
189
+ }
190
+ }
191
+
192
+ // define props as getter/setter on prototype
193
+ for (let p of propDefs) {
194
+ let coerce = p.type ? types[p.type] : auto
195
+ Object.defineProperty(C.prototype, p.name, {
196
+ get() { return this._de_props[p.name] },
197
+ set(v) {
198
+ let val = coerce(v)
199
+ this._de_props[p.name] = val
200
+ if (this._de_init && this.state) this.state[p.name] = val
201
+ // skip reflection for functions — can't round-trip through attributes
202
+ if (typeof val === 'function') return
203
+ let s = serialize(val, p.type)
204
+ this._de_reflecting = true
205
+ if (s == null) this.removeAttribute(p.name)
206
+ else this.setAttribute(p.name, s)
207
+ this._de_reflecting = false
208
+ },
209
+ enumerable: true,
210
+ configurable: true
211
+ })
212
+ }
213
+
214
+ // register (skip if already defined)
215
+ let name = ext || tag
216
+ if (!customElements.get(name))
217
+ customElements.define(name, C, ext ? { extends: tag } : undefined)
218
+
219
+ return C
220
+ }
221
+
222
+
223
+ /**
224
+ * Execute script text via <script> element injection (no eval/new Function).
225
+ * Wraps in IIFE with `this` bound to element instance.
226
+ */
227
+ function runScript(text, thisArg) {
228
+ let s = document.createElement('script')
229
+ let stripped = text.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(['"`])(?:(?!\1)[^\\]|\\.)*\1/g, '')
230
+ let isAsync = /\bawait\b/.test(stripped)
231
+ let wrapper = isAsync
232
+ ? `(async function(){${text}}).call(document.currentScript._de_this)`
233
+ : `(function(){${text}}).call(document.currentScript._de_this)`
234
+ s._de_this = thisArg
235
+ s.textContent = wrapper
236
+ document.head.appendChild(s)
237
+ s.remove()
238
+ }
239
+
240
+
241
+ /** CSS scoping for light DOM via CSS nesting. :host → tag. */
242
+ function scopeCSS(css, tag) {
243
+ css = css.replace(/:host\b(?:\(([^)]*)\))?/g, (_, sel) => sel ? `${tag}${sel}` : tag)
244
+ return `${tag} { ${css} }`
245
+ }
246
+
247
+
248
+ /**
249
+ * <define-element> — scans children for component definitions.
250
+ */
251
+ class DefineElement extends HTMLElement {
252
+ connectedCallback() {
253
+ queueMicrotask(() => this._init())
254
+ }
255
+
256
+ _init() {
257
+ let defs = [...this.children].filter(c => {
258
+ let ln = c.localName
259
+ return ln !== 'template' && ln !== 'script' && ln !== 'style'
260
+ })
261
+ for (let def of defs) def.remove()
262
+ this.remove()
263
+ for (let def of defs) define(def)
264
+ }
265
+ }
266
+
267
+ DefineElement.processor = null
268
+
269
+ customElements.define('define-element', DefineElement)
270
+
271
+ export { DefineElement, define }
package/package.json CHANGED
@@ -1,30 +1,42 @@
1
1
  {
2
2
  "name": "define-element",
3
- "version": "0.0.0",
4
- "description": "Template, defining custom elements",
5
- "main": "index.js",
3
+ "version": "1.1.0",
4
+ "description": "A custom element to define custom elements",
5
+ "type": "module",
6
+ "main": "define-element.js",
7
+ "unpkg": "define-element.js",
8
+ "files": [
9
+ "define-element.js"
10
+ ],
11
+ "sideEffects": true,
6
12
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
13
+ "test": "node -r ./test/register.cjs test/test.js"
8
14
  },
9
15
  "repository": {
10
16
  "type": "git",
11
- "url": "git+https://github.com/spectjs/define-element.git"
17
+ "url": "git+https://github.com/dy/define-element.git"
12
18
  },
13
19
  "keywords": [
14
- "uce-template",
15
- "declarative-custom-element",
16
- "vue3",
17
- "reactive",
18
- "spect",
19
20
  "custom-element",
20
21
  "web-components",
21
- "element-props",
22
- "template-parts"
22
+ "declarative",
23
+ "html",
24
+ "no-build",
25
+ "template",
26
+ "shadow-dom"
23
27
  ],
24
28
  "author": "dmitry iv.",
25
29
  "license": "ISC",
26
30
  "bugs": {
27
- "url": "https://github.com/spectjs/define-element/issues"
31
+ "url": "https://github.com/dy/define-element/issues"
28
32
  },
29
- "homepage": "https://github.com/spectjs/define-element#readme"
33
+ "homepage": "https://github.com/dy/define-element#readme",
34
+ "devDependencies": {
35
+ "@github/template-parts": "^0.7.1",
36
+ "alpinejs": "^3.15.8",
37
+ "jsdom": "^28.1.0",
38
+ "petite-vue": "^0.4.1",
39
+ "tst": "^9.2.2",
40
+ "wait-please": "^3.1.0"
41
+ }
30
42
  }