@jxsuite/compiler 1.4.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,16 +46,16 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@apidevtools/json-schema-ref-parser": "^15.5.0",
49
- "@jxsuite/create": "^1.2.1",
50
- "@jxsuite/runtime": "^1.3.1",
51
- "@jxsuite/schema": "^1.4.0",
49
+ "@jxsuite/create": "^1.2.2",
50
+ "@jxsuite/runtime": "^1.3.2",
51
+ "@jxsuite/schema": "^1.5.0",
52
52
  "esbuild": "^0.28.1",
53
53
  "sharp": "^0.35.3"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@happy-dom/global-registrator": "^20.11.1",
57
- "@jxsuite/connector": "^0.4.1",
58
- "@jxsuite/parser": "^1.4.0",
57
+ "@jxsuite/connector": "^0.4.2",
58
+ "@jxsuite/parser": "^1.4.1",
59
59
  "@types/mdast": "^4.0.4"
60
60
  }
61
61
  }
package/src/shared.ts CHANGED
@@ -77,8 +77,133 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
77
77
  }
78
78
  return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
79
79
  }
80
+
81
+ /**
82
+ * Emit the import-clause binding for one Function-def `$src` entry.
83
+ *
84
+ * `$export` names the export inside the module and defaults to the state key (spec.md §5.3 4d).
85
+ * When the two differ the clause has to alias, because the local name the rest of the generated
86
+ * module calls is the state key — importing the bare key instead requests an export the module does
87
+ * not have, and the browser rejects the whole module at link time.
88
+ *
89
+ * Aliasing rather than renaming call sites is what makes the awkward cases work: two keys may alias
90
+ * the same export, a key may collide with another entry's export name, and `$export: "default"` is
91
+ * only reachable as `default as key`.
92
+ *
93
+ * @param {string} key - State key, and the local binding name
94
+ * @param {unknown} def - The Function definition
95
+ * @returns {string}
96
+ */
97
+ export function srcImportBinding(key: string, def: unknown) {
98
+ const declared = (def as { $export?: unknown } | null | undefined)?.$export;
99
+ const exportName = typeof declared === "string" && declared !== "" ? declared : key;
100
+ return exportName === key ? key : `${exportName} as ${key}`;
101
+ }
102
+
103
+ /**
104
+ * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
105
+ *
106
+ * Shared by the client and element targets so both honour `manual`, template URLs and
107
+ * `method`/`headers`/`body` the same way. They diverged once: the element target emitted no fetch
108
+ * at all, leaving every Request entry permanently `null` with nothing thrown.
109
+ *
110
+ * A template `url` is read inside the effect, so the fetch re-runs whenever the interpolated state
111
+ * changes; an URL that still interpolates to `undefined` is skipped rather than fetched.
112
+ *
113
+ * @param {string} key - State key receiving the response
114
+ * @param {JxPrototypeDef} def - The Request definition
115
+ * @param {{ statePrefix?: string; indent?: string; collect?: string }} [opts] - `statePrefix` is
116
+ * the expression the emitted code assigns through (`state` for the client module scope,
117
+ * `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`, when
118
+ * given, is an array expression the effect runner is pushed onto so the caller can `stop()` it on
119
+ * teardown.
120
+ * @returns {string}
121
+ */
122
+ export function emitRequestFetch(
123
+ key: string,
124
+ def: JxPrototypeDef,
125
+ opts: { statePrefix?: string; indent?: string; collect?: string } = {},
126
+ ) {
127
+ const { statePrefix = "state", indent = "", collect } = opts;
128
+ const { url } = def;
129
+ const isTemplateUrl = isTemplateString(url);
130
+ const method = def.method ?? "GET";
131
+
132
+ if (def.manual) {
133
+ return `${indent}// ${key}: manual Request — fetch triggered by user action`;
134
+ }
135
+
136
+ const lines: string[] = [
137
+ `// ${key}: auto-fetch from ${isTemplateUrl ? "(dynamic URL)" : url}`,
138
+ collect ? `${collect}.push(effect(() => {` : "effect(() => {",
139
+ ];
140
+
141
+ if (isTemplateUrl) {
142
+ lines.push(
143
+ ` const url = \`${withStatePrefix(url as string, statePrefix)}\`;`,
144
+ ' if (!url || url === "undefined" || url.includes("undefined")) return;',
145
+ );
146
+ } else {
147
+ lines.push(` const url = ${JSON.stringify(url)};`);
148
+ }
149
+
150
+ const fetchOpts: string[] = [];
151
+ if (method !== "GET") {
152
+ fetchOpts.push(`method: ${JSON.stringify(method)}`);
153
+ }
154
+ if (def.headers) {
155
+ fetchOpts.push(`headers: ${JSON.stringify(def.headers)}`);
156
+ }
157
+ if (def.body) {
158
+ const bodyStr =
159
+ typeof def.body === "object"
160
+ ? JSON.stringify(JSON.stringify(def.body))
161
+ : JSON.stringify(def.body);
162
+ fetchOpts.push(`body: ${bodyStr}`);
163
+ }
164
+
165
+ const optsStr = fetchOpts.length > 0 ? `, { ${fetchOpts.join(", ")} }` : "";
166
+ lines.push(
167
+ ` fetch(url${optsStr})`,
168
+ " .then(r => r.ok ? r.json() : Promise.reject(r.statusText))",
169
+ ` .then(d => { ${statePrefix}.${key} = d; })`,
170
+ ` .catch(e => { ${statePrefix}.${key} = { error: String(e) }; });`,
171
+ collect ? "}));" : "});",
172
+ );
173
+
174
+ return lines.map((line) => `${indent}${line}`).join("\n");
175
+ }
176
+
177
+ /**
178
+ * Rebase the bare `state.` reads in a template string onto `statePrefix`.
179
+ *
180
+ * Only the `${…}` interpolation holes are rewritten. A blanket replace would corrupt the literal
181
+ * text around them — `/api/state.json?q=${state.q}` would request `/api/this.state.json` — and the
182
+ * `\b` guard keeps an identifier that merely ends in `state` (`substate.z`) intact. Reads already
183
+ * carrying the prefix are left alone.
184
+ *
185
+ * @param {string} str
186
+ * @param {string} statePrefix
187
+ * @returns {string}
188
+ */
189
+ function withStatePrefix(str: string, statePrefix: string) {
190
+ if (statePrefix === "state") {
191
+ return str;
192
+ }
193
+ return str.replaceAll(
194
+ /\$\{([^}]*)\}/g,
195
+ (_match: string, expr: string) =>
196
+ `\${${expr.replaceAll(/(?<!this\.)\bstate\./g, `${statePrefix}.`)}}`,
197
+ );
198
+ }
80
199
  export type { ExpressionNode } from "@jxsuite/runtime/expression";
