@jxsuite/compiler 1.2.0 → 1.4.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.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "type": "module",
20
20
  "exports": {
21
21
  ".": "./src/compiler.ts",
22
+ "./asset-mounts": "./src/site/asset-mounts.ts",
22
23
  "./format-host": "./src/site/format-host.ts",
23
24
  "./project-sections": "./src/site/project-sections.ts",
24
25
  "./schema-command": "./src/site/schema-command.ts",
@@ -45,16 +46,16 @@
45
46
  },
46
47
  "dependencies": {
47
48
  "@apidevtools/json-schema-ref-parser": "^15.5.0",
48
- "@jxsuite/create": "^1.1.1",
49
- "@jxsuite/runtime": "^1.2.0",
50
- "@jxsuite/schema": "^1.2.0",
49
+ "@jxsuite/create": "^1.2.1",
50
+ "@jxsuite/runtime": "^1.3.1",
51
+ "@jxsuite/schema": "^1.4.0",
51
52
  "esbuild": "^0.28.1",
52
53
  "sharp": "^0.35.3"
53
54
  },
54
55
  "devDependencies": {
55
- "@happy-dom/global-registrator": "^20.10.6",
56
- "@jxsuite/connector": "^0.3.1",
57
- "@jxsuite/parser": "^1.2.0",
56
+ "@happy-dom/global-registrator": "^20.11.1",
57
+ "@jxsuite/connector": "^0.4.1",
58
+ "@jxsuite/parser": "^1.4.0",
58
59
  "@types/mdast": "^4.0.4"
59
60
  }
60
61
  }
package/src/cli.ts CHANGED
@@ -26,7 +26,7 @@ Commands:
26
26
  dev [root] Start the dev server (requires @jxsuite/server and Bun)
27
27
  preview [root] Serve an already-built dist/ directory
28
28
  schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
29
- validate [root] Validate project.json against its generated project.schema.json
29
+ validate [root] Validate the whole project: project.json, documents, classes, and fragments
30
30
  db push [root] Sync the data section's tables to their connections (additive-only)
31
31
 
32
32
  Options:
@@ -161,18 +161,15 @@ Options:
161
161
  }
