solid-tag-runtime 0.0.2 → 0.0.3

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/src/html.js CHANGED
@@ -9,7 +9,11 @@ const DEFAULT_SELECTOR = [
9
9
  'script[type="text/solid-module"]',
10
10
  ].join(",");
11
11
 
12
+ const RUNTIME_SCOPE_ATTRIBUTE = "data-solid-runtime";
13
+ const elementOwners = new WeakMap();
14
+ const defaultControllers = new WeakMap();
12
15
  let anonymousSequence = 0;
16
+ let controllerSequence = 0;
13
17
 
14
18
  export class HTMLModuleError extends SolidTagRuntimeError {
15
19
  constructor(message, options = {}) {
@@ -21,165 +25,522 @@ export class HTMLModuleError extends SolidTagRuntimeError {
21
25
  }
22
26
  }
23
27
 
24
- /**
25
- * Discover runtime module scripts under a Document/Element-like root, define
26
- * all of them first, then optionally execute scripts marked with `entry`.
27
- */
28
- export async function registerHTML(runtime, options = {}) {
29
- assertRuntime(runtime);
30
-
31
- const root = getRoot(options, "registerHTML");
32
- const elements = Array.from(root.querySelectorAll(options.selector ?? DEFAULT_SELECTOR));
33
-
34
- return registerElements(runtime, elements, {
35
- ...options,
36
- root,
37
- });
28
+ export class HTMLModuleOwnershipError extends HTMLModuleError {
29
+ constructor(message, options = {}) {
30
+ super(message, options);
31
+ this.name = "HTMLModuleOwnershipError";
32
+ this.ownerScope = options.ownerScope;
33
+ this.requestedScope = options.requestedScope;
34
+ }
38
35
  }
39
36
 
40
37
  /**
41
- * Observe a DOM root for newly-added runtime module scripts.
42
- *
43
- * Existing scripts are registered by default. Every discovered mutation batch
44
- * preserves the same invariant as registerHTML(): all modules in the batch are
45
- * defined before any entry module from that batch executes.
38
+ * Create a persistent DOM/HTML adapter for one solid-tag-runtime instance.
46
39
  *
47
- * v0.0.2 intentionally observes additions only. Mutating or removing a script
48
- * that was already registered does not update/remove its runtime module.
40
+ * The controller owns DOM-to-module relationships. All ingestion paths
41
+ * (scan, observer, registerElement, append, addModule) converge on the same
42
+ * module-level WeakMap so an element can only be claimed once.
49
43
  */
50
- export async function observeHTML(runtime, options = {}) {
44
+ export function createHTMLRuntime(runtime, options = {}) {
51
45
  assertRuntime(runtime);
52
46
 
53
- const root = getRoot(options, "observeHTML");
54
- const MutationObserverImpl = options.MutationObserver ?? globalThis.MutationObserver;
47
+ const controller = {
48
+ id: `solid-tag-html-${++controllerSequence}`,
49
+ runtime,
50
+ scope: normalizeScope(options.scope),
51
+ root: options.root,
52
+ selector: options.selector ?? DEFAULT_SELECTOR,
53
+ acceptUnscoped: options.acceptUnscoped !== false,
54
+ executeEntries: options.executeEntries !== false,
55
+ fetch: options.fetch,
56
+ baseUrl: options.baseUrl,
57
+ MutationObserver: options.MutationObserver,
58
+ subtree: options.subtree !== false,
59
+ createElement: options.createElement,
60
+ appendTo: options.appendTo,
61
+ bindings: new Map(),
62
+ observer: undefined,
63
+ observerRoot: undefined,
64
+ ignoredExisting: new WeakSet(),
65
+ queue: Promise.resolve(),
66
+ lastError: undefined,
67
+ connected: false,
68
+ initial: emptyRegistrationResult(),
69
+ };
55
70
 
56
- if (typeof MutationObserverImpl !== "function") {
57
- throw new TypeError(
58
- "observeHTML() requires MutationObserver support or a MutationObserver option.",
71
+ const api = {
72
+ get runtime() {
73
+ return runtime;
74
+ },
75
+ get scope() {
76
+ return controller.scope;
77
+ },
78
+ get connected() {
79
+ return controller.connected;
80
+ },
81
+ get initial() {
82
+ return controller.initial;
83
+ },
84
+ register,
85
+ observe,
86
+ flush,
87
+ disconnect,
88
+ defineElement,
89
+ registerElement,
90
+ append,
91
+ addModule,
92
+ owns,
93
+ getModuleId,
94
+ getElement,
95
+ };
96
+
97
+ controller.api = api;
98
+ return api;
99
+
100
+ /** One-shot scan of matching scripts. */
101
+ async function register(callOptions = {}) {
102
+ const root = getRoot(mergeOptions(controller, callOptions), "HTML runtime register");
103
+ const selector = callOptions.selector ?? controller.selector;
104
+ const elements = Array.from(root.querySelectorAll(selector)).filter(element =>
105
+ acceptsDiscoveredElement(controller, element, callOptions),
59
106
  );
107
+
108
+ return processElements(controller, elements, {
109
+ ...callOptions,
110
+ root,
111
+ origin: callOptions.origin ?? "initial-scan",
112
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
113
+ });
60
114
  }
61
115
 
62
- const selector = options.selector ?? DEFAULT_SELECTOR;
63
- const seen = new WeakSet();
64
- let connected = true;
65
- let lastError;
66
- let queue = Promise.resolve();
116
+ /**
117
+ * Start MutationObserver-backed discovery. Existing matching scripts are
118
+ * registered by default. The controller itself is returned for chaining.
119
+ */
120
+ async function observe(callOptions = {}) {
121
+ if (controller.connected) return api;
122
+
123
+ const merged = mergeOptions(controller, callOptions);
124
+ const root = getRoot(merged, "HTML runtime observe");
125
+ const MutationObserverImpl = callOptions.MutationObserver ?? controller.MutationObserver ?? globalThis.MutationObserver;
126
+
127
+ if (typeof MutationObserverImpl !== "function") {
128
+ throw new TypeError(
129
+ "observe() requires MutationObserver support or a MutationObserver option.",
130
+ );
131
+ }
132
+
133
+ controller.observerRoot = root;
134
+ controller.connected = true;
135
+ controller.lastError = undefined;
136
+ controller.ignoredExisting = new WeakSet();
67
137
 
68
- if (options.registerExisting === false) {
69
- for (const element of Array.from(root.querySelectorAll(selector))) {
70
- seen.add(element);
138
+ if (callOptions.registerExisting === false) {
139
+ for (const element of Array.from(root.querySelectorAll(callOptions.selector ?? controller.selector))) {
140
+ if (acceptsDiscoveredElement(controller, element, callOptions)) {
141
+ controller.ignoredExisting.add(element);
142
+ }
143
+ }
144
+ controller.initial = emptyRegistrationResult();
145
+ }
146
+
147
+ const observer = new MutationObserverImpl(() => {
148
+ void enqueueObserverScan(controller, callOptions).catch(() => {});
149
+ });
150
+
151
+ controller.observer = observer;
152
+ observer.observe(root, {
153
+ childList: true,
154
+ subtree: callOptions.subtree ?? controller.subtree,
155
+ });
156
+
157
+ try {
158
+ if (callOptions.registerExisting !== false) {
159
+ controller.initial = await enqueueObserverScan(controller, {
160
+ ...callOptions,
161
+ origin: "observer-initial",
162
+ });
163
+ }
164
+ } catch (error) {
165
+ disconnect();
166
+ throw error;
71
167
  }
168
+
169
+ return api;
72
170
  }
73
171
 
74
- const scanAndRegister = async () => {
75
- const elements = Array.from(root.querySelectorAll(selector)).filter(
76
- element => !seen.has(element),
77
- );
172
+ /** Wait for observer work, then synchronously scan for newly discoverable scripts. */
173
+ async function flush(callOptions = {}) {
174
+ await controller.queue;
78
175
 
79
- if (elements.length === 0) return emptyRegistrationResult();
176
+ if (controller.lastError) {
177
+ const error = controller.lastError;
178
+ controller.lastError = undefined;
179
+ throw error;
180
+ }
80
181
 
81
- return registerElements(
82
- runtime,
83
- elements,
84
- {
85
- ...options,
86
- root,
87
- },
88
- element => seen.add(element),
89
- );
90
- };
182
+ if (!controller.connected) return emptyRegistrationResult();
183
+ return enqueueObserverScan(controller, callOptions);
184
+ }
185
+
186
+ function disconnect() {
187
+ if (!controller.connected) return;
188
+ controller.connected = false;
189
+ controller.observer?.disconnect();
190
+ controller.observer = undefined;
191
+ controller.observerRoot = undefined;
192
+ }
193
+
194
+ /** Define one element without automatically executing an entry module. */
195
+ async function defineElement(element, callOptions = {}) {
196
+ const result = await processElements(controller, [element], {
197
+ ...callOptions,
198
+ origin: callOptions.origin ?? "define-element",
199
+ explicit: true,
200
+ executeEntries: false,
201
+ });
202
+ return result.modules[0] ?? definitionFromOwnedElement(controller, element);
203
+ }
91
204
 
92
- const reportError = error => {
93
- lastError = error;
205
+ /** Explicitly claim/register an element for this controller. */
206
+ async function registerElement(element, callOptions = {}) {
207
+ const result = await processElements(controller, [element], {
208
+ ...callOptions,
209
+ origin: callOptions.origin ?? "register-element",
210
+ explicit: true,
211
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
212
+ });
213
+ return result.modules[0] ?? definitionFromOwnedElement(controller, element);
214
+ }
215
+
216
+ /**
217
+ * Claim a user-created script before DOM insertion, append it to this
218
+ * controller's target, then register it directly. The observer will see the
219
+ * mutation later but skip it because ownership was already established.
220
+ */
221
+ async function append(element, callOptions = {}) {
222
+ const claim = claimElement(controller, element, {
223
+ ...callOptions,
224
+ origin: callOptions.origin ?? "append",
225
+ explicit: true,
226
+ });
227
+
228
+ const target = getAppendTarget(controller, callOptions);
229
+ appendElement(target, element);
230
+
231
+ const result = await processClaims(controller, claim.fresh ? [claim.record] : [], {
232
+ ...callOptions,
233
+ origin: callOptions.origin ?? "append",
234
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
235
+ });
236
+
237
+ if (!claim.fresh) await claim.record.promise;
238
+ return result.modules[0] ?? claim.record.definition;
239
+ }
94
240
 
95
- if (typeof options.onError === "function") {
96
- options.onError(error);
97
- return;
241
+ /** Create a runtime script element and add it through append(). */
242
+ async function addModule(moduleOptions = {}) {
243
+ const format = normalizeFormat(moduleOptions.format ?? inferFormat({
244
+ explicitFormat: undefined,
245
+ language: moduleOptions.language,
246
+ type: moduleOptions.type,
247
+ moduleId: moduleOptions.id ?? moduleOptions.module,
248
+ sourceUrl: moduleOptions.src,
249
+ }));
250
+
251
+ const createElement = moduleOptions.createElement
252
+ ?? controller.createElement
253
+ ?? getDocumentForCreation(controller, moduleOptions)?.createElement?.bind(getDocumentForCreation(controller, moduleOptions));
254
+
255
+ if (typeof createElement !== "function") {
256
+ throw new TypeError(
257
+ "addModule() requires document.createElement(), a root ownerDocument, or a createElement option.",
258
+ );
98
259
  }
99
260
 
100
- // MutationObserver callbacks cannot be awaited by the DOM. Keep the error
101
- // available for flush(), but also make background failures visible.
102
- globalThis.console?.error?.("solid-tag-runtime/html observer error", error);
103
- };
261
+ const element = createElement("script");
262
+ if (!element || typeof element.setAttribute !== "function") {
263
+ throw new TypeError("addModule() createElement must return a script element-like object with setAttribute().");
264
+ }
104
265
 
105
- const enqueue = () => {
106
- const task = queue.then(scanAndRegister);
107
- queue = task.then(
108
- () => undefined,
109
- error => {
110
- reportError(error);
111
- },
112
- );
113
- return task;
114
- };
266
+ const type = moduleOptions.type ?? (format === "js" ? "solid-js" : "solid-jsx");
267
+ element.setAttribute("type", type);
268
+
269
+ const id = nonEmpty(moduleOptions.id ?? moduleOptions.module);
270
+ if (id) element.setAttribute("module", id);
271
+ if (moduleOptions.src) element.setAttribute("src", String(moduleOptions.src));
272
+ if (moduleOptions.entry) element.setAttribute("entry", "");
273
+ if (moduleOptions.language) element.setAttribute("language", String(moduleOptions.language));
274
+ if (moduleOptions.source != null) element.textContent = String(moduleOptions.source);
275
+
276
+ const definition = await append(element, {
277
+ ...moduleOptions,
278
+ format,
279
+ origin: "add-module",
280
+ });
281
+
282
+ return {
283
+ ...definition,
284
+ element,
285
+ };
286
+ }
115
287
 
116
- const observer = new MutationObserverImpl(() => {
117
- // The settled queue owns background error reporting. Attach a rejection
118
- // handler here as well so the callback never creates an unhandled promise.
119
- void enqueue().catch(() => {});
120
- });
288
+ function owns(element) {
289
+ return elementOwners.get(element)?.owner === controller;
290
+ }
121
291
 
122
- observer.observe(root, {
123
- childList: true,
124
- subtree: options.subtree !== false,
292
+ function getModuleId(element) {
293
+ const record = elementOwners.get(element);
294
+ return record?.owner === controller ? record.moduleId : undefined;
295
+ }
296
+
297
+ function getElement(id) {
298
+ const normalized = normalizeModuleId(runtime, id);
299
+ return controller.bindings.get(normalized)?.element;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Discover runtime module scripts under a Document/Element-like root, define
305
+ * all of them first, then optionally execute scripts marked with `entry`.
306
+ *
307
+ * Backwards-compatible convenience wrapper over the runtime's default HTML
308
+ * controller.
309
+ */
310
+ export async function registerHTML(runtime, options = {}) {
311
+ return getDefaultController(runtime, options).register(options);
312
+ }
313
+
314
+ /** Backwards-compatible observer convenience wrapper. */
315
+ export async function observeHTML(runtime, options = {}) {
316
+ const controller = getDefaultController(runtime, options);
317
+ await controller.observe(options);
318
+ return controller;
319
+ }
320
+
321
+ /** Define one <script> element as a runtime module without executing it. */
322
+ export async function defineScript(runtime, element, options = {}) {
323
+ return getDefaultController(runtime, options).defineElement(element, options);
324
+ }
325
+
326
+ export const htmlModuleSelector = DEFAULT_SELECTOR;
327
+ export const htmlRuntimeScopeAttribute = RUNTIME_SCOPE_ATTRIBUTE;
328
+
329
+ async function enqueueObserverScan(controller, callOptions = {}) {
330
+ const task = controller.queue.then(async () => {
331
+ if (!controller.connected) return emptyRegistrationResult();
332
+
333
+ const root = controller.observerRoot ?? getRoot(mergeOptions(controller, callOptions), "HTML runtime observer");
334
+ const selector = callOptions.selector ?? controller.selector;
335
+ const elements = Array.from(root.querySelectorAll(selector)).filter(element => {
336
+ if (controller.ignoredExisting.has(element)) return false;
337
+ if (!acceptsDiscoveredElement(controller, element, callOptions)) return false;
338
+
339
+ const ownership = elementOwners.get(element);
340
+ if (!ownership) return true;
341
+ if (ownership.owner === controller) return false;
342
+
343
+ throw ownershipError(controller, element, ownership);
344
+ });
345
+
346
+ return processElements(controller, elements, {
347
+ ...callOptions,
348
+ root,
349
+ origin: callOptions.origin ?? "observer",
350
+ executeEntries: callOptions.executeEntries ?? controller.executeEntries,
351
+ });
125
352
  });
126
353
 
127
- let initial = emptyRegistrationResult();
354
+ controller.queue = task.then(
355
+ () => undefined,
356
+ error => {
357
+ controller.lastError = error;
358
+ if (typeof callOptions.onError === "function") {
359
+ callOptions.onError(error);
360
+ } else {
361
+ globalThis.console?.error?.("solid-tag-runtime/html observer error", error);
362
+ }
363
+ },
364
+ );
365
+
366
+ return task;
367
+ }
368
+
369
+ async function processElements(controller, elements, options = {}) {
370
+ const freshRecords = [];
371
+ const existingRecords = [];
372
+
373
+ // Claim the entire batch synchronously before any asynchronous source loading.
374
+ // This prevents observer/manual registration races and reserves module IDs.
375
+ for (const element of elements) {
376
+ if (!options.explicit && !acceptsDiscoveredElement(controller, element, options)) continue;
377
+
378
+ const claim = claimElement(controller, element, options);
379
+ if (claim.fresh) freshRecords.push(claim.record);
380
+ else existingRecords.push(claim.record);
381
+ }
382
+
383
+ const result = await processClaims(controller, freshRecords, options);
384
+
385
+ // Same-controller repeated registration is idempotent. Await currently pending
386
+ // registrations, but do not report/re-execute them as newly registered modules.
387
+ await Promise.all(existingRecords.map(record => record.promise));
388
+ return result;
389
+ }
128
390
 
391
+ async function processClaims(controller, records, options = {}) {
392
+ if (records.length === 0) return emptyRegistrationResult();
393
+
394
+ let prepared;
395
+ try {
396
+ prepared = await Promise.all(records.map(record => prepareRecordSource(controller, record, options)));
397
+ } catch (error) {
398
+ for (const record of records) rejectRecord(record, error);
399
+ throw error;
400
+ }
401
+
402
+ const modules = [];
129
403
  try {
130
- if (options.registerExisting !== false) {
131
- initial = await enqueue();
404
+ // Preserve the graph invariant: every module in this discovered batch is
405
+ // defined before any entry in the batch executes.
406
+ for (let index = 0; index < records.length; index++) {
407
+ const record = records[index];
408
+ const item = prepared[index];
409
+ controller.runtime.define(record.moduleId, item.source, { format: record.format });
410
+
411
+ record.state = "defined";
412
+ record.definition = definitionFromRecord(record);
413
+ record.resolve(record.definition);
414
+ modules.push(record.definition);
132
415
  }
133
416
  } catch (error) {
134
- connected = false;
135
- observer.disconnect();
417
+ for (const record of records) {
418
+ if (record.state === "claimed" || record.state === "loading") rejectRecord(record, error);
419
+ }
136
420
  throw error;
137
421
  }
138
422
 
423
+ const entries = modules.filter(module => module.entry);
424
+ const executed = [];
425
+
426
+ if (options.executeEntries !== false) {
427
+ for (const entry of entries) {
428
+ const record = elementOwners.get(entry.element);
429
+ if (record) record.state = "executing";
430
+
431
+ try {
432
+ const namespace = await controller.runtime.import(entry.id);
433
+ if (record) record.state = "ready";
434
+ executed.push({ id: entry.id, namespace });
435
+ } catch (error) {
436
+ if (record) {
437
+ record.state = "error";
438
+ record.error = error;
439
+ }
440
+ throw error;
441
+ }
442
+ }
443
+ }
444
+
139
445
  return {
140
- initial,
446
+ modules,
447
+ entries: entries.map(entry => entry.id),
448
+ executed,
449
+ };
450
+ }
141
451
 
142
- get connected() {
143
- return connected;
144
- },
452
+ function claimElement(controller, element, options = {}) {
453
+ assertElement(element, options.origin ?? "HTML runtime");
145
454
 
146
- async flush() {
147
- await queue;
455
+ const existing = elementOwners.get(element);
456
+ if (existing) {
457
+ if (existing.owner === controller) return { fresh: false, record: existing };
458
+ throw ownershipError(controller, element, existing);
459
+ }
148
460
 
149
- if (lastError) {
150
- const error = lastError;
151
- lastError = undefined;
152
- throw error;
153
- }
461
+ assertExplicitScopeCompatibility(controller, element, options);
462
+ stampScope(controller, element, options);
154
463
 
155
- return enqueue();
156
- },
464
+ const metadata = inspectElement(controller, element, options);
465
+ const moduleId = normalizeModuleId(controller.runtime, metadata.id);
466
+ const binding = controller.bindings.get(moduleId);
157
467
 
158
- disconnect() {
159
- if (!connected) return;
160
- connected = false;
161
- observer.disconnect();
162
- },
468
+ if (binding && binding.element !== element) {
469
+ throw new HTMLModuleError(
470
+ `HTML module ${JSON.stringify(moduleId)} is already bound to another script element in this HTML runtime.`,
471
+ { element, moduleId, sourceUrl: metadata.sourceUrl },
472
+ );
473
+ }
474
+
475
+ if (!binding && controller.runtime.has(moduleId)) {
476
+ throw new HTMLModuleError(
477
+ `Runtime module ${JSON.stringify(moduleId)} already exists and is not owned by this HTML runtime. Use runtime.update() for explicit replacement.`,
478
+ { element, moduleId, sourceUrl: metadata.sourceUrl },
479
+ );
480
+ }
481
+
482
+ let resolve;
483
+ let reject;
484
+ const promise = new Promise((resolvePromise, rejectPromise) => {
485
+ resolve = resolvePromise;
486
+ reject = rejectPromise;
487
+ });
488
+ // A failed background observer registration may not have a caller awaiting
489
+ // this per-element promise. Mark the rejection as observed while preserving
490
+ // the original promise for explicit callers.
491
+ void promise.catch(() => {});
492
+
493
+ const record = {
494
+ owner: controller,
495
+ element,
496
+ moduleId,
497
+ format: metadata.format,
498
+ entry: metadata.entry,
499
+ sourceUrl: metadata.sourceUrl,
500
+ inline: !metadata.sourceUrl,
501
+ state: "claimed",
502
+ origin: options.origin ?? "unknown",
503
+ promise,
504
+ resolve,
505
+ reject,
506
+ definition: undefined,
507
+ error: undefined,
163
508
  };
509
+
510
+ elementOwners.set(element, record);
511
+ controller.bindings.set(moduleId, record);
512
+ return { fresh: true, record };
164
513
  }
165
514
 
166
- /**
167
- * Define one <script> element as a runtime module without executing it.
168
- */
169
- export async function defineScript(runtime, element, options = {}) {
170
- assertRuntime(runtime);
515
+ async function prepareRecordSource(controller, record, options) {
516
+ record.state = "loading";
171
517
 
172
- if (!element || typeof element.getAttribute !== "function") {
173
- throw new TypeError("defineScript() expects a script element-like object.");
518
+ let source;
519
+ if (record.sourceUrl) {
520
+ source = await fetchSource(
521
+ record.sourceUrl,
522
+ options.fetch ?? controller.fetch ?? globalThis.fetch,
523
+ record.element,
524
+ record.moduleId,
525
+ );
526
+ } else {
527
+ source = record.element.textContent ?? "";
174
528
  }
175
529
 
530
+ return { source };
531
+ }
532
+
533
+ function inspectElement(controller, element, options = {}) {
176
534
  const type = normalizeType(element.getAttribute("type"));
177
535
  const entry = hasAttribute(element, "entry");
178
536
  const srcAttribute = nonEmpty(element.getAttribute("src"));
179
537
  const explicitId = nonEmpty(element.getAttribute("module"));
180
- const sourceUrl = srcAttribute
181
- ? resolveSourceUrl(srcAttribute, options.baseUrl ?? element.baseURI ?? options.root?.baseURI)
182
- : undefined;
538
+ const baseUrl = options.baseUrl
539
+ ?? controller.baseUrl
540
+ ?? element.baseURI
541
+ ?? options.root?.baseURI
542
+ ?? controller.root?.baseURI;
543
+ const sourceUrl = srcAttribute ? resolveSourceUrl(srcAttribute, baseUrl) : undefined;
183
544
 
184
545
  const format = inferFormat({
185
546
  explicitFormat: options.format,
@@ -190,7 +551,6 @@ export async function defineScript(runtime, element, options = {}) {
190
551
  });
191
552
 
192
553
  const id = explicitId ?? sourceUrl ?? (entry ? createAnonymousId(format) : undefined);
193
-
194
554
  if (!id) {
195
555
  throw new HTMLModuleError(
196
556
  "Inline solid runtime module scripts require a `module` attribute unless they are marked `entry`.",
@@ -198,54 +558,152 @@ export async function defineScript(runtime, element, options = {}) {
198
558
  );
199
559
  }
200
560
 
201
- let source;
202
- if (sourceUrl) {
203
- source = await fetchSource(sourceUrl, options.fetch ?? globalThis.fetch, element, id);
204
- } else {
205
- source = element.textContent ?? "";
206
- }
207
-
208
- runtime.define(id, source, { format });
561
+ return { id, format, entry, sourceUrl };
562
+ }
209
563
 
564
+ function definitionFromRecord(record) {
210
565
  return {
211
- id: runtime.resolve(id),
212
- format,
213
- entry,
214
- sourceUrl,
215
- inline: !sourceUrl,
216
- element,
566
+ id: record.moduleId,
567
+ format: record.format,
568
+ entry: record.entry,
569
+ sourceUrl: record.sourceUrl,
570
+ inline: record.inline,
571
+ element: record.element,
217
572
  };
218
573
  }
219
574
 
220
- export const htmlModuleSelector = DEFAULT_SELECTOR;
575
+ function definitionFromOwnedElement(controller, element) {
576
+ const record = elementOwners.get(element);
577
+ if (!record || record.owner !== controller) return undefined;
578
+ return record.definition ?? definitionFromRecord(record);
579
+ }
221
580
 
222
- async function registerElements(runtime, elements, options, onDefined) {
223
- const modules = [];
581
+ function rejectRecord(record, error) {
582
+ if (record.state === "error") return;
583
+ record.state = "error";
584
+ record.error = error;
585
+ record.reject(error);
586
+ }
224
587
 
225
- // Define the complete discovered batch before evaluating any entry. This
226
- // allows an entry to import another module discovered later in the same scan.
227
- for (const element of elements) {
228
- const definition = await defineScript(runtime, element, options);
229
- modules.push(definition);
230
- onDefined?.(element, definition);
588
+ function acceptsDiscoveredElement(controller, element, options = {}) {
589
+ const elementScope = nonEmpty(element?.getAttribute?.(RUNTIME_SCOPE_ATTRIBUTE));
590
+ const scope = normalizeScope(options.scope ?? controller.scope);
591
+ const acceptUnscoped = options.acceptUnscoped ?? controller.acceptUnscoped;
592
+
593
+ if (scope) {
594
+ if (elementScope) return elementScope === scope;
595
+ return acceptUnscoped !== false;
231
596
  }
232
597
 
233
- const entries = modules.filter(module => module.entry);
234
- const executed = [];
598
+ // An unscoped controller never silently steals a declaratively scoped script.
599
+ return !elementScope;
600
+ }
235
601
 
236
- if (options.executeEntries !== false) {
237
- for (const entry of entries) {
238
- executed.push({
239
- id: entry.id,
240
- namespace: await runtime.import(entry.id),
241
- });
242
- }
602
+ function assertExplicitScopeCompatibility(controller, element, options = {}) {
603
+ const requestedScope = normalizeScope(options.scope ?? controller.scope);
604
+ const elementScope = nonEmpty(element.getAttribute(RUNTIME_SCOPE_ATTRIBUTE));
605
+
606
+ if (elementScope && requestedScope !== elementScope) {
607
+ throw new HTMLModuleOwnershipError(
608
+ `Script is scoped to HTML runtime ${JSON.stringify(elementScope)} and cannot be claimed by ${JSON.stringify(requestedScope ?? "(unscoped)")}.`,
609
+ {
610
+ element,
611
+ ownerScope: elementScope,
612
+ requestedScope,
613
+ },
614
+ );
615
+ }
616
+ }
617
+
618
+ function stampScope(controller, element, options = {}) {
619
+ const scope = normalizeScope(options.scope ?? controller.scope);
620
+ if (!scope) return;
621
+ if (nonEmpty(element.getAttribute(RUNTIME_SCOPE_ATTRIBUTE))) return;
622
+
623
+ if (typeof element.setAttribute !== "function") {
624
+ throw new TypeError(
625
+ `Scoped HTML runtimes require script elements with setAttribute() so ${RUNTIME_SCOPE_ATTRIBUTE} can be recorded.`,
626
+ );
627
+ }
628
+
629
+ element.setAttribute(RUNTIME_SCOPE_ATTRIBUTE, scope);
630
+ }
631
+
632
+ function ownershipError(controller, element, ownership) {
633
+ return new HTMLModuleOwnershipError(
634
+ `Runtime script element is already owned by HTML runtime ${JSON.stringify(ownership.owner.scope ?? ownership.owner.id)} and cannot be claimed by ${JSON.stringify(controller.scope ?? controller.id)}.`,
635
+ {
636
+ element,
637
+ moduleId: ownership.moduleId,
638
+ sourceUrl: ownership.sourceUrl,
639
+ ownerScope: ownership.owner.scope,
640
+ requestedScope: controller.scope,
641
+ },
642
+ );
643
+ }
644
+
645
+ function getDefaultController(runtime, options = {}) {
646
+ assertRuntime(runtime);
647
+ let controller = defaultControllers.get(runtime);
648
+ if (!controller) {
649
+ controller = createHTMLRuntime(runtime, {
650
+ root: options.root,
651
+ selector: options.selector,
652
+ executeEntries: options.executeEntries,
653
+ fetch: options.fetch,
654
+ baseUrl: options.baseUrl,
655
+ MutationObserver: options.MutationObserver,
656
+ subtree: options.subtree,
657
+ createElement: options.createElement,
658
+ appendTo: options.appendTo,
659
+ scope: options.scope,
660
+ acceptUnscoped: options.acceptUnscoped,
661
+ });
662
+ defaultControllers.set(runtime, controller);
243
663
  }
664
+ return controller;
665
+ }
244
666
 
667
+ function getAppendTarget(controller, options = {}) {
668
+ const explicit = options.appendTo ?? controller.appendTo;
669
+ if (explicit) return explicit;
670
+
671
+ const root = options.root ?? controller.root ?? globalThis.document;
672
+ if (root?.body) return root.body;
673
+ if (root?.documentElement && root.documentElement !== root) return root.documentElement;
674
+ return root;
675
+ }
676
+
677
+ function appendElement(target, element) {
678
+ if (target && typeof target.append === "function") {
679
+ target.append(element);
680
+ return;
681
+ }
682
+ if (target && typeof target.appendChild === "function") {
683
+ target.appendChild(element);
684
+ return;
685
+ }
686
+ throw new TypeError("append() requires an append target with append() or appendChild().");
687
+ }
688
+
689
+ function getDocumentForCreation(controller, options = {}) {
690
+ return options.document
691
+ ?? controller.root?.ownerDocument
692
+ ?? controller.root
693
+ ?? globalThis.document;
694
+ }
695
+
696
+ function mergeOptions(controller, options) {
245
697
  return {
246
- modules,
247
- entries: entries.map(entry => entry.id),
248
- executed,
698
+ root: options.root ?? controller.root,
699
+ selector: options.selector ?? controller.selector,
700
+ executeEntries: options.executeEntries ?? controller.executeEntries,
701
+ fetch: options.fetch ?? controller.fetch,
702
+ baseUrl: options.baseUrl ?? controller.baseUrl,
703
+ MutationObserver: options.MutationObserver ?? controller.MutationObserver,
704
+ subtree: options.subtree ?? controller.subtree,
705
+ scope: options.scope ?? controller.scope,
706
+ acceptUnscoped: options.acceptUnscoped ?? controller.acceptUnscoped,
249
707
  };
250
708
  }
251
709
 
@@ -260,7 +718,7 @@ function emptyRegistrationResult() {
260
718
  function getRoot(options, caller) {
261
719
  const root = options.root ?? globalThis.document;
262
720
  if (!root || typeof root.querySelectorAll !== "function") {
263
- throw new TypeError(`${caller}() requires a DOM root with querySelectorAll().`);
721
+ throw new TypeError(`${caller} requires a DOM root with querySelectorAll().`);
264
722
  }
265
723
  return root;
266
724
  }
@@ -271,6 +729,20 @@ function assertRuntime(runtime) {
271
729
  }
272
730
  }
273
731
 
732
+ function assertElement(element, caller) {
733
+ if (!element || typeof element.getAttribute !== "function") {
734
+ throw new TypeError(`${caller} expects a script element-like object.`);
735
+ }
736
+ }
737
+
738
+ function normalizeScope(value) {
739
+ return nonEmpty(value);
740
+ }
741
+
742
+ function normalizeModuleId(runtime, id) {
743
+ return runtime.resolve(String(id));
744
+ }
745
+
274
746
  function normalizeType(value) {
275
747
  return String(value ?? "").trim().toLowerCase();
276
748
  }
@@ -336,8 +808,6 @@ function resolveSourceUrl(src, baseUrl) {
336
808
  if (baseUrl) return new URL(src, baseUrl).href;
337
809
  return new URL(src).href;
338
810
  } catch {
339
- // A DOM element normally supplies baseURI. Keeping the original string is
340
- // still useful for custom DOM-like environments and injected fetchers.
341
811
  return src;
342
812
  }
343
813
  }