81
200
 
201
+ /**
202
+ * Non-enumerable marker on a build-time scope listing the state keys that hold no build-time value.
203
+ * Non-enumerable so it stays out of `Object.entries(scope)` and out of template evaluation.
204
+ */
205
+ const RUNTIME_ONLY_KEYS = Symbol("jx.runtimeOnlyStateKeys");
206
+
82
207
  // CDN defaults
83
208
  export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
84
209
  export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
@@ -275,6 +400,8 @@ export interface CompileContext {
275
400
  scope: Record<string, unknown>;
276
401
  scopeDefs: Record<string, unknown>;
277
402
  media: Record<string, string>;
403
+ /** True inside a `<pre>`-like subtree, where emitters must not indent nested children. */
404
+ preformatted?: boolean;
278
405
  }
279
406
 
280
407
  export function createCompileContext(
@@ -299,6 +426,18 @@ export function buildInitialScope(
299
426
  parentScope: Record<string, unknown> | null = null,
300
427
  ) {
301
428
  const scope = Object.create(parentScope ?? null) as Record<string, unknown>;
429
+ // Keys whose real value only exists after hydration, so prerender must not interpolate them —
430
+ // See `readsRuntimeOnlyState`. Seeded from the parent so nested scopes inherit the marks.
431
+ const inherited = (parentScope as Record<PropertyKey, unknown> | null)?.[RUNTIME_ONLY_KEYS];
432
+ const runtimeOnly = new Set<string>(inherited instanceof Set ? (inherited as Set<string>) : []);
433
+ // Attached up front, and mutated in place by the passes below, so the template pass at the end can
434
+ // Consult the marks the earlier passes added.
435
+ Object.defineProperty(scope, RUNTIME_ONLY_KEYS, {
436
+ configurable: true,
437
+ enumerable: false,
438
+ value: runtimeOnly,
439
+ writable: true,
440
+ });
302
441
 
303
442
  for (const [key, def] of Object.entries(defs)) {
304
443
  if (typeof def !== "object" || def === null || Array.isArray(def)) {
@@ -315,9 +454,13 @@ export function buildInitialScope(
315
454
  }
316
455
  }
317
456
 
457
+ // Template-string entries are deferred to a third pass: whether one is resolvable depends on the
458
+ // Runtime-only marks the loop below adds, and key order must not decide the answer.
459
+ const templateDefs: [string, string][] = [];
460
+
318
461
  for (const [key, def] of Object.entries(defs)) {
319
462
  if (typeof def === "string" && isTemplateString(def)) {
320
- defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(def, scope));
463
+ templateDefs.push([key, def]);
321
464
  continue;
322
465
  }
323
466
  if (!def || typeof def !== "object") {
@@ -359,18 +502,27 @@ export function buildInitialScope(
359
502
  void runStatements(body, s ?? scope, event ?? null);
360
503
  });
361
504
  } else if (typeof def.body === "string") {
362
- const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
505
+ const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
363
506
  const { body } = def;
364
- const fn = new Function("state", ...names, body) as (
365
- state: Record<string, unknown>,
366
- ) => unknown;
507
+ // Declared names bind by name, not by position (spec.md §5.3 4d): a parameter literally
508
+ // Named `state` receives the scope, anything else receives the event. `state` is prepended
509
+ // When undeclared so a body may always reference it. Unconditionally prepending it instead —
510
+ // The older behaviour here — produced a duplicate parameter for the `["state", "event"]`
511
+ // Form that examples/components/task-manager.json uses, leaving `state` undefined.
512
+ const params = declared.includes("state") ? declared : ["state", ...declared];
513
+ const fn = new Function(...params, body) as (...args: unknown[]) => unknown;
514
+ const invoke = (state: Record<string, unknown>, event?: unknown) =>
515
+ fn(...params.map((name) => (name === "state" ? state : event)));
367
516
  if (bodyReturnsValue(body)) {
368
- defineLazyScopeValue(scope, key, () => fn(scope));
517
+ defineLazyScopeValue(scope, key, () => invoke(scope));
369
518
  } else {
370
- setOwnScopeValue(scope, key, fn);
519
+ setOwnScopeValue(scope, key, invoke);
371
520
  }
372
521
  } else {
522
+ // Bodyless `$src` Function — the real implementation is only loaded in the browser, so this
523
+ // Is a placeholder standing in for its callable shape, never its value.
373
524
  setOwnScopeValue(scope, key, () => {});
525
+ runtimeOnly.add(key);
374
526
  }
375
527
  continue;
376
528
  }
@@ -379,9 +531,24 @@ export function buildInitialScope(
379
531
  (def.$prototype === "LocalStorage" || def.$prototype === "SessionStorage")
380
532
  ) {
381
533
  setOwnScopeValue(scope, key, cloneValue(def.default ?? null));
534
+ continue;
535
+ }
536
+ if (isPrototypeDef(def) && def.$prototype === "Request") {
537
+ // A client-timing fetch has no build-time value at all — not even a placeholder.
538
+ runtimeOnly.add(key);
382
539
  }
383
540
  }
