@jxsuite/compiler 1.1.0 → 1.3.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.
@@ -79,6 +79,7 @@ export async function resolvePrototypes(
79
79
  config?: Record<string, unknown>;
80
80
  sections?: Record<string, unknown>;
81
81
  registry?: ExtensionRegistry;
82
+ registerBundle?: (specifier: string) => void;
82
83
  } = {},
83
84
  ) {
84
85
  const imports = doc.imports ?? {};
@@ -107,6 +108,20 @@ export async function resolvePrototypes(
107
108
  root: projectRoot,
108
109
  route,
109
110
  });
111
+ // A lowered def may name client modules it depends on via `$bundle`
112
+ // (extensions.md §8.3): the site build bundles each specifier for the browser.
113
+ // The key is host metadata, not part of the core def shape — strip it.
114
+ if (lowered && typeof lowered === "object" && !Array.isArray(lowered)) {
115
+ const bundles = (lowered as JsonObject).$bundle;
116
+ if (Array.isArray(bundles)) {
117
+ for (const specifier of bundles) {
118
+ if (typeof specifier === "string") {
119
+ projectContext.registerBundle?.(specifier);
120
+ }
121
+ }
122
+ }
123
+ delete (lowered as JsonObject).$bundle;
124
+ }
110
125
  state[key] = lowered as JxStateDefinition;
111
126
  } catch (error) {
112
127
  console.warn(
@@ -3,10 +3,12 @@
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 artifacts. All fragment refs are PROJECT-RELATIVE, POSIX-style `./` paths:
7
- * paths that resolve inside the project are emitted as-is; bare-specifier packages resolving
8
- * through workspace symlinks (outside the root) fall back to the conventional
9
- * `./node_modules/<package>/<subpath>` form, which resolves once dependencies are installed.
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.
10
12
  */
11
13
 
12
14
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -57,11 +59,22 @@ export async function writeProjectSchemas(projectRoot: string) {
57
59
  }
58
60
  }
59
61
 
60
- const projectSchema = emitProjectSchema({ corePath, fieldSchemaRefs, fragments });
61
- const documentSchema = emitDocumentSchema({
62
- corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
63
- pathsValueRefs,
64
- });
62
+ // Bundle before committing: the emitted relative refs are an intermediate form only.
63
+ 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
+ );
65
78
 
66
79
  const projectSchemaPath = resolve(projectRoot, "project.schema.json");
67
80
  const documentSchemaPath = resolve(projectRoot, "document.schema.json");
@@ -9,7 +9,6 @@
9
9
  */
10
10
 
