browser-module-runtime 0.0.5 → 0.0.7

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 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.1
8
+ npm install browser-module-runtime@0.0.7
9
9
  ```
10
10
 
11
11
  ## Define, import and update modules
@@ -73,7 +73,7 @@ registerHTML(runtime,{scope:'app',root:document}); // No initial scan or observe
73
73
  ```
74
74
 
75
75
  ```html
76
- <runtime-render scope="app" module="/Counter.jsx" props='{"initial":5}'></runtime-render>
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.registerExisting()`; 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.
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
@@ -14,12 +14,14 @@ export interface HTMLController {
14
14
  appendScript(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
15
15
  /** Controlled append and register without a subtree scan. */
16
16
  appendAndRegister(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
17
- /** Explicit one-time discovery of scripts under root. */
17
+ /** Preferred explicit, repeatable processing of existing source declarations. */
18
+ process():Promise<void>;
19
+ /** @deprecated Use process(). */
18
20
  registerExisting():Promise<void>;
19
- /** Alias for registerExisting. */
21
+ /** @deprecated Use process(). */
20
22
  scan():Promise<void>;
21
- setRoot(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
22
- moveTo(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
23
+ setRoot(root:Document|Element,options?:{processExisting?:boolean;registerExisting?:boolean}):Promise<Document|Element>;
24
+ moveTo(root:Document|Element,options?:{processExisting?:boolean;registerExisting?:boolean}):Promise<Document|Element>;
23
25
  disconnect():Promise<void>;
24
26
  }
25
27
  export interface RegisterHTMLOptions {
@@ -34,3 +36,8 @@ export interface RegisterHTMLOptions {
34
36
  }
35
37
  /** Does not scan automatically. <runtime-render> itself is an autonomous custom element. */
36
38
  export declare function registerHTML(runtime:Runtime,options?:RegisterHTMLOptions):HTMLController;
39
+
40
+ /** High-level one-shot HTML discovery. Registers the controller and processes existing inert scripts.
41
+ * Returns the controller for subsequent root, observer and lifecycle operations.
42
+ */
43
+ export declare function processHTML(runtime:Runtime,options?:RegisterHTMLOptions):Promise<HTMLController>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-module-runtime",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/html/index.js CHANGED
@@ -1,14 +1,8 @@
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,readRenderProps,renderPropFingerprint} from './props.js';
4
5
 
5
- function parseObject(value,context){
6
- if(value==null||value==='')return {};
7
- let result;
8
- try{result=JSON.parse(value);}catch(e){throw new Error(`Invalid JSON in ${context}: ${e.message}`);}
9
- if(!result||typeof result!=='object'||Array.isArray(result))throw new Error(`${context} must be a JSON object`);
10
- return result;
11
- }
12
6
  function emit(el,type,detail){const win=el.ownerDocument.defaultView;el.dispatchEvent(new win.CustomEvent(type,{detail,bubbles:true,composed:true}));}
13
7
  function state(el,value){el.setAttribute('data-runtime-state',value);}
14
8
  function rootDocument(root){return root?.nodeType===9?root:root?.ownerDocument;}
@@ -23,15 +17,23 @@ function defineAutonomousElements(doc){
23
17
  if(!win.customElements.get('runtime-render')){
24
18
  class RuntimeRender extends win.HTMLElement {
25
19
  static get observedAttributes(){return ['scope','runtime','data-runtime','module','component','props'];}
26
- 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;}
27
21
  connectedCallback(){
28
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
+ }
29
31
  this._reconcile();
30
32
  }
31
33
  disconnectedCallback(){
32
34
  const generation=++this._generation;
33
35
  if(this._pending&&!this._handle){this._key=null;this._abort?.abort();}
34
- 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');}});
35
37
  }
36
38
  attributeChangedCallback(name,oldValue,newValue){if(oldValue!==newValue&&this.isConnected)this._reconcile();}
37
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)});}}
@@ -45,7 +47,7 @@ function defineAutonomousElements(doc){
45
47
  const match=getScopeRegistry(doc).resolve(this);
46
48
  const id=this.getAttribute('module')||'';
47
49
  const exportName=this.getAttribute('component')||'default';
48
- const propsText=this.getAttribute('props')||'{}';
50
+ const propsText=(this.getAttribute('props')||'{}')+'|'+renderPropFingerprint(this);
49
51
  const key=match.controller&&`${match.controller.id}|${id}|${exportName}`;
50
52
  // Reconcile only on meaningful changes, not unrelated registry notifications.
51
53
  if(match.error&&this._lastErrorMessage===match.error.message)return;
@@ -61,7 +63,7 @@ function defineAutonomousElements(doc){
61
63
  if(match.error){this._report(match.error);if(previous)previous.dispose().catch(e=>this._report(e));return;}
62
64
  if(!controller||!id){state(this,'pending');if(previous)previous.dispose().catch(e=>this._report(e));return;}
63
65
  let props;
64
- try{props=parseObject(propsText,'runtime-render props');}catch(e){this._report(e);if(previous)previous.dispose().catch(err=>this._report(err));return;}
66
+ try{props=readRenderProps(this);}catch(e){this._report(e);if(previous)previous.dispose().catch(err=>this._report(err));return;}
65
67
  this._reported=null;
66
68
  if(previous&&this._previousKey===key){
67
69
  // Update props in place where supported, otherwise remount.
@@ -172,11 +174,13 @@ export function registerHTML(runtime,options={}){
172
174
  await registerScript(script);
173
175
  return script;
174
176
  }
175
- async function setRoot(nextRoot,{registerExisting=false}={}){
177
+ async function setRoot(nextRoot,{processExisting,registerExisting=false}={}){
178
+ // processExisting is preferred; registerExisting remains a legacy alias.
179
+ const shouldProcess=processExisting??registerExisting;
176
180
  if(!alive)throw new Error('HTML controller is disconnected');
177
181
  if(rootDocument(nextRoot)!==doc)throw new Error('HTML root must remain in the same document');
178
182
  if(observer)observer.disconnect();currentRoot=nextRoot;registry.notify();enableObservation();
179
- if(registerExisting)await scheduleScan();
183
+ if(shouldProcess)await scheduleScan();
180
184
  return nextRoot;
181
185
  }
182
186
  async function moveTo(nextRoot,opts){const moved=await setRoot(nextRoot,opts);appendTarget=nextRoot;return moved;}
@@ -187,5 +191,20 @@ export function registerHTML(runtime,options={}){
187
191
  }
188
192
  registry.add(controller);
189
193
  enableObservation();
190
- return {name:resolvedScope,scope:resolvedScope,runtime,defineElement,registerScript,appendScript,appendAndRegister,registerExisting:scheduleScan,scan:scheduleScan,setRoot,moveTo,disconnect,get root(){return currentRoot;},get appendTarget(){return appendTarget;}};
194
+ return {name:resolvedScope,scope:resolvedScope,runtime,defineElement,registerScript,appendScript,appendAndRegister,process:scheduleScan,registerExisting:scheduleScan,scan:scheduleScan,setRoot,moveTo,disconnect,get root(){return currentRoot;},get appendTarget(){return appendTarget;}};
195
+ }
196
+
197
+ /** Create a scoped HTML controller and process existing source declarations once.
198
+ * Unlike registerHTML(), this convenience function explicitly performs discovery.
199
+ * Returns the controller so callers can use appendAndRegister(), setRoot() or disconnect().
200
+ */
201
+ export async function processHTML(runtime,options={}){
202
+ const controller=registerHTML(runtime,options);
203
+ try{
204
+ await controller.process();
205
+ return controller;
206
+ }catch(error){
207
+ await controller.disconnect();
208
+ throw error;
209
+ }
191
210
  }
@@ -0,0 +1,162 @@
1
+ /** Parse declarative object props without eval or executing JavaScript.
2
+ * Accepts JSON and a safe JS-literal subset (unquoted keys, single-quoted
3
+ * strings, trailing commas, nested arrays/objects). No expressions or code.
4
+ */
5
+ export function parseObjectProps(value, context = 'props') {
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
+ }
19
+ if (value.length > 100_000) throw new SyntaxError(`${context}: props literal is too large`);
20
+ let offset = 0;
21
+ const error = (message) => { throw new SyntaxError(`${context}: ${message} at character ${offset + 1}`); };
22
+ const peek = () => value[offset];
23
+ const space = () => { while (offset < value.length && /\s/.test(peek())) offset++; };
24
+ const forbidKey = (key) => {
25
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') error(`Forbidden object key ${key}`);
26
+ return key;
27
+ };
28
+ function str() {
29
+ const quote = value[offset++];
30
+ let result = '';
31
+ while (offset < value.length) {
32
+ let c = value[offset++];
33
+ if (c === quote) return result;
34
+ if (c === '\n' || c === '\r') error('Unterminated string');
35
+ if (c !== '\\') { result += c; continue; }
36
+ if (offset >= value.length) error('Unterminated string escape');
37
+ c = value[offset++];
38
+ const escapes = {n:'\n', r:'\r', t:'\t', b:'\b', f:'\f', v:'\v', '0':'\0'};
39
+ if (Object.hasOwn(escapes, c)) { result += escapes[c]; continue; }
40
+ if (c === 'u' || c === 'x') {
41
+ const count = c === 'u' ? 4 : 2;
42
+ const hex = value.slice(offset, offset + count);
43
+ if (!new RegExp(`^[a-fA-F0-9]{${count}}$`).test(hex)) error('Invalid hex string escape');
44
+ result += String.fromCharCode(parseInt(hex, 16)); offset += count; continue;
45
+ }
46
+ if ('\\\'"/'.includes(c)) { result += c; continue; }
47
+ error(`Unsupported string escape \\${c}`);
48
+ }
49
+ error('Unterminated string');
50
+ }
51
+ function readValue(depth = 0) {
52
+ if (depth > 40) error('Props nesting is too deep');
53
+ space();
54
+ const c = peek();
55
+ if (c === '{') return object(depth + 1);
56
+ if (c === '[') return array(depth + 1);
57
+ if (c === '"' || c === "'") return str();
58
+ const next = value.slice(offset);
59
+ const number = next.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
60
+ if (number) {
61
+ offset += number[0].length;
62
+ const n = Number(number[0]);
63
+ if (!Number.isFinite(n)) error('Numbers must be finite');
64
+ return n;
65
+ }
66
+ for (const [word, literal] of [['true',true],['false',false],['null',null]]) {
67
+ if (next.startsWith(word) && !/[\w$]/.test(next[word.length] ?? '')) {
68
+ offset += word.length; return literal;
69
+ }
70
+ }
71
+ error('Expected a literal value (no expressions or functions)');
72
+ }
73
+ function key() {
74
+ space();
75
+ if (peek() === '"' || peek() === "'") return forbidKey(str());
76
+ const token = value.slice(offset).match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
77
+ if (!token) error('Expected an object key');
78
+ offset += token[0].length;
79
+ return forbidKey(token[0]);
80
+ }
81
+ function object(depth) {
82
+ offset++; space();
83
+ const result = {};
84
+ if (peek() === '}') { offset++; return result; }
85
+ while (offset < value.length) {
86
+ const prop = key();space();
87
+ if (value[offset++] !== ':') error('Expected : after an object key');
88
+ const val = readValue(depth);
89
+ if (Object.hasOwn(result, prop)) error(`Duplicate object key ${prop}`);
90
+ result[prop] = val;
91
+ space();
92
+ const delimiter = value[offset++];
93
+ if (delimiter === '}') return result;
94
+ if (delimiter !== ',') error('Expected , or }');
95
+ space();
96
+ if (peek() === '}') { offset++; return result; }
97
+ }
98
+ error('Unclosed object');
99
+ }
100
+ function array(depth) {
101
+ offset++; space();
102
+ const result = [];
103
+ if (peek() === ']') { offset++; return result; }
104
+ while (offset < value.length) {
105
+ result.push(readValue(depth));space();
106
+ const delimiter = value[offset++];
107
+ if (delimiter === ']') return result;
108
+ if (delimiter !== ',') error('Expected , or ]');
109
+ space();
110
+ if (peek() === ']') { offset++; return result; }
111
+ }
112
+ error('Unclosed array');
113
+ }
114
+ space();
115
+ if (objectOnly && peek() !== '{') {
116
+ // Preserve the old object-only error wording for callers relying on it.
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`); }
118
+ catch (e) { if (e.message.includes('must be a JSON object')) throw e; }
119
+ error('Props must be a JSON object or safe object literal');
120
+ }
121
+ const result = readValue();space();
122
+ if (offset !== value.length) error('Unexpected trailing content');
123
+ return result;
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
+ }