162
162
  } else if (command === "validate") {
163
163
  try {
164
- const { validateProjectFile } = await import("@jxsuite/schema/validate-project");
165
- const { valid, errors } = await validateProjectFile(projectRoot);
166
- if (valid) {
167
- console.log(`project.json is valid (${projectRoot})`);
164
+ const { formatProjectTreeIssues, validateProjectTree } =
165
+ await import("./site/validate-command.ts");
166
+ const result = await validateProjectTree(projectRoot);
167
+ if (result.valid) {
168
+ console.log(`Project is valid (${result.checked} files checked in ${projectRoot})`);
168
169
  } else {
169
- console.error(`project.json is INVALID (${projectRoot}):`);
170
- for (const error of errors ?? []) {
171
- const { instancePath, message } = (error ?? {}) as {
172
- instancePath?: string;
173
- message?: string;
174
- };
175
- console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
170
+ console.error(`Project is INVALID (${projectRoot}):`);
171
+ for (const line of formatProjectTreeIssues(result)) {
172
+ console.error(line);
176
173
  }
177
174
  process.exit(1);
178
175
  }
package/src/shared.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  isExpressionDef,
24
24
  isFunctionDef,
25
25
  isMappedArray,
26
+ bodyReturnsValue,
26
27
  isNamedFormulaDef,
27
28
  isPrototypeDef,
28
29
  isRef,
@@ -79,7 +80,7 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
79
80
  export type { ExpressionNode } from "@jxsuite/runtime/expression";
80
81
 
81
82
  // CDN defaults
82
- export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.32";
83
+ export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
83
84
  export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
84
85
 
85
86
  // ─── Schema keywords & detection ─────────────────────────────────────────────
@@ -363,7 +364,7 @@ export function buildInitialScope(
363
364
  const fn = new Function("state", ...names, body) as (
364
365
  state: Record<string, unknown>,
365
366
  ) => unknown;
366
- if (body.includes("return")) {
367
+ if (bodyReturnsValue(body)) {
367
368
  defineLazyScopeValue(scope, key, () => fn(scope));
368
369
  } else {
369
370
  setOwnScopeValue(scope, key, fn);
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Asset-mounts — host side of the extension `assets` capability (specs/extensions.md §8.5).
3
+ *
4
+ * A section-owner class publishes directories that may sit outside the project root (an external
5
+ * content source's co-located images) at a stable site URL. The build resolves those URLs while
6
+ * optimizing images, collects the ones its compiled output actually references, and copies just
7
+ * those files into dist — the source directory itself is never mirrored, so entry files never leak
8
+ * into the build output.
9
+ *
10
+ * @module asset-mounts
11
+ * @docs framework/site/images
12
+ */
13
+
14
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
15
+ import { dirname, resolve } from "node:path";
16
+ import {
17
+ collectAssetUrls,
18
+ normalizeAssetPrefix,
19
+ resolveAssetUrl,
20
+ } from "@jxsuite/schema/asset-paths";
21
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
22
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
23
+ import type { ProjectConfig } from "@jxsuite/schema/types";
24
+
25
+ /**
26
+ * Gather every asset mount contributed by the project's extensions.
27
+ *
28
+ * Gating matches `emit` (extensions.md §8.4): a class owning a project section contributes only
29
+ * when the project declares a non-empty value for that section. Mounts claiming the same URL prefix
30
+ * for different directories are a configuration error — the first wins and the clash is reported.
31
+ *
32
+ * @param {ExtensionRegistry} registry - The project's extension registry
33
+ * @param {ProjectConfig} projectConfig
34
+ * @param {string} projectRoot - Absolute project root
35
+ * @returns {Promise<{ mounts: AssetMount[]; errors: string[] }>}
36
+ */
37
+ export async function loadAssetMounts(
38
+ registry: ExtensionRegistry,
39
+ projectConfig: ProjectConfig,
40
+ projectRoot: string,
41
+ ): Promise<{ mounts: AssetMount[]; errors: string[] }> {
42
+ const mounts: AssetMount[] = [];
43
+ const errors: string[] = [];
44
+ const byPrefix = new Map<string, AssetMount>();
45
+
46
+ for (const entry of registry.assetProviders()) {
47
+ const sectionKey = entry.project?.key;
48
+ const sectionValue = sectionKey
49
+ ? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
50
+ : undefined;
51
+ if (
52
+ sectionKey &&
53
+ (sectionValue == null ||
54
+ (typeof sectionValue === "object" && Object.keys(sectionValue).length === 0))
55
+ ) {
56
+ continue;
57
+ }
58
+ try {
59
+ const declared = (await entry.call("assets", sectionValue ?? null, {
60
+ projectConfig,
61
+ root: projectRoot,
62
+ })) as AssetMount[] | null;
63
+ for (const mount of declared ?? []) {
64
+ if (!mount?.urlPrefix || !mount.dir) {
65
+ continue;
66
+ }
67
+ const normalized: AssetMount = {
68
+ dir: mount.dir,
69
+ urlPrefix: normalizeAssetPrefix(mount.urlPrefix),
70
+ };
71
+ const prior = byPrefix.get(normalized.urlPrefix);
72
+ if (prior) {
73
+ if (prior.dir !== normalized.dir) {
74
+ errors.push(
75
+ `Asset mount conflict: "${normalized.urlPrefix}" is claimed by both ` +
76
+ `"${prior.dir}" and "${normalized.dir}"`,
77
+ );
78
+ }
79
+ continue;
80
+ }
81
+ byPrefix.set(normalized.urlPrefix, normalized);
82
+ mounts.push(normalized);
83
+ }
84
+ } catch (error) {
85
+ errors.push(`Error in ${entry.name}.assets: ${(error as Error).message}`);
86
+ }
87
+ }
88
+
89
+ return { errors, mounts };
90
+ }
91
+
92
+ /**
93
+ * Add every mounted URL referenced by a compiled artifact to `into`.
94
+ *
95
+ * @param {string} text - Compiled HTML or CSS
96
+ * @param {readonly AssetMount[]} mounts
97
+ * @param {Set<string>} into - Accumulator across the whole build
98
+ */
99
+ export function collectAssetRefs(text: string, mounts: readonly AssetMount[], into: Set<string>) {
100
+ if (mounts.length === 0 || !text) {
101
+ return;
102
+ }
103
+ for (const url of collectAssetUrls(text, mounts)) {
104
+ into.add(url);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Copy referenced mounted assets into the build output, each at its own URL path.
110
+ *
111
+ * @param {Iterable<string>} urls - Mounted URLs collected from the compiled output
112
+ * @param {readonly AssetMount[]} mounts
113
+ * @param {string} outDir - Absolute build output directory
114
+ * @returns {{ copied: number; missing: string[] }} Files written, and URLs that resolved to nothing
115
+ */
116
+ export function copyMountedAssets(
117
+ urls: Iterable<string>,
118
+ mounts: readonly AssetMount[],
119
+ outDir: string,
120
+ ): { copied: number; missing: string[] } {
121
+ let copied = 0;
122
+ const missing: string[] = [];
123
+
124
+ for (const url of urls) {
125
+ // The single containment gate is resolveAssetUrl: it decodes once and refuses `.`/`..`,
126
+ // Empty segments, and undecodable input, so a surviving URL cannot escape either the mount
127
+ // Directory it reads from or the outDir path it writes to.
128
+ const source = resolveAssetUrl(mounts, url);
129
+ if (!source || !existsSync(source)) {
130
+ missing.push(url);
131
+ continue;
132
+ }
133
+ const target = resolve(outDir, decodeURIComponent(url).replace(/^\//, ""));
134
+ mkdirSync(dirname(target), { recursive: true });
135
+ cpSync(source, target);
136
+ copied += 1;
137
+ }
138
+
139
+ return { copied, missing };
140
+ }
@@ -16,7 +16,9 @@ import {
16
16
  processImage,
17
17
  } from "./image-optimizer.ts";
18
18
  import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
19
+ import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
19
20
 
21
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
20
22
  import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
21
23
  import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
22
24
  import type { CacheManifest } from "./image-cache.ts";
@@ -193,13 +195,20 @@ function isAllowedRemote(src: string, config: ImageConfig) {
193
195
  /**
194
196
  * Resolve a src path to an absolute filesystem path.
195
197
  *
196
- * Handles paths starting with "/" (relative to public dir or project root).
198
+ * Extension asset mounts win first — they publish directories that may sit outside the project root
199
+ * (extensions.md §8.5), so a mounted src has no other resolution. Otherwise paths starting with "/"
200
+ * resolve into `public/`, and relative ones from the project root.
197
201
  *
198
202
  * @param {string} src
199
203
  * @param {string} projectRoot
204
+ * @param {readonly AssetMount[]} [mounts]
200
205
  * @returns {string}
201
206
  */
202
- function resolveImagePath(src: string, projectRoot: string) {
207
+ function resolveImagePath(src: string, projectRoot: string, mounts?: readonly AssetMount[]) {
208
+ const mounted = mounts?.length ? resolveAssetUrl(mounts, src) : null;
209
+ if (mounted) {
210
+ return mounted;
211
+ }
203
212
  if (src.startsWith("/")) {
204
213
  return resolve(projectRoot, "public", src.slice(1));
205
214
  }
@@ -218,6 +227,7 @@ function resolveImagePath(src: string, projectRoot: string) {
218
227
  * @param {string} projectRoot
219
228
  * @param {CacheManifest | null} cache - Variant cache (build mode); unused in cloudflare mode
220
229
  * @param {ImageMetaCache} [metaCache] - Dimension/hash memo (cloudflare mode)
230
+ * @param {readonly AssetMount[]} [mounts] - Extension asset mounts (extensions.md §8.5)
221
231
  * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
222
232
  */
223
233
  export async function transformImageNodes(
@@ -226,6 +236,7 @@ export async function transformImageNodes(
226
236
  projectRoot: string,
227
237
  cache: CacheManifest | null,
228
238
  metaCache?: ImageMetaCache,
239
+ mounts: readonly AssetMount[] = [],
229
240
  ) {
230
241
  const imageRefs = new Map<string, ImageManifest>();
231
242
 
@@ -234,7 +245,7 @@ export async function transformImageNodes(
234
245
  }
235
246
 
236
247
  const meta = metaCache ?? new Map();
237
- await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs);
248
+ await walkAndTransform(doc, config, projectRoot, cache, meta, imageRefs, mounts);
238
249
 
239
250
  return { imageRefs };
240
251
  }
@@ -246,6 +257,7 @@ export async function transformImageNodes(
246
257
  * @param {CacheManifest | null} cache
247
258
  * @param {ImageMetaCache} metaCache
248
259
  * @param {Map<string, ImageManifest>} imageRefs
260
+ * @param {readonly AssetMount[]} mounts
249
261
  */
250
262
  async function walkAndTransform(
251
263
  node: JxMutableNode | JxDocument,
@@ -254,13 +266,14 @@ async function walkAndTransform(
254
266
  cache: CacheManifest | null,
255
267
  metaCache: ImageMetaCache,
256
268
  imageRefs: Map<string, ImageManifest>,
269
+ mounts: readonly AssetMount[],
257
270
  ) {
258
271
  if (!node || typeof node !== "object") {
259
272
  return;
260
273
  }
261
274
 
262
275
  if (node.tagName === "img") {
263
- await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs);
276
+ await transformImgNode(node, config, projectRoot, cache, metaCache, imageRefs, mounts);
264
277
  }
265
278
 
266
279
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -271,6 +284,7 @@ async function walkAndTransform(
271
284
  cache,
272
285
  metaCache,
273
286
  imageRefs,
287
+ mounts,
274
288
  );
275
289
  }
276
290
 
@@ -279,7 +293,7 @@ async function walkAndTransform(
279
293
  if (typeof child === "string") {
280
294
  continue;
281
295
  }
282
- await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs);
296
+ await walkAndTransform(child, config, projectRoot, cache, metaCache, imageRefs, mounts);
283
297
  }
284
298
  }
285
299
  }
@@ -291,6 +305,7 @@ async function walkAndTransform(
291
305
  * @param {CacheManifest | null} cache
292
306
  * @param {ImageMetaCache} metaCache
293
307
  * @param {Map<string, ImageManifest>} imageRefs
308
+ * @param {readonly AssetMount[]} mounts
294
309
  */
295
310
  async function transformImgNode(
296
311
  node: JxMutableNode | JxDocument,
@@ -299,6 +314,7 @@ async function transformImgNode(
299
314
  cache: CacheManifest | null,
300
315
  metaCache: ImageMetaCache,
301
316
  imageRefs: Map<string, ImageManifest>,
317
+ mounts: readonly AssetMount[],
302
318
  ) {
303
319
  if (!node.attributes) {
304
320
  node.attributes = {};
@@ -325,7 +341,7 @@ async function transformImgNode(
325
341
  // Fit=scale-down avoid upscaling past the source size.
326
342
  srcset = buildCloudflareSrcset(src, config, Infinity, null);
327
343
  } else {
328
- const absoluteSrc = resolveImagePath(src, projectRoot);
344
+ const absoluteSrc = resolveImagePath(src, projectRoot, mounts);
329
345
  if (!existsSync(absoluteSrc)) {
330
346
  return;
331
347
  }
@@ -382,6 +398,7 @@ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
382
398
  * @param {CacheManifest | null} cache
383
399
  * @param {ImageMetaCache} metaCache
384
400
  * @param {Map<string, ImageManifest>} imageRefs
401
+ * @param {readonly AssetMount[]} mounts
385
402
  * @returns {Promise<string>}
386
403
  */
387
404
  async function transformInnerHtmlImages(
@@ -391,6 +408,7 @@ async function transformInnerHtmlImages(
391
408
  cache: CacheManifest | null,
392
409
  metaCache: ImageMetaCache,
393
410
  imageRefs: Map<string, ImageManifest>,
411
+ mounts: readonly AssetMount[],
394
412
  ) {
395
413
  /** @type {{ match: string; replacement: string }[]} */
396
414
  const replacements = [];
@@ -418,7 +436,7 @@ async function transformInnerHtmlImages(
418
436
  continue;
419
437
  }
420
438
 
421
- const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot);
439
+ const absoluteSrc = remote ? null : resolveImagePath(src, projectRoot, mounts);
422
440
  if (absoluteSrc && !existsSync(absoluteSrc)) {
423
441
  continue;
424
442
  }
@@ -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");
@@ -32,6 +32,8 @@ import { loadProjectConfig } from "./site-loader.ts";
32
32
  import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
33
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
34
34
  import { loadProjectSections } from "./project-sections.ts";
35
+ import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mounts.ts";
36
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
35
37
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
36
38
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
37
39
  import { resolveLayout } from "./layout-resolver.ts";
@@ -208,6 +210,26 @@ export async function buildSite(
208
210
  log(` Loaded ${sectionKeys.length} section(s): ${sectionKeys.join(", ")}`);
209
211
  }
210
212
 
213
+ // ── 3c. Extension asset mounts (extensions.md §8.5) ─────────────────────
214
+ // Directories published at a site URL — typically an external content source's co-located
215
+ // Images. They resolve for image optimization below, and the files pages actually reference
216
+ // Are copied into dist in step 7c.
217
+ const { errors: mountErrors, mounts: assetMounts } = await loadAssetMounts(
218
+ registry,
219
+ projectConfig,
220
+ projectRoot,
221
+ );
222
+ errors.push(...mountErrors);
223
+ for (const message of mountErrors) {
224
+ console.error(message);
225
+ }
226
+ if (assetMounts.length > 0) {
227
+ log(
228
+ ` Mounted ${assetMounts.length} asset dir(s): ${assetMounts.map((m) => m.urlPrefix).join(", ")}`,
229
+ );
230
+ }
231
+ const assetRefs = new Set<string>();
232
+
211
233
  // ── 4. Expand dynamic routes ────────────────────────────────────────────
212
234
  const routes = await expandDynamicRoutes(
213
235
  staticRoutes,
@@ -262,6 +284,7 @@ export async function buildSite(
262
284
  const css = buildComponentCSS(doc.tagName, doc.style, doc, projectConfig.$media ?? {});
263
285
  if (css) {
264
286
  componentCSS.set(doc.tagName, css);
287
+ collectAssetRefs(css, assetMounts, assetRefs);
265
288
  writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
266
289
  fileCount += 1;
267
290
  }
@@ -325,6 +348,7 @@ export async function buildSite(
325
348
  formatRegistry,
326
349
  registry,
327
350
  rewriteSidecarSrc,
351
+ assetMounts,
328
352
  );
329
353
 
330
354
  // Determine which component tags are fully static (for script omission)
@@ -345,6 +369,10 @@ export async function buildSite(
345
369
  );
346
370
  }
347
371
 
372
+ // Mounted assets this page actually references — scanned from the finished HTML so a
373
+ // Reference is caught wherever it came from (markdown image, hand-authored page, $head).
374
+ collectAssetRefs(result.html, assetMounts, assetRefs);
375
+
348
376
  // Determine output path
349
377
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
350
378
  mkdirSync(dirname(outPath), { recursive: true });
@@ -568,7 +596,19 @@ export async function buildSite(
568
596
  console.warn("sitemap.xml skipped — set `url` in project.json to enable sitemap generation.");
569
597
  }
570
598
 
571
- // ── 7c. Copy public/ assets ─────────────────────────────────────────────
599
+ // ── 7c. Copy referenced mounted assets (extensions.md §8.5) ─────────────
600
+ // Only files the compiled output references are copied, so a content source's entry files
601
+ // Never land in dist. Runs before the public/ copy, which keeps shadowing everything.
602
+ if (assetRefs.size > 0) {
603
+ const { copied, missing } = copyMountedAssets(assetRefs, assetMounts, outDir);
604
+ fileCount += copied;
605
+ log(`Copying ${copied} mounted asset(s)...`);
606
+ for (const url of missing) {
607
+ console.warn(`Referenced asset not found: ${url}`);
608
+ }
609
+ }
610
+
611
+ // ── 7c(ii). Copy public/ assets ─────────────────────────────────────────
572
612
  if (existsSync(publicDir)) {
573
613
  log("Copying public/ assets...");
574
614
  cpSync(publicDir, outDir, { recursive: true });
@@ -630,6 +670,7 @@ async function compilePage(
630
670
  formatRegistry?: FormatRegistry,
631
671
  registry?: ExtensionRegistry,
632
672
  rewriteSidecarSrc?: (specifier: string, docDir: string | null) => string,
673
+ assetMounts: readonly AssetMount[] = [],
633
674
  ) {
634
675
  // Load the raw page document (.json natively, other formats via the registry)
635
676
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
@@ -744,6 +785,7 @@ async function compilePage(
744
785
  projectRoot,
745
786
  imageCache,
746
787
  imageMetaCache ?? undefined,
788
+ assetMounts,
747
789
  );
748
790
  }
749
791