384
541
 
542
+ for (const [key, template] of templateDefs) {
543
+ // A state entry that interpolates a runtime-only key cannot resolve until hydration either, so
544
+ // It is runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`)
545
+ // Into every template that reads *this* entry, one step removed from the original.
546
+ if (readsRuntimeOnlyState(template, scope)) {
547
+ runtimeOnly.add(key);
548
+ }
549
+ defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
550
+ }
551
+
385
552
  return scope;
386
553
  }
387
554
 
@@ -465,12 +632,51 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
465
632
  return scope[refValue] ?? null;
466
633
  }
467
634
 
635
+ /**
636
+ * Detect whether a template string interpolates a state value that only exists after hydration.
637
+ *
638
+ * Evaluating one of those at build time is worse than leaving it alone. The build-time scope holds
639
+ * a `() => {}` placeholder for a bodyless `$src` Function and nothing at all for a `Request`, so
640
+ * the template resolves to text like `() => {}`, `undefined` or `""` — and that text _replaces_ the
641
+ * template in the emitted HTML, so the client-side binding is destroyed rather than merely wrong.
642
+ * Returning "unresolvable" instead leaves the template in place for the client to populate.
643
+ *
644
+ * A read that _calls_ the value is left alone: invoking a build-time callable is exactly how named
645
+ * formulas (spec.md §19.4c) are meant to be used during prerender.
646
+ *
647
+ * @param {string} str
648
+ * @param {Record<string, unknown>} scope
649
+ * @returns {boolean}
650
+ */
651
+ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
652
+ if (!scope) {
653
+ return false;
654
+ }
655
+ const runtimeOnly = (scope as Record<PropertyKey, unknown>)[RUNTIME_ONLY_KEYS];
656
+ for (const match of str.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*(\()?/g)) {
657
+ if (match[2]) {
658
+ continue;
659
+ }
660
+ const key = match[1] as string;
661
+ if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
662
+ return true;
663
+ }
664
+ if (typeof scope[key] === "function") {
665
+ return true;
666
+ }
667
+ }
668
+ return false;
669
+ }
670
+
468
671
  /**
469
672
  * @param {string} str
470
673
  * @param {Record<string, unknown>} scope
471
674
  * @returns {unknown}
472
675
  */
