@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,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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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
+ }