@zvndev/darkmown 1.5.0 → 2.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 +17 -3
- package/CHANGELOG.md +28 -0
- package/README.md +44 -16
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +19 -6
- package/package.json +1 -1
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +4 -0
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.wd +1 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +70 -13
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +56 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +66 -1
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +53 -21
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +59 -75
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +377 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +4 -0
- package/src/templates/blog/site/pages/index.skin +3 -0
- package/src/templates/blog/site/pages/index.wd +10 -4
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +4 -0
- package/src/templates/dashboard/site/pages/index.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- package/src/templates/landing/site/pages/index.wd +1 -0
- package/src/templates/starter/site/_/nav.wd +4 -0
- package/src/templates/starter/site/pages/about.md +18 -1
- package/src/templates/starter/site/pages/about.skin +69 -0
- package/src/templates/starter/site/pages/index.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.wd +1 -0
- package/src/templates/blog/site/_/posts.json +0 -16
package/src/compiler/page.js
CHANGED
|
@@ -12,7 +12,12 @@ import { htmlHasHighlight } from "../highlight.js";
|
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { createCompilation, createScope } from "./context.js";
|
|
14
14
|
import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
collectColocatedAssets,
|
|
17
|
+
scanMarkdownHints,
|
|
18
|
+
scopedSkinFor,
|
|
19
|
+
stampScope
|
|
20
|
+
} from "./includes.js";
|
|
16
21
|
import { escapeHtml } from "./interpolation.js";
|
|
17
22
|
import { selectMd } from "./markdown.js";
|
|
18
23
|
|
|
@@ -26,6 +31,13 @@ import { selectMd } from "./markdown.js";
|
|
|
26
31
|
* @typedef {import("./context.js").CompiledPage} CompiledPage
|
|
27
32
|
*/
|
|
28
33
|
|
|
34
|
+
// Skip-to-content link: the first focusable element on every page, visually
|
|
35
|
+
// hidden until keyboard focus reveals it. Styled inline (not via a skin) so it
|
|
36
|
+
// behaves on pages with no stylesheet at all; it rides ahead of page skins in
|
|
37
|
+
// the head, so a project skin can restyle `.wd-skip-link` freely.
|
|
38
|
+
const SKIP_LINK_STYLE =
|
|
39
|
+
"<style>.wd-skip-link{position:absolute;top:0;left:0;z-index:100;padding:.55rem 1.1rem;background:#18221d;color:#f7f3ea;font:600 .9rem/1.2 system-ui,sans-serif;text-decoration:none;border-radius:0 0 8px 0;transform:translateY(-150%)}.wd-skip-link:focus{transform:none}</style>";
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Compile a page source file into a full HTML document plus its assets.
|
|
31
43
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
@@ -42,6 +54,12 @@ export function compilePage(file, context, options = {}) {
|
|
|
42
54
|
const compiled = compileDocument(file, context, [], options.vars, options.collections);
|
|
43
55
|
const title = compiled.meta.title || "Darkmown";
|
|
44
56
|
const description = compiled.meta.description || "";
|
|
57
|
+
// Per-page document language for the `<html lang>` attribute (`lang: fr` in
|
|
58
|
+
// frontmatter). Defaults to English.
|
|
59
|
+
const lang =
|
|
60
|
+
typeof compiled.meta.lang === "string" && compiled.meta.lang.trim()
|
|
61
|
+
? compiled.meta.lang.trim()
|
|
62
|
+
: "en";
|
|
45
63
|
// Optional `image:` frontmatter sets the social-share preview (absolute URL).
|
|
46
64
|
const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
|
|
47
65
|
/** @type {string[]} */
|
|
@@ -68,10 +86,8 @@ export function compilePage(file, context, options = {}) {
|
|
|
68
86
|
// The framework highlight stylesheet rides ahead of page skins so a project's
|
|
69
87
|
// own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
|
|
70
88
|
// linked only on pages with a highlighted code block (`hasCode`).
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
: [];
|
|
74
|
-
const cssLinks = [...highlightLink, ...compiled.assets.skins]
|
|
89
|
+
const highlightHref = compiled.assets.hasCode ? ["/__wd/highlight.css"] : [];
|
|
90
|
+
const cssLinks = [...highlightHref, ...compiled.assets.skins]
|
|
75
91
|
.map((href) => `<link rel="stylesheet" href="${href}">`)
|
|
76
92
|
.join("\n");
|
|
77
93
|
// RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
|
|
@@ -143,20 +159,25 @@ export function compilePage(file, context, options = {}) {
|
|
|
143
159
|
const stamped =
|
|
144
160
|
scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
|
|
145
161
|
const body = enhanceImages(stamped, context);
|
|
162
|
+
const { content, target } = ensureMainLandmark(body);
|
|
146
163
|
|
|
147
164
|
return {
|
|
148
165
|
meta: compiled.meta,
|
|
166
|
+
deps: compiled.deps,
|
|
167
|
+
collectionsUsed: compiled.collectionsUsed,
|
|
149
168
|
html: `<!doctype html>
|
|
150
|
-
<html lang="
|
|
169
|
+
<html lang="${escapeHtml(lang)}">
|
|
151
170
|
<head>
|
|
152
171
|
<meta charset="utf-8">
|
|
153
172
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
154
173
|
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
155
174
|
<link rel="icon" href="${favicon}">${feedLink}
|
|
175
|
+
${SKIP_LINK_STYLE}
|
|
156
176
|
${cssLinks}${transitions}${speculation}
|
|
157
177
|
</head>
|
|
158
178
|
<body>
|
|
159
|
-
|
|
179
|
+
<a class="wd-skip-link" href="#${escapeHtml(target)}">Skip to content</a>
|
|
180
|
+
${content}
|
|
160
181
|
${scripts}
|
|
161
182
|
</body>
|
|
162
183
|
</html>`,
|
|
@@ -166,6 +187,25 @@ ${scripts}
|
|
|
166
187
|
};
|
|
167
188
|
}
|
|
168
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Guarantee the page body exposes a main-content landmark the skip link can
|
|
192
|
+
* target. A body that already has a `<main>` keeps it: an author-supplied id
|
|
193
|
+
* wins as the skip target; a `<main>` without one gets `id="main"` stamped on.
|
|
194
|
+
* A body with no `<main>` at all is wrapped whole. Pure compile-time string
|
|
195
|
+
* pass (like {@link enhanceImages}) — a static page stays static.
|
|
196
|
+
* @param {string} html Assembled page body HTML.
|
|
197
|
+
* @returns {{ content: string, target: string }}
|
|
198
|
+
*/
|
|
199
|
+
function ensureMainLandmark(html) {
|
|
200
|
+
const open = html.match(/<main\b[^>]*>/i);
|
|
201
|
+
if (!open) return { content: `<main id="main">\n${html}\n</main>`, target: "main" };
|
|
202
|
+
// The id attribute must start after whitespace (or a closing quote — sloppy
|
|
203
|
+
// but browser-parsed) so `data-id="…"` never masquerades as the real id.
|
|
204
|
+
const id = open[0].match(/[\s"']id\s*=\s*["']([^"']*)["']/i);
|
|
205
|
+
if (id) return { content: html, target: id[1] || "main" };
|
|
206
|
+
return { content: html.replace(open[0], `${open[0].slice(0, -1)} id="main">`), target: "main" };
|
|
207
|
+
}
|
|
208
|
+
|
|
169
209
|
/**
|
|
170
210
|
* Harden every `<img>` in the assembled page body — compile-time only, zero
|
|
171
211
|
* runtime JS. Bare markdown images reflow the page as they decode (cumulative
|
|
@@ -203,70 +243,6 @@ export function enhanceImages(html, paths) {
|
|
|
203
243
|
});
|
|
204
244
|
}
|
|
205
245
|
|
|
206
|
-
/**
|
|
207
|
-
* Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
|
|
208
|
-
* elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
|
|
209
|
-
* are plain characters and must never be treated as tags. The content runs to the
|
|
210
|
-
* first matching close tag.
|
|
211
|
-
* @type {Set<string>}
|
|
212
|
-
*/
|
|
213
|
-
const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
|
|
217
|
-
* half of compile-time scoped styles, mirroring {@link enhanceImages}: a
|
|
218
|
-
* compile-time post-pass over assembled HTML, zero runtime JS. Every element
|
|
219
|
-
* start tag that doesn't already carry a scope attribute gets one, so the scoped
|
|
220
|
-
* stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
|
|
221
|
-
* this subtree and nowhere else. Void/self-closing tags are stamped too (the
|
|
222
|
-
* attribute is valid on any element). Closing tags, comments, doctype, and the
|
|
223
|
-
* `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
|
|
224
|
-
* (e.g. a nested include with its own scope) keeps its own — first stamp wins.
|
|
225
|
-
*
|
|
226
|
-
* Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
|
|
227
|
-
* `title`) are special-cased: their OPENING tag is stamped (valid), but their
|
|
228
|
-
* BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
|
|
229
|
-
* markup, so stamping there would corrupt the content. A small tokenizer walks
|
|
230
|
-
* tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
|
|
231
|
-
* element, copies through verbatim until the matching close tag.
|
|
232
|
-
* @param {string} html Subtree HTML to scope.
|
|
233
|
-
* @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
|
|
234
|
-
* @returns {string}
|
|
235
|
-
*/
|
|
236
|
-
export function stampScope(html, scopeId) {
|
|
237
|
-
// Opening tag: name + (quote-aware) attribute list + optional self-close slash.
|
|
238
|
-
const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
239
|
-
let out = "";
|
|
240
|
-
let cursor = 0;
|
|
241
|
-
for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
|
|
242
|
-
const [tag, name, attrs, slash] = m;
|
|
243
|
-
out += html.slice(cursor, m.index); // text/markup before this opening tag
|
|
244
|
-
const stamped = /\bdata-wd-scope\s*=/.test(attrs)
|
|
245
|
-
? tag // first stamp wins — a pre-stamped tag keeps its own scope
|
|
246
|
-
: `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
|
|
247
|
-
out += stamped;
|
|
248
|
-
cursor = m.index + tag.length;
|
|
249
|
-
|
|
250
|
-
// Raw-text element (not self-closed): copy its body through untouched and
|
|
251
|
-
// resume scanning after the matching close tag, so nothing inside is stamped.
|
|
252
|
-
if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
|
|
253
|
-
const rest = html.slice(cursor);
|
|
254
|
-
const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
|
|
255
|
-
if (!closeMatch) {
|
|
256
|
-
out += rest; // unterminated raw-text element — copy the remainder verbatim
|
|
257
|
-
cursor = html.length;
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
const through = closeMatch.index + closeMatch[0].length; // body + close tag
|
|
261
|
-
out += rest.slice(0, through); // byte-intact
|
|
262
|
-
cursor += through;
|
|
263
|
-
openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
out += html.slice(cursor); // trailing text after the last opening tag
|
|
267
|
-
return out;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
246
|
/**
|
|
271
247
|
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
272
248
|
* @param {string} tag
|
|
@@ -323,7 +299,9 @@ export function compileDocument(file, context, stack = [], vars = {}, collection
|
|
|
323
299
|
html: result.html,
|
|
324
300
|
assets: comp.assets,
|
|
325
301
|
warnings: comp.warnings,
|
|
326
|
-
pagination: comp.pagination
|
|
302
|
+
pagination: comp.pagination,
|
|
303
|
+
deps: comp.deps,
|
|
304
|
+
collectionsUsed: comp.collectionsUsed
|
|
327
305
|
};
|
|
328
306
|
}
|
|
329
307
|
|
|
@@ -347,13 +325,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
347
325
|
}
|
|
348
326
|
|
|
349
327
|
const raw = fs.readFileSync(file, "utf8");
|
|
350
|
-
const { meta, body } = parseFrontmatter(raw, file);
|
|
328
|
+
const { meta, body, bodyLine } = parseFrontmatter(raw, file);
|
|
351
329
|
warnLikelyFrontmatter(raw, file, comp);
|
|
330
|
+
comp.deps.add(file);
|
|
352
331
|
collectColocatedAssets(file, context, comp.assets);
|
|
353
332
|
|
|
354
333
|
if (path.extname(file) === ".md") {
|
|
355
334
|
scanMarkdownHints(body, file, comp);
|
|
356
|
-
|
|
335
|
+
// Share the compilation's heading-slug counters (as renderProse does), so a
|
|
336
|
+
// `.md` included from a `.wd` page still dedupes anchors document-wide.
|
|
337
|
+
const html = selectMd(meta).render(body, { headingSlugs: comp.headingSlugs });
|
|
357
338
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
358
339
|
return { meta, html };
|
|
359
340
|
}
|
|
@@ -363,13 +344,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
363
344
|
/** @type {Ctx} */
|
|
364
345
|
const ctx = {
|
|
365
346
|
file,
|
|
347
|
+
bodyLine,
|
|
366
348
|
context,
|
|
367
349
|
stack: [...stack, real],
|
|
368
350
|
scope: createScope(scope, { meta }),
|
|
369
351
|
comp,
|
|
370
352
|
sections,
|
|
371
353
|
loopItem,
|
|
372
|
-
md: selectMd(meta)
|
|
354
|
+
md: selectMd(meta),
|
|
355
|
+
compileBody,
|
|
356
|
+
compileFile
|
|
373
357
|
};
|
|
374
358
|
const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
|
|
375
359
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// State declarations: the `:state` / `:store` / `:computed` / `:theme` handlers
|
|
3
|
+
// plus the shared declare* helpers (declareState/declareStore/declareErrorState)
|
|
4
|
+
// the `:fetch` and form handlers lean on to register their auto-declared keys.
|
|
5
|
+
//
|
|
6
|
+
// Every handler receives the 0-based line `index` so its malformed/invalid
|
|
7
|
+
// errors report `file:line` (via `at`) — matching the unclosed-block errors —
|
|
8
|
+
// while keeping the message text and `Use:` hints intact.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { at } from "./context.js";
|
|
12
|
+
import { FORMATTERS } from "./format.js";
|
|
13
|
+
import { escapeHtml, getPath, parseStateValue, safeScriptJson } from "./interpolation.js";
|
|
14
|
+
import { compileComputedExpr } from "./predicates.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} line
|
|
22
|
+
* @param {Ctx} ctx
|
|
23
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function handleState(line, ctx, index) {
|
|
27
|
+
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
28
|
+
if (!match) throw new Error(`Malformed :state in ${at(ctx, index)}: ${line}`);
|
|
29
|
+
const value = parseStateValue(match[2], at(ctx, index));
|
|
30
|
+
const key = declareState(match[1], value, ctx);
|
|
31
|
+
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
32
|
+
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register a state key in the current section scope, enabling the runtime.
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @param {Ctx} ctx
|
|
40
|
+
* @returns {string} The fully-qualified state key.
|
|
41
|
+
*/
|
|
42
|
+
export function declareState(name, value, ctx) {
|
|
43
|
+
if (ctx.loopItem)
|
|
44
|
+
throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
45
|
+
if (ctx.comp.stores.has(name))
|
|
46
|
+
throw new Error(
|
|
47
|
+
`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
48
|
+
);
|
|
49
|
+
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
50
|
+
if (ctx.comp.state.has(key))
|
|
51
|
+
throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
52
|
+
ctx.comp.state.set(key, value);
|
|
53
|
+
ctx.comp.assets.runtime = true;
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} line
|
|
59
|
+
* @param {Ctx} ctx
|
|
60
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function handleStore(line, ctx, index) {
|
|
64
|
+
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
65
|
+
if (!match)
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]`
|
|
68
|
+
);
|
|
69
|
+
const value = parseStateValue(match[2], at(ctx, index));
|
|
70
|
+
const name = declareStore(match[1], value, ctx);
|
|
71
|
+
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
72
|
+
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a page-global store. The bare name is added to `comp.state` so every
|
|
77
|
+
* resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
|
|
78
|
+
* in `comp.stores` for collision checks. Never section-scoped.
|
|
79
|
+
* @param {string} name
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @param {Ctx} ctx
|
|
82
|
+
* @returns {string} The store name (also its bare state key).
|
|
83
|
+
*/
|
|
84
|
+
export function declareStore(name, value, ctx) {
|
|
85
|
+
if (ctx.comp.stores.has(name))
|
|
86
|
+
throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
|
|
87
|
+
if (ctx.comp.state.has(name))
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
90
|
+
);
|
|
91
|
+
ctx.comp.stores.add(name);
|
|
92
|
+
ctx.comp.state.set(name, value);
|
|
93
|
+
ctx.comp.assets.runtime = true;
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
|
|
99
|
+
* round-trip :form so error fallbacks have a key to bind.
|
|
100
|
+
* @param {string} key
|
|
101
|
+
* @param {Ctx} ctx
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
104
|
+
export function declareErrorState(key, ctx) {
|
|
105
|
+
const errorKey = `${key}_error`;
|
|
106
|
+
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} line
|
|
111
|
+
* @param {Ctx} ctx
|
|
112
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function handleComputed(line, ctx, index) {
|
|
116
|
+
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
117
|
+
if (!match)
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
|
|
120
|
+
);
|
|
121
|
+
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
122
|
+
/** @type {unknown} */
|
|
123
|
+
let initial;
|
|
124
|
+
try {
|
|
125
|
+
/** @param {string} key @param {string} [path] */
|
|
126
|
+
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
127
|
+
// Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
|
|
128
|
+
// has the same initial value the runtime will recompute to.
|
|
129
|
+
const agg = (
|
|
130
|
+
/** @type {string} */ name,
|
|
131
|
+
/** @type {any} */ list,
|
|
132
|
+
/** @type {string} */ field
|
|
133
|
+
) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
|
|
134
|
+
initial = new Function("S", "A", `return (${expr});`)(read, agg);
|
|
135
|
+
} catch {
|
|
136
|
+
console.warn(
|
|
137
|
+
`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
|
|
138
|
+
);
|
|
139
|
+
initial = null;
|
|
140
|
+
}
|
|
141
|
+
const key = declareState(match[1], initial ?? null, ctx);
|
|
142
|
+
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `:theme [name] [= "auto"]` declares a durable `:store` (default name `theme`,
|
|
147
|
+
* seed `"auto"`) and reflects its value onto `<html data-theme>`. This layers a
|
|
148
|
+
* manual light/dark switch over the OS preference: `"auto"` follows the skin's
|
|
149
|
+
* `tokens dark` media query, while `"light"`/`"dark"` force the matching
|
|
150
|
+
* `:root[data-theme]` scope. Wire a switch with ordinary store actions —
|
|
151
|
+
* `:button "Dark" -> theme = "dark"`. Durable, so the choice survives reloads.
|
|
152
|
+
* @param {string} line
|
|
153
|
+
* @param {Ctx} ctx
|
|
154
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function handleTheme(line, ctx, index) {
|
|
158
|
+
const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
|
|
159
|
+
if (!match) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const name = match[1] || "theme";
|
|
165
|
+
const seed = match[2] != null ? parseStateValue(match[2], at(ctx, index)) : "auto";
|
|
166
|
+
const storeName = declareStore(name, seed, ctx);
|
|
167
|
+
return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
|
|
168
|
+
}
|