@jxsuite/compiler 1.3.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.3.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": {
@@ -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.2.0",
49
- "@jxsuite/runtime": "^1.3.0",
50
- "@jxsuite/schema": "^1.3.0",
49
+ "@jxsuite/create": "^1.2.2",
50
+ "@jxsuite/runtime": "^1.3.2",
51
+ "@jxsuite/schema": "^1.5.0",
51
52
  "esbuild": "^0.28.1",
52
53
  "sharp": "^0.35.3"
53
54
  },
54
55
  "devDependencies": {
55
- "@happy-dom/global-registrator": "^20.11.0",
56
- "@jxsuite/connector": "^0.4.0",
57
- "@jxsuite/parser": "^1.3.0",
56
+ "@happy-dom/global-registrator": "^20.11.1",
57
+ "@jxsuite/connector": "^0.4.2",
58
+ "@jxsuite/parser": "^1.4.1",
58
59
  "@types/mdast": "^4.0.4"
59
60
  }
60
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
  }
@@ -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
  }