@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.
- package/dist/compiler.js +165 -0
- package/package.json +38 -0
- package/src/cli.js +59 -0
- package/src/compiler.js +148 -0
- package/src/shared.js +690 -0
- package/src/site/content-loader.js +452 -0
- package/src/site/context-injection.js +152 -0
- package/src/site/head-merger.js +161 -0
- package/src/site/layout-resolver.js +182 -0
- package/src/site/pages-discovery.js +272 -0
- package/src/site/prototype-resolver.js +161 -0
- package/src/site/site-build.js +600 -0
- package/src/site/site-loader.js +85 -0
- package/src/targets/compile-class.js +194 -0
- package/src/targets/compile-client.js +806 -0
- package/src/targets/compile-element.js +619 -0
- package/src/targets/compile-server.js +57 -0
- package/src/targets/compile-static.js +155 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* @param {string} str
|
|
596
|
+
* @returns {string}
|
|
597
|
+
*/
|
|
598
|
+
function escapeAttr(str) {
|
|
599
|
+
return String(str).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
+
}
|