@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.
- package/CHANGELOG.md +22 -0
- package/README.md +60 -3
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +23 -5
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +66 -10
- package/src/cli.js +216 -77
- package/src/compiler/body.js +432 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1149 -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 +189 -0
- package/src/compiler/loops.js +644 -0
- package/src/compiler/markdown.js +233 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +281 -0
- package/src/compiler.js +26 -2753
- package/src/dev.js +3 -1
- package/src/headers.js +93 -0
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +83 -59
- package/src/scaffold.js +158 -131
- package/src/skin.js +62 -3
- package/src/statics.js +19 -2
- package/site/_/og.png +0 -0
|
@@ -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("&", "&")
|
|
185
|
+
.replaceAll("<", "<")
|
|
186
|
+
.replaceAll(">", ">")
|
|
187
|
+
.replaceAll('"', """)
|
|
188
|
+
.replaceAll("'", "'");
|
|
189
|
+
}
|