@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/dist/cli.js +167 -168
- package/dist/compiler.js +105 -107
- package/package.json +8 -7
- package/src/shared.ts +236 -8
- package/src/site/asset-mounts.ts +140 -0
- package/src/site/image-transform.ts +25 -7
- package/src/site/schema-command.ts +37 -77
- package/src/site/site-build.ts +96 -15
- package/src/site/validate-command.ts +133 -37
- package/src/targets/compile-client.ts +63 -67
- package/src/targets/compile-element.ts +246 -31
- package/src/targets/compile-static.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
49
|
-
"@jxsuite/runtime": "^1.3.
|
|
50
|
-
"@jxsuite/schema": "^1.
|
|
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.
|
|
56
|
-
"@jxsuite/connector": "^0.4.
|
|
57
|
-
"@jxsuite/parser": "^1.
|
|
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
|
-
|
|
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
|
|
505
|
+
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
363
506
|
const { body } = def;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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, () =>
|
|
517
|
+
defineLazyScopeValue(scope, key, () => invoke(scope));
|
|
369
518
|
} else {
|
|
370
|
-
setOwnScopeValue(scope, key,
|
|
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(
|
|
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
|
-
*
|
|
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
|
}
|