@jxsuite/compiler 0.0.1

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.
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Site-build.js — Multi-page build orchestrator
3
+ *
4
+ * Coordinates the full site build pipeline: 1. Load project.json 2. Discover pages/ routes 3.
5
+ * Expand dynamic routes ($paths) 4. For each route: resolve layout, merge $head, inject context,
6
+ * compile 5. Emit compiled files to dist/ 6. Generate redirects
7
+ *
8
+ * This is the Phase 1 implementation of site-architecture spec §12.
9
+ */
10
+
11
+ import {
12
+ readFileSync,
13
+ writeFileSync,
14
+ mkdirSync,
15
+ existsSync,
16
+ rmSync,
17
+ cpSync,
18
+ readdirSync,
19
+ } from "node:fs";
20
+ import { resolve, dirname, join } from "node:path";
21
+ import { loadProjectConfig } from "./site-loader.js";
22
+ import { discoverPages, expandDynamicRoutes } from "./pages-discovery.js";
23
+ import { resolveLayout } from "./layout-resolver.js";
24
+ import { mergeHead, renderHead } from "./head-merger.js";
25
+ import { injectContext } from "./context-injection.js";
26
+ import { compile, compileServer } from "../compiler.js";
27
+ import { compileElement } from "../targets/compile-element.js";
28
+ import {
29
+ buildInitialScope,
30
+ isTemplateString,
31
+ evaluateStaticTemplate,
32
+ DEFAULT_REACTIVITY_SRC,
33
+ DEFAULT_LIT_HTML_SRC,
34
+ } from "../shared.js";
35
+ import { loadCollections, loadContentConfig, resolveCollectionRefs } from "./content-loader.js";
36
+ import { resolvePrototypes } from "./prototype-resolver.js";
37
+
38
+ /**
39
+ * Build an entire Jx site from a project directory.
40
+ *
41
+ * @param {string} projectRoot - Absolute path to the project root (contains project.json)
42
+ * @param {object} [options]
43
+ * @param {boolean} [options.clean] - Remove outDir before building
44
+ * @param {boolean} [options.verbose] - Log progress
45
+ * @returns {Promise<{ routes: number; files: number; errors: string[] }>}
46
+ */
47
+ export async function buildSite(projectRoot, options = {}) {
48
+ const { clean = true, verbose = false } = options;
49
+ /** @type {string[]} */
50
+ const errors = [];
51
+ const log = verbose ? console.log.bind(console) : () => {};
52
+
53
+ // ── 1. Load project configuration ──────────────────────────────────────────
54
+ log("Loading project.json...");
55
+ const { config: projectConfig } = loadProjectConfig(projectRoot);
56
+
57
+ const outDir = resolve(projectRoot, projectConfig.build.outDir);
58
+ const pagesDir = resolve(projectRoot, "pages");
59
+ const publicDir = resolve(projectRoot, "public");
60
+ const trailingSlash = projectConfig.build.trailingSlash ?? "always";
61
+
62
+ // ── 2. Clean output directory ───────────────────────────────────────────
63
+ if (clean && existsSync(outDir)) {
64
+ rmSync(outDir, { recursive: true, force: true });
65
+ }
66
+ mkdirSync(outDir, { recursive: true });
67
+
68
+ // ── 3. Discover routes ──────────────────────────────────────────────────
69
+ if (!existsSync(pagesDir)) {
70
+ throw new Error(`pages/ directory not found in ${projectRoot}`);
71
+ }
72
+
73
+ log("Discovering pages...");
74
+ const staticRoutes = discoverPages(pagesDir);
75
+ log(` Found ${staticRoutes.length} page(s)`);
76
+
77
+ // ── 3b. Load content collections ──────────────────────────────────────
78
+ log("Loading content collections...");
79
+ const collections = await loadCollections(projectRoot, projectConfig);
80
+ if (collections.size > 0) {
81
+ log(` Loaded ${collections.size} collection(s): ${[...collections.keys()].join(", ")}`);
82
+ // Resolve cross-collection $ref references
83
+ const contentConfig = loadContentConfig(projectRoot, projectConfig);
84
+ if (contentConfig) {
85
+ resolveCollectionRefs(collections, contentConfig.config);
86
+ }
87
+ }
88
+
89
+ // ── 4. Expand dynamic routes ────────────────────────────────────────────
90
+ const routes = await expandDynamicRoutes(staticRoutes, projectRoot, collections);
91
+ log(` ${routes.length} route(s) after expansion`);
92
+
93
+ let fileCount = 0;
94
+
95
+ // ── 5. Compile site components ──────────────────────────────────────────
96
+ const componentsDir = resolve(projectRoot, "components");
97
+ /** @type {string[]} */
98
+ const compiledComponentTags = [];
99
+ if (existsSync(componentsDir)) {
100
+ log("Compiling components...");
101
+ const componentFiles = readdirSync(componentsDir).filter((/** @type {string} */ f) =>
102
+ f.endsWith(".json"),
103
+ );
104
+ const componentOutDir = resolve(outDir, "components");
105
+ mkdirSync(componentOutDir, { recursive: true });
106
+
107
+ for (const file of componentFiles) {
108
+ try {
109
+ const componentPath = resolve(componentsDir, file);
110
+ const result = await compileElement(componentPath);
111
+ for (const f of result.files) {
112
+ const outName = f.path.includes("/")
113
+ ? /** @type {string} */ (f.path.split("/").pop())
114
+ : f.path;
115
+ writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
116
+ if (f.tagName) compiledComponentTags.push(f.tagName);
117
+ fileCount++;
118
+ }
119
+ } catch (e) {
120
+ const err = /** @type {any} */ (e);
121
+ errors.push(`Error compiling component ${file}: ${err.message}`);
122
+ console.error(`Error compiling component ${file}: ${err.message}`);
123
+ }
124
+ }
125
+ log(
126
+ ` Compiled ${compiledComponentTags.length} component(s): ${compiledComponentTags.join(", ")}`,
127
+ );
128
+ }
129
+
130
+ // ── 6. Compile each route ───────────────────────────────────────────────
131
+
132
+ for (const route of routes) {
133
+ try {
134
+ log(` Compiling ${route.urlPattern} ...`);
135
+ const result = await compilePage(route, projectConfig, projectRoot, collections);
136
+
137
+ // Inject component scripts if the page references any compiled components
138
+ if (compiledComponentTags.length > 0) {
139
+ result.html = injectComponentScripts(result.html, compiledComponentTags);
140
+ }
141
+
142
+ // Determine output path
143
+ const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
144
+ mkdirSync(dirname(outPath), { recursive: true });
145
+ writeFileSync(outPath, result.html, "utf8");
146
+ fileCount++;
147
+
148
+ // Write any additional files (island modules, etc.)
149
+ for (const file of result.files) {
150
+ const filePath = resolve(dirname(outPath), file.path);
151
+ mkdirSync(dirname(filePath), { recursive: true });
152
+ writeFileSync(filePath, file.content, "utf8");
153
+ fileCount++;
154
+ }
155
+
156
+ // Write server handler if present
157
+ if (result.serverHandler) {
158
+ const serverPath = resolve(dirname(outPath), "_server.js");
159
+ writeFileSync(serverPath, result.serverHandler, "utf8");
160
+ fileCount++;
161
+ }
162
+ } catch (e) {
163
+ const err = /** @type {any} */ (e);
164
+ const msg = `Error compiling ${route.urlPattern}: ${err.message}`;
165
+ errors.push(msg);
166
+ console.error(msg);
167
+ }
168
+ }
169
+
170
+ // ── 7. Generate redirects ───────────────────────────────────────────────
171
+ if (projectConfig.redirects && Object.keys(projectConfig.redirects).length > 0) {
172
+ log("Generating redirects...");
173
+ const redirectFiles = generateRedirects(projectConfig.redirects, outDir);
174
+ fileCount += redirectFiles;
175
+ }
176
+
177
+ // ── 7. Copy public/ assets ──────────────────────────────────────────────
178
+ if (existsSync(publicDir)) {
179
+ log("Copying public/ assets...");
180
+ cpSync(publicDir, outDir, { recursive: true });
181
+ }
182
+
183
+ // ── 8. Copy declarative file mappings ──────────────────────────────────
184
+ if (projectConfig.copy) {
185
+ log("Copying mapped files...");
186
+ for (const [src, dest] of Object.entries(projectConfig.copy)) {
187
+ const srcPath = resolve(projectRoot, /** @type {string} */ (src));
188
+ const destPath = resolve(outDir, /** @type {string} */ (dest));
189
+ mkdirSync(dirname(destPath), { recursive: true });
190
+ cpSync(srcPath, destPath);
191
+ }
192
+ }
193
+
194
+ // ── 9. Summary ──────────────────────────────────────────────────────────
195
+ log(`\nBuild complete: ${routes.length} routes, ${fileCount} files`);
196
+ if (errors.length > 0) {
197
+ log(` ${errors.length} error(s)`);
198
+ }
199
+
200
+ return { routes: routes.length, files: fileCount, errors };
201
+ }
202
+
203
+ /**
204
+ * Compile a single page within the site build context.
205
+ *
206
+ * Pipeline: load JSON → resolve layout → inject context → merge head → compile
207
+ *
208
+ * @param {any} route
209
+ * @param {any} projectConfig
210
+ * @param {string} projectRoot
211
+ * @param {Map<string, any[]>} [collections]
212
+ * @returns {Promise<{ html: string; files: any[]; serverHandler: string | null }>}
213
+ */
214
+ async function compilePage(route, projectConfig, projectRoot, collections = new Map()) {
215
+ // Load the raw page document
216
+ let pageDoc = JSON.parse(readFileSync(route.sourcePath, "utf8"));
217
+
218
+ // Resolve layout (wraps page in layout with slot distribution)
219
+ const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
220
+
221
+ // Extract head arrays before they get lost in the merge
222
+ const pageHead = pageDoc.$head ?? layoutDoc._pageHead ?? [];
223
+ const layoutHead = layoutDoc.$head ?? [];
224
+ const pageTitle = pageDoc.title ?? layoutDoc._pageTitle ?? null;
225
+
226
+ // Clean up internal properties
227
+ delete layoutDoc._pageHead;
228
+ delete layoutDoc._pageTitle;
229
+
230
+ // Inject $site and $page context, resolve ContentCollection/ContentEntry
231
+ injectContext(layoutDoc, projectConfig, route, collections, projectRoot);
232
+
233
+ // Resolve generic $prototype entries via .class.json imports
234
+ await resolvePrototypes(layoutDoc, route, projectRoot);
235
+
236
+ // Build scope from resolved state so template strings in title/$head can be evaluated
237
+ const scope = buildInitialScope(layoutDoc.state ?? {});
238
+
239
+ // Determine the page title — resolve template strings against the scope
240
+ let title = pageTitle ?? projectConfig.name ?? "Jx Site";
241
+ if (typeof title === "string" && isTemplateString(title)) {
242
+ title = evaluateStaticTemplate(title, scope) ?? title;
243
+ }
244
+
245
+ // Resolve template strings in $head entries
246
+ const resolvedPageHead = resolveHeadTemplates(pageHead, scope);
247
+ const resolvedLayoutHead = resolveHeadTemplates(layoutHead, scope);
248
+
249
+ // Resolve template strings in the document tree (innerHTML, textContent, style, attributes)
250
+ // so that timing: "compiler" data is baked into the static HTML
251
+ resolveDocTemplates(layoutDoc, scope);
252
+
253
+ // Strip resolved timing: "compiler" state entries — they're now baked into the tree
254
+ // and keeping them would cause isDynamic() to misclassify the page as dynamic
255
+ if (layoutDoc.state) {
256
+ for (const [key, def] of Object.entries(layoutDoc.state)) {
257
+ if (key === "$site" || key === "$page") continue;
258
+ if (
259
+ def &&
260
+ typeof def === "object" &&
261
+ !Array.isArray(def) &&
262
+ /** @type {any} */ (def).timing === "compiler"
263
+ ) {
264
+ delete layoutDoc.state[key];
265
+ }
266
+ }
267
+ }
268
+
269
+ // Resolve bare npm specifiers in $head (e.g. "@pkg/name/file.css" → "/node_modules/@pkg/name/file.css")
270
+ const resolvedSiteHead = resolveHeadBareSpecifiers(projectConfig.$head ?? []);
271
+
272
+ // Merge $head from site + layout + page
273
+ const mergedHead = mergeHead(resolvedSiteHead, resolvedLayoutHead, resolvedPageHead, {
274
+ title,
275
+ charset: projectConfig.defaults?.charset ?? "utf-8",
276
+ siteName: projectConfig.name,
277
+ siteUrl: projectConfig.url,
278
+ pageUrl: route.urlPattern,
279
+ });
280
+
281
+ // Compile the document using the existing compiler
282
+ const result = await compile(layoutDoc, {
283
+ title,
284
+ lang: projectConfig.defaults?.lang ?? "en",
285
+ });
286
+
287
+ // Post-process: inject merged <head> content into the compiled HTML
288
+ result.html = injectHead(result.html, mergedHead, projectConfig.defaults?.lang ?? "en");
289
+
290
+ // Inject <script type="module"> for npm $elements (cherry-picked component imports)
291
+ const npmElements = (layoutDoc.$elements ?? []).filter(
292
+ (/** @type {any} */ e) => typeof e === "string" && !e.startsWith("./") && !e.startsWith("../"),
293
+ );
294
+ if (npmElements.length > 0) {
295
+ result.html = injectNpmElementScripts(result.html, npmElements);
296
+ }
297
+
298
+ // Compile server handler if applicable
299
+ /** @type {string | null} */
300
+ let serverHandler = null;
301
+ try {
302
+ const serverResult = await compileServer(route.sourcePath);
303
+ if (serverResult) {
304
+ serverHandler = serverResult;
305
+ }
306
+ } catch {
307
+ // No server entries — that's fine
308
+ }
309
+
310
+ return { html: result.html, files: result.files, serverHandler };
311
+ }
312
+
313
+ /**
314
+ * Resolve template strings in $head entries against the compiled scope.
315
+ *
316
+ * @param {any[]} headEntries
317
+ * @param {any} scope
318
+ * @returns {any[]}
319
+ */
320
+ function resolveHeadTemplates(headEntries, scope) {
321
+ return headEntries.map((/** @type {any} */ entry) => {
322
+ if (!entry || typeof entry !== "object") return entry;
323
+ const resolved = { ...entry };
324
+ if (resolved.attributes) {
325
+ resolved.attributes = { ...resolved.attributes };
326
+ for (const [k, v] of Object.entries(resolved.attributes)) {
327
+ if (typeof v === "string" && isTemplateString(v)) {
328
+ resolved.attributes[k] = evaluateStaticTemplate(v, scope) ?? v;
329
+ }
330
+ }
331
+ }
332
+ if (typeof resolved.textContent === "string" && isTemplateString(resolved.textContent)) {
333
+ resolved.textContent =
334
+ evaluateStaticTemplate(resolved.textContent, scope) ?? resolved.textContent;
335
+ }
336
+ return resolved;
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Resolve bare npm specifiers in $head entry attributes (href, src). e.g.
342
+ * "@shoelace-style/shoelace/dist/themes/light.css" →
343
+ * "/node_modules/@shoelace-style/shoelace/dist/themes/light.css"
344
+ *
345
+ * @param {any[]} headEntries
346
+ * @returns {any[]}
347
+ */
348
+ function resolveHeadBareSpecifiers(headEntries) {
349
+ return headEntries.map((/** @type {any} */ entry) => {
350
+ if (!entry || typeof entry !== "object" || !entry.attributes) return entry;
351
+ const resolved = { ...entry, attributes: { ...entry.attributes } };
352
+ for (const key of ["href", "src"]) {
353
+ const val = resolved.attributes[key];
354
+ if (typeof val === "string" && isBareSpecifier(val)) {
355
+ resolved.attributes[key] = `/node_modules/${val}`;
356
+ }
357
+ }
358
+ return resolved;
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Check if a string is a bare npm specifier (not a relative/absolute path or URL).
364
+ *
365
+ * @param {string} s
366
+ * @returns {boolean}
367
+ */
368
+ function isBareSpecifier(s) {
369
+ return (
370
+ !s.startsWith("/") &&
371
+ !s.startsWith("./") &&
372
+ !s.startsWith("../") &&
373
+ !s.startsWith("http") &&
374
+ !s.startsWith("data:")
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Recursively resolve template strings in a document tree against a scope. Mutates the document in
380
+ * place — evaluates ${...} in innerHTML, textContent, style values, and attribute values.
381
+ *
382
+ * @param {any} node
383
+ * @param {any} scope
384
+ */
385
+ function resolveDocTemplates(node, scope) {
386
+ if (!node || typeof node !== "object") return;
387
+
388
+ if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
389
+ node.innerHTML = evaluateStaticTemplate(node.innerHTML, scope) ?? node.innerHTML;
390
+ }
391
+ if (typeof node.textContent === "string" && isTemplateString(node.textContent)) {
392
+ node.textContent = evaluateStaticTemplate(node.textContent, scope) ?? node.textContent;
393
+ }
394
+ if (node.style && typeof node.style === "object") {
395
+ for (const [k, v] of Object.entries(node.style)) {
396
+ if (typeof v === "string" && isTemplateString(v)) {
397
+ node.style[k] = evaluateStaticTemplate(v, scope) ?? v;
398
+ }
399
+ }
400
+ }
401
+ if (node.attributes && typeof node.attributes === "object") {
402
+ for (const [k, v] of Object.entries(node.attributes)) {
403
+ if (typeof v === "string" && isTemplateString(v)) {
404
+ node.attributes[k] = evaluateStaticTemplate(v, scope) ?? v;
405
+ }
406
+ }
407
+ }
408
+ if (Array.isArray(node.children)) {
409
+ for (const child of node.children) {
410
+ resolveDocTemplates(child, scope);
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Inject component script tags into compiled HTML for any referenced custom elements. Adds an
417
+ * import map and module scripts before </body>.
418
+ *
419
+ * @param {string} html
420
+ * @param {string[]} allComponentTags - All compiled component tag names
421
+ * @returns {string}
422
+ */
423
+ function injectComponentScripts(html, allComponentTags) {
424
+ // Find which components are actually referenced in this page
425
+ const usedTags = allComponentTags.filter(
426
+ (/** @type {string} */ tag) => html.includes(`<${tag}`), // matches <tag> and <tag ...>
427
+ );
428
+ if (usedTags.length === 0) return html;
429
+
430
+ // Build import map (needed for @vue/reactivity and lit-html)
431
+ const importMap = `<script type="importmap">
432
+ {
433
+ "imports": {
434
+ "@vue/reactivity": "${DEFAULT_REACTIVITY_SRC}",
435
+ "lit-html": "${DEFAULT_LIT_HTML_SRC}"
436
+ }
437
+ }
438
+ </script>`;
439
+
440
+ const moduleScripts = usedTags
441
+ .map(
442
+ (/** @type {string} */ tag) => `<script type="module" src="/components/${tag}.js"></script>`,
443
+ )
444
+ .join("\n ");
445
+
446
+ // Check if an import map already exists (from islands etc.)
447
+ const hasImportMap = html.includes('<script type="importmap">');
448
+ const injection = (hasImportMap ? "" : `${importMap}\n `) + moduleScripts;
449
+
450
+ return html.replace("</body>", ` ${injection}\n</body>`);
451
+ }
452
+
453
+ /**
454
+ * Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
455
+ * Bare specifiers are resolved to /node_modules/ paths.
456
+ *
457
+ * @param {string} html
458
+ * @param {string[]} npmElements - Bare specifier strings, e.g.
459
+ * "@shoelace-style/shoelace/components/button/button.js"
460
+ * @returns {string}
461
+ */
462
+ function injectNpmElementScripts(html, npmElements) {
463
+ const scripts = npmElements
464
+ .map(
465
+ (/** @type {string} */ spec) => `<script type="module" src="/node_modules/${spec}"></script>`,
466
+ )
467
+ .join("\n ");
468
+
469
+ return html.replace("</body>", ` ${scripts}\n</body>`);
470
+ }
471
+
472
+ /**
473
+ * Replaces the compiler's default <head> section with our merged version.
474
+ *
475
+ * @param {string} html
476
+ * @param {any[]} headEntries
477
+ * @param {string} lang
478
+ * @returns {string}
479
+ */
480
+ function injectHead(html, headEntries, lang) {
481
+ const headHtml = renderHead(headEntries);
482
+
483
+ // Replace the existing <head>...</head> block, preserving compiler-generated <style> and <script> blocks
484
+ const headPattern = /<head>([\s\S]*?)<\/head>/i;
485
+ const existingMatch = html.match(headPattern);
486
+ let preservedBlocks = "";
487
+ if (existingMatch) {
488
+ const styles = existingMatch[1].match(/<style>[\s\S]*?<\/style>/gi);
489
+ if (styles) preservedBlocks += "\n " + styles.join("\n ");
490
+ const scripts = existingMatch[1].match(/<script[\s\S]*?<\/script>/gi);
491
+ if (scripts) preservedBlocks += "\n " + scripts.join("\n ");
492
+ }
493
+ if (headPattern.test(html)) {
494
+ html = html.replace(headPattern, `<head>\n ${headHtml}${preservedBlocks}\n</head>`);
495
+ }
496
+
497
+ // Set the lang attribute on <html>
498
+ html = html.replace(/<html\s[^>]*>/i, (/** @type {string} */ match) => {
499
+ if (/lang=/.test(match)) {
500
+ return match.replace(/lang="[^"]*"/, `lang="${lang}"`);
501
+ }
502
+ return match.replace("<html", `<html lang="${lang}"`);
503
+ });
504
+
505
+ return html;
506
+ }
507
+
508
+ /**
509
+ * Convert a URL pattern to an output file path.
510
+ *
511
+ * "/" → dist/index.html "/about" → dist/about/index.html (with trailingSlash: "always")
512
+ * "/blog/hello" → dist/blog/hello/index.html
513
+ *
514
+ * @param {string} urlPattern
515
+ * @param {string} outDir
516
+ * @param {string} trailingSlash
517
+ * @returns {string}
518
+ */
519
+ function routeToOutputPath(urlPattern, outDir, trailingSlash) {
520
+ if (urlPattern === "/") {
521
+ return join(outDir, "index.html");
522
+ }
523
+
524
+ // Remove leading slash
525
+ const segments = urlPattern.replace(/^\//, "");
526
+
527
+ if (trailingSlash === "always") {
528
+ return join(outDir, segments, "index.html");
529
+ }
530
+
531
+ // trailingSlash: "never" or default
532
+ return join(outDir, `${segments}.html`);
533
+ }
534
+
535
+ /**
536
+ * Generate redirect files (HTML meta refresh and _redirects).
537
+ *
538
+ * @param {Record<string, any>} redirects
539
+ * @param {string} outDir
540
+ * @returns {number} Number of files written
541
+ */
542
+ function generateRedirects(redirects, outDir) {
543
+ let count = 0;
544
+ /** @type {string[]} */
545
+ const redirectLines = [];
546
+
547
+ for (const [source, target] of Object.entries(redirects)) {
548
+ const dest = typeof target === "object" ? target.destination : target;
549
+ const status = typeof target === "object" ? (target.status ?? 301) : 301;
550
+
551
+ // Skip patterns with :param or * — these need platform-specific handling
552
+ if (source.includes(":") || source.includes("*")) {
553
+ redirectLines.push(`${source} ${dest} ${status}`);
554
+ continue;
555
+ }
556
+
557
+ // Static redirect — emit an HTML file with meta refresh
558
+ const htmlPath = routeToOutputPath(source, outDir, "always");
559
+ const html = `<!DOCTYPE html>
560
+ <html>
561
+ <head>
562
+ <meta charset="utf-8">
563
+ <meta http-equiv="refresh" content="0;url=${escapeAttr(dest)}">
564
+ <link rel="canonical" href="${escapeAttr(dest)}">
565
+ <title>Redirecting...</title>
566
+ </head>
567
+ <body>
568
+ <p>Redirecting to <a href="${escapeAttr(dest)}">${escapeHtml(dest)}</a>...</p>
569
+ </body>
570
+ </html>`;
571
+ mkdirSync(dirname(htmlPath), { recursive: true });
572
+ writeFileSync(htmlPath, html, "utf8");
573
+ count++;
574
+ redirectLines.push(`${source} ${dest} ${status}`);
575
+ }
576
+
577
+ // Write _redirects file (Netlify/Cloudflare format)
578
+ if (redirectLines.length > 0) {
579
+ writeFileSync(join(outDir, "_redirects"), redirectLines.join("\n") + "\n", "utf8");
580
+ count++;
581
+ }
582
+
583
+ return count;
584
+ }
585
+
586
+ /**
587
+ * @param {string} str
588
+ * @returns {string}
589
+ */
590
+ function escapeHtml(str) {
591
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
592
+ }
593
+
594
+ /**
595
+ * @param {string} str
596
+ * @returns {string}
597
+ */
598
+ function escapeAttr(str) {
599
+ return String(str).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
600
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Site-loader.js — Load and validate project.json configuration
3
+ *
4
+ * Parses the project root's project.json file and provides normalized configuration with sensible
5
+ * defaults for all project-level properties.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+
11
+ /**
12
+ * Default project configuration. All properties are optional in project.json; these defaults fill
13
+ * in anything the author omits.
14
+ */
15
+ const DEFAULTS = {
16
+ name: "Jx Site",
17
+ url: "",
18
+ defaults: {
19
+ layout: null,
20
+ lang: "en",
21
+ charset: "utf-8",
22
+ },
23
+ $head: [],
24
+ imports: {},
25
+ $media: {},
26
+ style: {},
27
+ state: {},
28
+ collections: {},
29
+ redirects: {},
30
+ build: {
31
+ outDir: "./dist",
32
+ format: "directory",
33
+ trailingSlash: "always",
34
+ },
35
+ };
36
+
37
+ /**
38
+ * Load and validate project.json from a project root.
39
+ *
40
+ * @param {string} projectRoot - Absolute path to the project directory
41
+ * @returns {{ config: Record<string, any>; configPath: string; projectRoot: string }}
42
+ * @throws {Error} If project.json is missing or invalid JSON
43
+ */
44
+ export function loadProjectConfig(projectRoot) {
45
+ const configPath = resolve(projectRoot, "project.json");
46
+
47
+ if (!existsSync(configPath)) {
48
+ throw new Error(`project.json not found in ${projectRoot}`);
49
+ }
50
+
51
+ let raw;
52
+ try {
53
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
54
+ } catch (e) {
55
+ const err = /** @type {any} */ (e);
56
+ throw new Error(`Invalid JSON in ${configPath}: ${err.message}`);
57
+ }
58
+
59
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
60
+ throw new Error(`project.json must be a JSON object, got ${typeof raw}`);
61
+ }
62
+
63
+ // Deep merge with defaults
64
+ const config = {
65
+ ...DEFAULTS,
66
+ ...raw,
67
+ defaults: { ...DEFAULTS.defaults, ...raw.defaults },
68
+ build: { ...DEFAULTS.build, ...raw.build },
69
+ };
70
+
71
+ // Preserve arrays and objects that shouldn't be shallow-merged
72
+ if (raw.$head) config.$head = raw.$head;
73
+ if (raw.$media) config.$media = raw.$media;
74
+ if (raw.style) config.style = raw.style;
75
+ if (raw.state) config.state = raw.state;
76
+ if (raw.redirects) config.redirects = raw.redirects;
77
+ if (raw.imports) config.imports = raw.imports;
78
+ if (raw.collections) config.collections = raw.collections;
79
+
80
+ return {
81
+ config,
82
+ configPath,
83
+ projectRoot: resolve(projectRoot),
84
+ };
85
+ }