473
676
  export function evaluateStaticTemplate(str: string, scope: Record<string, unknown>) {
677
+ if (readsRuntimeOnlyState(str, scope)) {
678
+ return null;
679
+ }
474
680
  try {
475
681
  const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
476
682
  if (singleExprMatch) {
@@ -613,6 +819,26 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
613
819
  return out;
614
820
  }
615
821
 
822
+ /**
823
+ * Tags rendered with `white-space: pre` by every UA, where inter-element whitespace is content
824
+ * rather than formatting. Emitters indent nested children for readability; inside these tags — and
825
+ * anywhere below them, since `white-space` inherits — that indentation would render as literal
826
+ * blank lines and leading spaces, so children must be concatenated with no separator.
827
+ */
828
+ export const PREFORMATTED_TAGS = new Set(["pre", "textarea", "listing", "plaintext", "xmp"]);
829
+
830
+ /**
831
+ * The separator to place between an element's compiled children: none inside a preformatted
832
+ * subtree, a newline plus `indent` elsewhere.
833
+ *
834
+ * @param {boolean | undefined} preformatted
835
+ * @param {string} [indent]
836
+ * @returns {string}
837
+ */
838
+ export function childSeparator(preformatted: boolean | undefined, indent = " "): string {
839
+ return preformatted ? "" : `\n${indent}`;
840
+ }
841
+
616
842
  /**
617
843
  * Build the inner HTML (textContent or children) for a node.
618
844
  *
@@ -622,6 +848,7 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
622
848
  * scope: Record<string, unknown>;
623
849
  * scopeDefs: Record<string, unknown>;
624
850
  * media: Record<string, string>;
851
+ * preformatted?: boolean;
625
852
  * }} context
626
853
  * @param {(def: unknown, raw: unknown, context: unknown) => string} childCompiler
627
854
  * @returns {string}
@@ -633,6 +860,7 @@ export function buildInner(
633
860
  scope: Record<string, unknown> | null;
634
861
  scopeDefs: Record<string, unknown>;
635
862
  media: Record<string, string>;
863
+ preformatted?: boolean;
636
864
  },
637
865
  childCompiler: (def: unknown, raw: unknown, context: unknown) => string,
638
866
  ) {
@@ -652,7 +880,7 @@ export function buildInner(
652
880
  const childRaw = (rawChildren as (JxElement | string)[] | undefined)?.[i] ?? c;
653
881
  return childCompiler(c, childRaw, context);
654
882
  })
655
- .join("\n ");
883
+ .join(childSeparator(context.preformatted));
656
884
  }
657
885
  return "";
658
886
  }
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * Composes the per-project entry documents (project.schema.json and document.schema.json) from the
5
5
  * core schemas and each enabled extension's shipped fragments, then writes them into the project
6
- * root as committed, SELF-CONTAINED bundles: every fragment ref is resolved through
7
- * {@link bundleSchema}, embedding the core + fragment resources under `$defs` keyed by their
8
- * canonical `$id`s. Relative `./node_modules/...` refs only resolve through Node module resolution,
9
- * which editors do not perform (hoisted workspaces leave projects without their own node_modules) —
10
- * the bundled form resolves offline for ajv `jx validate`, VS Code's JSON language service, and the
11
- * studio's Monaco alike, with one resolution behavior for all consumers.
6
+ * root as committed, SELF-CONTAINED, SINGLE-RESOURCE schemas: every fragment ref is resolved
7
+ * through {@link bundleSchema} (embedding the core + fragment resources under `$defs`) and then
8
+ * collapsed by {@link flattenSchema}, which drops the embedded `$id`s and rewrites every ref to a
9
+ * root-relative JSON Pointer. Both passes are needed for one resolution behavior across all
10
+ * consumers: relative `./node_modules/...` refs only resolve through Node module resolution, which
11
+ * editors do not perform (hoisted workspaces leave projects without their own node_modules), while
12
+ * `$id`-scoped refs inside a compound document resolve only in a fully compliant validator — VS
13
+ * Code's JSON language service and Monaco resolve `#/...` against the document root and fetch
14
+ * anything with a URI before the `#` over the network. Root pointers are what ajv, the language
15
+ * service, and Monaco all agree on.
12
16
  */
13
17
 
14
18
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -16,8 +20,8 @@ import { createRequire } from "node:module";
16
20
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
17
21
  import {
18
22
  bundleSchema,
19
- emitDocumentSchema,
20
- emitProjectSchema,
23
+ composeProjectSchemas,
24
+ flattenSchema,
21
25
  } from "@jxsuite/schema/project-schemas";
22
26
  import { buildProjectExtensionRegistry } from "./format-host.ts";
23
27
  import { loadProjectConfig } from "./site-loader.ts";
@@ -37,44 +41,22 @@ export async function writeProjectSchemas(projectRoot: string) {
37
41
  const { config } = loadProjectConfig(projectRoot);
38
42
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
39
43
 
40
- const corePath = coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER);
41
- const fragments: string[] = [];
42
- const pathsValueRefs: string[] = [];
43
- const fieldSchemaRefs: string[] = [];
44
- for (const ext of registry.extensions) {
45
- const projectFragment = ext.schemas.project;
46
- if (projectFragment) {
47
- fragments.push(fragmentRef(projectRoot, ext, projectFragment));
48
- }
49
- const documentFragment = ext.schemas.document;
50
- if (documentFragment) {
51
- pathsValueRefs.push(...fragmentDefsRefs(documentFragment));
52
- }
53
- // The `schemas.fields` manifest convention: a fragment whose $defs members are extension
54
- // Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
55
- const fieldsFragment = ext.schemas.fields;
56
- if (fieldsFragment) {
57
- fieldSchemaRefs.push(...fragmentDefsRefs(fieldsFragment));
58
- fragments.push(fragmentRef(projectRoot, ext, fieldsFragment));
59
- }
60
- }
61
-
62
- // Bundle before committing: the emitted relative refs are an intermediate form only.
44
+ /* Composition itself is host-agnostic (@jxsuite/schema `composeProjectSchemas`) — the filesystem
45
+ appears only in what this host contributes: project-relative refs for the fragments the
46
+ registry resolved, and a loader restricted to the project root. The cloud session composes the
47
+ same two documents from bundled artifacts through the same function. */
63
48
  const root = resolve(projectRoot);
64
- const loadJson = restrictedSchemaLoader(root);
65
- const projectSchema = await bundleSchema(
66
- emitProjectSchema({ corePath, fieldSchemaRefs, fragments }),
67
- loadJson,
68
- root,
69
- );
70
- const documentSchema = await bundleSchema(
71
- emitDocumentSchema({
72
- corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
73
- pathsValueRefs,
74
- }),
75
- loadJson,
76
- root,
77
- );
49
+ const { document: documentSchema, project: projectSchema } = await composeProjectSchemas({
50
+ baseDir: root,
51
+ coreDocumentRef: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
52
+ coreProjectRef: coreSchemaRef(projectRoot, CORE_PROJECT_SPECIFIER),
53
+ extensions: registry.extensions.map((ext) => ({
54
+ document: ext.schemas.document && fragmentRef(projectRoot, ext, ext.schemas.document),
55
+ fields: ext.schemas.fields && fragmentRef(projectRoot, ext, ext.schemas.fields),
56
+ project: ext.schemas.project && fragmentRef(projectRoot, ext, ext.schemas.project),
57
+ })),
58
+ loadJson: restrictedSchemaLoader(root),
59
+ });
78
60
 
79
61
  const projectSchemaPath = resolve(projectRoot, "project.schema.json");
80
62
  const documentSchemaPath = resolve(projectRoot, "document.schema.json");
@@ -115,16 +97,16 @@ export async function readBundledProjectSchemas(projectRoot: string) {
115
97
  }
116
98
 
117
99
  const loadJson = restrictedSchemaLoader(root);
118
- const project = await bundleSchema(
119
- JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<string, unknown>,
120
- loadJson,
121
- root,
122
- );
123
- const document = await bundleSchema(
124
- JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<string, unknown>,
125
- loadJson,
126
- root,
127
- );
100
+ const projectFile = JSON.parse(readFileSync(projectSchemaPath, "utf8")) as Record<
101
+ string,
102
+ unknown
103
+ >;
104
+ const documentFile = JSON.parse(readFileSync(documentSchemaPath, "utf8")) as Record<
105
+ string,
106
+ unknown
107
+ >;
108
+ const project = flattenSchema(await bundleSchema(projectFile, loadJson, root));
109
+ const document = flattenSchema(await bundleSchema(documentFile, loadJson, root));
128
110
  return { document, project };
129
111
  }
130
112
 
@@ -231,25 +213,3 @@ function projectRelativeRef(projectRoot: string, absPath: string, conventional:
231
213
  }
232
214
  return rel.startsWith("./") ? rel : `./${rel}`;
233
215
  }
