@zvndev/darkmown 0.18.0 → 0.20.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.
@@ -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";
@@ -0,0 +1,189 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Interpolation primitives: path/value resolution, state-key lookup, scalar
3
+ // parsing, and HTML escaping. These are the leaf utilities the directive
4
+ // handlers, loops, predicates, and markdown layer all build on — this module
5
+ // imports only the shared typedefs, so it sits near the root of the DAG.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * @typedef {import("./context.js").Scope} Scope
10
+ * @typedef {import("./context.js").Ctx} Ctx
11
+ */
12
+
13
+ /**
14
+ * Walk the scope chain for a top-level variable.
15
+ * @param {Scope} scope
16
+ * @param {string} name
17
+ * @returns {{ found: true, value: unknown } | { found: false }}
18
+ */
19
+ export function lookupVar(scope, name) {
20
+ /** @type {Scope | null} */
21
+ let current = scope;
22
+ for (; current; current = current.parent) {
23
+ if (name in current.vars) return { found: true, value: current.vars[name] };
24
+ }
25
+ return { found: false };
26
+ }
27
+
28
+ /**
29
+ * Resolve a dotted path against the static scope chain.
30
+ * @param {string} expr
31
+ * @param {Ctx} ctx
32
+ * @returns {{ found: true, value: unknown } | { found: false }}
33
+ */
34
+ export function lookupPath(expr, ctx) {
35
+ const segs = expr.split(".");
36
+ const head = lookupVar(ctx.scope, segs[0]);
37
+ if (!head.found) return { found: false };
38
+ return { found: true, value: getPath(head.value, segs.slice(1)) };
39
+ }
40
+
41
+ /**
42
+ * Safely read a dotted path off a value, rejecting prototype-pollution segments.
43
+ * @param {unknown} value
44
+ * @param {string[]} segments
45
+ * @returns {unknown}
46
+ */
47
+ export function getPath(value, segments) {
48
+ /** @type {any} */
49
+ let current = value;
50
+ for (const segment of segments) {
51
+ if (current == null) return undefined;
52
+ if (["constructor", "prototype", "__proto__"].includes(segment)) return undefined;
53
+ current = current[segment];
54
+ }
55
+ return current;
56
+ }
57
+
58
+ /**
59
+ * Validate a dotted path shape, rejecting prototype-pollution segments at every
60
+ * level. Shared by actions/loops/fetch. Throws with a corrective `Use:` hint.
61
+ * @param {string} path Dotted path, e.g. `cart.items`.
62
+ * @param {Ctx} ctx
63
+ * @param {string} use Corrective suggestion for the error message.
64
+ * @returns {string[]} The validated segments.
65
+ */
66
+ export function validatePath(path, ctx, use) {
67
+ const segs = path.split(".");
68
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
69
+ throw new Error(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`);
70
+ }
71
+ return segs;
72
+ }
73
+
74
+ /**
75
+ * Resolve a bare state name to its fully-qualified key, walking section scopes.
76
+ * @param {string} name
77
+ * @param {Ctx} ctx
78
+ * @returns {string | null}
79
+ */
80
+ export function resolveStateKey(name, ctx) {
81
+ for (let i = ctx.sections.length - 1; i >= 0; i--) {
82
+ const key = `${ctx.sections[i]}:${name}`;
83
+ if (ctx.comp.state.has(key)) return key;
84
+ }
85
+ return ctx.comp.state.has(name) ? name : null;
86
+ }
87
+
88
+ /**
89
+ * Render an interpolated value as text. Arrays join with ", " (matching the
90
+ * documented `{ meta.tags }` behavior); a bare object is a mistake — fail
91
+ * loudly with a fix rather than emitting "[object Object]".
92
+ * @param {unknown} value
93
+ * @param {string} expr
94
+ * @param {Ctx} ctx
95
+ * @returns {string}
96
+ */
97
+ export function interpolateLeaf(value, expr, ctx) {
98
+ if (value === null || value === undefined) return "";
99
+ if (Array.isArray(value)) return value.join(", ");
100
+ if (typeof value === "object") {
101
+ throw new Error(
102
+ `Cannot interpolate the object value "{ ${expr} }" in ${ctx.file}. Interpolate a field instead — e.g. { ${expr}.title } — or iterate it with @loop ${expr} into item.`
103
+ );
104
+ }
105
+ return String(value);
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Literals
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
114
+ * @param {unknown} value
115
+ * @returns {string}
116
+ */
117
+ export function safeScriptJson(value) {
118
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
119
+ }
120
+
121
+ /**
122
+ * @param {string} raw
123
+ * @returns {unknown}
124
+ */
125
+ export function parseStateValue(raw) {
126
+ const value = raw.trim();
127
+ if (/^["'].*["']$/.test(value)) return stripQuotes(value);
128
+ if (value === "true") return true;
129
+ if (value === "false") return false;
130
+ if (value === "null") return null;
131
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
132
+ try {
133
+ return JSON.parse(value);
134
+ } catch {
135
+ return value;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * @param {string} raw
141
+ * @returns {string | number | boolean}
142
+ */
143
+ export function parseScalar(raw) {
144
+ const trimmed = raw.trim();
145
+ if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
146
+ if (trimmed === "true") return true;
147
+ if (trimmed === "false") return false;
148
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
149
+ return trimmed;
150
+ }
151
+
152
+ /**
153
+ * @param {string} [value]
154
+ * @returns {string}
155
+ */
156
+ export function stripQuotes(value) {
157
+ return value?.replace(/^["']|["']$/g, "") ?? "";
158
+ }
159
+
160
+ /**
161
+ * Turn a field name / state key into a human-readable accessible name, e.g.
162
+ * `quest` → "Quest", `user-name` → "User name", `firstName` → "First name".
163
+ * Hyphens, underscores, and camelCase boundaries become spaces; the first letter
164
+ * is capitalized. Used to auto-derive aria-label on generated form inputs.
165
+ * @param {string} name
166
+ * @returns {string}
167
+ */
168
+ export function humanizeName(name) {
169
+ const words = name
170
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase
171
+ .replace(/[-_]+/g, " ") // hyphens/underscores → spaces
172
+ .trim()
173
+ .toLowerCase();
174
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
175
+ }
176
+
177
+ /**
178
+ * Escape a value for safe inclusion in HTML text and attribute contexts.
179
+ * @param {unknown} value
180
+ * @returns {string}
181
+ */
182
+ export function escapeHtml(value) {
183
+ return String(value)
184
+ .replaceAll("&", "&amp;")
185
+ .replaceAll("<", "&lt;")
186
+ .replaceAll(">", "&gt;")
187
+ .replaceAll('"', "&quot;")
188
+ .replaceAll("'", "&#39;");
189
+ }