11
11
  import {
12
- copyFileSync,
13
12
  cpSync,
14
13
  existsSync,
15
14
  mkdirSync,
@@ -19,8 +18,16 @@ import {
19
18
  statSync,
20
19
  writeFileSync,
21
20
  } from "node:fs";
22
- import { basename, dirname, join, resolve } from "node:path";
21
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
22
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
23
+ import { isNpmSpecifier, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
+ import {
25
+ CLIENT_EXTERNALS,
26
+ bundleEntry,
27
+ bundleWorkerSource,
28
+ isBundleableSrc,
29
+ resolveSidecarEntry,
30
+ } from "./bundler.ts";
24
31
  import { loadProjectConfig } from "./site-loader.ts";
25
32
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
26
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
@@ -40,10 +47,12 @@ import {
40
47
  buildInitialScope,
41
48
  collectServerEntries,
42
49
  collectStyles,
50
+ colorSchemePrePaintScript,
43
51
  evaluateStaticTemplate,
44
52
  isComponentFullyStatic,
45
53
  isTemplateString,
46
54
  preRenderComponentHtml,
55
+ pureSchemeOf,
47
56
  renderStaticNode,
48
57
  resolveRefValue,
49
58
  resolveStaticValue,
@@ -106,6 +115,43 @@ export async function buildSite(
106
115
  const publicDir = resolve(projectRoot, "public");
107
116
  const trailingSlash = projectConfig.build.trailingSlash ?? "always";
108
117
 
118
+ // Client sidecar bundles: bundleable Function-def `$src` specifiers (and lowered-def `$bundle`
119
+ // Hints) are rewritten to their /assets/ URL during compilation, registered here, and bundled
120
+ // In step 6d (spec.md §12). Keyed by asset path so duplicate specifiers bundle once.
121
+ const sidecarBundles = new Map<string, { entryPath: string; specifier: string }>();
122
+ const rewriteSidecarSrc = (specifier: string, docDir: string | null): string => {
123
+ if (!isBundleableSrc(specifier)) {
124
+ return specifier;
125
+ }
126
+ try {
127
+ const entryPath = resolveSidecarEntry(specifier, docDir ?? projectRoot, projectRoot);
128
+ // A declared-but-absent relative sidecar keeps its verbatim import (the historical
129
+ // Behavior) with a warning — the doc may only ever run interpreted. Unresolvable npm
130
+ // Specifiers stay hard errors: a missing dependency should fail the build.
131
+ if (!isNpmSpecifier(specifier) && !existsSync(entryPath)) {
132
+ console.warn(`Sidecar "${specifier}" not found at ${entryPath} — emitting unbundled`);
133
+ return specifier;
134
+ }
135
+ // Relative specifiers key on their project-relative resolved path so `./x.js` declared in
136
+ // Two directories cannot collide on one slug; npm specifiers are location-independent.
137
+ const assetKey = isNpmSpecifier(specifier)
138
+ ? specifier
139
+ : `./${relative(projectRoot, entryPath)}`;
140
+ const assetPath = sidecarAssetPath(assetKey);
141
+ const existing = sidecarBundles.get(assetPath);
142
+ if (existing && existing.entryPath !== entryPath) {
143
+ throw new Error(
144
+ `"${specifier}" and "${existing.specifier}" both bundle to ${assetPath} — rename one`,
145
+ );
146
+ }
147
+ sidecarBundles.set(assetPath, { entryPath, specifier });
148
+ return assetPath;
149
+ } catch (error) {
150
+ errors.push(`Sidecar "${specifier}": ${(error as Error).message}`);
151
+ return specifier;
152
+ }
153
+ };
154
+
109
155
  // ── 1b. Build the extension registry from project.json#/extensions ─────
110
156
  const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
111
157
  const formatRegistry = registry.formats;
@@ -194,6 +240,7 @@ export async function buildSite(
194
240
  const result = await compileElement(componentPath, {
195
241
  ...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
196
242
  formats: formatRegistry,
243
+ rewriteSrc: rewriteSidecarSrc,
197
244
  });
198
245
  for (const f of result.files) {
199
246
  // Reduce the emitted path (an absolute source path with .json swapped to .js) to a bare
@@ -277,6 +324,7 @@ export async function buildSite(
277
324
  imageMetaCache,
278
325
  formatRegistry,
279
326
  registry,
327
+ rewriteSidecarSrc,
280
328
  );
281
329
 
282
330
  // Determine which component tags are fully static (for script omission)
@@ -412,10 +460,23 @@ export async function buildSite(
412
460
  : compileSiteServer([...deduped.values()], { adapter, connectors, mounts });
413
461
 
414
462
  if (workerSource) {
463
+ // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
464
+ // Modules, connectors, hono, and user server functions are inlined, so dist deploys
465
+ // Without node_modules or deploy-time bundling.
415
466
  const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
416
- writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
417
- fileCount += 1;
418
- log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
467
+ try {
468
+ await bundleWorkerSource(workerSource, {
469
+ adapter,
470
+ outfile: resolve(outDir, workerName),
471
+ projectRoot,
472
+ });
473
+ fileCount += 1;
474
+ log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
475
+ } catch (error) {
476
+ const msg = `Error bundling dist/${workerName}: ${(error as Error).message}`;
477
+ errors.push(msg);
478
+ console.error(msg);
479
+ }
419
480
 
420
481
  if (adapter === "cloudflare-pages") {
421
482
  // Only invoke the worker for server routes; everything else stays static.
@@ -426,17 +487,68 @@ export async function buildSite(
426
487
  );
427
488
  fileCount += 1;
428
489
  }
490
+ }
491
+ }
492
+
493
+ // ── 6d. Bundle client sidecar modules (spec.md §12) ─────────────────────
494
+ if (sidecarBundles.size > 0) {
495
+ log(`Bundling ${sidecarBundles.size} client sidecar module(s)...`);
496
+ for (const [assetPath, bundle] of sidecarBundles) {
497
+ try {
498
+ const outfile = resolve(outDir, assetPath.replace(/^\//, ""));
499
+ mkdirSync(dirname(outfile), { recursive: true });
500
+ await bundleEntry(
501
+ { entryPath: bundle.entryPath, outfile },
502
+ { external: CLIENT_EXTERNALS, target: "browser" },
503
+ );
504
+ fileCount += 1;
505
+ log(` ${bundle.specifier} → ${assetPath}`);
506
+ } catch (error) {
507
+ const msg = `Error bundling sidecar "${bundle.specifier}": ${(error as Error).message}`;
508
+ errors.push(msg);
509
+ console.error(msg);
510
+ }
511
+ }
512
+ }
429
513
 
430
- // Copy server source files into dist/components/ so worker imports resolve
431
- const distComponentsDir = resolve(outDir, "components");
432
- mkdirSync(distComponentsDir, { recursive: true });
433
- for (const { src } of deduped.values()) {
434
- const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
435
- const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
436
- if (existsSync(srcFile)) {
437
- copyFileSync(srcFile, destFile);
514
+ // ── 6e. Extension-emitted assets (extensions.md §8.4) ───────────────────
515
+ // Section-owner classes with an `emit` capability contribute derived build artifacts
516
+ // (search indexes, feeds, …). The host writes the returned files so extensions stay pure;
517
+ // Paths are outDir-relative and guarded against traversal. Note the `public/` copy (7c)
518
+ // Runs later and can shadow emitted files — same semantics as sitemap.xml.
519
+ for (const entry of registry.emitters()) {
520
+ const sectionKey = entry.project?.key;
521
+ const sectionValue = sectionKey
522
+ ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
523
+ : undefined;
524
+ if (
525
+ sectionKey &&
526
+ (sectionValue == null ||
527
+ (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
528
+ ) {
529
+ continue; // Section owner with nothing declared — same gating as sections/mounts
530
+ }
531
+ try {
532
+ const emitted = (await entry.call("emit", sectionValue ?? null, {
533
+ projectConfig,
534
+ root: projectRoot,
535
+ routes,
536
+ sections,
537
+ })) as { path: string; content: string | Uint8Array }[] | null;
538
+ for (const file of emitted ?? []) {
539
+ const target = resolve(outDir, file.path.replace(/^\//, ""));
540
+ if (!target.startsWith(outDir + sep)) {
541
+ throw new Error(`emit path escapes outDir: ${file.path}`);
438
542
  }
543
+ mkdirSync(dirname(target), { recursive: true });
544
+ writeFileSync(target, file.content);
545
+ fileCount += 1;
546
+ log(` ${entry.name} emitted ${file.path}`);
439
547
  }
548
+ } catch (error) {
549
+ const msg = `Error in ${entry.name}.emit: ${(error as Error).message}`;
550
+ errors.push(msg);
551
+ console.error(msg);
440
552
  }
441
553
  }
442
554
 
@@ -517,6 +629,7 @@ async function compilePage(
517
629
  imageMetaCache: ImageMetaCache | null = null,
518
630
  formatRegistry?: FormatRegistry,
519
631
  registry?: ExtensionRegistry,
632
+ rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
520
633
  ) {
521
634
  // Load the raw page document (.json natively, other formats via the registry)
522
635
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -541,6 +654,13 @@ async function compilePage(
541
654
  config: projectConfig,
542
655
  sections,
543
656
  ...(registry === undefined ? {} : { registry }),
657
+ ...(rewriteSidecarSrc === undefined
658
+ ? {}
659
+ : {
660
+ registerBundle: (specifier: string) => {
661
+ rewriteSidecarSrc(specifier, null);
662
+ },
663
+ }),
544
664
  });
545
665
 
546
666
  // Build scope from resolved state so template strings in title/$head can be evaluated
@@ -609,6 +729,13 @@ async function compilePage(
609
729
  layoutDoc.$media = { ...projectConfig.$media, ...layoutDoc.$media };
610
730
  }
611
731
 
732
+ // Declaring a pure color-scheme query opts the page into the forced-scheme contract: restore
733
+ // The visitor's persisted scheme before first paint (spec §9.5). Injected after the
734
+ // Charset/viewport defaults and ahead of every preserved <style> block.
735
+ if (Object.values(layoutDoc.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)) {
736
+ mergedHead.splice(2, 0, { tagName: "script", textContent: colorSchemePrePaintScript() });
737
+ }
738
+
612
739
  // Transform <img> nodes for responsive image optimization
613
740
  if (projectConfig.images?.optimize && (imageCache || imageMetaCache)) {
614
741
  await transformImageNodes(
@@ -623,7 +750,14 @@ async function compilePage(
623
750
  // Compile the document using the existing compiler
624
751
  const result = await compile(layoutDoc, {
625
752
  lang: projectConfig.defaults?.lang ?? "en",
753
+ prePaintScheme: false, // Injected via the merged <head> above, not the target template
626
754
  projectStyle: projectConfig.style ?? null,
755
+ ...(rewriteSidecarSrc === undefined
756
+ ? {}
757
+ : {
758
+ rewriteSrc: (specifier: string) =>
759
+ rewriteSidecarSrc(specifier, dirname(route.sourcePath as string)),
760
+ }),
627
761
  title,
628
762
  });
629
763
 
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Validate-command — `jx validate` whole-project implementation.
3
+ *
4
+ * Extends the original project.json check into the full compliance walk:
5
+ *
6
+ * 1. `project.json` against the generated entry schema (validateProjectFile);
7
+ * 2. The committed entry documents are SELF-CONTAINED (no residual relative `$ref`s — the
8
+ * editor-resolution guarantee of the bundled form, specs/extensions.md §5.4);
9
+ * 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
10
+ * (core + extension `$paths` union — the same schema the studio consumes);
11
+ * 4. Every project-local `*.class.json` against the generated class schema;
12
+ * 5. Every enabled extension's schema fragments compile standalone against the shipped default union
13
+ * resources.
14
+ *
15
+ * Validation failures are data (issues in the result); infrastructure failures throw.
16
+ */
17
+
18
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { join, relative, resolve } from "node:path";
21
+ import { generateClassSchema } from "@jxsuite/schema";
22
+ import { validateProjectFile } from "@jxsuite/schema/validate-project";
23
+ import { buildProjectExtensionRegistry } from "./format-host.ts";
24
+ import { loadProjectConfig } from "./site-loader.ts";
25
+ import { readBundledProjectSchemas } from "./schema-command.ts";
26
+
27
+ /** Directories that hold Jx documents validated against the document schema. */
28
+ const DOCUMENT_DIRS = ["components", "pages", "layouts"] as const;
29
+
30
+ /** Directory names never walked for class files. */
31
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
32
+
33
+ /** Shipped default union resources fragments may reference by canonical URI. */
34
+ const SHIPPED_RESOURCE_SPECIFIERS = [
35
+ "@jxsuite/schema/schemas/project.core.schema.json",
36
+ "@jxsuite/schema/schemas/project.fields.schema.json",
37
+ "@jxsuite/schema/schemas/document.paths.schema.json",
38
+ ];
39
+
40
+ export interface ProjectTreeIssue {
41
+ /** Project-relative file path (or absolute for extension fragments outside the root). */
42
+ file: string;
43
+ /** Ajv error objects, or `{ message }` records for structural issues. */
44
+ errors: unknown[];
45
+ }
46
+
47
+ export interface ProjectTreeValidationResult {
48
+ valid: boolean;
49
+ /** Number of files checked across all five walks. */
50
+ checked: number;
51
+ issues: ProjectTreeIssue[];
52
+ }
53
+
54
+ // Minimal structural types for the optional `ajv` dependency (same pattern as validate-project).
55
+ interface AjvValidateFn {
56
+ (doc: unknown): boolean;
57
+ errors?: unknown[] | null;
58
+ }
59
+ interface AjvInstance {
60
+ compile: (schema: unknown) => AjvValidateFn;
61
+ addSchema: (schema: unknown) => AjvInstance;
62
+ }
63
+ type AjvCtor = new (opts: {
64
+ allErrors: boolean;
65
+ ownProperties: boolean;
66
+ strict: boolean;
67
+ }) => AjvInstance;
68
+ type AddFormatsFn = (ajv: AjvInstance) => void;
69
+
70
+ async function loadAjv(): Promise<{ Ajv: AjvCtor; addFormats: AddFormatsFn }> {
71
+ try {
72
+ // The schemas are JSON Schema 2020-12, so use the matching Ajv build.
73
+ const { default: Ajv } = (await import("ajv/dist/2020")) as unknown as { default: AjvCtor };
74
+ // @ts-expect-error — resolved through @jxsuite/schema's dependency
75
+ const { default: addFormats } = (await import("ajv-formats")) as { default: AddFormatsFn };
76
+ return { addFormats, Ajv };
77
+ } catch (error) {
78
+ throw new Error("Project validation requires ajv and ajv-formats: bun add ajv ajv-formats", {
79
+ cause: error,
80
+ });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate a whole project tree. See the module docstring for the five walks.
86
+ *
87
+ * @param {string} projectRoot - Absolute path to the project directory
88
+ * @returns {Promise<ProjectTreeValidationResult>}
89
+ */
90
+ export async function validateProjectTree(
91
+ projectRoot: string,
92
+ ): Promise<ProjectTreeValidationResult> {
93
+ const root = resolve(projectRoot);
94
+ const issues: ProjectTreeIssue[] = [];
95
+ let checked = 0;
96
+
97
+ // 1. project.json against the generated entry schema.
98
+ const projectResult = await validateProjectFile(root);
99
+ checked += 1;
100
+ if (!projectResult.valid) {
101
+ issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
102
+ }
103
+
104
+ // 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
105
+ for (const name of ["project.schema.json", "document.schema.json"]) {
106
+ const path = resolve(root, name);
107
+ if (!existsSync(path)) {
108
+ continue;
109
+ }
110
+ checked += 1;
111
+ const residual: string[] = [];
112
+ collectRelativeRefs(JSON.parse(readFileSync(path, "utf8")), residual);
113
+ if (residual.length > 0) {
114
+ issues.push({
115
+ errors: residual.map((ref) => ({
116
+ message:
117
+ `relative $ref "${ref}" — committed entry documents must be self-contained; ` +
118
+ `regenerate with \`jx schema\``,
119
+ })),
120
+ file: name,
121
+ });
122
+ }
123
+ }
124
+
125
+ const { addFormats, Ajv } = await loadAjv();
126
+
127
+ // 3. Documents against the bundled document schema (the same schema the studio consumes).
128
+ const { document: documentSchema } = await readBundledProjectSchemas(root);
129
+ const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
130
+ addFormats(docAjv);
131
+ const validateDoc = docAjv.compile(documentSchema);
132
+ for (const dir of DOCUMENT_DIRS) {
133
+ for (const file of walkJsonFiles(resolve(root, dir))) {
134
+ if (file.endsWith(".class.json")) {
135
+ continue; // Validated against the class schema below.
136
+ }
137
+ checked += 1;
138
+ const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
139
+ if (!validateDoc(doc)) {
140
+ issues.push({ errors: validateDoc.errors ?? [], file: relative(root, file) });
141
+ }
142
+ }
143
+ }
144
+
145
+ // 4. Project-local class definitions against the generated class schema.
146
+ const classAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
147
+ addFormats(classAjv);
148
+ const validateClass = classAjv.compile(generateClassSchema());
149
+ for (const file of walkClassFiles(root)) {
150
+ checked += 1;
151
+ const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
152
+ if (!validateClass(doc)) {
153
+ issues.push({ errors: validateClass.errors ?? [], file: relative(root, file) });
154
+ }
155
+ }
156
+
157
+ // 5. Enabled extensions' schema fragments compile standalone against the shipped defaults.
158
+ const { config } = loadProjectConfig(root);
159
+ const registry = await buildProjectExtensionRegistry(root, config);
160
+ const selfRequire = createRequire(import.meta.url);
161
+ for (const ext of registry.extensions) {
162
+ for (const kind of ["project", "document", "fields"] as const) {
163
+ const fragmentPath = ext.schemas[kind];
164
+ if (!fragmentPath) {
165
+ continue;
166
+ }
167
+ checked += 1;
168
+ const fragAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
169
+ addFormats(fragAjv);
170
+ for (const specifier of SHIPPED_RESOURCE_SPECIFIERS) {
171
+ const resourcePath = selfRequire.resolve(specifier);
172
+ const resource = JSON.parse(readFileSync(resourcePath, "utf8")) as Record<string, unknown>;
173
+ fragAjv.addSchema(resource);
174
+ }
175
+ try {
176
+ fragAjv.compile(JSON.parse(readFileSync(fragmentPath, "utf8")));
177
+ } catch (error) {
178
+ issues.push({
179
+ errors: [{ message: error instanceof Error ? error.message : String(error) }],
180
+ file: `${ext.specifier} (${kind} fragment)`,
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ return { checked, issues, valid: issues.length === 0 };
187
+ }
188
+
189
+ /** Collect relative-path `$ref`s (anything without a URI scheme that isn't a `#` pointer). */
190
+ function collectRelativeRefs(node: unknown, out: string[]): void {
191
+ if (Array.isArray(node)) {
192
+ for (const item of node) {
193
+ collectRelativeRefs(item, out);
194
+ }
195
+ return;
196
+ }
197
+ if (node === null || typeof node !== "object") {
198
+ return;
199
+ }
200
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
201
+ if (
202
+ key === "$ref" &&
203
+ typeof value === "string" &&
204
+ !value.startsWith("#") &&
205
+ !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
206
+ ) {
207
+ out.push(value);
208
+ }
209
+ collectRelativeRefs(value, out);
210
+ }
211
+ }
212
+
213
+ /** Recursively list `.json` files under a directory (absent directories yield nothing). */
214
+ function walkJsonFiles(dir: string): string[] {
215
+ if (!existsSync(dir)) {
216
+ return [];
217
+ }
218
+ const out: string[] = [];
219
+ for (const name of readdirSync(dir)) {
220
+ const path = join(dir, name);
221
+ if (statSync(path).isDirectory()) {
222
+ out.push(...walkJsonFiles(path));
223
+ } else if (name.endsWith(".json")) {
224
+ out.push(path);
225
+ }
226
+ }
227
+ return out.toSorted();
228
+ }
229
+
230
+ /** Recursively list `*.class.json` files under the root, skipping vendored/output dirs. */
231
+ function walkClassFiles(root: string): string[] {
232
+ const out: string[] = [];
233
+ const walk = (dir: string) => {
234
+ for (const name of readdirSync(dir)) {
235
+ if (SKIP_DIRS.has(name)) {
236
+ continue;
237
+ }
238
+ const path = join(dir, name);
239
+ if (statSync(path).isDirectory()) {
240
+ walk(path);
241
+ } else if (name.endsWith(".class.json")) {
242
+ out.push(path);
243
+ }
244
+ }
245
+ };
246
+ walk(root);
247
+ return out.toSorted();
248
+ }
249
+
250
+ /** Format a result's issues as printable lines (one per error) for the CLI. */
251
+ export function formatProjectTreeIssues(result: ProjectTreeValidationResult): string[] {
252
+ const lines: string[] = [];
253
+ for (const issue of result.issues) {
254
+ lines.push(`${issue.file}:`);
255
+ for (const error of issue.errors) {
256
+ const { instancePath, message } = (error ?? {}) as {
257
+ instancePath?: string;
258
+ message?: string;
259
+ };
260
+ lines.push(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
261
+ }
262
+ }
263
+ return lines;
264
+ }
@@ -16,6 +16,7 @@ import {
16
16
  DEFAULT_LIT_HTML_SRC,
17
17
  DEFAULT_REACTIVITY_SRC,
18
18
  buildAttrs,
19
+ colorSchemePrePaintScript,
19
20
  compileExpression,
20
21
  compileStatements,
21
22
  compileStyles,
@@ -26,6 +27,7 @@ import {
26
27
  isRefObject,
27
28
  isSchemaOnly,
28
29
  isTemplateString,
30
+ pureSchemeOf,
29
31
  resolveStaticValue,
30
32
  } from "../shared.ts";
31
33
  import {
@@ -76,6 +78,7 @@ export function compileClient(
76
78
  litHtmlSrc?: string;
77
79
  modulePath?: string;
78
80
  projectStyle?: JxStyle | null;
81
+ prePaintScheme?: boolean;
79
82
  [key: string]: unknown;
80
83
  },
81
84
  ) {
@@ -238,6 +241,7 @@ export function compileClient(
238
241
  srcImportMap,
239
242
  counter,
240
243
  reactivitySrc,
244
+ opts.rewriteSrc as ((specifier: string) => string) | undefined,
241
245
  );
242
246
 
243
247
  // Build importmap entries — always include lit-html since compiled custom elements need it
@@ -246,13 +250,20 @@ export function compileClient(
246
250
  ` "lit-html": "${litHtmlSrc}"`,
247
251
  ];
248
252
 
253
+ // Forced-scheme contract (spec §9.5): restore the persisted scheme before any styles apply
254
+ const prePaint =
255
+ opts.prePaintScheme !== false &&
256
+ Object.values(raw.$media ?? {}).some((q) => pureSchemeOf(String(q)) !== null)
257
+ ? `<script>${colorSchemePrePaintScript()}</script>\n `
258
+ : "";
259
+
249
260
  const html = `<!DOCTYPE html>
250
261
  <html lang="en">
251
262
  <head>
252
263
  <meta charset="utf-8">
253
264
  <meta name="viewport" content="width=device-width, initial-scale=1">
254
265
  <title>${escapeHtml(title)}</title>
255
- <script type="importmap">
266
+ ${prePaint}<script type="importmap">
256
267
  {
257
268
  "imports": {
258
269
  ${importmapEntries.join(",\n")}
@@ -757,6 +768,7 @@ function emitClientModule(
757
768
  needsLit: boolean;
758
769
  },
759
770
  _reactivitySrc: string,
771
+ rewriteSrc?: (specifier: string) => string,
760
772
  ) {
761
773
  const lines: string[] = [];
762
774
  const { needsLit } = counter;
@@ -776,9 +788,11 @@ function emitClientModule(
776
788
  lines.push("import { html, render } from 'lit-html';");
777
789
  }
778
790
 
779
- // $src imports
791
+ // $src imports — bundleable specifiers (npm:…, ./relative) are rewritten to their /assets/
792
+ // Bundle URL; the site build bundles them after all documents compile (spec.md §12).
780
793
  for (const [src, names] of srcImportMap) {
781
- lines.push(`import { ${[...names].join(", ")} } from '${src}';`);
794
+ const importPath = rewriteSrc ? rewriteSrc(src) : src;
795
+ lines.push(`import { ${[...names].join(", ")} } from '${importPath}';`);
782
796
  }
783
797
 
784
798
  // State — reactive state