@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head-merger.js — $head merge pipeline
|
|
3
|
+
*
|
|
4
|
+
* Merges <head> element arrays from three levels:
|
|
5
|
+
*
|
|
6
|
+
* 1. Site.json.$head — global (e.g., favicon, global stylesheet)
|
|
7
|
+
* 2. Layout.$head — layout-level (e.g., shared nav scripts)
|
|
8
|
+
* 3. Page.$head — page-specific (e.g., per-page meta tags)
|
|
9
|
+
*
|
|
10
|
+
* Per site-architecture spec §8:
|
|
11
|
+
*
|
|
12
|
+
* - Later levels override earlier levels for the same element
|
|
13
|
+
* - Deduplication by tagName + key attribute (name, property, rel+href)
|
|
14
|
+
* - Charset and viewport are auto-injected if missing
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merge $head arrays from site, layout, and page levels.
|
|
19
|
+
*
|
|
20
|
+
* @param {any[]} [siteHead] - Site.json $head entries
|
|
21
|
+
* @param {any[]} [layoutHead] - Layout $head entries (may be empty)
|
|
22
|
+
* @param {any[]} [pageHead] - Page $head entries (may be empty)
|
|
23
|
+
* @param {any} [context] - { title, lang, charset, url, pageUrl }
|
|
24
|
+
* @returns {any[]} Merged, deduplicated $head array
|
|
25
|
+
*/
|
|
26
|
+
export function mergeHead(siteHead = [], layoutHead = [], pageHead = [], context = {}) {
|
|
27
|
+
// Start with auto-injected defaults
|
|
28
|
+
const defaults = [
|
|
29
|
+
{ tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
|
|
30
|
+
{
|
|
31
|
+
tagName: "meta",
|
|
32
|
+
attributes: { name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Merge layers: site → layout → page (later wins)
|
|
37
|
+
/** @type {Map<string, any>} */
|
|
38
|
+
const merged = new Map();
|
|
39
|
+
|
|
40
|
+
for (const entry of [...defaults, ...siteHead, ...layoutHead, ...pageHead]) {
|
|
41
|
+
const key = headEntryKey(entry);
|
|
42
|
+
merged.set(key, entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Insert <title> if present
|
|
46
|
+
const title = context.title ?? context.siteName ?? "Jx Site";
|
|
47
|
+
merged.set("title", { tagName: "title", children: [title] });
|
|
48
|
+
|
|
49
|
+
// Add canonical URL if provided
|
|
50
|
+
if (context.pageUrl && context.siteUrl) {
|
|
51
|
+
const canonical = new URL(context.pageUrl, context.siteUrl).href;
|
|
52
|
+
merged.set("link:canonical", {
|
|
53
|
+
tagName: "link",
|
|
54
|
+
attributes: { rel: "canonical", href: canonical },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Array.from(merged.values());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a deduplication key for a <head> element. Elements with the same key are considered
|
|
63
|
+
* duplicates; the last one wins.
|
|
64
|
+
*
|
|
65
|
+
* @param {any} entry
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function headEntryKey(entry) {
|
|
69
|
+
if (!entry || typeof entry !== "object") return String(entry);
|
|
70
|
+
|
|
71
|
+
const tag = entry.tagName ?? "unknown";
|
|
72
|
+
const attrs = entry.attributes ?? {};
|
|
73
|
+
|
|
74
|
+
// <title> — singleton
|
|
75
|
+
if (tag === "title") return "title";
|
|
76
|
+
|
|
77
|
+
// <meta charset> — singleton
|
|
78
|
+
if (attrs.charset) return "meta:charset";
|
|
79
|
+
|
|
80
|
+
// <meta name="...""> — keyed by name
|
|
81
|
+
if (tag === "meta" && attrs.name) return `meta:${attrs.name}`;
|
|
82
|
+
|
|
83
|
+
// <meta property="..."> — keyed by property (Open Graph)
|
|
84
|
+
if (tag === "meta" && attrs.property) return `meta:${attrs.property}`;
|
|
85
|
+
|
|
86
|
+
// <link rel="..." href="..."> — keyed by rel+href
|
|
87
|
+
if (tag === "link" && attrs.rel) {
|
|
88
|
+
return `link:${attrs.rel}:${attrs.href ?? ""}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// <script src="..."> — keyed by src
|
|
92
|
+
if (tag === "script" && attrs.src) return `script:${attrs.src}`;
|
|
93
|
+
|
|
94
|
+
// <style> — unique per content hash
|
|
95
|
+
if (tag === "style") {
|
|
96
|
+
const content = Array.isArray(entry.children) ? entry.children.join("") : "";
|
|
97
|
+
return `style:${simpleHash(content)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Fallback — use full JSON serialization
|
|
101
|
+
return `${tag}:${JSON.stringify(entry)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Simple string hash for deduplication (not cryptographic).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} str
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function simpleHash(str) {
|
|
111
|
+
let hash = 0;
|
|
112
|
+
for (let i = 0; i < str.length; i++) {
|
|
113
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
114
|
+
}
|
|
115
|
+
return hash.toString(36);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a merged $head array to HTML string for insertion into <head>.
|
|
120
|
+
*
|
|
121
|
+
* @param {any[]} headEntries - Merged head entries
|
|
122
|
+
* @returns {string} HTML string
|
|
123
|
+
*/
|
|
124
|
+
export function renderHead(headEntries) {
|
|
125
|
+
return headEntries.map((/** @type {any} */ e) => renderHeadEntry(e)).join("\n ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render a single $head entry to an HTML string.
|
|
130
|
+
*
|
|
131
|
+
* @param {any} entry
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function renderHeadEntry(entry) {
|
|
135
|
+
if (typeof entry === "string") return entry;
|
|
136
|
+
if (!entry || typeof entry !== "object") return "";
|
|
137
|
+
|
|
138
|
+
const tag = entry.tagName;
|
|
139
|
+
const attrs = entry.attributes ?? {};
|
|
140
|
+
const attrStr = Object.entries(attrs)
|
|
141
|
+
.map(([k, v]) => (v === true ? k : `${k}="${escapeAttr(/** @type {any} */ (v))}"`))
|
|
142
|
+
.join(" ");
|
|
143
|
+
|
|
144
|
+
const open = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
|
|
145
|
+
|
|
146
|
+
// Void elements (no closing tag)
|
|
147
|
+
const VOID = new Set(["meta", "link", "base", "br", "hr", "img", "input"]);
|
|
148
|
+
if (VOID.has(tag)) return open;
|
|
149
|
+
|
|
150
|
+
// Elements with content
|
|
151
|
+
const content = Array.isArray(entry.children) ? entry.children.join("") : "";
|
|
152
|
+
return `${open}${content}</${tag}>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {any} val
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function escapeAttr(val) {
|
|
160
|
+
return String(val).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
161
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout-resolver.js — Layout loading and slot distribution at compile time
|
|
3
|
+
*
|
|
4
|
+
* Resolves $layout references, loads layout JSON files, and distributes page content into layout
|
|
5
|
+
* <slot> elements. This is the compile-time equivalent of the runtime's distributeSlots()
|
|
6
|
+
* algorithm.
|
|
7
|
+
*
|
|
8
|
+
* Per site-architecture spec §5:
|
|
9
|
+
*
|
|
10
|
+
* - Layouts are JSON files in the layouts/ directory
|
|
11
|
+
* - Pages reference layouts via "$layout": "./layouts/base.json"
|
|
12
|
+
* - The page's children are distributed into the layout's <slot> elements
|
|
13
|
+
* - Named slots use attributes.slot on page children
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a page's layout, wrapping the page content in the layout structure.
|
|
21
|
+
*
|
|
22
|
+
* @param {any} pageDoc - The raw page JSON document
|
|
23
|
+
* @param {any} projectConfig - Site configuration (for defaults.layout)
|
|
24
|
+
* @param {string} projectRoot - Project root directory
|
|
25
|
+
* @returns {any} The merged document (layout wrapping page content)
|
|
26
|
+
*/
|
|
27
|
+
export function resolveLayout(pageDoc, projectConfig, projectRoot) {
|
|
28
|
+
// Determine which layout to use
|
|
29
|
+
const layoutRef = pageDoc.$layout ?? projectConfig.defaults?.layout ?? null;
|
|
30
|
+
|
|
31
|
+
if (!layoutRef) {
|
|
32
|
+
// No layout — return page as-is
|
|
33
|
+
return pageDoc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Load the layout file
|
|
37
|
+
const layoutPath = resolve(projectRoot, layoutRef);
|
|
38
|
+
if (!existsSync(layoutPath)) {
|
|
39
|
+
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @type {any} */
|
|
43
|
+
let layoutDoc;
|
|
44
|
+
try {
|
|
45
|
+
layoutDoc = JSON.parse(readFileSync(layoutPath, "utf8"));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
const err = /** @type {any} */ (e);
|
|
48
|
+
throw new Error(`Invalid layout JSON at ${layoutPath}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for nested layouts (layout inheriting from another layout)
|
|
52
|
+
if (layoutDoc.$layout) {
|
|
53
|
+
layoutDoc = resolveLayout(layoutDoc, projectConfig, projectRoot);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Distribute page children into layout slots
|
|
57
|
+
const pageChildren = pageDoc.children ?? [];
|
|
58
|
+
const merged = deepClone(layoutDoc);
|
|
59
|
+
|
|
60
|
+
distributeSlots(merged, pageChildren);
|
|
61
|
+
|
|
62
|
+
// Merge page-level properties onto the resolved document
|
|
63
|
+
// Page state extends layout state
|
|
64
|
+
if (pageDoc.state) {
|
|
65
|
+
merged.state = { ...merged.state, ...pageDoc.state };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Page $media extends layout $media
|
|
69
|
+
if (pageDoc.$media) {
|
|
70
|
+
merged.$media = { ...merged.$media, ...pageDoc.$media };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Page style extends layout style
|
|
74
|
+
if (pageDoc.style) {
|
|
75
|
+
merged.style = { ...merged.style, ...pageDoc.style };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Page attributes extend layout attributes
|
|
79
|
+
if (pageDoc.attributes) {
|
|
80
|
+
merged.attributes = { ...merged.attributes, ...pageDoc.attributes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Preserve page-level metadata
|
|
84
|
+
if (pageDoc.$head) merged._pageHead = pageDoc.$head;
|
|
85
|
+
if (pageDoc.title) merged._pageTitle = pageDoc.title;
|
|
86
|
+
|
|
87
|
+
// Remove $layout from merged doc (already resolved)
|
|
88
|
+
delete merged.$layout;
|
|
89
|
+
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Distribute children into <slot> elements within a layout document tree. This is the compile-time
|
|
95
|
+
* equivalent of the runtime's distributeSlots().
|
|
96
|
+
*
|
|
97
|
+
* Algorithm:
|
|
98
|
+
*
|
|
99
|
+
* 1. Find all <slot> elements in the layout tree
|
|
100
|
+
* 2. For each child with attributes.slot, distribute to the matching named slot
|
|
101
|
+
* 3. Remaining children go into the default (unnamed) slot
|
|
102
|
+
* 4. Replace each <slot> element with its distributed children
|
|
103
|
+
*
|
|
104
|
+
* @param {any} node - Layout document tree (mutated in place)
|
|
105
|
+
* @param {any[]} children - Page children to distribute
|
|
106
|
+
*/
|
|
107
|
+
function distributeSlots(node, children) {
|
|
108
|
+
if (!node || typeof node !== "object") return;
|
|
109
|
+
if (!Array.isArray(node.children)) return;
|
|
110
|
+
|
|
111
|
+
// Collect named and default children
|
|
112
|
+
/** @type {Map<string, any[]>} */
|
|
113
|
+
const named = new Map(); // slot name → children[]
|
|
114
|
+
/** @type {any[]} */
|
|
115
|
+
const defaults = []; // children without a slot target
|
|
116
|
+
|
|
117
|
+
for (const child of children) {
|
|
118
|
+
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
119
|
+
const slotName = child.attributes.slot;
|
|
120
|
+
if (!named.has(slotName)) named.set(slotName, []);
|
|
121
|
+
/** @type {any[]} */ (named.get(slotName)).push(child);
|
|
122
|
+
} else {
|
|
123
|
+
defaults.push(child);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Walk the tree and replace <slot> elements
|
|
128
|
+
fillSlots(node, named, defaults);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Recursively walk the tree and replace <slot> elements with distributed content.
|
|
133
|
+
*
|
|
134
|
+
* @param {any} node
|
|
135
|
+
* @param {Map<string, any[]>} named
|
|
136
|
+
* @param {any[]} defaults
|
|
137
|
+
*/
|
|
138
|
+
function fillSlots(node, named, defaults) {
|
|
139
|
+
if (!node || typeof node !== "object") return;
|
|
140
|
+
if (!Array.isArray(node.children)) return;
|
|
141
|
+
|
|
142
|
+
/** @type {any[]} */
|
|
143
|
+
const newChildren = [];
|
|
144
|
+
|
|
145
|
+
for (const child of node.children) {
|
|
146
|
+
if (child && typeof child === "object" && child.tagName === "slot") {
|
|
147
|
+
const slotName = child.attributes?.name;
|
|
148
|
+
|
|
149
|
+
if (slotName && named.has(slotName)) {
|
|
150
|
+
// Named slot — replace with matching children
|
|
151
|
+
newChildren.push(.../** @type {any[]} */ (named.get(slotName)));
|
|
152
|
+
named.delete(slotName); // consumed
|
|
153
|
+
} else if (!slotName && defaults.length > 0) {
|
|
154
|
+
// Default slot — replace with unassigned children
|
|
155
|
+
newChildren.push(...defaults);
|
|
156
|
+
// Don't clear defaults — only one default slot should exist,
|
|
157
|
+
// but if there are multiple, the first one wins
|
|
158
|
+
} else {
|
|
159
|
+
// No matching content — keep slot's fallback children
|
|
160
|
+
if (child.children) {
|
|
161
|
+
newChildren.push(...child.children);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// Not a slot — recurse into it
|
|
166
|
+
fillSlots(child, named, defaults);
|
|
167
|
+
newChildren.push(child);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
node.children = newChildren;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Deep clone a JSON-serializable object.
|
|
176
|
+
*
|
|
177
|
+
* @param {any} obj
|
|
178
|
+
* @returns {any}
|
|
179
|
+
*/
|
|
180
|
+
function deepClone(obj) {
|
|
181
|
+
return JSON.parse(JSON.stringify(obj));
|
|
182
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages-discovery.js — File-based route discovery
|
|
3
|
+
*
|
|
4
|
+
* Scans the pages/ directory and builds a route table mapping URL paths to their source JSON files,
|
|
5
|
+
* layouts, and metadata.
|
|
6
|
+
*
|
|
7
|
+
* Conventions (per site-architecture spec §4): pages/index.json → / pages/about.json → /about
|
|
8
|
+
* pages/about/index.json → /about pages/blog/[slug].json → /blog/:slug (dynamic)
|
|
9
|
+
* pages/docs/[...path].json → /docs/* (catch-all) pages/_component.json → NOT routed (underscore
|
|
10
|
+
* prefix)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { resolve, relative, extname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} Route
|
|
18
|
+
* @property {string} urlPattern - URL pattern (e.g. "/blog/:slug")
|
|
19
|
+
* @property {string} sourcePath - Absolute path to the .json source file
|
|
20
|
+
* @property {string} relativePath - Path relative to pages/ dir
|
|
21
|
+
* @property {boolean} isDynamic - Whether route has parameters
|
|
22
|
+
* @property {boolean} isCatchAll - Whether route uses [...param] spread
|
|
23
|
+
* @property {string[]} params - Parameter names (e.g. ["slug"])
|
|
24
|
+
* @property {string | null} $layout - Layout override from page frontmatter, if any
|
|
25
|
+
* @property {Record<string, string>} [_pathParams] - Resolved path parameters
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Discover all routable pages in a pages/ directory.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} pagesDir - Absolute path to the pages/ directory
|
|
32
|
+
* @returns {Route[]} Sorted route table (static routes first, then dynamic)
|
|
33
|
+
*/
|
|
34
|
+
export function discoverPages(pagesDir) {
|
|
35
|
+
/** @type {Route[]} */
|
|
36
|
+
const routes = [];
|
|
37
|
+
walkDir(pagesDir, pagesDir, routes);
|
|
38
|
+
|
|
39
|
+
// Sort: static routes first, then by specificity (more segments = more specific)
|
|
40
|
+
routes.sort((a, b) => {
|
|
41
|
+
if (a.isDynamic !== b.isDynamic) return a.isDynamic ? 1 : -1;
|
|
42
|
+
if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
|
|
43
|
+
return a.urlPattern.localeCompare(b.urlPattern);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return routes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recursively walk the pages directory tree.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} dir
|
|
53
|
+
* @param {string} pagesRoot
|
|
54
|
+
* @param {Route[]} routes
|
|
55
|
+
*/
|
|
56
|
+
function walkDir(dir, pagesRoot, routes) {
|
|
57
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const fullPath = join(dir, entry.name);
|
|
61
|
+
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
// Skip underscore-prefixed directories
|
|
64
|
+
if (entry.name.startsWith("_")) continue;
|
|
65
|
+
walkDir(fullPath, pagesRoot, routes);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Only process .json files
|
|
70
|
+
if (extname(entry.name) !== ".json") continue;
|
|
71
|
+
|
|
72
|
+
// Skip underscore-prefixed files (local components, not routes)
|
|
73
|
+
if (entry.name.startsWith("_")) continue;
|
|
74
|
+
|
|
75
|
+
const relativePath = relative(pagesRoot, fullPath);
|
|
76
|
+
const route = fileToRoute(relativePath, fullPath);
|
|
77
|
+
if (route) routes.push(route);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert a file path relative to pages/ into a Route object.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} relativePath - E.g. "blog/[slug].json"
|
|
85
|
+
* @param {string} absolutePath - Full filesystem path
|
|
86
|
+
* @returns {Route}
|
|
87
|
+
*/
|
|
88
|
+
function fileToRoute(relativePath, absolutePath) {
|
|
89
|
+
// Remove .json extension
|
|
90
|
+
let urlPath = relativePath.replace(/\.json$/, "");
|
|
91
|
+
|
|
92
|
+
// Normalize path separators
|
|
93
|
+
urlPath = urlPath.split("\\").join("/");
|
|
94
|
+
|
|
95
|
+
// index files map to their parent directory
|
|
96
|
+
if (urlPath.endsWith("/index")) {
|
|
97
|
+
urlPath = urlPath.slice(0, -6) || "/";
|
|
98
|
+
} else if (urlPath === "index") {
|
|
99
|
+
urlPath = "/";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Ensure leading slash
|
|
103
|
+
if (!urlPath.startsWith("/")) urlPath = "/" + urlPath;
|
|
104
|
+
|
|
105
|
+
// Extract parameters from bracket syntax
|
|
106
|
+
/** @type {string[]} */
|
|
107
|
+
const params = [];
|
|
108
|
+
let isDynamic = false;
|
|
109
|
+
let isCatchAll = false;
|
|
110
|
+
|
|
111
|
+
// Convert [param] → :param and [...param] → *
|
|
112
|
+
const urlPattern = urlPath.replace(
|
|
113
|
+
/\[\.\.\.(\w+)\]|\[(\w+)\]/g,
|
|
114
|
+
(/** @type {string} */ match, /** @type {string} */ spread, /** @type {string} */ named) => {
|
|
115
|
+
if (spread) {
|
|
116
|
+
isCatchAll = true;
|
|
117
|
+
isDynamic = true;
|
|
118
|
+
params.push(spread);
|
|
119
|
+
return "*";
|
|
120
|
+
}
|
|
121
|
+
isDynamic = true;
|
|
122
|
+
params.push(named);
|
|
123
|
+
return `:${named}`;
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Peek at the page JSON to extract $layout if present
|
|
128
|
+
/** @type {string | null} */
|
|
129
|
+
let $layout = null;
|
|
130
|
+
try {
|
|
131
|
+
const raw = JSON.parse(readFileSync(absolutePath, "utf8"));
|
|
132
|
+
if (typeof raw.$layout === "string") {
|
|
133
|
+
$layout = raw.$layout;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Skip unreadable files — will error during compilation
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
urlPattern,
|
|
141
|
+
sourcePath: absolutePath,
|
|
142
|
+
relativePath,
|
|
143
|
+
isDynamic,
|
|
144
|
+
isCatchAll,
|
|
145
|
+
params,
|
|
146
|
+
$layout,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Expand dynamic routes by resolving $paths from each dynamic page.
|
|
152
|
+
*
|
|
153
|
+
* Supports three $paths shapes (per spec §4.3): 1. Collection-based: { collection: "blog", param:
|
|
154
|
+
* "slug", field: "id" } 2. Explicit values: { values: ["en", "fr"], param: "lang" } 3. Data file
|
|
155
|
+
* ref: { "$ref": "./data/products.json", param: "id", field: "sku" } 4. Legacy array: [{ slug:
|
|
156
|
+
* "hello" }, { slug: "world" }]
|
|
157
|
+
*
|
|
158
|
+
* @param {Route[]} routes - Discovered route table
|
|
159
|
+
* @param {string} projectRoot - Project root for resolving $ref paths
|
|
160
|
+
* @param {Map<string, any[]>} [collections] - Loaded content collections (from content-loader)
|
|
161
|
+
* @returns {Promise<Route[]>} Expanded routes with concrete paths
|
|
162
|
+
*/
|
|
163
|
+
export async function expandDynamicRoutes(routes, projectRoot, collections = new Map()) {
|
|
164
|
+
/** @type {Route[]} */
|
|
165
|
+
const expanded = [];
|
|
166
|
+
|
|
167
|
+
for (const route of routes) {
|
|
168
|
+
if (!route.isDynamic) {
|
|
169
|
+
expanded.push(route);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Read the page to look for $paths
|
|
174
|
+
/** @type {any} */
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = JSON.parse(readFileSync(route.sourcePath, "utf8"));
|
|
178
|
+
} catch {
|
|
179
|
+
expanded.push(route);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!raw.$paths) {
|
|
184
|
+
console.warn(`Warning: dynamic route ${route.urlPattern} has no $paths — skipping`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const pathEntries = resolvePathEntries(raw.$paths, projectRoot, collections);
|
|
189
|
+
|
|
190
|
+
for (const pathEntry of pathEntries) {
|
|
191
|
+
let concreteUrl = route.urlPattern;
|
|
192
|
+
for (const [param, value] of Object.entries(pathEntry)) {
|
|
193
|
+
concreteUrl = concreteUrl.replace(`:${param}`, /** @type {string} */ (value));
|
|
194
|
+
concreteUrl = concreteUrl.replace("*", /** @type {string} */ (value));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
expanded.push({
|
|
198
|
+
...route,
|
|
199
|
+
urlPattern: concreteUrl,
|
|
200
|
+
isDynamic: false,
|
|
201
|
+
isCatchAll: false,
|
|
202
|
+
params: [],
|
|
203
|
+
_pathParams: pathEntry,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return expanded;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve $paths into an array of param objects.
|
|
213
|
+
*
|
|
214
|
+
* @param {any} $paths - The $paths declaration
|
|
215
|
+
* @param {string} projectRoot
|
|
216
|
+
* @param {Map<string, any[]>} collections
|
|
217
|
+
* @returns {Record<string, any>[]} Array of { paramName: value } objects
|
|
218
|
+
*/
|
|
219
|
+
function resolvePathEntries($paths, projectRoot, collections) {
|
|
220
|
+
// Legacy: array of param objects
|
|
221
|
+
if (Array.isArray($paths)) {
|
|
222
|
+
return $paths;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Collection-based: { collection: "blog", param: "slug", field: "id" }
|
|
226
|
+
if ($paths.collection) {
|
|
227
|
+
const entries = collections.get($paths.collection);
|
|
228
|
+
if (!entries || entries.length === 0) {
|
|
229
|
+
console.warn(
|
|
230
|
+
`Warning: $paths references collection "${$paths.collection}" but it has no entries`,
|
|
231
|
+
);
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const param = $paths.param ?? "slug";
|
|
235
|
+
const field = $paths.field ?? "id";
|
|
236
|
+
return entries.map((/** @type {any} */ entry) => ({
|
|
237
|
+
[param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Explicit values: { values: ["en", "fr"], param: "lang" }
|
|
242
|
+
if (Array.isArray($paths.values)) {
|
|
243
|
+
const param = $paths.param ?? "value";
|
|
244
|
+
return $paths.values.map((/** @type {any} */ v) => ({ [param]: v }));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
|
|
248
|
+
if ($paths.$ref) {
|
|
249
|
+
const filePath = resolve(projectRoot, $paths.$ref);
|
|
250
|
+
/** @type {any} */
|
|
251
|
+
let data;
|
|
252
|
+
try {
|
|
253
|
+
data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const err = /** @type {any} */ (e);
|
|
256
|
+
console.warn(`Warning: $paths.$ref could not load "${$paths.$ref}": ${err.message}`);
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(data)) {
|
|
260
|
+
console.warn(`Warning: $paths.$ref "${$paths.$ref}" must be a JSON array`);
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const param = $paths.param ?? "id";
|
|
264
|
+
const field = $paths.field ?? "id";
|
|
265
|
+
return data.map((/** @type {any} */ item) => ({
|
|
266
|
+
[param]: item[field] ?? item.id ?? String(item),
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.warn(`Warning: unrecognized $paths shape — skipping`);
|
|
271
|
+
return [];
|
|
272
|
+
}
|