@zvndev/darkmown 0.19.0 → 1.0.0
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/AGENTS.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +22 -4
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +457 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1345 -0
- package/src/compiler/format.js +244 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +223 -0
- package/src/compiler/loops.js +715 -0
- package/src/compiler/markdown.js +242 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +314 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +8 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +168 -14
- package/src/scaffold.js +158 -131
- package/src/skin.js +11 -2
- package/site/_/og.png +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Format pipes — the value layer. `{ value | name:arg:arg | name2 }` runs a
|
|
3
|
+
// resolved value through a whitelist of formatters; `{ list | sum:"price" }`
|
|
4
|
+
// aggregates a list. The same formatter math is mirrored, compactly, in
|
|
5
|
+
// src/runtime.js for the reactive path — tests/format.test.js + the compile↔
|
|
6
|
+
// runtime parity test guard against drift. Nothing here is eval'd: pipe stages
|
|
7
|
+
// are a fixed, compile-time-validated registry, exactly like the predicate and
|
|
8
|
+
// action grammars.
|
|
9
|
+
//
|
|
10
|
+
// Folds at build time when the piped value is static (zero-JS); a value that
|
|
11
|
+
// reads :state/:store emits `data-wd-fmt` and re-formats in the runtime.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
16
|
+
* @typedef {{ name: string, args: Array<string | number | boolean | null> }} Stage
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// --- shared math (kept tiny and mirrored in the runtime) -------------------
|
|
20
|
+
|
|
21
|
+
/** @param {unknown} v @returns {number} */
|
|
22
|
+
const toNum = (v) => {
|
|
23
|
+
const n = typeof v === "number" ? v : Number.parseFloat(String(v));
|
|
24
|
+
return Number.isFinite(n) ? n : 0;
|
|
25
|
+
};
|
|
26
|
+
/** @param {unknown} v @returns {any[]} */
|
|
27
|
+
const toList = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
|
|
28
|
+
/** Read one shallow field off a row, or the row itself when no field is given. */
|
|
29
|
+
const pick = (/** @type {any} */ row, /** @type {string} */ f) =>
|
|
30
|
+
f != null && f !== "" ? (row == null ? undefined : row[f]) : row;
|
|
31
|
+
/** @param {any[]} list @param {string} f @returns {number[]} */
|
|
32
|
+
const nums = (list, f) => list.map((row) => toNum(pick(row, f)));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Coerce ISO strings, epoch milliseconds (number or numeric string), or a Date
|
|
36
|
+
* into a Date. Returns an invalid Date for unparseable input (formatters guard).
|
|
37
|
+
* @param {unknown} v
|
|
38
|
+
* @returns {Date}
|
|
39
|
+
*/
|
|
40
|
+
function toDate(v) {
|
|
41
|
+
if (v instanceof Date) return v;
|
|
42
|
+
if (typeof v === "number") return new Date(v);
|
|
43
|
+
const s = String(v).trim();
|
|
44
|
+
return /^-?\d+$/.test(s) ? new Date(Number(s)) : new Date(s);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {Date} d @param {Intl.DateTimeFormatOptions} opts @param {string} fallback */
|
|
48
|
+
const fmtDate = (d, opts, fallback) =>
|
|
49
|
+
Number.isNaN(d.getTime()) ? String(fallback) : new Intl.DateTimeFormat(undefined, opts).format(d);
|
|
50
|
+
|
|
51
|
+
// --- aggregates (shared by pipes and `:computed`) --------------------------
|
|
52
|
+
|
|
53
|
+
/** @type {Record<string, (list: any[], field?: string) => number>} */
|
|
54
|
+
export const AGGREGATES = {
|
|
55
|
+
sum: (list, f) => nums(list, f || "").reduce((a, n) => a + n, 0),
|
|
56
|
+
avg: (list, f) => (list.length ? AGGREGATES.sum(list, f) / list.length : 0),
|
|
57
|
+
min: (list, f) => (list.length ? Math.min(...nums(list, f || "")) : 0),
|
|
58
|
+
max: (list, f) => (list.length ? Math.max(...nums(list, f || "")) : 0),
|
|
59
|
+
count: (list) => list.length
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// --- the formatter registry ------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The whitelist. Each formatter is a pure function of (value, args) — no clock,
|
|
66
|
+
* no DOM, no globals beyond `Intl` — so a static value folds deterministically at
|
|
67
|
+
* build time and a reactive value formats identically in the browser.
|
|
68
|
+
* @type {Record<string, (value: any, args: any[]) => string | number>}
|
|
69
|
+
*/
|
|
70
|
+
export const FORMATTERS = {
|
|
71
|
+
// numbers / money
|
|
72
|
+
money: (v, [currency = "USD", locale]) =>
|
|
73
|
+
new Intl.NumberFormat(locale || undefined, {
|
|
74
|
+
style: "currency",
|
|
75
|
+
currency: String(currency || "USD")
|
|
76
|
+
}).format(toNum(v)),
|
|
77
|
+
number: (v, [dec]) =>
|
|
78
|
+
new Intl.NumberFormat(
|
|
79
|
+
undefined,
|
|
80
|
+
dec == null ? {} : { minimumFractionDigits: +dec, maximumFractionDigits: +dec }
|
|
81
|
+
).format(toNum(v)),
|
|
82
|
+
percent: (v, [dec = 0]) =>
|
|
83
|
+
new Intl.NumberFormat(undefined, {
|
|
84
|
+
style: "percent",
|
|
85
|
+
minimumFractionDigits: +dec,
|
|
86
|
+
maximumFractionDigits: +dec
|
|
87
|
+
}).format(toNum(v)),
|
|
88
|
+
round: (v, [dec = 0]) => {
|
|
89
|
+
const p = 10 ** +dec;
|
|
90
|
+
return Math.round(toNum(v) * p) / p;
|
|
91
|
+
},
|
|
92
|
+
// dates (format a given instant — pure, no "now")
|
|
93
|
+
date: (v, [style = "medium"]) => fmtDate(toDate(v), { dateStyle: /** @type {any} */ (style) }, v),
|
|
94
|
+
time: (v, [style = "short"]) => fmtDate(toDate(v), { timeStyle: /** @type {any} */ (style) }, v),
|
|
95
|
+
datetime: (v, [ds = "medium", ts = "short"]) =>
|
|
96
|
+
fmtDate(
|
|
97
|
+
toDate(v),
|
|
98
|
+
{ dateStyle: /** @type {any} */ (ds), timeStyle: /** @type {any} */ (ts) },
|
|
99
|
+
v
|
|
100
|
+
),
|
|
101
|
+
// text
|
|
102
|
+
upper: (v) => String(v ?? "").toUpperCase(),
|
|
103
|
+
lower: (v) => String(v ?? "").toLowerCase(),
|
|
104
|
+
capitalize: (v) => {
|
|
105
|
+
const s = String(v ?? "");
|
|
106
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
107
|
+
},
|
|
108
|
+
truncate: (v, [length = 50, suffix = "…"]) => {
|
|
109
|
+
const s = String(v ?? "");
|
|
110
|
+
return s.length > +length ? s.slice(0, +length).trimEnd() + String(suffix) : s;
|
|
111
|
+
},
|
|
112
|
+
trim: (v) => String(v ?? "").trim(),
|
|
113
|
+
pluralize: (v, [singular = "", plural]) => {
|
|
114
|
+
const n = toNum(v);
|
|
115
|
+
const word =
|
|
116
|
+
Math.abs(n) === 1 ? String(singular) : plural != null ? String(plural) : `${singular}s`;
|
|
117
|
+
return `${new Intl.NumberFormat().format(n)} ${word}`;
|
|
118
|
+
},
|
|
119
|
+
default: (v, [fallback = ""]) => {
|
|
120
|
+
const empty = v == null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
121
|
+
return empty ? String(fallback) : v;
|
|
122
|
+
},
|
|
123
|
+
// aggregates (operate on a list)
|
|
124
|
+
sum: (v, [f]) => AGGREGATES.sum(toList(v), f),
|
|
125
|
+
avg: (v, [f]) => AGGREGATES.avg(toList(v), f),
|
|
126
|
+
min: (v, [f]) => AGGREGATES.min(toList(v), f),
|
|
127
|
+
max: (v, [f]) => AGGREGATES.max(toList(v), f),
|
|
128
|
+
count: (v) => AGGREGATES.count(toList(v)),
|
|
129
|
+
join: (v, [sep = ", ", f]) =>
|
|
130
|
+
toList(v)
|
|
131
|
+
.map((row) => pick(row, /** @type {string} */ (f)))
|
|
132
|
+
.join(String(sep))
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Formatter names, for error messages and the runtime mirror check. */
|
|
136
|
+
export const FORMATTER_NAMES = Object.keys(FORMATTERS);
|
|
137
|
+
|
|
138
|
+
// --- parsing ---------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split a string on a separator that is not inside a quoted run. Keeps pipe
|
|
142
|
+
* arguments like `"a | b"` or `":"` intact.
|
|
143
|
+
* @param {string} s
|
|
144
|
+
* @param {string} sep single character
|
|
145
|
+
* @returns {string[]}
|
|
146
|
+
*/
|
|
147
|
+
function splitTop(s, sep) {
|
|
148
|
+
/** @type {string[]} */
|
|
149
|
+
const out = [];
|
|
150
|
+
let cur = "";
|
|
151
|
+
let quote = "";
|
|
152
|
+
for (const c of s) {
|
|
153
|
+
if (quote) {
|
|
154
|
+
cur += c;
|
|
155
|
+
if (c === quote) quote = "";
|
|
156
|
+
} else if (c === '"' || c === "'") {
|
|
157
|
+
quote = c;
|
|
158
|
+
cur += c;
|
|
159
|
+
} else if (c === sep) {
|
|
160
|
+
out.push(cur);
|
|
161
|
+
cur = "";
|
|
162
|
+
} else cur += c;
|
|
163
|
+
}
|
|
164
|
+
out.push(cur);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {string} t @returns {string | number | boolean | null} */
|
|
169
|
+
function parseArg(t) {
|
|
170
|
+
const v = t.trim();
|
|
171
|
+
if (/^"[^"]*"$/.test(v) || /^'[^']*'$/.test(v)) return v.slice(1, -1);
|
|
172
|
+
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
|
|
173
|
+
if (v === "true") return true;
|
|
174
|
+
if (v === "false") return false;
|
|
175
|
+
if (v === "null") return null;
|
|
176
|
+
return v;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parse the inside of a `{ … }` binding into its path and an ordered list of
|
|
181
|
+
* pipe stages. `{ price | money }` → `{ path: "price", stages: [{name:"money",args:[]}] }`.
|
|
182
|
+
* Stage validation (unknown formatter) happens in {@link validatePipes}.
|
|
183
|
+
* @param {string} inner
|
|
184
|
+
* @returns {{ path: string, stages: Stage[] }}
|
|
185
|
+
*/
|
|
186
|
+
export function parsePipes(inner) {
|
|
187
|
+
const segs = splitTop(inner, "|");
|
|
188
|
+
const path = segs[0].trim();
|
|
189
|
+
/** @type {Stage[]} */
|
|
190
|
+
const stages = [];
|
|
191
|
+
for (let i = 1; i < segs.length; i++) {
|
|
192
|
+
const raw = segs[i].trim();
|
|
193
|
+
const m = /^([A-Za-z][A-Za-z0-9_]*)\s*(?::([\s\S]*))?$/.exec(raw);
|
|
194
|
+
if (!m) return { path, stages: [{ name: raw, args: [] }] }; // malformed → flagged by validate
|
|
195
|
+
stages.push({ name: m[1], args: m[2] == null ? [] : splitTop(m[2], ":").map(parseArg) });
|
|
196
|
+
}
|
|
197
|
+
return { path, stages };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Compile-time validation: every stage must name a known formatter. Throws a
|
|
202
|
+
* corrective compile error otherwise. Returns the parsed `{ path, stages }`.
|
|
203
|
+
* @param {string} inner
|
|
204
|
+
* @param {Ctx} ctx
|
|
205
|
+
* @returns {{ path: string, stages: Stage[] }}
|
|
206
|
+
*/
|
|
207
|
+
export function validatePipes(inner, ctx) {
|
|
208
|
+
const parsed = parsePipes(inner);
|
|
209
|
+
for (const stage of parsed.stages) {
|
|
210
|
+
if (!Object.hasOwn(FORMATTERS, stage.name)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Run a resolved value through a pipe chain at build time. Returns the final
|
|
221
|
+
* value (a string or number); the caller stringifies for output. Mirrors the
|
|
222
|
+
* runtime's `applyFmt`.
|
|
223
|
+
* @param {any} value
|
|
224
|
+
* @param {Stage[]} stages
|
|
225
|
+
* @returns {any}
|
|
226
|
+
*/
|
|
227
|
+
export function applyPipeline(value, stages) {
|
|
228
|
+
let out = value;
|
|
229
|
+
for (const stage of stages) out = FORMATTERS[stage.name](out, stage.args);
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Serialize stages to the compact `[[name,[args]]]` form carried in data-wd-fmt. */
|
|
234
|
+
export const fmtAttr = (/** @type {Stage[]} */ stages) =>
|
|
235
|
+
JSON.stringify(stages.map((s) => [s.name, s.args]));
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parse a `data-wd-fmt` JSON payload (already HTML-decoded) back into stages.
|
|
239
|
+
* Mirrors the runtime's own parse so the initial paint and the live update agree.
|
|
240
|
+
* @param {string} json
|
|
241
|
+
* @returns {Stage[]}
|
|
242
|
+
*/
|
|
243
|
+
export const stagesFromAttr = (json) =>
|
|
244
|
+
/** @type {[string, any[]][]} */ (JSON.parse(json)).map(([name, args]) => ({ name, args }));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Frontmatter: split a raw file into `meta` + `body`, parse inline scalar/array
|
|
3
|
+
// values, and warn when a file looks like it forgot the opening `---`.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { stripQuotes } from "./interpolation.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import("./context.js").Meta} Meta
|
|
10
|
+
* @typedef {import("./context.js").FrontmatterValue} FrontmatterValue
|
|
11
|
+
* @typedef {import("./context.js").Compilation} Compilation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split a raw file into its frontmatter `meta` and `body`.
|
|
16
|
+
* @param {string} raw Full file contents.
|
|
17
|
+
* @param {string} [file] Source path, used only for error messages.
|
|
18
|
+
* @returns {{ meta: Meta, body: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function parseFrontmatter(raw, file) {
|
|
21
|
+
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
22
|
+
const end = raw.indexOf("\n---", 3);
|
|
23
|
+
if (end === -1) {
|
|
24
|
+
const where = file ? ` in ${file}` : "";
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const front = raw.slice(4, end).trim();
|
|
30
|
+
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
31
|
+
/** @type {Meta} */
|
|
32
|
+
const meta = {};
|
|
33
|
+
for (const line of front.split("\n")) {
|
|
34
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
35
|
+
if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
|
|
36
|
+
}
|
|
37
|
+
return { meta, body };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Warn (non-fatal) when a file looks like it MEANT to open frontmatter but forgot
|
|
42
|
+
* the leading `---`: the first content line is a `key: value` pair and a bare
|
|
43
|
+
* `---` fence appears within the opening lines. Conservative — a normal markdown
|
|
44
|
+
* body whose first line happens to contain a colon (with no early `---`) is left
|
|
45
|
+
* alone, since `---` is also a valid horizontal rule.
|
|
46
|
+
* @param {string} raw
|
|
47
|
+
* @param {string} file
|
|
48
|
+
* @param {Compilation} comp
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
export function warnLikelyFrontmatter(raw, file, comp) {
|
|
52
|
+
if (raw.startsWith("---\n")) return; // a real opener: parseFrontmatter handled it
|
|
53
|
+
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < lines.length && !lines[i].trim()) i++; // skip leading blanks
|
|
56
|
+
if (i >= lines.length || !/^[A-Za-z][\w-]*:\s*\S/.test(lines[i])) return;
|
|
57
|
+
// Only warn when the run of lines up to a bare `---` is ALL `key: value` pairs:
|
|
58
|
+
// a forgotten opener, not prose that merely contains a colon and a later rule.
|
|
59
|
+
for (let j = i; j < lines.length && j < i + 12; j++) {
|
|
60
|
+
const t = lines[j].trim();
|
|
61
|
+
if (t === "---") {
|
|
62
|
+
comp.warnings.push(
|
|
63
|
+
`${file}: this looks like frontmatter missing its opening "---". Use: a "---" line before the fields (and another "---" to close) — otherwise the block renders as page text.`
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (t && !/^[A-Za-z][\w-]*:\s*\S/.test(t)) return; // hit prose → not frontmatter
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
72
|
+
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
73
|
+
// the parser stays single-pass and line-based.
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} raw
|
|
76
|
+
* @returns {FrontmatterValue}
|
|
77
|
+
*/
|
|
78
|
+
function parseFrontmatterValue(raw) {
|
|
79
|
+
const value = raw.trim();
|
|
80
|
+
if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
|
|
81
|
+
return stripQuotes(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} raw
|
|
86
|
+
* @returns {string[]}
|
|
87
|
+
*/
|
|
88
|
+
function parseInlineArray(raw) {
|
|
89
|
+
const inner = raw.slice(1, -1);
|
|
90
|
+
if (inner.trim() === "") return [];
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const items = [];
|
|
93
|
+
let buf = "";
|
|
94
|
+
let quote = null;
|
|
95
|
+
let quoted = false;
|
|
96
|
+
const push = () => {
|
|
97
|
+
items.push(quoted ? buf : buf.trim());
|
|
98
|
+
buf = "";
|
|
99
|
+
quoted = false;
|
|
100
|
+
};
|
|
101
|
+
for (const char of inner) {
|
|
102
|
+
if (quote) {
|
|
103
|
+
if (char === quote) quote = null;
|
|
104
|
+
else buf += char;
|
|
105
|
+
} else if ((char === '"' || char === "'") && buf.trim() === "") {
|
|
106
|
+
quote = char;
|
|
107
|
+
quoted = true;
|
|
108
|
+
buf = ""; // drop any whitespace between the comma and the opening quote
|
|
109
|
+
} else if (char === ",") {
|
|
110
|
+
push();
|
|
111
|
+
} else {
|
|
112
|
+
buf += char;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
push();
|
|
116
|
+
return items;
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Includes / assets: resolve `@include` (and `@loop` data) targets to absolute
|
|
3
|
+
// paths inside `site/pages` or `site/_`, register colocated `.skin`/`.js`
|
|
4
|
+
// siblings, and warn when a plain `.md` file uses `.wd`-only syntax.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { pageIncludeExtensions } from "./context.js";
|
|
10
|
+
import { stripQuotes } from "./interpolation.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {import("./context.js").Paths} Paths
|
|
14
|
+
* @typedef {import("./context.js").Assets} Assets
|
|
15
|
+
* @typedef {import("./context.js").Compilation} Compilation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a page's colocated `.skin`/`.js` siblings as emitted assets.
|
|
20
|
+
* @param {string} file
|
|
21
|
+
* @param {Paths} context
|
|
22
|
+
* @param {Assets} assets
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
export function collectColocatedAssets(file, context, assets) {
|
|
26
|
+
const ext = path.extname(file);
|
|
27
|
+
const stem = file.slice(0, -ext.length);
|
|
28
|
+
for (const [assetExt, folder] of [
|
|
29
|
+
[".skin", "styles"],
|
|
30
|
+
[".js", "scripts"]
|
|
31
|
+
]) {
|
|
32
|
+
const candidate = `${stem}${assetExt}`;
|
|
33
|
+
if (!fs.existsSync(candidate)) continue;
|
|
34
|
+
const rel = path.relative(context.cwd, candidate).replaceAll(path.sep, "/");
|
|
35
|
+
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
36
|
+
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
37
|
+
assets.files.set(candidate, publicPath);
|
|
38
|
+
if (assetExt === ".skin") assets.skins.add(publicPath);
|
|
39
|
+
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve an include spec to an absolute path inside `site/pages` or `site/_`.
|
|
45
|
+
* @param {string} spec Include target (may be quoted).
|
|
46
|
+
* @param {string} fromFile File requesting the include.
|
|
47
|
+
* @param {Paths} context
|
|
48
|
+
* @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
52
|
+
const clean = stripQuotes(spec);
|
|
53
|
+
const candidates = [];
|
|
54
|
+
if (clean.startsWith("/")) {
|
|
55
|
+
candidates.push(path.join(context.shelfRoot, clean.slice(1)));
|
|
56
|
+
} else {
|
|
57
|
+
candidates.push(path.resolve(path.dirname(fromFile), clean));
|
|
58
|
+
candidates.push(path.join(context.shelfRoot, clean));
|
|
59
|
+
}
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
const resolved = path.resolve(candidate);
|
|
62
|
+
if (!isAllowedInclude(resolved, context)) {
|
|
63
|
+
throw new Error(`Include "${spec}" from ${fromFile} resolves outside site/pages or site/_`);
|
|
64
|
+
}
|
|
65
|
+
if (!fs.existsSync(resolved)) continue;
|
|
66
|
+
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} file
|
|
74
|
+
* @param {Paths} context
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function isAllowedInclude(file, context) {
|
|
78
|
+
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
79
|
+
return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Plain .md hints
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Warn when a plain `.md` file contains `.wd`-only syntax that stays inert.
|
|
88
|
+
* @param {string} body
|
|
89
|
+
* @param {string} file
|
|
90
|
+
* @param {Compilation} comp
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
93
|
+
export function scanMarkdownHints(body, file, comp) {
|
|
94
|
+
let fence = null;
|
|
95
|
+
for (const line of body.split("\n")) {
|
|
96
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
97
|
+
if (fence) {
|
|
98
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
99
|
+
fence = null;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (fenceMatch) {
|
|
103
|
+
fence = fenceMatch[1];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const hit = line.match(
|
|
107
|
+
/^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:note|:sprint|:::)(\s|$)/
|
|
108
|
+
);
|
|
109
|
+
if (hit) {
|
|
110
|
+
comp.warnings.push(
|
|
111
|
+
`${file}: "${hit[1]}" is .wd syntax and stays plain text in .md — rename the file to .wd to activate it.`
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Compiler module wiring. The compiler is split across cohesive seams:
|
|
3
|
+
//
|
|
4
|
+
// context.js shared typedefs + tiny helpers (createCompilation, at, …)
|
|
5
|
+
// interpolation.js path/value resolution, state-key lookup, escapeHtml, literals
|
|
6
|
+
// frontmatter.js `---` block → meta + body, inline scalar/array parsing
|
|
7
|
+
// includes.js @include / @loop-data resolution, colocated assets, .md hints
|
|
8
|
+
// predicates.js where / when / :if / :computed expression compilers
|
|
9
|
+
// markdown.js markdown-it instances + plugins + prose interpolation
|
|
10
|
+
// loops.js @loop pipeline + row-template initial-paint fill
|
|
11
|
+
// directives.js the handle* family + :button action parser + demo directives
|
|
12
|
+
// body.js the line-based directive dispatcher (compileBody)
|
|
13
|
+
// page.js HTML page shell, image hardening, per-file compile
|
|
14
|
+
//
|
|
15
|
+
// This barrel re-exports the public API; `src/compiler.js` re-exports from here
|
|
16
|
+
// so every existing `import … from "./compiler.js"` keeps working unchanged.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export { parseFrontmatter } from "./frontmatter.js";
|
|
20
|
+
export { escapeHtml } from "./interpolation.js";
|
|
21
|
+
export { loopKeyOf } from "./loops.js";
|
|
22
|
+
export { compileDocument, compilePage, enhanceImages } from "./page.js";
|