browser-module-runtime 0.0.6 → 0.0.8
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.
- package/README.md +6 -6
- package/html.d.ts +11 -12
- package/package.json +1 -1
- package/src/html/index.js +36 -13
- package/src/html/props.js +52 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ A framework-neutral browser module runtime with a native ESM linker, independent
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npm install browser-module-runtime@0.0.
|
|
8
|
+
npm install browser-module-runtime@0.0.8
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Define, import and update modules
|
|
@@ -59,7 +59,7 @@ await handle.dispose();
|
|
|
59
59
|
### Public exports
|
|
60
60
|
|
|
61
61
|
- `browser-module-runtime`: runtime, build pipeline, cache and resolver utilities
|
|
62
|
-
- `browser-module-runtime/html`: `
|
|
62
|
+
- `browser-module-runtime/html`: `createHTMLRuntime` and generic `<runtime-render>` integration
|
|
63
63
|
- `browser-module-runtime/packages`: package policy utilities
|
|
64
64
|
- `browser-module-runtime/cache`: compile-cache implementations
|
|
65
65
|
|
|
@@ -68,12 +68,12 @@ await handle.dispose();
|
|
|
68
68
|
### Scoped HTML (development branch)
|
|
69
69
|
|
|
70
70
|
```js
|
|
71
|
-
import {
|
|
72
|
-
|
|
71
|
+
import {createHTMLRuntime} from 'browser-module-runtime/html';
|
|
72
|
+
createHTMLRuntime(runtime,{scope:'app',root:document}); // No initial scan or observer
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
```html
|
|
76
|
-
<runtime-render scope="app" module="/Counter.jsx"
|
|
76
|
+
<runtime-render scope="app" module="/Counter.jsx" prop:initial={5}></runtime-render>
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
The custom element waits for the runtime and module automatically. For already parsed inert script declarations use an explicit `await html.
|
|
79
|
+
Individual `prop:*` attributes support typed brace-wrapped safe literals, raw strings, and boolean presence; they override bulk `props` and react to attribute changes. The custom element waits for the runtime and module automatically. For already parsed inert script declarations use an explicit `await html.process()`; choose `observe:true` only when discovering uncontrolled future script insertions is necessary. Use `html.appendAndRegister(script)` for controlled injection. See `docs/SCOPED_HTML.md` in the workspace. These behaviors are **not** part of previously published npm `0.0.4` without a new release.
|
package/html.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type {Runtime} from './index.js';
|
|
2
2
|
import type {WebComponentOptions} from './web-components.js';
|
|
3
3
|
export interface HTMLController {
|
|
4
|
-
/** Scope name; `name` is a backwards compatible alias. */
|
|
5
|
-
readonly name:string;
|
|
6
4
|
readonly scope:string;
|
|
7
5
|
readonly root:Document|Element;
|
|
8
6
|
readonly appendTarget:Document|Element;
|
|
@@ -14,18 +12,14 @@ export interface HTMLController {
|
|
|
14
12
|
appendScript(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
|
|
15
13
|
/** Controlled append and register without a subtree scan. */
|
|
16
14
|
appendAndRegister(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
setRoot(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
|
|
22
|
-
moveTo(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
|
|
15
|
+
/** Preferred explicit, repeatable processing of existing source declarations. */
|
|
16
|
+
process():Promise<void>;
|
|
17
|
+
setRoot(root:Document|Element,options?:{processExisting?:boolean}):Promise<Document|Element>;
|
|
18
|
+
moveTo(root:Document|Element,options?:{processExisting?:boolean}):Promise<Document|Element>;
|
|
23
19
|
disconnect():Promise<void>;
|
|
24
20
|
}
|
|
25
|
-
export interface
|
|
21
|
+
export interface HTMLRuntimeOptions {
|
|
26
22
|
scope?:string;
|
|
27
|
-
/** Backward compatible alias for scope. */
|
|
28
|
-
name?:string;
|
|
29
23
|
root?:Document|Element;
|
|
30
24
|
/** Whether an element without an explicit scope can be owned by this controller. */
|
|
31
25
|
acceptUnscoped?:boolean;
|
|
@@ -33,4 +27,9 @@ export interface RegisterHTMLOptions {
|
|
|
33
27
|
observe?:boolean;
|
|
34
28
|
}
|
|
35
29
|
/** Does not scan automatically. <runtime-render> itself is an autonomous custom element. */
|
|
36
|
-
export declare function
|
|
30
|
+
export declare function createHTMLRuntime(runtime:Runtime,options?:HTMLRuntimeOptions):HTMLController;
|
|
31
|
+
|
|
32
|
+
/** High-level one-shot HTML discovery. Registers the controller and processes existing inert scripts.
|
|
33
|
+
* Returns the controller for subsequent root, observer and lifecycle operations.
|
|
34
|
+
*/
|
|
35
|
+
export declare function processHTML(runtime:Runtime,options?:HTMLRuntimeOptions):Promise<HTMLController>;
|
package/package.json
CHANGED
package/src/html/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Scoped HTML integration: autonomous rendering, explicit discovery, opt-in observation. */
|
|
2
2
|
import {registerWebComponent} from '../web-components/index.js';
|
|
3
3
|
import {getScopeRegistry,requestedScope,containsRoot} from './scopes.js';
|
|
4
|
-
import {parseObjectProps as parseObject} from './props.js';
|
|
4
|
+
import {parseObjectProps as parseObject,readRenderProps,renderPropFingerprint} from './props.js';
|
|
5
5
|
|
|
6
6
|
function emit(el,type,detail){const win=el.ownerDocument.defaultView;el.dispatchEvent(new win.CustomEvent(type,{detail,bubbles:true,composed:true}));}
|
|
7
7
|
function state(el,value){el.setAttribute('data-runtime-state',value);}
|
|
@@ -17,15 +17,23 @@ function defineAutonomousElements(doc){
|
|
|
17
17
|
if(!win.customElements.get('runtime-render')){
|
|
18
18
|
class RuntimeRender extends win.HTMLElement {
|
|
19
19
|
static get observedAttributes(){return ['scope','runtime','data-runtime','module','component','props'];}
|
|
20
|
-
constructor(){super();this._owner=null;this._handle=null;this._abort=null;this._generation=0;this._unsubscribe=null;this._pending=null;this._lastProps='';this._reported=null;}
|
|
20
|
+
constructor(){super();this._owner=null;this._handle=null;this._abort=null;this._generation=0;this._unsubscribe=null;this._pending=null;this._lastProps='';this._reported=null;this._propObserver=null;}
|
|
21
21
|
connectedCallback(){
|
|
22
22
|
if(!this._unsubscribe)this._unsubscribe=getScopeRegistry(doc).subscribe(()=>this._reconcile());
|
|
23
|
+
// Custom elements cannot wildcard observedAttributes; observe only
|
|
24
|
+
// prop:* changes, without scanning the document or polling.
|
|
25
|
+
if(!this._propObserver&&typeof win.MutationObserver==='function'){
|
|
26
|
+
this._propObserver=new win.MutationObserver(records=>{
|
|
27
|
+
if(records.some(r=>r.attributeName?.toLowerCase().startsWith('prop:')))this._reconcile();
|
|
28
|
+
});
|
|
29
|
+
this._propObserver.observe(this,{attributes:true});
|
|
30
|
+
}
|
|
23
31
|
this._reconcile();
|
|
24
32
|
}
|
|
25
33
|
disconnectedCallback(){
|
|
26
34
|
const generation=++this._generation;
|
|
27
35
|
if(this._pending&&!this._handle){this._key=null;this._abort?.abort();}
|
|
28
|
-
queueMicrotask(()=>{if(!this.isConnected&&generation===this._generation){this._unsubscribe?.();this._unsubscribe=null;this._key=null;this._pending=null;this._release().catch(e=>this._report(e));state(this,'disposed');}});
|
|
36
|
+
queueMicrotask(()=>{if(!this.isConnected&&generation===this._generation){this._unsubscribe?.();this._unsubscribe=null;this._propObserver?.disconnect();this._propObserver=null;this._key=null;this._pending=null;this._release().catch(e=>this._report(e));state(this,'disposed');}});
|
|
29
37
|
}
|
|
30
38
|
attributeChangedCallback(name,oldValue,newValue){if(oldValue!==newValue&&this.isConnected)this._reconcile();}
|
|
31
39
|
_report(error){state(this,'error');if(this._reported!==error){this._reported=error;emit(this,'runtime-error',{error,module:this.getAttribute('module'),scope:requestedScope(this)});}}
|
|
@@ -39,7 +47,7 @@ function defineAutonomousElements(doc){
|
|
|
39
47
|
const match=getScopeRegistry(doc).resolve(this);
|
|
40
48
|
const id=this.getAttribute('module')||'';
|
|
41
49
|
const exportName=this.getAttribute('component')||'default';
|
|
42
|
-
const propsText=this.getAttribute('props')||'{}';
|
|
50
|
+
const propsText=(this.getAttribute('props')||'{}')+'|'+renderPropFingerprint(this);
|
|
43
51
|
const key=match.controller&&`${match.controller.id}|${id}|${exportName}`;
|
|
44
52
|
// Reconcile only on meaningful changes, not unrelated registry notifications.
|
|
45
53
|
if(match.error&&this._lastErrorMessage===match.error.message)return;
|
|
@@ -55,7 +63,7 @@ function defineAutonomousElements(doc){
|
|
|
55
63
|
if(match.error){this._report(match.error);if(previous)previous.dispose().catch(e=>this._report(e));return;}
|
|
56
64
|
if(!controller||!id){state(this,'pending');if(previous)previous.dispose().catch(e=>this._report(e));return;}
|
|
57
65
|
let props;
|
|
58
|
-
try{props=
|
|
66
|
+
try{props=readRenderProps(this);}catch(e){this._report(e);if(previous)previous.dispose().catch(err=>this._report(err));return;}
|
|
59
67
|
this._reported=null;
|
|
60
68
|
if(previous&&this._previousKey===key){
|
|
61
69
|
// Update props in place where supported, otherwise remount.
|
|
@@ -110,14 +118,14 @@ function defineAutonomousElements(doc){
|
|
|
110
118
|
}
|
|
111
119
|
let sequence=0;
|
|
112
120
|
/** Register a scoped HTML controller. Never scans automatically. */
|
|
113
|
-
export function
|
|
114
|
-
const {
|
|
115
|
-
if(!root)throw new TypeError('
|
|
121
|
+
export function createHTMLRuntime(runtime,options={}){
|
|
122
|
+
const {scope="default",root=globalThis.document,observe=false,acceptUnscoped=true}=options;
|
|
123
|
+
if(!root)throw new TypeError('createHTMLRuntime requires a DOM root');
|
|
116
124
|
const doc=rootDocument(root);
|
|
117
|
-
if(!doc)throw new TypeError('
|
|
125
|
+
if(!doc)throw new TypeError('createHTMLRuntime root must belong to a document');
|
|
118
126
|
const registry=getScopeRegistry(doc);
|
|
119
127
|
defineAutonomousElements(doc);
|
|
120
|
-
const resolvedScope=scope
|
|
128
|
+
const resolvedScope=scope;
|
|
121
129
|
let alive=true,observer=null,currentRoot=root,appendTarget=root,pending=Promise.resolve();
|
|
122
130
|
const controller={id:++sequence,scope:resolvedScope,get root(){return currentRoot;},acceptUnscoped,runtime,get alive(){return alive;},defineElement};
|
|
123
131
|
function defineElement(tag,config){return registerWebComponent(runtime,{...config,tag,document:doc});}
|
|
@@ -166,11 +174,11 @@ export function registerHTML(runtime,options={}){
|
|
|
166
174
|
await registerScript(script);
|
|
167
175
|
return script;
|
|
168
176
|
}
|
|
169
|
-
async function setRoot(nextRoot,{
|
|
177
|
+
async function setRoot(nextRoot,{processExisting=false}={}){
|
|
170
178
|
if(!alive)throw new Error('HTML controller is disconnected');
|
|
171
179
|
if(rootDocument(nextRoot)!==doc)throw new Error('HTML root must remain in the same document');
|
|
172
180
|
if(observer)observer.disconnect();currentRoot=nextRoot;registry.notify();enableObservation();
|
|
173
|
-
if(
|
|
181
|
+
if(processExisting)await scheduleScan();
|
|
174
182
|
return nextRoot;
|
|
175
183
|
}
|
|
176
184
|
async function moveTo(nextRoot,opts){const moved=await setRoot(nextRoot,opts);appendTarget=nextRoot;return moved;}
|
|
@@ -181,5 +189,20 @@ export function registerHTML(runtime,options={}){
|
|
|
181
189
|
}
|
|
182
190
|
registry.add(controller);
|
|
183
191
|
enableObservation();
|
|
184
|
-
return {
|
|
192
|
+
return {scope:resolvedScope,runtime,defineElement,registerScript,appendScript,appendAndRegister,process:scheduleScan,setRoot,moveTo,disconnect,get root(){return currentRoot;},get appendTarget(){return appendTarget;}};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Create a scoped HTML controller and process existing source declarations once.
|
|
196
|
+
* Unlike createHTMLRuntime(), this convenience function explicitly performs discovery.
|
|
197
|
+
* Returns the controller so callers can use appendAndRegister(), setRoot() or disconnect().
|
|
198
|
+
*/
|
|
199
|
+
export async function processHTML(runtime,options={}){
|
|
200
|
+
const controller=createHTMLRuntime(runtime,options);
|
|
201
|
+
try{
|
|
202
|
+
await controller.process();
|
|
203
|
+
return controller;
|
|
204
|
+
}catch(error){
|
|
205
|
+
await controller.disconnect();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
185
208
|
}
|
package/src/html/props.js
CHANGED
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
* strings, trailing commas, nested arrays/objects). No expressions or code.
|
|
4
4
|
*/
|
|
5
5
|
export function parseObjectProps(value, context = 'props') {
|
|
6
|
-
|
|
6
|
+
return parseValue(value, context, true);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Parse the safe literal subset for individual `prop:name={value}` attributes. */
|
|
10
|
+
export function parseDataLiteral(value, context = 'prop') {
|
|
11
|
+
return parseValue(value, context, false);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseValue(value, context, objectOnly) {
|
|
15
|
+
if (value == null || value.trim() === '') {
|
|
16
|
+
if (objectOnly) return {};
|
|
17
|
+
throw new SyntaxError(`${context}: Empty data literal`);
|
|
18
|
+
}
|
|
7
19
|
if (value.length > 100_000) throw new SyntaxError(`${context}: props literal is too large`);
|
|
8
20
|
let offset = 0;
|
|
9
21
|
const error = (message) => { throw new SyntaxError(`${context}: ${message} at character ${offset + 1}`); };
|
|
@@ -100,7 +112,7 @@ export function parseObjectProps(value, context = 'props') {
|
|
|
100
112
|
error('Unclosed array');
|
|
101
113
|
}
|
|
102
114
|
space();
|
|
103
|
-
if (peek() !== '{') {
|
|
115
|
+
if (objectOnly && peek() !== '{') {
|
|
104
116
|
// Preserve the old object-only error wording for callers relying on it.
|
|
105
117
|
try { const json = JSON.parse(value); if (json === null || typeof json !== 'object' || Array.isArray(json)) throw new Error(`${context} must be a JSON object or safe object literal`); }
|
|
106
118
|
catch (e) { if (e.message.includes('must be a JSON object')) throw e; }
|
|
@@ -110,3 +122,41 @@ export function parseObjectProps(value, context = 'props') {
|
|
|
110
122
|
if (offset !== value.length) error('Unexpected trailing content');
|
|
111
123
|
return result;
|
|
112
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Declarative per-prop attributes override the bulk props object. Mirrors the
|
|
128
|
+
* legacy Solid semantics without evaluating expressions from HTML markup.
|
|
129
|
+
*
|
|
130
|
+
* prop:initial={5} => 5
|
|
131
|
+
* prop:enabled={true} => true
|
|
132
|
+
* prop:label="Hello" => "Hello"
|
|
133
|
+
* prop:options={{x:1}} => {x:1}
|
|
134
|
+
* prop:busy => true
|
|
135
|
+
*/
|
|
136
|
+
export function readRenderProps(element) {
|
|
137
|
+
const props = parseObjectProps(element.getAttribute('props'), 'runtime-render props');
|
|
138
|
+
for (const attribute of Array.from(element.attributes ?? [])) {
|
|
139
|
+
const name = String(attribute.name).toLowerCase();
|
|
140
|
+
if (!name.startsWith('prop:')) continue;
|
|
141
|
+
const key = name.slice(5).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
142
|
+
if (!key) continue;
|
|
143
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
|
|
144
|
+
throw new SyntaxError(`runtime-render prop:${key}: Forbidden attribute key`);
|
|
145
|
+
}
|
|
146
|
+
const raw = String(attribute.value);
|
|
147
|
+
const trimmed = raw.trim();
|
|
148
|
+
const parsed = raw === '' ? true : trimmed.startsWith('{') && trimmed.endsWith('}')
|
|
149
|
+
? parseDataLiteral(trimmed.slice(1, -1), `runtime-render prop:${key}`)
|
|
150
|
+
: raw;
|
|
151
|
+
props[key] = parsed;
|
|
152
|
+
}
|
|
153
|
+
return props;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Immutable fingerprint of dynamic `prop:*` attributes for mount reconciliation. */
|
|
157
|
+
export function renderPropFingerprint(element) {
|
|
158
|
+
return JSON.stringify(Array.from(element.attributes ?? [])
|
|
159
|
+
.filter(attr => String(attr.name).toLowerCase().startsWith('prop:'))
|
|
160
|
+
.map(attr => [String(attr.name).toLowerCase(), String(attr.value)])
|
|
161
|
+
.sort(([a], [b]) => a.localeCompare(b)));
|
|
162
|
+
}
|