@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.
Files changed (69) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +22 -4
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +71 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +174 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/builder.js +20 -14
  48. package/src/cli.js +216 -77
  49. package/src/compiler/body.js +457 -0
  50. package/src/compiler/context.js +160 -0
  51. package/src/compiler/directives.js +1345 -0
  52. package/src/compiler/format.js +244 -0
  53. package/src/compiler/frontmatter.js +117 -0
  54. package/src/compiler/includes.js +116 -0
  55. package/src/compiler/index.js +22 -0
  56. package/src/compiler/interpolation.js +223 -0
  57. package/src/compiler/loops.js +715 -0
  58. package/src/compiler/markdown.js +242 -0
  59. package/src/compiler/page.js +267 -0
  60. package/src/compiler/predicates.js +314 -0
  61. package/src/compiler.js +26 -2932
  62. package/src/dev.js +3 -1
  63. package/src/headers.js +8 -4
  64. package/src/index.js +22 -0
  65. package/src/router.js +3 -1
  66. package/src/runtime.js +168 -14
  67. package/src/scaffold.js +158 -131
  68. package/src/skin.js +11 -2
  69. 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";