234
-
235
- /**
236
- * Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
237
- * by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
238
- * URIs, never file locations). Serves both the document paths union and the fields-extras union.
239
- *
240
- * @param {string} fragmentPath - Resolved absolute fragment path
241
- * @returns {string[]}
242
- */
243
- function fragmentDefsRefs(fragmentPath: string): string[] {
244
- let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
245
- try {
246
- fragment = JSON.parse(readFileSync(fragmentPath, "utf8")) as typeof fragment;
247
- } catch {
248
- return [];
249
- }
250
- const { $id } = fragment;
251
- if (typeof $id !== "string" || !fragment.$defs) {
252
- return [];
253
- }
254
- return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
255
- }
@@ -256,20 +256,44 @@ export async function buildSite(
256
256
  const componentOutDir = resolve(outDir, "components");
257
257
  mkdirSync(componentOutDir, { recursive: true });
258
258
 
259
+ // Emitted modules are named after the component's `tagName` — the name the page's loader
260
+ // `<script>` and the CSS sidecar both use. So a component whose source basename differs from
261
+ // Its tag needs its `$elements` imports renamed to match, and those get resolved before the
262
+ // Dependency itself is compiled. Hence: read every component's tag up front.
263
+ const componentTagByPath = new Map<string, string>();
264
+ for (const file of componentFiles) {
265
+ const componentPath = resolve(componentsDir, file);
266
+ try {
267
+ const { tagName: depTag } = await readPageDocument(componentPath, formatRegistry);
268
+ if (depTag) {
269
+ componentTagByPath.set(componentPath, depTag);
270
+ }
271
+ } catch {
272
+ // Left to the compile pass below, which reports the parse failure with its file context.
273
+ }
274
+ }
275
+
259
276
  for (const file of componentFiles) {
260
277
  try {
261
278
  const componentPath = resolve(componentsDir, file);
262
279
  const result = await compileElement(componentPath, {
263
280
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
264
281
  formats: formatRegistry,
282
+ resolveElementPath: (refPath: string, currentDir: string | null) => {
283
+ const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
284
+ const depTag = componentTagByPath.get(depPath);
285
+ return depTag ? `./${depTag}.js` : refPath.replace(/\.json$/, ".js");
286
+ },
265
287
  rewriteSrc: rewriteSidecarSrc,
266
288
  });
267
289
  for (const f of result.files) {
268
- // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
269
- // Filename for the dist/components/ output. basename handles both / and \ — a
270
- // Forward-slash-only split left the full drive path on Windows, so resolve() then wrote
271
- // The sidecar back into the source tree instead of dist.
272
- const outName = basename(f.path);
290
+ // Name the output after the tag. `injectComponentScripts` emits
291
+ // `<script src="/components/<tag>.js">` and the CSS sidecar is written as `<tag>.css`, so
292
+ // A source basename here left the loader pointing at a file that was never written.
293
+ // Basename is the fallback, and handles both / and \ — a forward-slash-only split left
294
+ // The full drive path on Windows, so resolve() then wrote the sidecar back into the
295
+ // Source tree instead of dist.
296
+ const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
273
297
  writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
274
298
  if (f.tagName) {
275
299
  compiledComponentTags.push(f.tagName);
@@ -366,6 +390,7 @@ export async function buildSite(
366
390
  compiledComponentTags,
367
391
  componentCSS,
368
392
  staticTags,
393
+ result.files.map((f: { content: string }) => f.content).join("\n"),
369
394
  );
370
395
  }
371
396
 
@@ -1151,9 +1176,14 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1151
1176
  }
1152
1177
  const rawChildren = node.children;
1153
1178
  // Legacy whole-children repeater: expand the items into the node's static children.
1179
+ // Only when the expansion actually produced nodes. `expandMappedArrayStatic` returns `null` when
1180
+ // `items` cannot be resolved at build time and `[]` when it resolves to an empty array — and `[]`
1181
+ // Is truthy, so an empty build-time list used to replace the repeater with nothing, discarding the
1182
+ // Definition the client needed. There is no prerendered output to preserve in that case, so the
1183
+ // Repeater is left in place for the client to bind (a page containing one is never zero-JS).
1154
1184
  if (isMappedArray(rawChildren)) {
1155
1185
  const expanded = expandMappedArrayStatic(rawChildren, scope);
1156
- if (expanded) {
1186
+ if (expanded && expanded.length > 0) {
1157
1187
  node.children = expanded;
1158
1188
  return;
1159
1189
  }
@@ -1183,10 +1213,11 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
1183
1213
  continue;
1184
1214
  }
1185
1215
  }
1186
- // Array pseudo-element among siblings: expand its items in place.
1216
+ // Array pseudo-element among siblings: expand its items in place, on the same
1217
+ // Produced-nothing rule as the whole-children branch above.
1187
1218
  if (isMappedArray(child)) {
1188
1219
  const expanded = expandMappedArrayStatic(child, scope);
1189
- if (expanded) {
1220
+ if (expanded && expanded.length > 0) {
1190
1221
  node.children.splice(i, 1, ...expanded);
1191
1222
  i += expanded.length;
1192
1223
  continue;
@@ -1351,6 +1382,7 @@ function expandComponents(
1351
1382
  * @param {string[]} allComponentTags - All compiled component tag names
1352
1383
  * @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
1353
1384
  * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
1385
+ * @param {string} [islandSource] - Concatenated island modules emitted for this page
1354
1386
  * @returns {string}
1355
1387
  */
1356
1388
  function injectComponentScripts(
@@ -1358,11 +1390,15 @@ function injectComponentScripts(
1358
1390
  allComponentTags: string[],
1359
1391
  cssMap = new Map<string, string>(),
1360
1392
  staticTags = new Set<string>(),
1393
+ islandSource = "",
1361
1394
  ) {
1362
- // Find which components are actually referenced in this page
1363
- const usedTags = allComponentTags.filter(
1364
- (tag: string) => html.includes(`<${tag}`), // Matches <tag> and <tag ...>
1365
- );
1395
+ // A component reaches a page two ways: as a literal tag in the static HTML, or referenced only
1396
+ // From inside an island's client template — in which case the tag exists solely in the island's
1397
+ // Compiled JS and never in the HTML the page ships. Searching the HTML alone missed the second
1398
+ // Kind entirely, so its module was never loaded and the element sat in the DOM un-upgraded.
1399
+ const inHtml = (tag: string) => html.includes(`<${tag}`); // Matches <tag> and <tag ...>
1400
+ const inIsland = (tag: string) => islandSource.includes(`<${tag}`);
1401
+ const usedTags = allComponentTags.filter((tag: string) => inHtml(tag) || inIsland(tag));
1366
1402
  if (usedTags.length === 0) {
1367
1403
  return html;
1368
1404
  }
@@ -1377,8 +1413,11 @@ function injectComponentScripts(
1377
1413
  result = result.replace("</head>", ` ${cssLinks}\n</head>`);
1378
1414
  }
1379
1415
 
1380
- // Only inject JS for components that have non-static instances
1381
- const jsTags = usedTags.filter((tag: string) => !staticTags.has(tag));
1416
+ // Only inject JS for components that have non-static instances. An island-rendered instance is
1417
+ // Never prerendered, so it needs its module even when the component itself is fully static —
1418
+ // That module is what fills the element in client-side. This holds when the tag ALSO appears in
1419
+ // The static HTML: those instances are prerendered, but the ones the island creates are not.
1420
+ const jsTags = usedTags.filter((tag: string) => inIsland(tag) || !staticTags.has(tag));
1382
1421
  if (jsTags.length === 0) {
1383
1422
  return result;
1384
1423
  }