define-element 0.0.0 → 1.0.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 +234 -0
  2. package/define-element.js +264 -0
  3. package/package.json +24 -14
package/README.md ADDED
@@ -0,0 +1,234 @@
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
+ * Components as content, not code — paste into any page, CMS, or markdown
6
+ * No class, no build, no framework — one `<script>` tag
7
+ * Typed props, scoped styles, shadow DOM, slots, lifecycle
8
+ * Pluggable template engine — native 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
9
+
10
+
11
+ ```html
12
+ <define-element>
13
+ <x-greeting name:string="world">
14
+ <template>
15
+ <p part="msg"></p>
16
+ </template>
17
+ <script>
18
+ const update = () => this.part.msg.textContent = `Hello, ${this.name}!`
19
+ update()
20
+ this.onattributechanged = update
21
+ </script>
22
+ <style>:host { font-style: italic }</style>
23
+ </x-greeting>
24
+ </define-element>
25
+
26
+ <x-greeting></x-greeting>
27
+ <x-greeting name="Arjuna"></x-greeting>
28
+ ```
29
+
30
+ ```html
31
+ <script src="https://unpkg.com/define-element"></script>
32
+ ```
33
+
34
+ or `$ npm i define-element` → `import 'define-element'`
35
+
36
+
37
+ ## Definition
38
+
39
+ Elements are defined by-example inside `<define-element>`. Each child becomes a custom element. A definition can contain `<template>`, `<script>`, and `<style>`.
40
+
41
+ ```html
42
+ <define-element>
43
+ <my-element greeting:string="hello">
44
+ <template>...</template>
45
+ <style>...</style>
46
+ <script>...</script>
47
+ </my-element>
48
+ </define-element>
49
+ ```
50
+
51
+ Multiple definitions per block. After processing, `<define-element>` removes itself. Without `<template>`, instance content is preserved as-is.
52
+
53
+
54
+ ## Props
55
+
56
+ Declared as attributes with optional types:
57
+
58
+ ```html
59
+ <x-widget count:number="0" label:string="Click me" active:boolean>
60
+ ```
61
+
62
+ | Type | Coercion | Default |
63
+ |------|----------|---------|
64
+ | `:string` | `String(v)` | `""` |
65
+ | `:number` | `Number(v)` | `0` |
66
+ | `:boolean` | `true` unless `"false"` or `null` | `false` |
67
+ | `:date` | `new Date(v)` | `null` |
68
+ | `:array` | `JSON.parse(v)` | `[]` |
69
+ | `:object` | `JSON.parse(v)` | `{}` |
70
+ | (none) | auto-detect | as-is |
71
+
72
+ Properties reflect to attributes and vice versa. Instance attributes override definition defaults.
73
+
74
+
75
+ ## Template, Parts & Script
76
+
77
+ `<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`).
78
+
79
+ ```html
80
+ <define-element>
81
+ <x-clock>
82
+ <template>
83
+ <time part="display"></time>
84
+ </template>
85
+ <script>
86
+ let id
87
+ const tick = () => this.part.display.textContent = new Date().toLocaleTimeString()
88
+ tick()
89
+ this.onconnected = () => id = setInterval(tick, 1000)
90
+ this.ondisconnected = () => clearInterval(id)
91
+ </script>
92
+ <style>:host { font-family: monospace; }</style>
93
+ </x-clock>
94
+ </define-element>
95
+ ```
96
+
97
+ | Access | Description |
98
+ |--------|-------------|
99
+ | `this` | The element instance |
100
+ | `this.count` | Prop value |
101
+ | `this.state` | Template state (from processor or plain object) |
102
+ | `this.part.x` | DOM ref via `part="x"` |
103
+ | `this.onconnected` | Connected callback |
104
+ | `this.ondisconnected` | Disconnected callback |
105
+ | `this.onadopted` | Adopted callback |
106
+ | `this.onattributechanged` | Attribute changed callback |
107
+
108
+ 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.
109
+
110
+
111
+ ## Style
112
+
113
+ `<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.
114
+
115
+
116
+ ## Shadow DOM & Slots
117
+
118
+ Add `shadowrootmode` to the template for encapsulation. Slots work natively:
119
+
120
+ ```html
121
+ <define-element>
122
+ <x-dialog open:boolean>
123
+ <template shadowrootmode="open">
124
+ <dialog part="dialog">
125
+ <header><slot name="title">Notice</slot></header>
126
+ <slot></slot>
127
+ <footer><button part="close">Close</button></footer>
128
+ </dialog>
129
+ </template>
130
+ <script>
131
+ const sync = () => this.open ? this.part.dialog.showModal() : this.part.dialog.close()
132
+ this.part.close.onclick = () => this.open = false
133
+ this.onattributechanged = sync
134
+ sync()
135
+ </script>
136
+ <style>
137
+ dialog::backdrop { background: rgba(0,0,0,.5); }
138
+ header { font-weight: bold; margin-bottom: .5em; }
139
+ footer { margin-top: 1em; text-align: right; }
140
+ </style>
141
+ </x-dialog>
142
+ </define-element>
143
+
144
+ <x-dialog open>
145
+ <span slot="title">Confirm</span>
146
+ <p>Are you sure?</p>
147
+ </x-dialog>
148
+ ```
149
+
150
+
151
+ ## Processor
152
+
153
+ Pluggable template engine. Without a processor, templates are static HTML. Set `.processor` to a `(root, state) => state` function — called once per instance after template content is cloned into `root`.
154
+
155
+ `root` is the render target — the element itself (light DOM) or its `shadowRoot` (shadow DOM), with template content already cloned in. The original `<template>` element is available as `root.template` for processors that need it.
156
+
157
+ ```js
158
+ import sprae from 'sprae'
159
+
160
+ // sprae matches the signature directly — returns a reactive store
161
+ customElements.get('define-element').processor = sprae
162
+ ```
163
+
164
+ ```html
165
+ <define-element>
166
+ <x-counter count:number="0">
167
+ <template>
168
+ <button :onclick="count++">
169
+ Count: <span :text="count"></span>
170
+ </button>
171
+ </template>
172
+ </x-counter>
173
+ </define-element>
174
+ ```
175
+
176
+ No `<script>` needed — [sprae](https://github.com/dy/sprae) updates the template automatically when state changes. Other processors:
177
+
178
+ ```js
179
+ let DE = customElements.get('define-element')
180
+
181
+ // @github/template-parts ({{x}} interpolation, W3C Template Instantiation proposal)
182
+ import { TemplateInstance } from '@github/template-parts'
183
+ DE.processor = (root, state) => {
184
+ root.replaceChildren(new TemplateInstance(root.template, state))
185
+ return state
186
+ }
187
+
188
+ // petite-vue (v-text, v-bind, {{ }})
189
+ import { createApp, reactive } from 'petite-vue'
190
+ DE.processor = (root, state) => (createApp(state).mount(root), reactive(state))
191
+
192
+ // Alpine.js (x-text, x-bind, @click)
193
+ import Alpine from 'alpinejs'
194
+ DE.processor = (root, state) => {
195
+ let r = Alpine.reactive(state)
196
+ Alpine.addScopeToNode(root, r)
197
+ Alpine.initTree(root)
198
+ return r
199
+ }
200
+ ```
201
+
202
+ Frameworks with their own component models (Lit, Vue, Stencil) are better used directly.
203
+
204
+
205
+ ## Progressive Enhancement
206
+
207
+ 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.
208
+
209
+ 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.
210
+
211
+ 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.
212
+
213
+
214
+ ## Why
215
+
216
+ 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).
217
+
218
+ 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.
219
+
220
+ This ~265-line polyfill is evidence that the W3C proposal is implementable and useful. Ship it natively.
221
+
222
+
223
+ ## Alternatives
224
+
225
+ <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>
226
+
227
+ [Detailed comparison →](./docs/alternatives.md)
228
+
229
+
230
+ ### License
231
+
232
+ [Krishnized](https://github.com/krishnized/license) ISC
233
+
234
+ <p align="center"><a href="https://github.com/krishnized/license">ॐ</a></p>
@@ -0,0 +1,264 @@
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
+ if (tpl && !root.firstChild) {
109
+ root.appendChild(tpl.content.cloneNode(true))
110
+ }
111
+
112
+ // inject style
113
+ if (styleText) {
114
+ if (shadowMode) {
115
+ if (adoptedSheet) {
116
+ root.adoptedStyleSheets = [adoptedSheet]
117
+ } else {
118
+ let s = document.createElement('style')
119
+ s.textContent = styleText
120
+ root.prepend(s)
121
+ }
122
+ } else {
123
+ // light DOM: @scope
124
+ let s = document.createElement('style')
125
+ s.textContent = scopeCSS(styleText, tag)
126
+ s.setAttribute('data-de', tag)
127
+ if (!injectedStyles.has(tag)) {
128
+ injectedStyles.add(tag)
129
+ document.head.appendChild(s)
130
+ }
131
+ }
132
+ }
133
+
134
+ // collect parts
135
+ this.part = {}
136
+ root.querySelectorAll('[part]').forEach(p =>
137
+ this.part[p.getAttribute('part')] = p
138
+ )
139
+
140
+ // expose original template on root for processors that need it
141
+ if (tpl) root.template = tpl
142
+
143
+ // build initial state from prop defaults + current attributes
144
+ let state = {}
145
+ for (let p of propDefs) {
146
+ let coerce = p.type ? types[p.type] : auto
147
+ let attrVal = this.getAttribute(p.name)
148
+ state[p.name] = attrVal != null ? coerce(attrVal) : this._de_props[p.name]
149
+ }
150
+
151
+ // run processor (per-definition > global)
152
+ let p = DefineElement.processor
153
+ if (p) {
154
+ let result = p(root, state)
155
+ this.state = result || state
156
+ } else {
157
+ this.state = state
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
+ let def = propMap[name]
177
+ if (!def) return
178
+
179
+ let coerce = def.type ? types[def.type] : auto
180
+ let val = coerce(newVal)
181
+ this._de_props[name] = val
182
+
183
+ if (this._de_init && this.state) {
184
+ this.state[name] = val
185
+ }
186
+
187
+ this.onattributechanged?.({ attributeName: name, oldValue: oldVal, newValue: newVal })
188
+ }
189
+ }
190
+
191
+ // define props as getter/setter on prototype
192
+ for (let p of propDefs) {
193
+ let coerce = p.type ? types[p.type] : auto
194
+ Object.defineProperty(C.prototype, p.name, {
195
+ get() { return this._de_props[p.name] },
196
+ set(v) {
197
+ let val = coerce(v)
198
+ this._de_props[p.name] = val
199
+ if (this._de_init && this.state) this.state[p.name] = val
200
+ let s = serialize(val, p.type)
201
+ if (s == null) this.removeAttribute(p.name)
202
+ else this.setAttribute(p.name, s)
203
+ },
204
+ enumerable: true,
205
+ configurable: true
206
+ })
207
+ }
208
+
209
+ // register (skip if already defined)
210
+ let name = ext || tag
211
+ if (!customElements.get(name))
212
+ customElements.define(name, C, ext ? { extends: tag } : undefined)
213
+
214
+ return C
215
+ }
216
+
217
+
218
+ /**
219
+ * Execute script text via <script> element injection (no eval/new Function).
220
+ * Wraps in IIFE with `this` bound to element instance.
221
+ */
222
+ function runScript(text, thisArg) {
223
+ let s = document.createElement('script')
224
+ let stripped = text.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(['"`])(?:(?!\1)[^\\]|\\.)*\1/g, '')
225
+ let isAsync = /\bawait\b/.test(stripped)
226
+ let wrapper = isAsync
227
+ ? `(async function(){${text}}).call(document.currentScript._de_this)`
228
+ : `(function(){${text}}).call(document.currentScript._de_this)`
229
+ s._de_this = thisArg
230
+ s.textContent = wrapper
231
+ document.head.appendChild(s)
232
+ s.remove()
233
+ }
234
+
235
+
236
+ /** CSS scoping for light DOM via CSS nesting. :host → tag. */
237
+ function scopeCSS(css, tag) {
238
+ css = css.replace(/:host\b(?:\(([^)]*)\))?/g, (_, sel) => sel ? `${tag}${sel}` : tag)
239
+ return `${tag} { ${css} }`
240
+ }
241
+
242
+
243
+ /**
244
+ * <define-element> — scans children for component definitions.
245
+ */
246
+ class DefineElement extends HTMLElement {
247
+ connectedCallback() {
248
+ queueMicrotask(() => this._init())
249
+ }
250
+
251
+ _init() {
252
+ let defs = [...this.children].filter(c => {
253
+ let ln = c.localName
254
+ return ln !== 'template' && ln !== 'script' && ln !== 'style'
255
+ })
256
+ for (let def of defs) def.remove()
257
+ this.remove()
258
+ for (let def of defs) define(def)
259
+ }
260
+ }
261
+
262
+ DefineElement.processor = null
263
+
264
+ customElements.define('define-element', DefineElement)
package/package.json CHANGED
@@ -1,30 +1,40 @@
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.0.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": ["define-element.js"],
9
+ "sideEffects": true,
6
10
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
11
+ "test": "node -r ./test/register.cjs test/test.js"
8
12
  },
