@jxsuite/compiler 0.28.2 → 0.28.4
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 +159 -150
- package/dist/compiler.js +100 -95
- package/package.json +5 -5
- package/src/cli.ts +6 -6
- package/src/compile-cli.ts +6 -4
- package/src/compiler.ts +34 -23
- package/src/shared.ts +402 -231
- package/src/site/content-loader.ts +54 -29
- package/src/site/context-injection.ts +13 -9
- package/src/site/format-host.ts +26 -15
- package/src/site/head-merger.ts +37 -18
- package/src/site/image-cache.ts +13 -12
- package/src/site/image-optimizer.ts +30 -20
- package/src/site/image-transform.ts +136 -78
- package/src/site/layout-resolver.ts +33 -18
- package/src/site/pages-discovery.ts +61 -39
- package/src/site/prototype-resolver.ts +45 -21
- package/src/site/site-build.ts +212 -136
- package/src/site/site-loader.ts +70 -43
- package/src/targets/compile-class.ts +70 -51
- package/src/targets/compile-client.ts +271 -227
- package/src/targets/compile-element.ts +230 -146
- package/src/targets/compile-server.ts +9 -5
- package/src/targets/compile-static.ts +27 -19
package/src/site/site-build.ts
CHANGED
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
-
writeFileSync,
|
|
13
12
|
copyFileSync,
|
|
14
|
-
mkdirSync,
|
|
15
|
-
existsSync,
|
|
16
|
-
rmSync,
|
|
17
13
|
cpSync,
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
18
16
|
readdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
19
|
} from "node:fs";
|
|
20
|
-
import {
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { isMappedArray, isRef } from "@jxsuite/schema/guards";
|
|
21
22
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
22
23
|
import { discoverPages, expandDynamicRoutes, readPageDocument } from "./pages-discovery.ts";
|
|
23
24
|
import { buildProjectFormatRegistry } from "./format-host.ts";
|
|
@@ -28,35 +29,39 @@ import { injectContext } from "./context-injection.ts";
|
|
|
28
29
|
import { compile, compileServer, compileSiteServer } from "../compiler.ts";
|
|
29
30
|
import { compileElement } from "../targets/compile-element.ts";
|
|
30
31
|
import {
|
|
32
|
+
DEFAULT_LIT_HTML_SRC,
|
|
33
|
+
DEFAULT_REACTIVITY_SRC,
|
|
34
|
+
buildComponentCSS,
|
|
31
35
|
buildInitialScope,
|
|
32
|
-
|
|
36
|
+
collectServerEntries,
|
|
37
|
+
collectStyles,
|
|
33
38
|
evaluateStaticTemplate,
|
|
34
|
-
preRenderComponentHtml,
|
|
35
39
|
isComponentFullyStatic,
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
isTemplateString,
|
|
41
|
+
preRenderComponentHtml,
|
|
38
42
|
renderStaticNode,
|
|
39
|
-
resolveStaticValue,
|
|
40
43
|
resolveRefValue,
|
|
41
|
-
|
|
42
|
-
DEFAULT_LIT_HTML_SRC,
|
|
44
|
+
resolveStaticValue,
|
|
43
45
|
} from "../shared.ts";
|
|
44
|
-
import {
|
|
46
|
+
import { loadContentConfig, loadContentTypes, resolveContentTypeRefs } from "./content-loader.ts";
|
|
45
47
|
import { resolvePrototypes } from "./prototype-resolver.ts";
|
|
46
48
|
import { transformImageNodes } from "./image-transform.ts";
|
|
47
|
-
import { loadCache, saveCache
|
|
49
|
+
import { getImageCacheDir, loadCache, saveCache } from "./image-cache.ts";
|
|
48
50
|
import type { ImageConfig } from "./image-optimizer.ts";
|
|
49
51
|
import type { ImageMetaCache } from "./image-transform.ts";
|
|
50
52
|
import type {
|
|
53
|
+
JsonValue,
|
|
54
|
+
JxAttributeValue,
|
|
51
55
|
JxElement,
|
|
52
|
-
JxMutableNode,
|
|
53
|
-
JxStyle,
|
|
54
56
|
JxHeadEntry,
|
|
57
|
+
JxMutableNode,
|
|
55
58
|
JxStateDefinition,
|
|
59
|
+
JxStyle,
|
|
56
60
|
ProjectConfig,
|
|
57
61
|
} from "@jxsuite/schema/types";
|
|
58
62
|
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
59
63
|
import type { SiteRoute } from "../types.ts";
|
|
64
|
+
import type { CacheManifest } from "./image-cache.js";
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
67
|
* Build an entire Jx site from a project directory.
|
|
@@ -99,7 +104,7 @@ export async function buildSite(
|
|
|
99
104
|
|
|
100
105
|
// ── 2. Clean output directory ───────────────────────────────────────────
|
|
101
106
|
if (clean && existsSync(outDir)) {
|
|
102
|
-
rmSync(outDir, {
|
|
107
|
+
rmSync(outDir, { force: true, recursive: true });
|
|
103
108
|
}
|
|
104
109
|
mkdirSync(outDir, { recursive: true });
|
|
105
110
|
|
|
@@ -133,8 +138,8 @@ export async function buildSite(
|
|
|
133
138
|
// ── 5. Compile site components ──────────────────────────────────────────
|
|
134
139
|
const componentsDir = resolve(projectRoot, "components");
|
|
135
140
|
const compiledComponentTags: string[] = [];
|
|
136
|
-
const componentCSS
|
|
137
|
-
const componentDefs
|
|
141
|
+
const componentCSS = new Map<string, string>(); // TagName → CSS text
|
|
142
|
+
const componentDefs = new Map<string, JxElement>(); // TagName → parsed component definition
|
|
138
143
|
if (existsSync(componentsDir)) {
|
|
139
144
|
log("Compiling components...");
|
|
140
145
|
const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
|
|
@@ -148,14 +153,16 @@ export async function buildSite(
|
|
|
148
153
|
try {
|
|
149
154
|
const componentPath = resolve(componentsDir, file);
|
|
150
155
|
const result = await compileElement(componentPath, {
|
|
151
|
-
$media: projectConfig.$media,
|
|
156
|
+
...(projectConfig.$media ? { $media: projectConfig.$media } : {}),
|
|
152
157
|
formats: formatRegistry,
|
|
153
158
|
});
|
|
154
159
|
for (const f of result.files) {
|
|
155
160
|
const outName = f.path.includes("/") ? (f.path.split("/").pop() as string) : f.path;
|
|
156
161
|
writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
|
|
157
|
-
if (f.tagName)
|
|
158
|
-
|
|
162
|
+
if (f.tagName) {
|
|
163
|
+
compiledComponentTags.push(f.tagName);
|
|
164
|
+
}
|
|
165
|
+
fileCount += 1;
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
// Pre-render component HTML scaffold and CSS sidecar
|
|
@@ -166,11 +173,11 @@ export async function buildSite(
|
|
|
166
173
|
if (css) {
|
|
167
174
|
componentCSS.set(doc.tagName, css);
|
|
168
175
|
writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
|
|
169
|
-
fileCount
|
|
176
|
+
fileCount += 1;
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
|
-
} catch (
|
|
173
|
-
const err =
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const err = error as Error;
|
|
174
181
|
errors.push(`Error compiling component ${file}: ${err.message}`);
|
|
175
182
|
console.error(`Error compiling component ${file}: ${err.message}`);
|
|
176
183
|
}
|
|
@@ -187,7 +194,7 @@ export async function buildSite(
|
|
|
187
194
|
for (const [, doc] of componentDefs) {
|
|
188
195
|
const entries = collectServerEntries(doc);
|
|
189
196
|
for (const entry of entries) {
|
|
190
|
-
const resolvedSrc =
|
|
197
|
+
const resolvedSrc = `./components/${entry.src.replace(/^\.\//, "")}`;
|
|
191
198
|
siteServerEntries.push({
|
|
192
199
|
exportName: entry.exportName,
|
|
193
200
|
src: resolvedSrc,
|
|
@@ -225,9 +232,11 @@ export async function buildSite(
|
|
|
225
232
|
);
|
|
226
233
|
|
|
227
234
|
// Determine which component tags are fully static (for script omission)
|
|
228
|
-
const staticTags
|
|
235
|
+
const staticTags = new Set<string>();
|
|
229
236
|
for (const [tag, def] of componentDefs) {
|
|
230
|
-
if (isComponentFullyStatic(def))
|
|
237
|
+
if (isComponentFullyStatic(def)) {
|
|
238
|
+
staticTags.add(tag);
|
|
239
|
+
}
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
// Inject component CSS and JS scripts
|
|
@@ -244,29 +253,33 @@ export async function buildSite(
|
|
|
244
253
|
const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
|
|
245
254
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
246
255
|
writeFileSync(outPath, result.html, "utf8");
|
|
247
|
-
fileCount
|
|
256
|
+
fileCount += 1;
|
|
248
257
|
|
|
249
258
|
// Write serialized export sidecars alongside HTML (formats with exportTarget: true)
|
|
250
259
|
for (const fmt of formatRegistry.withCapability("serialize")) {
|
|
251
|
-
if (!fmt.exportTarget)
|
|
260
|
+
if (!fmt.exportTarget) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
252
263
|
try {
|
|
253
264
|
const content = (await fmt.call("serialize", result.doc, {
|
|
254
|
-
|
|
265
|
+
buildScope: (state: Record<string, JxStateDefinition>) =>
|
|
266
|
+
buildInitialScope(state, null),
|
|
255
267
|
componentDefs,
|
|
256
268
|
evaluateTemplate: (value: string, scope: Record<string, unknown>) => {
|
|
257
|
-
if (!isTemplateString(value))
|
|
269
|
+
if (!isTemplateString(value)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
258
272
|
return evaluateStaticTemplate(value, scope) ?? value;
|
|
259
273
|
},
|
|
260
|
-
|
|
261
|
-
buildInitialScope(state, null),
|
|
274
|
+
mode: "export",
|
|
262
275
|
})) as string;
|
|
263
276
|
if (content) {
|
|
264
277
|
const sidecarPath = outPath.replace(/\.html$/, fmt.extensions[0]);
|
|
265
278
|
writeFileSync(sidecarPath, content, "utf8");
|
|
266
|
-
fileCount
|
|
279
|
+
fileCount += 1;
|
|
267
280
|
}
|
|
268
|
-
} catch (
|
|
269
|
-
const err =
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const err = error as Error;
|
|
270
283
|
errors.push(`Error exporting ${fmt.name} for ${route.urlPattern}: ${err.message}`);
|
|
271
284
|
}
|
|
272
285
|
}
|
|
@@ -276,17 +289,17 @@ export async function buildSite(
|
|
|
276
289
|
const filePath = resolve(dirname(outPath), file.path);
|
|
277
290
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
278
291
|
writeFileSync(filePath, file.content, "utf8");
|
|
279
|
-
fileCount
|
|
292
|
+
fileCount += 1;
|
|
280
293
|
}
|
|
281
294
|
|
|
282
295
|
// Write server handler if present
|
|
283
296
|
if (result.serverHandler) {
|
|
284
297
|
const serverPath = resolve(dirname(outPath), "_server.js");
|
|
285
298
|
writeFileSync(serverPath, result.serverHandler, "utf8");
|
|
286
|
-
fileCount
|
|
299
|
+
fileCount += 1;
|
|
287
300
|
}
|
|
288
|
-
} catch (
|
|
289
|
-
const err =
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const err = error as Error;
|
|
290
303
|
const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
|
|
291
304
|
errors.push(msg);
|
|
292
305
|
console.error(msg);
|
|
@@ -310,16 +323,18 @@ export async function buildSite(
|
|
|
310
323
|
|
|
311
324
|
// ── 6c. Generate site-wide server worker ────────────────────────────────
|
|
312
325
|
if (projectConfig.build.adapter) {
|
|
313
|
-
const adapter = projectConfig.build
|
|
326
|
+
const { adapter } = projectConfig.build;
|
|
314
327
|
log("Generating site-wide server worker...");
|
|
315
328
|
|
|
316
329
|
const deduped = new Map();
|
|
317
330
|
for (const entry of siteServerEntries) {
|
|
318
|
-
if (!deduped.has(entry.exportName))
|
|
331
|
+
if (!deduped.has(entry.exportName)) {
|
|
332
|
+
deduped.set(entry.exportName, entry);
|
|
333
|
+
}
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
// Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
|
|
322
|
-
//
|
|
337
|
+
// Functions/ directory convention only works from the project root, not from dist/.
|
|
323
338
|
// A static-only Pages site needs no worker at all.
|
|
324
339
|
const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0;
|
|
325
340
|
const workerSource = skipWorker ? null : compileSiteServer([...deduped.values()], { adapter });
|
|
@@ -327,17 +342,17 @@ export async function buildSite(
|
|
|
327
342
|
if (workerSource) {
|
|
328
343
|
const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
|
|
329
344
|
writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
|
|
330
|
-
fileCount
|
|
345
|
+
fileCount += 1;
|
|
331
346
|
log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
|
|
332
347
|
|
|
333
348
|
if (adapter === "cloudflare-pages") {
|
|
334
349
|
// Only invoke the worker for server routes; everything else stays static.
|
|
335
350
|
writeFileSync(
|
|
336
351
|
resolve(outDir, "_routes.json"),
|
|
337
|
-
JSON.stringify({
|
|
352
|
+
`${JSON.stringify({ exclude: [], include: ["/_jx/*"], version: 1 }, null, 2)}\n`,
|
|
338
353
|
"utf8",
|
|
339
354
|
);
|
|
340
|
-
fileCount
|
|
355
|
+
fileCount += 1;
|
|
341
356
|
}
|
|
342
357
|
|
|
343
358
|
// Copy server source files into dist/components/ so worker imports resolve
|
|
@@ -383,7 +398,7 @@ export async function buildSite(
|
|
|
383
398
|
log(` ${errors.length} error(s)`);
|
|
384
399
|
}
|
|
385
400
|
|
|
386
|
-
return {
|
|
401
|
+
return { errors, files: fileCount, routes: routes.length };
|
|
387
402
|
}
|
|
388
403
|
|
|
389
404
|
/**
|
|
@@ -409,9 +424,9 @@ async function compilePage(
|
|
|
409
424
|
route: SiteRoute,
|
|
410
425
|
projectConfig: ProjectConfig,
|
|
411
426
|
projectRoot: string,
|
|
412
|
-
contentTypes
|
|
413
|
-
imageCache:
|
|
414
|
-
componentDefs
|
|
427
|
+
contentTypes = new Map<string, ContentLoaderEntry[]>(),
|
|
428
|
+
imageCache: CacheManifest | null = null,
|
|
429
|
+
componentDefs = new Map<string, JxElement>(),
|
|
415
430
|
imageMetaCache: ImageMetaCache | null = null,
|
|
416
431
|
formatRegistry?: FormatRegistry,
|
|
417
432
|
) {
|
|
@@ -422,9 +437,9 @@ async function compilePage(
|
|
|
422
437
|
const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
|
|
423
438
|
|
|
424
439
|
// Extract head arrays before they get lost in the merge
|
|
425
|
-
const pageHead = pageDoc.$head ?? layoutDoc._pageHead ??
|
|
426
|
-
const layoutHead = layoutDoc.$head ?? [];
|
|
427
|
-
const pageTitle = pageDoc.title ?? layoutDoc._pageTitle ??
|
|
440
|
+
const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
|
|
441
|
+
const layoutHead = (layoutDoc.$head ?? []) as JxHeadEntry[];
|
|
442
|
+
const pageTitle = (pageDoc.title ?? layoutDoc._pageTitle ?? null) as string | null;
|
|
428
443
|
|
|
429
444
|
// Clean up internal properties
|
|
430
445
|
delete layoutDoc._pageHead;
|
|
@@ -453,19 +468,28 @@ async function compilePage(
|
|
|
453
468
|
const resolvedLayoutHead = resolveHeadTemplates(layoutHead, scope);
|
|
454
469
|
|
|
455
470
|
// Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
|
|
456
|
-
//
|
|
471
|
+
// So that timing: "compiler" data is baked into the static HTML
|
|
457
472
|
resolveDocTemplates(layoutDoc, scope);
|
|
458
473
|
|
|
459
|
-
// Expand registered custom elements (apply $props, pre-render, mark static/prerendered)
|
|
460
|
-
|
|
474
|
+
// Expand registered custom elements (apply $props, pre-render, mark static/prerendered).
|
|
475
|
+
// Slot children are serialized to HTML strings during expansion — before compileStyles can walk
|
|
476
|
+
// Them — so their static styles are collected here and injected as a page style block below.
|
|
477
|
+
const slotCss: SlotCssCollector = {
|
|
478
|
+
counter: { n: 0 },
|
|
479
|
+
media: { ...projectConfig.$media, ...layoutDoc.$media },
|
|
480
|
+
rules: [],
|
|
481
|
+
};
|
|
482
|
+
expandComponents(layoutDoc, componentDefs, slotCss);
|
|
461
483
|
|
|
462
484
|
// Strip resolved timing: "compiler" state entries — they're now baked into the tree
|
|
463
|
-
//
|
|
485
|
+
// And keeping them would cause isDynamic() to misclassify the page as dynamic.
|
|
464
486
|
// Also strip resolved content arrays (from ContentCollection) that have been
|
|
465
|
-
//
|
|
487
|
+
// Baked into unrolled map templates.
|
|
466
488
|
if (layoutDoc.state) {
|
|
467
489
|
for (const [key, def] of Object.entries(layoutDoc.state)) {
|
|
468
|
-
if (key === "$site" || key === "$page")
|
|
490
|
+
if (key === "$site" || key === "$page") {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
469
493
|
if (
|
|
470
494
|
def &&
|
|
471
495
|
typeof def === "object" &&
|
|
@@ -485,7 +509,7 @@ async function compilePage(
|
|
|
485
509
|
// Merge $head from site + layout + page
|
|
486
510
|
const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
|
|
487
511
|
title,
|
|
488
|
-
charset: projectConfig.defaults?.charset ?? "
|
|
512
|
+
charset: projectConfig.defaults?.charset ?? "utf8",
|
|
489
513
|
...(projectConfig.name != null && { siteName: projectConfig.name }),
|
|
490
514
|
...(projectConfig.url != null && { siteUrl: projectConfig.url }),
|
|
491
515
|
pageUrl: route.urlPattern,
|
|
@@ -509,11 +533,19 @@ async function compilePage(
|
|
|
509
533
|
|
|
510
534
|
// Compile the document using the existing compiler
|
|
511
535
|
const result = await compile(layoutDoc, {
|
|
512
|
-
title,
|
|
513
536
|
lang: projectConfig.defaults?.lang ?? "en",
|
|
514
537
|
projectStyle: projectConfig.style ?? null,
|
|
538
|
+
title,
|
|
515
539
|
});
|
|
516
540
|
|
|
541
|
+
// Inject CSS rules collected from component slot content
|
|
542
|
+
if (slotCss.rules.length > 0) {
|
|
543
|
+
result.html = result.html.replace(
|
|
544
|
+
"</head>",
|
|
545
|
+
`<style>\n${slotCss.rules.join("\n")}\n</style>\n</head>`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
517
549
|
// Post-process: inject merged <head> content into the compiled HTML
|
|
518
550
|
result.html = injectHead(result.html, mergedHead, projectConfig.defaults?.lang ?? "en");
|
|
519
551
|
|
|
@@ -522,7 +554,7 @@ async function compilePage(
|
|
|
522
554
|
(e: JxElement | string) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
|
|
523
555
|
);
|
|
524
556
|
if (npmElements.length > 0) {
|
|
525
|
-
result.html = injectNpmElementScripts(result.html,
|
|
557
|
+
result.html = injectNpmElementScripts(result.html, npmElements as string[]);
|
|
526
558
|
}
|
|
527
559
|
|
|
528
560
|
// Compile server handler if applicable (skip when provider bundles site-wide)
|
|
@@ -539,10 +571,10 @@ async function compilePage(
|
|
|
539
571
|
}
|
|
540
572
|
|
|
541
573
|
return {
|
|
542
|
-
|
|
574
|
+
doc: layoutDoc,
|
|
543
575
|
files: result.files,
|
|
576
|
+
html: result.html,
|
|
544
577
|
serverHandler,
|
|
545
|
-
doc: layoutDoc,
|
|
546
578
|
};
|
|
547
579
|
}
|
|
548
580
|
|
|
@@ -555,7 +587,9 @@ async function compilePage(
|
|
|
555
587
|
*/
|
|
556
588
|
function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string, unknown>) {
|
|
557
589
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
558
|
-
if (!entry || typeof entry !== "object")
|
|
590
|
+
if (!entry || typeof entry !== "object") {
|
|
591
|
+
return entry;
|
|
592
|
+
}
|
|
559
593
|
const resolved = { ...entry };
|
|
560
594
|
if (resolved.attributes) {
|
|
561
595
|
resolved.attributes = { ...resolved.attributes };
|
|
@@ -584,7 +618,9 @@ function resolveHeadTemplates(headEntries: JxHeadEntry[], scope: Record<string,
|
|
|
584
618
|
*/
|
|
585
619
|
function resolveHeadBareSpecifiers(headEntries: JxHeadEntry[]) {
|
|
586
620
|
return headEntries.map((entry: JxHeadEntry) => {
|
|
587
|
-
if (!entry || typeof entry !== "object" || !entry.attributes)
|
|
621
|
+
if (!entry || typeof entry !== "object" || !entry.attributes) {
|
|
622
|
+
return entry;
|
|
623
|
+
}
|
|
588
624
|
const resolved = { ...entry, attributes: { ...entry.attributes } };
|
|
589
625
|
for (const key of ["href", "src"]) {
|
|
590
626
|
const val = resolved.attributes[key];
|
|
@@ -615,41 +651,46 @@ function isBareSpecifier(s: string) {
|
|
|
615
651
|
/**
|
|
616
652
|
* Deep-clone a map template, resolving template strings and $ref values against the given scope.
|
|
617
653
|
*
|
|
618
|
-
* @param {
|
|
654
|
+
* @param {JxElement} template
|
|
619
655
|
* @param {Record<string, unknown>} scope
|
|
620
|
-
* @returns {
|
|
656
|
+
* @returns {JxElement}
|
|
621
657
|
*/
|
|
622
|
-
function expandMapTemplate(template:
|
|
623
|
-
if (!template || typeof template !== "object")
|
|
624
|
-
|
|
658
|
+
function expandMapTemplate(template: JxElement, scope: Record<string, unknown>): JxElement {
|
|
659
|
+
if (!template || typeof template !== "object") {
|
|
660
|
+
return template;
|
|
661
|
+
}
|
|
662
|
+
const node = {} as JxElement;
|
|
625
663
|
for (const [k, v] of Object.entries(template)) {
|
|
626
664
|
if (k === "children" && Array.isArray(v)) {
|
|
627
665
|
node.children = v.map((child) => {
|
|
628
|
-
if (typeof child === "string")
|
|
666
|
+
if (typeof child === "string") {
|
|
667
|
+
return child;
|
|
668
|
+
}
|
|
629
669
|
return expandMapTemplate(/** @type {JxMutableNode} */ child, scope);
|
|
630
670
|
});
|
|
631
671
|
} else if (k === "style" && v && typeof v === "object") {
|
|
632
|
-
const style = { ...v
|
|
672
|
+
const style: JxStyle = { ...(v as JxStyle) };
|
|
633
673
|
for (const [sk, sv] of Object.entries(style)) {
|
|
634
|
-
if (
|
|
635
|
-
|
|
674
|
+
if (isTemplateString(sv)) {
|
|
675
|
+
// Template evaluation yields a substituted scalar for style values.
|
|
676
|
+
style[sk] = (evaluateMapTemplate(sv, scope) as string | number | undefined) ?? sv;
|
|
636
677
|
}
|
|
637
678
|
}
|
|
638
679
|
node.style = style;
|
|
639
680
|
} else if (k === "attributes" && v && typeof v === "object") {
|
|
640
|
-
const attrs = { ...v
|
|
681
|
+
const attrs = { ...(v as Record<string, JxAttributeValue>) };
|
|
641
682
|
for (const [ak, av] of Object.entries(attrs)) {
|
|
642
|
-
if (
|
|
643
|
-
attrs[ak] = evaluateMapTemplate(av, scope) ?? av;
|
|
683
|
+
if (isTemplateString(av)) {
|
|
684
|
+
attrs[ak] = (evaluateMapTemplate(av, scope) as JxAttributeValue | undefined) ?? av;
|
|
644
685
|
}
|
|
645
686
|
}
|
|
646
687
|
node.attributes = attrs;
|
|
647
688
|
} else if (k === "$props" && v && typeof v === "object") {
|
|
648
|
-
const props = { ...v
|
|
689
|
+
const props = { ...(v as Record<string, JsonValue>) };
|
|
649
690
|
for (const [pk, pv] of Object.entries(props)) {
|
|
650
|
-
if (
|
|
651
|
-
const resolved = evaluateMapTemplate(pv, scope);
|
|
652
|
-
//
|
|
691
|
+
if (isTemplateString(pv)) {
|
|
692
|
+
const resolved = evaluateMapTemplate(pv, scope) as JsonValue | undefined;
|
|
693
|
+
// Null = evaluation error → keep template string; undefined = missing data → use null
|
|
653
694
|
props[pk] = resolved !== null ? (resolved ?? null) : pv;
|
|
654
695
|
}
|
|
655
696
|
}
|
|
@@ -695,14 +736,16 @@ function evaluateMapTemplate(str: string, scope: Record<string, unknown>) {
|
|
|
695
736
|
* @param {Record<string, unknown>} scope
|
|
696
737
|
*/
|
|
697
738
|
function resolveDocTemplates(node: JxElement | string, scope: Record<string, unknown>) {
|
|
698
|
-
if (!node || typeof node !== "object")
|
|
739
|
+
if (!node || typeof node !== "object") {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
699
742
|
|
|
700
743
|
if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
|
|
701
744
|
const resolved = evaluateStaticTemplate(node.innerHTML, scope);
|
|
702
745
|
if (resolved != null) {
|
|
703
746
|
// Encode any remaining `${` as HTML entities so the compile phase won't
|
|
704
|
-
//
|
|
705
|
-
//
|
|
747
|
+
// Re-interpret them as template expressions. After resolution, any `${` in the
|
|
748
|
+
// Result is literal content (e.g., code examples), not an intentional template.
|
|
706
749
|
node.innerHTML = String(resolved).replaceAll("${", "${");
|
|
707
750
|
}
|
|
708
751
|
}
|
|
@@ -731,32 +774,26 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
731
774
|
}
|
|
732
775
|
}
|
|
733
776
|
}
|
|
734
|
-
const rawChildren = node.children
|
|
735
|
-
if (
|
|
736
|
-
rawChildren
|
|
737
|
-
typeof rawChildren === "object" &&
|
|
738
|
-
!Array.isArray(rawChildren) &&
|
|
739
|
-
(rawChildren as JxMutableNode).$prototype === "Array"
|
|
740
|
-
) {
|
|
741
|
-
const arrayDef = rawChildren as JxMutableNode;
|
|
742
|
-
const itemsSrc = arrayDef.items;
|
|
777
|
+
const rawChildren = node.children;
|
|
778
|
+
if (isMappedArray(rawChildren)) {
|
|
779
|
+
const itemsSrc = rawChildren.items;
|
|
743
780
|
let items = null;
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
items = resolveRefValue(ref, scope);
|
|
781
|
+
if (isRef(itemsSrc)) {
|
|
782
|
+
items = resolveRefValue(itemsSrc.$ref, scope);
|
|
747
783
|
} else if (Array.isArray(itemsSrc)) {
|
|
748
784
|
items = itemsSrc;
|
|
749
785
|
}
|
|
750
|
-
|
|
786
|
+
const mapTemplate = rawChildren.map;
|
|
787
|
+
if (Array.isArray(items) && mapTemplate) {
|
|
751
788
|
node.children = items.map((item, index) => {
|
|
752
789
|
const childScope = Object.create(scope);
|
|
753
|
-
childScope.$map = {
|
|
790
|
+
childScope.$map = { index, item };
|
|
754
791
|
childScope["$map/item"] = item;
|
|
755
792
|
childScope["$map/index"] = index;
|
|
756
|
-
const expanded = expandMapTemplate(
|
|
757
|
-
resolveDocTemplates(expanded
|
|
793
|
+
const expanded = expandMapTemplate(mapTemplate, childScope);
|
|
794
|
+
resolveDocTemplates(expanded, childScope);
|
|
758
795
|
return expanded;
|
|
759
|
-
})
|
|
796
|
+
});
|
|
760
797
|
return;
|
|
761
798
|
}
|
|
762
799
|
}
|
|
@@ -786,29 +823,46 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
|
|
|
786
823
|
}
|
|
787
824
|
}
|
|
788
825
|
resolveDocTemplates(child, scope);
|
|
789
|
-
i
|
|
826
|
+
i += 1;
|
|
790
827
|
}
|
|
791
828
|
}
|
|
792
829
|
}
|
|
793
830
|
|
|
831
|
+
/** Collector for CSS rules extracted from component slot content during expansion. */
|
|
832
|
+
interface SlotCssCollector {
|
|
833
|
+
rules: string[];
|
|
834
|
+
counter: { n: number };
|
|
835
|
+
media: Record<string, string>;
|
|
836
|
+
}
|
|
837
|
+
|
|
794
838
|
/**
|
|
795
839
|
* Walk the document tree and expand registered custom elements in-place. Applies $props via
|
|
796
|
-
* preRenderComponentHtml, marks static/prerendered.
|
|
840
|
+
* preRenderComponentHtml, marks static/prerendered. Slot children get their static styles collected
|
|
841
|
+
* into `slotCss` (assigning jxs-N classes) before being serialized to HTML.
|
|
797
842
|
*
|
|
798
843
|
* @param {JxElement | string} node
|
|
799
844
|
* @param {Map<string, JxElement>} componentDefs
|
|
845
|
+
* @param {SlotCssCollector} [slotCss]
|
|
800
846
|
*/
|
|
801
|
-
function expandComponents(
|
|
802
|
-
|
|
847
|
+
function expandComponents(
|
|
848
|
+
node: JxElement | string,
|
|
849
|
+
componentDefs: Map<string, JxElement>,
|
|
850
|
+
slotCss?: SlotCssCollector,
|
|
851
|
+
) {
|
|
852
|
+
if (!node || typeof node !== "object") {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
803
855
|
if (Array.isArray(node)) {
|
|
804
|
-
|
|
856
|
+
for (const n of node) {
|
|
857
|
+
expandComponents(n, componentDefs, slotCss);
|
|
858
|
+
}
|
|
805
859
|
return;
|
|
806
860
|
}
|
|
807
861
|
|
|
808
862
|
// Recurse into children first (bottom-up expansion)
|
|
809
863
|
if (Array.isArray(node.children)) {
|
|
810
864
|
for (const child of node.children) {
|
|
811
|
-
expandComponents(child, componentDefs);
|
|
865
|
+
expandComponents(child, componentDefs, slotCss);
|
|
812
866
|
}
|
|
813
867
|
}
|
|
814
868
|
|
|
@@ -816,7 +870,14 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
|
|
|
816
870
|
if (def) {
|
|
817
871
|
const slotContent =
|
|
818
872
|
Array.isArray(node.children) && node.children.length > 0
|
|
819
|
-
? node.children
|
|
873
|
+
? node.children
|
|
874
|
+
.map((c: JxElement | string) => {
|
|
875
|
+
if (slotCss && c && typeof c === "object") {
|
|
876
|
+
collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
|
|
877
|
+
}
|
|
878
|
+
return renderStaticNode(c, {}, null);
|
|
879
|
+
})
|
|
880
|
+
.join("\n")
|
|
820
881
|
: null;
|
|
821
882
|
|
|
822
883
|
const innerHTML = preRenderComponentHtml(def, node.$props || null, slotContent);
|
|
@@ -827,17 +888,19 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
|
|
|
827
888
|
|
|
828
889
|
// Resolve template-string host styles with props (per-instance values like background-image)
|
|
829
890
|
if (def.style && node.$props) {
|
|
830
|
-
|
|
891
|
+
const stateDefs: Record<string, JxStateDefinition> = { ...def.state };
|
|
831
892
|
for (const [key, value] of Object.entries(node.$props)) {
|
|
832
|
-
|
|
833
|
-
|
|
893
|
+
stateDefs[key] =
|
|
894
|
+
key in stateDefs ? (value as JxStateDefinition) : (value as JxStateDefinition);
|
|
834
895
|
}
|
|
835
896
|
const scope = buildInitialScope(stateDefs, null);
|
|
836
897
|
const resolvedStyle: Record<string, unknown> = {};
|
|
837
898
|
for (const [prop, value] of Object.entries(def.style)) {
|
|
838
899
|
if (typeof value === "string" && isTemplateString(value)) {
|
|
839
900
|
const resolved = resolveStaticValue(value, scope);
|
|
840
|
-
if (resolved != null)
|
|
901
|
+
if (resolved != null) {
|
|
902
|
+
resolvedStyle[prop] = resolved;
|
|
903
|
+
}
|
|
841
904
|
}
|
|
842
905
|
}
|
|
843
906
|
if (Object.keys(resolvedStyle).length > 0) {
|
|
@@ -847,8 +910,11 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
|
|
|
847
910
|
|
|
848
911
|
delete node.$props;
|
|
849
912
|
|
|
850
|
-
if (isStatic)
|
|
851
|
-
|
|
913
|
+
if (isStatic) {
|
|
914
|
+
node.$static = true;
|
|
915
|
+
} else {
|
|
916
|
+
node.$prerendered = true;
|
|
917
|
+
}
|
|
852
918
|
}
|
|
853
919
|
}
|
|
854
920
|
|
|
@@ -865,27 +931,32 @@ function expandComponents(node: JxElement | string, componentDefs: Map<string, J
|
|
|
865
931
|
function injectComponentScripts(
|
|
866
932
|
html: string,
|
|
867
933
|
allComponentTags: string[],
|
|
868
|
-
cssMap
|
|
869
|
-
staticTags
|
|
934
|
+
cssMap = new Map<string, string>(),
|
|
935
|
+
staticTags = new Set<string>(),
|
|
870
936
|
) {
|
|
871
937
|
// Find which components are actually referenced in this page
|
|
872
938
|
const usedTags = allComponentTags.filter(
|
|
873
|
-
(tag: string) => html.includes(`<${tag}`), //
|
|
939
|
+
(tag: string) => html.includes(`<${tag}`), // Matches <tag> and <tag ...>
|
|
874
940
|
);
|
|
875
|
-
if (usedTags.length === 0)
|
|
941
|
+
if (usedTags.length === 0) {
|
|
942
|
+
return html;
|
|
943
|
+
}
|
|
876
944
|
|
|
877
945
|
// Inject CSS links in <head> for ALL components that have CSS sidecars
|
|
878
946
|
const cssLinks = usedTags
|
|
879
947
|
.filter((tag: string) => cssMap.has(tag))
|
|
880
948
|
.map((tag: string) => `<link rel="stylesheet" href="/components/${tag}.css">`)
|
|
881
949
|
.join("\n ");
|
|
950
|
+
let result = html;
|
|
882
951
|
if (cssLinks) {
|
|
883
|
-
|
|
952
|
+
result = result.replace("</head>", ` ${cssLinks}\n</head>`);
|
|
884
953
|
}
|
|
885
954
|
|
|
886
955
|
// Only inject JS for components that have non-static instances
|
|
887
956
|
const jsTags = usedTags.filter((tag: string) => !staticTags.has(tag));
|
|
888
|
-
if (jsTags.length === 0)
|
|
957
|
+
if (jsTags.length === 0) {
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
889
960
|
|
|
890
961
|
// Build import map (needed for @vue/reactivity and lit-html)
|
|
891
962
|
const importMap = `<script type="importmap">
|
|
@@ -902,10 +973,10 @@ function injectComponentScripts(
|
|
|
902
973
|
.join("\n ");
|
|
903
974
|
|
|
904
975
|
// Check if an import map already exists (from islands etc.)
|
|
905
|
-
const hasImportMap =
|
|
976
|
+
const hasImportMap = result.includes('<script type="importmap">');
|
|
906
977
|
const injection = (hasImportMap ? "" : `${importMap}\n `) + moduleScripts;
|
|
907
978
|
|
|
908
|
-
return
|
|
979
|
+
return result.replace("</body>", ` ${injection}\n</body>`);
|
|
909
980
|
}
|
|
910
981
|
|
|
911
982
|
/**
|
|
@@ -942,23 +1013,28 @@ function injectHead(html: string, headEntries: JxHeadEntry[], lang: string) {
|
|
|
942
1013
|
let preservedBlocks = "";
|
|
943
1014
|
if (existingMatch) {
|
|
944
1015
|
const styles = existingMatch[1].match(/<style>[\s\S]*?<\/style>/gi);
|
|
945
|
-
if (styles)
|
|
1016
|
+
if (styles) {
|
|
1017
|
+
preservedBlocks += `\n ${styles.join("\n ")}`;
|
|
1018
|
+
}
|
|
946
1019
|
const scripts = existingMatch[1].match(/<script[\s\S]*?<\/script>/gi);
|
|
947
|
-
if (scripts)
|
|
1020
|
+
if (scripts) {
|
|
1021
|
+
preservedBlocks += `\n ${scripts.join("\n ")}`;
|
|
1022
|
+
}
|
|
948
1023
|
}
|
|
949
|
-
|
|
950
|
-
|
|
1024
|
+
let result = html;
|
|
1025
|
+
if (headPattern.test(result)) {
|
|
1026
|
+
result = result.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
|
|
951
1027
|
}
|
|
952
1028
|
|
|
953
1029
|
// Set the lang attribute on <html>
|
|
954
|
-
|
|
1030
|
+
result = result.replace(/<html\s[^>]*>/i, (match: string) => {
|
|
955
1031
|
if (/lang=/.test(match)) {
|
|
956
1032
|
return match.replace(/lang="[^"]*"/, `lang="${lang}"`);
|
|
957
1033
|
}
|
|
958
1034
|
return match.replace("<html", `<html lang="${lang}"`);
|
|
959
1035
|
});
|
|
960
1036
|
|
|
961
|
-
return
|
|
1037
|
+
return result;
|
|
962
1038
|
}
|
|
963
1039
|
|
|
964
1040
|
/**
|
|
@@ -984,7 +1060,7 @@ function routeToOutputPath(urlPattern: string, outDir: string, trailingSlash: st
|
|
|
984
1060
|
return join(outDir, segments, "index.html");
|
|
985
1061
|
}
|
|
986
1062
|
|
|
987
|
-
//
|
|
1063
|
+
// TrailingSlash: "never" or default
|
|
988
1064
|
return join(outDir, `${segments}.html`);
|
|
989
1065
|
}
|
|
990
1066
|
|
|
@@ -1028,14 +1104,14 @@ function generateRedirects(
|
|
|
1028
1104
|
</html>`;
|
|
1029
1105
|
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
1030
1106
|
writeFileSync(htmlPath, html, "utf8");
|
|
1031
|
-
count
|
|
1107
|
+
count += 1;
|
|
1032
1108
|
redirectLines.push(`${source} ${dest} ${status}`);
|
|
1033
1109
|
}
|
|
1034
1110
|
|
|
1035
1111
|
// Write _redirects file (Netlify/Cloudflare format)
|
|
1036
1112
|
if (redirectLines.length > 0) {
|
|
1037
|
-
writeFileSync(join(outDir, "_redirects"), redirectLines.join("\n")
|
|
1038
|
-
count
|
|
1113
|
+
writeFileSync(join(outDir, "_redirects"), `${redirectLines.join("\n")}\n`, "utf8");
|
|
1114
|
+
count += 1;
|
|
1039
1115
|
}
|
|
1040
1116
|
|
|
1041
1117
|
return count;
|
|
@@ -1046,7 +1122,7 @@ function generateRedirects(
|
|
|
1046
1122
|
* @returns {string}
|
|
1047
1123
|
*/
|
|
1048
1124
|
function escapeHtml(str: string) {
|
|
1049
|
-
return String(str).
|
|
1125
|
+
return String(str).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
1050
1126
|
}
|
|
1051
1127
|
|
|
1052
1128
|
/**
|
|
@@ -1054,5 +1130,5 @@ function escapeHtml(str: string) {
|
|
|
1054
1130
|
* @returns {string}
|
|
1055
1131
|
*/
|
|
1056
1132
|
function escapeAttr(str: string) {
|
|
1057
|
-
return String(str).
|
|
1133
|
+
return String(str).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
1058
1134
|
}
|