@jxsuite/compiler 0.1.0 → 0.5.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 +257 -90
- package/package.json +21 -10
- package/src/compiler.js +15 -4
- package/src/shared.js +251 -6
- package/src/site/content-loader.js +3 -3
- package/src/site/pages-discovery.js +20 -8
- package/src/site/site-build.js +178 -14
- package/src/targets/compile-client.js +9 -7
- package/src/targets/compile-element.js +57 -2
- package/src/targets/compile-markdown.js +942 -0
- package/src/targets/compile-static.js +3 -2
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/compiler"
|
|
10
|
+
},
|
|
6
11
|
"bin": {
|
|
7
12
|
"jx": "./src/cli.js"
|
|
8
13
|
},
|
|
@@ -13,24 +18,30 @@
|
|
|
13
18
|
"type": "module",
|
|
14
19
|
"exports": {
|
|
15
20
|
".": "./src/compiler.js",
|
|
16
|
-
"./
|
|
17
|
-
"./site-loader": "./src/site/site-loader.js",
|
|
18
|
-
"./pages-discovery": "./src/site/pages-discovery.js",
|
|
19
|
-
"./layout-resolver": "./src/site/layout-resolver.js",
|
|
20
|
-
"./head-merger": "./src/site/head-merger.js",
|
|
21
|
+
"./content-loader": "./src/site/content-loader.js",
|
|
21
22
|
"./context-injection": "./src/site/context-injection.js",
|
|
22
|
-
"./
|
|
23
|
+
"./head-merger": "./src/site/head-merger.js",
|
|
24
|
+
"./layout-resolver": "./src/site/layout-resolver.js",
|
|
25
|
+
"./pages-discovery": "./src/site/pages-discovery.js",
|
|
26
|
+
"./site": "./src/site/site-build.js",
|
|
27
|
+
"./site-loader": "./src/site/site-loader.js"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"provenance": true
|
|
23
31
|
},
|
|
24
32
|
"scripts": {
|
|
25
|
-
"test": "bun test",
|
|
26
33
|
"build": "bun build src/compiler.js --outdir=dist --format=esm --minify",
|
|
27
34
|
"build:site": "bun src/cli.js build",
|
|
35
|
+
"test": "bun test",
|
|
28
36
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
29
37
|
},
|
|
30
38
|
"dependencies": {
|
|
31
39
|
"@apidevtools/json-schema-ref-parser": "^15.3.5",
|
|
32
|
-
"@jxsuite/parser": "
|
|
33
|
-
"@jxsuite/runtime": "
|
|
40
|
+
"@jxsuite/parser": "workspace:^",
|
|
41
|
+
"@jxsuite/runtime": "workspace:^",
|
|
42
|
+
"remark-gfm": "^4.0.1",
|
|
43
|
+
"remark-stringify": "^11.0.0",
|
|
44
|
+
"unified": "^11.0.5"
|
|
34
45
|
},
|
|
35
46
|
"devDependencies": {
|
|
36
47
|
"@happy-dom/global-registrator": "^20.9.0"
|
package/src/compiler.js
CHANGED
|
@@ -54,10 +54,21 @@ export async function compile(sourcePath, opts = {}) {
|
|
|
54
54
|
title = "Jx App",
|
|
55
55
|
reactivitySrc = DEFAULT_REACTIVITY_SRC,
|
|
56
56
|
litHtmlSrc = DEFAULT_LIT_HTML_SRC,
|
|
57
|
+
projectStyle = null,
|
|
57
58
|
} = opts;
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
let raw;
|
|
61
|
+
if (typeof sourcePath === "string") {
|
|
62
|
+
const source = readFileSync(sourcePath, "utf8");
|
|
63
|
+
if (sourcePath.endsWith(".md")) {
|
|
64
|
+
const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
|
|
65
|
+
raw = transpileJxMarkdown(source);
|
|
66
|
+
} else {
|
|
67
|
+
raw = JSON.parse(source);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
raw = sourcePath;
|
|
71
|
+
}
|
|
61
72
|
|
|
62
73
|
// Route 0: .class.json schema-defined class → JS class module
|
|
63
74
|
if (raw.$prototype === "Class") {
|
|
@@ -72,7 +83,7 @@ export async function compile(sourcePath, opts = {}) {
|
|
|
72
83
|
|
|
73
84
|
// Route 1: Fully static → plain HTML/CSS
|
|
74
85
|
if (!isDynamic(raw)) {
|
|
75
|
-
return compileStaticPage(raw, { title, reactivitySrc, litHtmlSrc });
|
|
86
|
+
return compileStaticPage(raw, { title, reactivitySrc, litHtmlSrc, projectStyle });
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
// Route 2: Custom element tagName (contains hyphen) → lit-html web component
|
|
@@ -109,7 +120,7 @@ export async function compile(sourcePath, opts = {}) {
|
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
// Route 3: Dynamic with standard tagName → pre-rendered HTML + reactive bindings
|
|
112
|
-
return compileClient(raw, { title, reactivitySrc, litHtmlSrc });
|
|
123
|
+
return compileClient(raw, { title, reactivitySrc, litHtmlSrc, projectStyle });
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
package/src/shared.js
CHANGED
|
@@ -325,8 +325,19 @@ export function resolveRefValue(refValue, scope) {
|
|
|
325
325
|
* @returns {any}
|
|
326
326
|
*/
|
|
327
327
|
export function evaluateStaticTemplate(str, scope) {
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
try {
|
|
329
|
+
// When the entire string is a single ${...} expression, evaluate it directly
|
|
330
|
+
// to preserve the return type (boolean, number, etc.) instead of stringifying.
|
|
331
|
+
const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
|
|
332
|
+
if (singleExprMatch) {
|
|
333
|
+
const fn = new Function("state", "$map", `return (${singleExprMatch[1]})`);
|
|
334
|
+
return fn(scope, scope?.$map);
|
|
335
|
+
}
|
|
336
|
+
const fn = new Function("state", "$map", `return \`${str}\``);
|
|
337
|
+
return fn(scope, scope?.$map);
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
330
341
|
}
|
|
331
342
|
|
|
332
343
|
/**
|
|
@@ -435,6 +446,10 @@ export function buildAttrs(def, scope) {
|
|
|
435
446
|
}
|
|
436
447
|
}
|
|
437
448
|
|
|
449
|
+
if (def.$props && typeof def.$props === "object") {
|
|
450
|
+
out += ` data-jx-props="${escapeHtml(JSON.stringify(def.$props))}"`;
|
|
451
|
+
}
|
|
452
|
+
|
|
438
453
|
return out;
|
|
439
454
|
}
|
|
440
455
|
|
|
@@ -476,10 +491,54 @@ export function buildInner(def, raw, context, childCompiler) {
|
|
|
476
491
|
* @param {Record<string, any>} [mediaQueries]
|
|
477
492
|
* @returns {string}
|
|
478
493
|
*/
|
|
479
|
-
export function compileStyles(doc, mediaQueries = {}) {
|
|
494
|
+
export function compileStyles(doc, mediaQueries = {}, projectStyle = null) {
|
|
480
495
|
/** @type {string[]} */
|
|
481
496
|
const rules = [];
|
|
482
|
-
|
|
497
|
+
|
|
498
|
+
// Emit project-level (site-wide) styles — CSS custom properties go on :root,
|
|
499
|
+
// everything else on body. Project-level style is implicitly :root, so a
|
|
500
|
+
// flat object like { "--bg": "#000", "margin": "0" } is the expected format.
|
|
501
|
+
if (projectStyle && typeof projectStyle === "object") {
|
|
502
|
+
for (const [key, val] of Object.entries(projectStyle)) {
|
|
503
|
+
if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[")) {
|
|
504
|
+
// Standalone selector (e.g. `.dark`)
|
|
505
|
+
rules.push(`${key} { ${toCSSText(/** @type {any} */ (val))} }`);
|
|
506
|
+
} else if (key.startsWith("@")) {
|
|
507
|
+
// @media block
|
|
508
|
+
const query = key.startsWith("@--")
|
|
509
|
+
? (mediaQueries[key.slice(1)] ?? key.slice(1))
|
|
510
|
+
: key.slice(1);
|
|
511
|
+
rules.push(`@media ${query} { body { ${toCSSText(/** @type {any} */ (val))} } }`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Collect CSS custom properties into :root {}
|
|
515
|
+
/** @type {Record<string, any>} */
|
|
516
|
+
const rootProps = {};
|
|
517
|
+
// Collect direct CSS properties into body {}
|
|
518
|
+
/** @type {Record<string, any>} */
|
|
519
|
+
const bodyProps = {};
|
|
520
|
+
for (const [key, val] of Object.entries(projectStyle)) {
|
|
521
|
+
if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@"))
|
|
522
|
+
continue;
|
|
523
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) continue;
|
|
524
|
+
if (key.startsWith("--")) {
|
|
525
|
+
rootProps[key] = val;
|
|
526
|
+
} else {
|
|
527
|
+
bodyProps[key] = val;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const rootCSS = toCSSText(/** @type {any} */ (rootProps));
|
|
531
|
+
if (rootCSS) {
|
|
532
|
+
rules.push(`:root { ${rootCSS} }`);
|
|
533
|
+
}
|
|
534
|
+
const bodyCSS = toCSSText(/** @type {any} */ (bodyProps));
|
|
535
|
+
if (bodyCSS) {
|
|
536
|
+
rules.push(`body { ${bodyCSS} }`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const counter = { n: 0 };
|
|
541
|
+
collectStyles(doc, rules, mediaQueries, "", counter);
|
|
483
542
|
if (rules.length === 0) return "";
|
|
484
543
|
return `<style>\n${rules.join("\n")}\n</style>`;
|
|
485
544
|
}
|
|
@@ -489,10 +548,28 @@ export function compileStyles(doc, mediaQueries = {}) {
|
|
|
489
548
|
* @param {string[]} rules
|
|
490
549
|
* @param {Record<string, any>} mediaQueries
|
|
491
550
|
* @param {string} [_parentSel]
|
|
551
|
+
* @param {{ n: number }} [counter]
|
|
492
552
|
*/
|
|
493
|
-
export function collectStyles(def, rules, mediaQueries, _parentSel = "") {
|
|
553
|
+
export function collectStyles(def, rules, mediaQueries, _parentSel = "", counter = { n: 0 }) {
|
|
494
554
|
if (!def || typeof def !== "object") return;
|
|
495
555
|
|
|
556
|
+
if (def.style) {
|
|
557
|
+
// Check if this element needs CSS rules (media queries, pseudo-classes, etc.)
|
|
558
|
+
const needsCSS = Object.keys(def.style).some(
|
|
559
|
+
(k) =>
|
|
560
|
+
k.startsWith("@") ||
|
|
561
|
+
k.startsWith(":") ||
|
|
562
|
+
k.startsWith(".") ||
|
|
563
|
+
k.startsWith("&") ||
|
|
564
|
+
k.startsWith("["),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// Auto-scope elements that need CSS rules but lack a unique selector
|
|
568
|
+
if (needsCSS && !def.id && !def.className) {
|
|
569
|
+
def.className = `jx-${counter.n++}`;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
496
573
|
const selector = def.id
|
|
497
574
|
? `#${def.id}`
|
|
498
575
|
: def.className
|
|
@@ -567,7 +644,7 @@ export function collectStyles(def, rules, mediaQueries, _parentSel = "") {
|
|
|
567
644
|
|
|
568
645
|
if (Array.isArray(def.children)) {
|
|
569
646
|
def.children.forEach((/** @type {any} */ c) => {
|
|
570
|
-
collectStyles(c, rules, mediaQueries, selector);
|
|
647
|
+
collectStyles(c, rules, mediaQueries, selector, counter);
|
|
571
648
|
});
|
|
572
649
|
}
|
|
573
650
|
}
|
|
@@ -688,3 +765,171 @@ function _walkServerEntries(def, entries) {
|
|
|
688
765
|
def.children.forEach((/** @type {any} */ c) => _walkServerEntries(c, entries));
|
|
689
766
|
}
|
|
690
767
|
}
|
|
768
|
+
|
|
769
|
+
// ─── Component pre-rendering ─────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
/** @type {Set<string>} */
|
|
772
|
+
const SELF_CLOSING = new Set(["input", "br", "hr", "img", "meta", "link", "area", "col"]);
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Recursively render a Jx node tree to static HTML for pre-rendering.
|
|
776
|
+
*
|
|
777
|
+
* @param {any} node
|
|
778
|
+
* @param {any} scope
|
|
779
|
+
* @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
|
|
780
|
+
* @returns {string}
|
|
781
|
+
*/
|
|
782
|
+
export function renderStaticNode(node, scope, slotContent = null) {
|
|
783
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
784
|
+
if (typeof node === "number" || typeof node === "boolean") return escapeHtml(String(node));
|
|
785
|
+
if (Array.isArray(node))
|
|
786
|
+
return node.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent)).join("\n");
|
|
787
|
+
if (!node || typeof node !== "object") return "";
|
|
788
|
+
|
|
789
|
+
// Skip mapped arrays — can't pre-render dynamic lists
|
|
790
|
+
if (node.$prototype === "Array") return "";
|
|
791
|
+
|
|
792
|
+
const tag = node.tagName ?? "div";
|
|
793
|
+
|
|
794
|
+
// Replace <slot> with provided slot content
|
|
795
|
+
if (tag === "slot" && slotContent != null) return slotContent;
|
|
796
|
+
|
|
797
|
+
const attrs = buildAttrs(node, scope);
|
|
798
|
+
|
|
799
|
+
if (SELF_CLOSING.has(tag)) return `<${tag}${attrs}>`;
|
|
800
|
+
|
|
801
|
+
let inner = "";
|
|
802
|
+
if (node.textContent !== undefined) {
|
|
803
|
+
const val = resolveStaticValue(node.textContent, scope);
|
|
804
|
+
inner = val != null ? escapeHtml(String(val)) : "";
|
|
805
|
+
} else if (node.innerHTML) {
|
|
806
|
+
const val = resolveStaticValue(node.innerHTML, scope);
|
|
807
|
+
inner = val != null ? val : node.innerHTML;
|
|
808
|
+
} else if (Array.isArray(node.children)) {
|
|
809
|
+
inner = node.children
|
|
810
|
+
.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent))
|
|
811
|
+
.join("\n");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return `<${tag}${attrs}>${inner}</${tag}>`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Pre-render a component definition to static HTML for its inner content.
|
|
819
|
+
*
|
|
820
|
+
* @param {any} doc - Component JSON definition
|
|
821
|
+
* @param {Record<string, any> | null} [propsOverride] - Instance-specific prop values to merge into
|
|
822
|
+
* state
|
|
823
|
+
* @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
|
|
824
|
+
* @returns {string} The pre-rendered innerHTML
|
|
825
|
+
*/
|
|
826
|
+
export function preRenderComponentHtml(doc, propsOverride = null, slotContent = null) {
|
|
827
|
+
let stateDefs = doc.state ?? {};
|
|
828
|
+
if (propsOverride) {
|
|
829
|
+
stateDefs = { ...stateDefs };
|
|
830
|
+
for (const [key, value] of Object.entries(propsOverride)) {
|
|
831
|
+
if (key in stateDefs) {
|
|
832
|
+
const existing = stateDefs[key];
|
|
833
|
+
// If the existing definition is an expanded signal with "default", override the default
|
|
834
|
+
if (
|
|
835
|
+
existing &&
|
|
836
|
+
typeof existing === "object" &&
|
|
837
|
+
!Array.isArray(existing) &&
|
|
838
|
+
"default" in existing
|
|
839
|
+
) {
|
|
840
|
+
stateDefs[key] = { ...existing, default: value };
|
|
841
|
+
} else {
|
|
842
|
+
stateDefs[key] = value;
|
|
843
|
+
}
|
|
844
|
+
} else {
|
|
845
|
+
stateDefs[key] = value;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const scope = buildInitialScope(stateDefs, null);
|
|
850
|
+
if (!Array.isArray(doc.children)) return "";
|
|
851
|
+
return doc.children
|
|
852
|
+
.map((/** @type {any} */ c) => renderStaticNode(c, scope, slotContent))
|
|
853
|
+
.join("\n");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Check if a component definition is fully static (no runtime behavior needed).
|
|
858
|
+
*
|
|
859
|
+
* Returns true when: no event handlers, no $prototype entries (Functions, Request, Storage), no
|
|
860
|
+
* $ref values. Conservative — returns false when uncertain.
|
|
861
|
+
*
|
|
862
|
+
* @param {any} doc - Component JSON definition
|
|
863
|
+
* @returns {boolean}
|
|
864
|
+
*/
|
|
865
|
+
export function isComponentFullyStatic(doc) {
|
|
866
|
+
return _isStaticNode(doc);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* @param {any} node
|
|
871
|
+
* @returns {boolean}
|
|
872
|
+
*/
|
|
873
|
+
function _isStaticNode(node) {
|
|
874
|
+
if (!node || typeof node !== "object") return true;
|
|
875
|
+
if (Array.isArray(node)) return node.every(_isStaticNode);
|
|
876
|
+
|
|
877
|
+
// Check for $prototype (Functions, Request, Storage, etc.)
|
|
878
|
+
if (node.$prototype) return false;
|
|
879
|
+
// Check for $ref
|
|
880
|
+
if (node.$ref) return false;
|
|
881
|
+
|
|
882
|
+
// Check state entries
|
|
883
|
+
if (node.state) {
|
|
884
|
+
for (const def of Object.values(node.state)) {
|
|
885
|
+
if (!def || typeof def !== "object") continue;
|
|
886
|
+
const d = /** @type {any} */ (def);
|
|
887
|
+
if (d.$prototype) return false;
|
|
888
|
+
if (d.$ref) return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Check for event handlers
|
|
893
|
+
for (const key of Object.keys(node)) {
|
|
894
|
+
if (key.startsWith("on") && key !== "observedAttributes") return false;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Recurse into children
|
|
898
|
+
if (Array.isArray(node.children)) {
|
|
899
|
+
if (!node.children.every(_isStaticNode)) return false;
|
|
900
|
+
} else if (node.children && typeof node.children === "object") {
|
|
901
|
+
// children descriptor object ($prototype: "Array", etc.)
|
|
902
|
+
if (node.children.$prototype) return false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Generate a CSS rule block for a component's root-level styles. Uses the tag name as the selector.
|
|
910
|
+
* Skips pseudo-selectors, media queries, nested rules, and template strings (runtime-only).
|
|
911
|
+
*
|
|
912
|
+
* @param {string} tagName - The custom element tag name (used as CSS selector)
|
|
913
|
+
* @param {any} styleDef - The component's style object
|
|
914
|
+
* @returns {string} CSS text, or empty string if no styles
|
|
915
|
+
*/
|
|
916
|
+
export function buildComponentCSS(tagName, styleDef) {
|
|
917
|
+
if (!styleDef || typeof styleDef !== "object") return "";
|
|
918
|
+
/** @type {string[]} */
|
|
919
|
+
const decls = [];
|
|
920
|
+
for (const [prop, value] of Object.entries(styleDef)) {
|
|
921
|
+
if (
|
|
922
|
+
prop.startsWith(":") ||
|
|
923
|
+
prop.startsWith(".") ||
|
|
924
|
+
prop.startsWith("&") ||
|
|
925
|
+
prop.startsWith("[") ||
|
|
926
|
+
prop.startsWith("@")
|
|
927
|
+
)
|
|
928
|
+
continue;
|
|
929
|
+
if (value === null || typeof value === "object") continue;
|
|
930
|
+
if (typeof value === "string" && isTemplateString(value)) continue;
|
|
931
|
+
decls.push(` ${camelToKebab(prop)}: ${value};`);
|
|
932
|
+
}
|
|
933
|
+
if (decls.length === 0) return "";
|
|
934
|
+
return `${tagName} {\n${decls.join("\n")}\n}\n`;
|
|
935
|
+
}
|
|
@@ -97,14 +97,14 @@ function parseCSV(csv) {
|
|
|
97
97
|
let _mdModule = null;
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Lazily import @
|
|
101
|
-
*
|
|
100
|
+
* Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
|
|
101
|
+
* MD collections exist.
|
|
102
102
|
*
|
|
103
103
|
* @returns {Promise<any>}
|
|
104
104
|
*/
|
|
105
105
|
async function getMarkdownModule() {
|
|
106
106
|
if (!_mdModule) {
|
|
107
|
-
_mdModule = await import("@
|
|
107
|
+
_mdModule = await import("@jxsuite/parser");
|
|
108
108
|
}
|
|
109
109
|
return _mdModule;
|
|
110
110
|
}
|
|
@@ -66,8 +66,9 @@ function walkDir(dir, pagesRoot, routes) {
|
|
|
66
66
|
continue;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// Only process .json files
|
|
70
|
-
|
|
69
|
+
// Only process .json and .md files
|
|
70
|
+
const ext = extname(entry.name);
|
|
71
|
+
if (ext !== ".json" && ext !== ".md") continue;
|
|
71
72
|
|
|
72
73
|
// Skip underscore-prefixed files (local components, not routes)
|
|
73
74
|
if (entry.name.startsWith("_")) continue;
|
|
@@ -86,8 +87,8 @@ function walkDir(dir, pagesRoot, routes) {
|
|
|
86
87
|
* @returns {Route}
|
|
87
88
|
*/
|
|
88
89
|
function fileToRoute(relativePath, absolutePath) {
|
|
89
|
-
// Remove .json extension
|
|
90
|
-
let urlPath = relativePath.replace(/\.json$/, "");
|
|
90
|
+
// Remove .json or .md extension
|
|
91
|
+
let urlPath = relativePath.replace(/\.(json|md)$/, "");
|
|
91
92
|
|
|
92
93
|
// Normalize path separators
|
|
93
94
|
urlPath = urlPath.split("\\").join("/");
|
|
@@ -124,13 +125,24 @@ function fileToRoute(relativePath, absolutePath) {
|
|
|
124
125
|
},
|
|
125
126
|
);
|
|
126
127
|
|
|
127
|
-
// Peek at the page
|
|
128
|
+
// Peek at the page to extract $layout if present
|
|
128
129
|
/** @type {string | null} */
|
|
129
130
|
let $layout = null;
|
|
130
131
|
try {
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
132
|
+
const source = readFileSync(absolutePath, "utf8");
|
|
133
|
+
if (absolutePath.endsWith(".md")) {
|
|
134
|
+
// Parse YAML frontmatter for $layout
|
|
135
|
+
const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
136
|
+
if (fmMatch) {
|
|
137
|
+
// Quick regex extraction — avoids full YAML dependency
|
|
138
|
+
const layoutMatch = fmMatch[1].match(/^\$layout:\s*(.+)/m);
|
|
139
|
+
if (layoutMatch) $layout = layoutMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const raw = JSON.parse(source);
|
|
143
|
+
if (typeof raw.$layout === "string") {
|
|
144
|
+
$layout = raw.$layout;
|
|
145
|
+
}
|
|
134
146
|
}
|
|
135
147
|
} catch {
|
|
136
148
|
// Skip unreadable files — will error during compilation
|