9
13
  "repository": {
10
14
  "type": "git",
11
- "url": "git+https://github.com/spectjs/define-element.git"
15
+ "url": "git+https://github.com/dy/define-element.git"
12
16
  },
13
17
  "keywords": [
14
- "uce-template",
15
- "declarative-custom-element",
16
- "vue3",
17
- "reactive",
18
- "spect",
19
18
  "custom-element",
20
19
  "web-components",
21
- "element-props",
22
- "template-parts"
20
+ "declarative",
21
+ "html",
22
+ "no-build",
23
+ "template",
24
+ "shadow-dom"
23
25
  ],
24
26
  "author": "dmitry iv.",
25
27
  "license": "ISC",
26
28
  "bugs": {
27
- "url": "https://github.com/spectjs/define-element/issues"
29
+ "url": "https://github.com/dy/define-element/issues"
28
30
  },
29
- "homepage": "https://github.com/spectjs/define-element#readme"
31
+ "homepage": "https://github.com/dy/define-element#readme",
32
+ "devDependencies": {
33
+ "@github/template-parts": "^0.7.1",
34
+ "alpinejs": "^3.15.8",
35
+ "jsdom": "^28.1.0",
36
+ "petite-vue": "^0.4.1",
37
+ "tst": "^9.2.2",
38
+ "wait-please": "^3.1.0"
39
+ }
30
40
  }