@zvndev/darkmown 0.20.0 → 1.1.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 (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +125 -5
  4. package/docs/spec-alignment.md +23 -3
  5. package/package.json +3 -3
  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 +72 -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 +183 -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/compiler/body.js +89 -3
  48. package/src/compiler/context.js +1 -1
  49. package/src/compiler/directives.js +199 -3
  50. package/src/compiler/format.js +244 -0
  51. package/src/compiler/interpolation.js +36 -1
  52. package/src/compiler/loops.js +95 -24
  53. package/src/compiler/markdown.js +16 -7
  54. package/src/compiler/predicates.js +35 -2
  55. package/src/headers.js +5 -0
  56. package/src/runtime.js +168 -14
  57. package/src/skin.js +9 -1
@@ -0,0 +1,130 @@
1
+ /* Showcase hub — layers on /base.wd. Gallery of the flagship apps. */
2
+
3
+ main.showcase
4
+ max-width 1080px
5
+
6
+ .sc-hero
7
+ padding 3.5rem 0 2.5rem
8
+ border-bottom 1px solid $line
9
+ margin-bottom 2.5rem
10
+
11
+ .sc-hero h1
12
+ max-width 18ch
13
+
14
+ .sc-hero > p
15
+ max-width 64ch
16
+ font-size 1.18rem
17
+ color $ink-soft
18
+ margin 1rem 0 1.75rem
19
+
20
+ .sc-cta
21
+ display flex
22
+ flex-wrap wrap
23
+
24
+ /* ── App gallery ────────────────────────────────────────────────── */
25
+
26
+ .sc-apps
27
+ display grid
28
+ grid-template-columns 1fr 1fr
29
+ gap 1.25rem
30
+ margin 1rem 0 3rem
31
+
32
+ @media (max-width: 760px)
33
+ .sc-apps
34
+ grid-template-columns 1fr
35
+
36
+ .sc-app
37
+ position relative
38
+ padding 1.6rem 1.6rem 1.4rem
39
+ border 1px solid $line
40
+ radius $radius-lg
41
+ overflow hidden
42
+ transition transform .14s ease, box-shadow .14s ease
43
+
44
+ .sc-app:hover
45
+ transform translateY(-3px)
46
+ box-shadow 0 18px 40px -22px rgba(28,24,20,.45)
47
+
48
+ /* A soft accent wash per app — its identity color. */
49
+ .sc-app::before
50
+ content ""
51
+ position absolute
52
+ inset 0 0 auto 0
53
+ height 4px
54
+
55
+ .sc-folio::before
56
+ background #b8462e
57
+
58
+ .sc-pulse::before
59
+ background #18b89a
60
+
61
+ .sc-forge::before
62
+ background #6d5ae6
63
+
64
+ .sc-compass::before
65
+ background #e0894a
66
+
67
+ .sc-ledger::before
68
+ background #c79a3a
69
+
70
+ .sc-emoji
71
+ font-size 2rem
72
+ line-height 1
73
+
74
+ .sc-app h3
75
+ margin .6rem 0 .5rem
76
+ font-size 1.45rem
77
+
78
+ .sc-app p
79
+ color $ink-soft
80
+ margin .4rem 0 1rem
81
+
82
+ .sc-open
83
+ display inline-block
84
+ margin-top .4rem
85
+ font-weight 600
86
+ text-decoration none
87
+ color $accent-deep
88
+
89
+ .sc-open:hover
90
+ text-decoration underline
91
+
92
+ /* ── Weight section ─────────────────────────────────────────────── */
93
+
94
+ .sc-weight
95
+ margin 1rem 0 3rem
96
+
97
+ .sc-scale
98
+ list-style none
99
+ padding 1.25rem 1.5rem
100
+ margin 1.25rem 0
101
+
102
+ .sc-scale li
103
+ padding .55rem 0
104
+ border-bottom 1px solid $line-soft
105
+ color $ink-soft
106
+
107
+ .sc-scale li:last-child
108
+ border-bottom 0
109
+
110
+ .sc-scale strong
111
+ color $ink
112
+
113
+ .sc-scale code
114
+ font-size 1em
115
+ font-weight 700
116
+ color $accent-deep
117
+ background $accent-soft
118
+
119
+ /* ── Mini demos ─────────────────────────────────────────────────── */
120
+
121
+ .sc-mini
122
+ padding 1.1rem 1.25rem
123
+
124
+ .sc-mini p
125
+ margin 0 0 .6rem
126
+ color $ink-soft
127
+
128
+ .sc-mini a
129
+ font-weight 600
130
+ text-decoration none
@@ -0,0 +1,168 @@
1
+ ---
2
+ title: Showcase — real apps built in Markdown
3
+ description: Five complete, beautiful apps — a storefront, a live dashboard, a pricing configurator, a product-finder quiz, and a sortable expense ledger — each one a readable Markdown file. See the app, then view its source.
4
+ image: https://darkmown.com/__wd/media/og-showcase.png
5
+ transitions: true
6
+ ---
7
+
8
+ @include /nav.wd
9
+ @include /base.wd
10
+
11
+ <main class="wide showcase">
12
+
13
+ <header class="sc-hero">
14
+ <span class="eyebrow">Built with Darkmown</span>
15
+
16
+ # Real apps. Written in Markdown.
17
+
18
+ Each of these is a complete, working app — a storefront with a cart, a live status dashboard, an interactive pricing builder, a guided quiz. Open one, use it, then **view its source**. It's a Markdown file you can read top to bottom.
19
+
20
+ <div class="sc-cta">
21
+ <a class="btn primary" href="/folio/">Open the storefront</a>
22
+ <a class="btn" href="/docs/">Read the docs</a>
23
+ </div>
24
+ </header>
25
+
26
+ <section class="sc-apps">
27
+
28
+ ::: card .sc-app .sc-folio
29
+ <span class="sc-emoji">🛍️</span>
30
+
31
+ ### Folio — boutique storefront
32
+
33
+ A multi-page shop where the cart **persists across pages, reloads, and browser tabs**, and navigation is instant. Live search, add-to-cart, checkout.
34
+
35
+ <span class="tag">static → reactive</span> <span class="tag">:store</span> <span class="tag">keyed @loop</span> <span class="tag">:form</span>
36
+
37
+ [Open Folio →](/folio/){.sc-open}
38
+ :::
39
+
40
+ ::: card .sc-app .sc-pulse
41
+ <span class="sc-emoji">📈</span>
42
+
43
+ ### Pulse — status dashboard
44
+
45
+ A service dashboard driven by `:fetch` — loading, error, and empty states, live refresh, sortable metrics, status badges. No hand-written JavaScript.
46
+
47
+ <span class="tag">:fetch lifecycle</span> <span class="tag">:computed</span> <span class="tag">.class when</span> <span class="tag">sort / where</span>
48
+
49
+ [Open Pulse →](/pulse/){.sc-open}
50
+ :::
51
+
52
+ ::: card .sc-app .sc-forge
53
+ <span class="sc-emoji">🧩</span>
54
+
55
+ ### Forge — plan configurator
56
+
57
+ Build your plan: toggle features, adjust seats, watch the price recompute instantly, get smart upsell hints, then check out — the classic "this needs React" interaction.
58
+
59
+ <span class="tag">:computed pricing</span> <span class="tag">:if logic</span> <span class="tag">toggles</span> <span class="tag">:form</span>
60
+
61
+ [Open Forge →](/forge/){.sc-open}
62
+ :::
63
+
64
+ ::: card .sc-app .sc-compass
65
+ <span class="sc-emoji">🧭</span>
66
+
67
+ ### Compass — product finder
68
+
69
+ A guided quiz that asks a few questions, scores your answers, and recommends a match — a branching state machine expressed entirely as `:if` steps.
70
+
71
+ <span class="tag">:if state machine</span> <span class="tag">:computed</span> <span class="tag">scored flow</span>
72
+
73
+ [Open Compass →](/compass/){.sc-open}
74
+ :::
75
+
76
+ ::: card .sc-app .sc-ledger
77
+ <span class="sc-emoji">📒</span>
78
+
79
+ ### Ledger — sortable expense table
80
+
81
+ A spreadsheet-grade data table: click a header to re-sort live, filter by merchant, watch running totals recompute, flip light/dark — formatted with `| money` and `| date`, totalled with `sum()`.
82
+
83
+ <span class="tag">format pipes</span> <span class="tag">aggregates</span> <span class="tag">reactive sort</span> <span class="tag">:theme</span>
84
+
85
+ [Open Ledger →](/ledger/){.sc-open}
86
+ :::
87
+
88
+ </section>
89
+
90
+ <section class="sc-weight">
91
+
92
+ ## What it costs
93
+
94
+ Darkmown's whole pitch is weight. A static page ships **zero** framework JavaScript — not "a little," none. The runtime loads only when a page actually declares reactivity, and it's one shared file, CI-enforced under 8 KB gzipped.
95
+
96
+ ::: card .sc-scale
97
+ - **Static Darkmown page** — `0 KB` framework JS. Forever.
98
+ - **Reactive Darkmown page** — one shared `~7.4 KB` gzipped runtime, no per-component bundles.
99
+ - **A typical SPA** — ships a framework runtime in the **~15–60 KB** gzipped range *before* your app code, on every page (Alpine ~15 KB, Vue ~18 KB runtime, React 19 ~59 KB).
100
+ :::
101
+
102
+ The two Darkmown numbers above are measured on every commit; the runtime budget fails CI if it crosses 8 KB. The comparison figures are representative min+gzip runtime sizes (June 2026) — the point isn't "smallest" (Preact is smaller), it's **nothing at all until you ask for it.**
103
+
104
+ </section>
105
+
106
+ <section class="sc-more">
107
+
108
+ ## The directive demos
109
+
110
+ Prefer to see each feature in isolation? These pages tour the building blocks the apps are made of:
111
+
112
+ <div class="grid auto">
113
+
114
+ ::: card .sc-mini
115
+ **Reactive** — state, keyed loops, scoped sections.
116
+
117
+ [Open →](/reactive/)
118
+ :::
119
+
120
+ ::: card .sc-mini
121
+ **Loops** — sort, paging, `@empty`, nesting.
122
+
123
+ [Open →](/loops/)
124
+ :::
125
+
126
+ ::: card .sc-mini
127
+ **Store** — durable, cross-tab global state.
128
+
129
+ [Open →](/store/)
130
+ :::
131
+
132
+ ::: card .sc-mini
133
+ **Theme** — zero-JS OS dark mode from a skin.
134
+
135
+ [Open →](/theme/)
136
+ :::
137
+
138
+ ::: card .sc-mini
139
+ **Swiper** — a draggable carousel via the escape hatch.
140
+
141
+ [Open →](/carousel/)
142
+ :::
143
+
144
+ ::: card .sc-mini
145
+ **Media** — `:video`, `:audio`, `:embed`, zero JS.
146
+
147
+ [Open →](/media/)
148
+ :::
149
+
150
+ ::: card .sc-mini
151
+ **Data & Forms** — fetch lifecycle, server round-trips.
152
+
153
+ [Open →](/data/)
154
+ :::
155
+
156
+ ::: card .sc-mini
157
+ **Plain Markdown** — a `.md` page, directives inert.
158
+
159
+ [Open →](/markdown/)
160
+ :::
161
+
162
+ </div>
163
+
164
+ </section>
165
+
166
+ </main>
167
+
168
+ @include /footer.wd
@@ -15,16 +15,20 @@ import {
15
15
  handleComputed,
16
16
  handleContainer,
17
17
  handleEffect,
18
+ handleEmbed,
19
+ handleEvery,
18
20
  handleFetch,
19
21
  handleForm,
20
22
  handleIf,
21
23
  handleInclude,
22
24
  handleInput,
25
+ handleMedia,
23
26
  handleSelect,
24
27
  handleState,
25
28
  handleStore,
26
29
  handleSubmit,
27
30
  handleTextarea,
31
+ handleTheme,
28
32
  renderDemoDirective
29
33
  } from "./directives.js";
30
34
  import { handleLoop } from "./loops.js";
@@ -94,12 +98,16 @@ export function compileBody(lines, ctx) {
94
98
  }
95
99
  if (/^:state\s/.test(line)) {
96
100
  flush();
97
- out.push(handleState(line, ctx, i));
101
+ const v = joinValueDirective(lines, i);
102
+ out.push(handleState(v.line, ctx, i));
103
+ i = v.end;
98
104
  continue;
99
105
  }
100
106
  if (/^:store\s/.test(line)) {
101
107
  flush();
102
- out.push(handleStore(line, ctx, i));
108
+ const v = joinValueDirective(lines, i);
109
+ out.push(handleStore(v.line, ctx, i));
110
+ i = v.end;
103
111
  continue;
104
112
  }
105
113
  if (/^:fetch\s/.test(line)) {
@@ -117,6 +125,29 @@ export function compileBody(lines, ctx) {
117
125
  out.push(handleEffect(line, ctx, i));
118
126
  continue;
119
127
  }
128
+ if (/^:every\s/.test(line)) {
129
+ flush();
130
+ out.push(handleEvery(line, ctx, i));
131
+ continue;
132
+ }
133
+ if (/^:theme(?:\s|$)/.test(line)) {
134
+ flush();
135
+ const v = joinValueDirective(lines, i);
136
+ out.push(handleTheme(v.line, ctx, i));
137
+ i = v.end;
138
+ continue;
139
+ }
140
+ const media = line.match(/^:(video|audio)\s/);
141
+ if (media) {
142
+ flush();
143
+ out.push(handleMedia(line, /** @type {"video" | "audio"} */ (media[1]), ctx, i));
144
+ continue;
145
+ }
146
+ if (/^:embed\s/.test(line)) {
147
+ flush();
148
+ out.push(handleEmbed(line, ctx, i));
149
+ continue;
150
+ }
120
151
  if (/^:form\s/.test(line)) {
121
152
  flush();
122
153
  const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
@@ -217,7 +248,7 @@ export function compileBody(lines, ctx) {
217
248
  // narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
218
249
  // followed by punctuation do not match).
219
250
  const KNOWN_DIRECTIVE =
220
- /^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
251
+ /^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
221
252
  /**
222
253
  * @param {string} line
223
254
  * @param {Ctx} ctx
@@ -236,6 +267,61 @@ function warnUnknownDirective(line, ctx) {
236
267
  }
237
268
  }
238
269
 
270
+ /**
271
+ * Net bracket depth of a string, ignoring brackets inside JSON string literals.
272
+ * Positive means more openers (`[`/`{`) than closers — the value is unbalanced.
273
+ * @param {string} s
274
+ * @returns {number}
275
+ */
276
+ function bracketDepth(s) {
277
+ let depth = 0;
278
+ let inStr = false;
279
+ let esc = false;
280
+ for (const ch of s) {
281
+ if (inStr) {
282
+ if (esc) esc = false;
283
+ else if (ch === "\\") esc = true;
284
+ else if (ch === '"') inStr = false;
285
+ continue;
286
+ }
287
+ if (ch === '"') inStr = true;
288
+ else if (ch === "[" || ch === "{") depth++;
289
+ else if (ch === "]" || ch === "}") depth--;
290
+ }
291
+ return depth;
292
+ }
293
+
294
+ /**
295
+ * `:state`/`:store`/`:theme` take a single-line value, but a JSON array/object
296
+ * seed reads far better across several lines. When the value after `=` opens an
297
+ * unbalanced `[`/`{` on the directive line, gather the contiguous continuation
298
+ * lines (stopping at a blank line, which delimits the literal) until the brackets
299
+ * balance, then collapse them into one physical line the existing single-line
300
+ * handler parses unchanged — JSON ignores the inter-token whitespace. A literal
301
+ * that never balances falls through as-is, so `parseStateValue` reports it.
302
+ * @param {string[]} lines
303
+ * @param {number} start Index of the directive line.
304
+ * @returns {{ line: string, end: number }} `end` = last consumed line index.
305
+ */
306
+ function joinValueDirective(lines, start) {
307
+ const opener = lines[start];
308
+ const eq = opener.indexOf("=");
309
+ if (eq === -1) return { line: opener, end: start };
310
+ const value = opener.slice(eq + 1);
311
+ // Only a JSON array/object literal can span lines; bare scalars/strings cannot.
312
+ if (!/^\s*[[{]/.test(value)) return { line: opener, end: start };
313
+ let depth = bracketDepth(value);
314
+ if (depth <= 0) return { line: opener, end: start }; // already balanced on one line
315
+ const parts = [opener];
316
+ let i = start;
317
+ while (depth > 0 && i + 1 < lines.length && lines[i + 1].trim() !== "") {
318
+ i++;
319
+ parts.push(lines[i]);
320
+ depth += bracketDepth(lines[i]);
321
+ }
322
+ return { line: parts.join(" "), end: i };
323
+ }
324
+
239
325
  /**
240
326
  * Capture lines from an opener until a matching close token, honoring nesting and fences.
241
327
  * @param {string[]} lines
@@ -97,7 +97,7 @@
97
97
  * Loop clause configuration shared by the static and reactive paths.
98
98
  * @typedef {object} LoopOpts
99
99
  * @property {Predicate | null} where
100
- * @property {{ key: string, dir: string } | null} sort
100
+ * @property {{ key: string, keyKind: "literal" | "key", dir: string, dirKind: "literal" | "key" } | null} sort A literal item field + asc/desc, or a `:state`/`:store` key for either (reactive, clickable-header sort).
101
101
  * @property {boolean} reverse
102
102
  * @property {NumArg | null} offset
103
103
  * @property {NumArg | null} limit
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { compileBody } from "./body.js";
13
13
  import { at, createScope, LOOP_META } from "./context.js";
14
+ import { FORMATTERS } from "./format.js";
14
15
  import { resolveInclude } from "./includes.js";
15
16
  import {
16
17
  escapeHtml,
@@ -174,7 +175,7 @@ export function handleContainer(header, bodyLines, ctx, index) {
174
175
  export function handleState(line, ctx, index) {
175
176
  const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
176
177
  if (!match) throw new Error(`Malformed :state in ${at(ctx.file, index)}: ${line}`);
177
- const value = parseStateValue(match[2]);
178
+ const value = parseStateValue(match[2], at(ctx.file, index));
178
179
  const key = declareState(match[1], value, ctx);
179
180
  const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
180
181
  return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
@@ -214,7 +215,7 @@ export function handleStore(line, ctx, index) {
214
215
  throw new Error(
215
216
  `Malformed :store in ${at(ctx.file, index)}: ${line}. Use: :store name = value [ephemeral]`
216
217
  );
217
- const value = parseStateValue(match[2]);
218
+ const value = parseStateValue(match[2], at(ctx.file, index));
218
219
  const name = declareStore(match[1], value, ctx);
219
220
  const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
220
221
  return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
@@ -411,7 +412,14 @@ export function handleComputed(line, ctx, index) {
411
412
  try {
412
413
  /** @param {string} key @param {string} [path] */
413
414
  const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
414
- initial = new Function("S", `return (${expr});`)(read);
415
+ // Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
416
+ // has the same initial value the runtime will recompute to.
417
+ const agg = (
418
+ /** @type {string} */ name,
419
+ /** @type {any} */ list,
420
+ /** @type {string} */ field
421
+ ) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
422
+ initial = new Function("S", "A", `return (${expr});`)(read, agg);
415
423
  } catch {
416
424
  console.warn(
417
425
  `:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
@@ -811,6 +819,194 @@ export function handleEffect(line, ctx, index) {
811
819
  return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
812
820
  }
813
821
 
822
+ const EVERY_USE =
823
+ "Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
824
+
825
+ /**
826
+ * Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
827
+ * @param {string} raw
828
+ * @returns {number | null}
829
+ */
830
+ function parseDuration(raw) {
831
+ const match = raw.trim().match(/^(\d+)(ms|s|m)?$/);
832
+ if (!match) return null;
833
+ const n = Number(match[1]);
834
+ return match[2] === "s" ? n * 1000 : match[2] === "m" ? n * 60000 : n;
835
+ }
836
+
837
+ /**
838
+ * Parse `:every <duration> -> <actions>` into a marker the runtime drives on a
839
+ * timer: every `<duration>` it runs `<actions>` (the same `:button` vocabulary,
840
+ * `;`-chained), and the interval auto-pauses while the tab is hidden. The one time
841
+ * primitive — behind live polling (`:every 5s -> board refetch`), clocks and
842
+ * countdowns (`:every 1s -> seconds++`), and slideshow autoplay (`:every 4s -> slide++`).
843
+ * @param {string} line
844
+ * @param {Ctx} ctx
845
+ * @param {number} index 0-based line index for `file:line` errors.
846
+ * @returns {string}
847
+ */
848
+ export function handleEvery(line, ctx, index) {
849
+ const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
850
+ if (!match) throw new Error(`Malformed :every in ${at(ctx.file, index)}: ${line}. ${EVERY_USE}`);
851
+ const ms = parseDuration(match[1]);
852
+ if (ms == null || ms <= 0) {
853
+ throw new Error(
854
+ `:every duration "${match[1]}" is not valid in ${at(ctx.file, index)}. ${EVERY_USE}`
855
+ );
856
+ }
857
+ ctx.comp.assets.runtime = true;
858
+ const action = parseAction(match[2], ctx);
859
+ const actions = Array.isArray(action) ? action : [action];
860
+ return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
861
+ }
862
+
863
+ /**
864
+ * `:theme [name] [= "auto"]` declares a durable `:store` (default name `theme`,
865
+ * seed `"auto"`) and reflects its value onto `<html data-theme>`. This layers a
866
+ * manual light/dark switch over the OS preference: `"auto"` follows the skin's
867
+ * `tokens dark` media query, while `"light"`/`"dark"` force the matching
868
+ * `:root[data-theme]` scope. Wire a switch with ordinary store actions —
869
+ * `:button "Dark" -> theme = "dark"`. Durable, so the choice survives reloads.
870
+ * @param {string} line
871
+ * @param {Ctx} ctx
872
+ * @param {number} index 0-based line index for `file:line` errors.
873
+ * @returns {string}
874
+ */
875
+ export function handleTheme(line, ctx, index) {
876
+ const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
877
+ if (!match) {
878
+ throw new Error(
879
+ `Malformed :theme in ${at(ctx.file, index)}: ${line}. Use: :theme (or :theme name = "auto")`
880
+ );
881
+ }
882
+ const name = match[1] || "theme";
883
+ const seed = match[2] != null ? parseStateValue(match[2], at(ctx.file, index)) : "auto";
884
+ const storeName = declareStore(name, seed, ctx);
885
+ return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
886
+ }
887
+
888
+ // ---------------------------------------------------------------------------
889
+ // Media — :video / :audio / :embed. Compile-time only (zero runtime): hardened
890
+ // HTML5 media and privacy-friendly, lazy iframe embeds. The extension stays the
891
+ // feature gate; these emit no `data-wd-*`, so a media-only page ships zero JS.
892
+ // ---------------------------------------------------------------------------
893
+
894
+ /** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
895
+ const MEDIA_SPEC = {
896
+ video: {
897
+ flags: ["controls", "autoplay", "loop", "muted", "playsinline"],
898
+ attrs: ["poster", "width", "height", "preload"]
899
+ },
900
+ audio: { flags: ["controls", "autoplay", "loop", "muted"], attrs: ["preload"] }
901
+ };
902
+
903
+ /**
904
+ * `:video /clip.mp4 [poster=…] [width=…] [height=…] [preload=…] [controls] [autoplay]
905
+ * [loop] [muted] [playsinline]` and `:audio /track.mp3 [preload=…] [controls] …` →
906
+ * a hardened `<video>`/`<audio>`. Defaults: `preload="metadata"`, and `controls`
907
+ * unless the clip is an `autoplay` background (autoplay also implies `muted`, which
908
+ * browsers require). Author flags/attrs always win. URLs use the `:fetch` scheme
909
+ * guard (relative or http(s)).
910
+ * @param {string} line
911
+ * @param {"video" | "audio"} kind
912
+ * @param {Ctx} ctx
913
+ * @param {number} index 0-based line index for `file:line` errors.
914
+ * @returns {string}
915
+ */
916
+ export function handleMedia(line, kind, ctx, index) {
917
+ const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
918
+ if (!match) {
919
+ throw new Error(
920
+ `Malformed :${kind} in ${at(ctx.file, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
921
+ );
922
+ }
923
+ const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
924
+ const spec = MEDIA_SPEC[kind];
925
+ /** @type {Set<string>} */
926
+ const flags = new Set();
927
+ /** @type {Map<string, string>} */
928
+ const attrs = new Map();
929
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
930
+ for (const token of (match[2] || "").matchAll(re)) {
931
+ if (token[3]) {
932
+ if (!spec.flags.includes(token[3]))
933
+ throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx.file, index)}`);
934
+ flags.add(token[3]);
935
+ } else {
936
+ if (!spec.attrs.includes(token[1]))
937
+ throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx.file, index)}`);
938
+ attrs.set(token[1], stripQuotes(token[2]));
939
+ }
940
+ }
941
+ // Sensible defaults; author input always wins.
942
+ if (flags.has("autoplay")) flags.add("muted"); // browsers block unmuted autoplay
943
+ if (kind === "audio" || !flags.has("autoplay")) flags.add("controls");
944
+ const parts = [`src="${escapeHtml(src)}"`];
945
+ if (attrs.has("poster"))
946
+ parts.push(
947
+ `poster="${escapeHtml(validateFetchUrl(/** @type {string} */ (attrs.get("poster")), ctx, `:${kind} poster`))}"`
948
+ );
949
+ for (const name of ["width", "height"]) {
950
+ if (attrs.has(name))
951
+ parts.push(`${name}="${escapeHtml(/** @type {string} */ (attrs.get(name)))}"`);
952
+ }
953
+ parts.push(`preload="${escapeHtml(attrs.get("preload") || "metadata")}"`);
954
+ for (const flag of spec.flags) if (flags.has(flag)) parts.push(flag);
955
+ return `<${kind} ${parts.join(" ")}></${kind}>`;
956
+ }
957
+
958
+ /**
959
+ * `:embed <url> [title="…"]` → a lazy, privacy-friendly responsive iframe. A
960
+ * YouTube or Vimeo URL is rewritten to its no-cookie / player embed; any other
961
+ * http(s) URL becomes a generic 16:9 iframe. Inline styles keep the wrapper
962
+ * self-contained (no framework CSS), so an embed-only page stays zero-JS/zero-CSS.
963
+ * @param {string} line
964
+ * @param {Ctx} ctx
965
+ * @param {number} index 0-based line index for `file:line` errors.
966
+ * @returns {string}
967
+ */
968
+ export function handleEmbed(line, ctx, index) {
969
+ const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
970
+ if (!match) {
971
+ throw new Error(
972
+ `Malformed :embed in ${at(ctx.file, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
973
+ );
974
+ }
975
+ const raw = stripQuotes(match[1]);
976
+ let title = "";
977
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
978
+ for (const token of (match[2] || "").matchAll(re)) {
979
+ if (token[1] !== "title")
980
+ throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx.file, index)}`);
981
+ title = stripQuotes(token[2]);
982
+ }
983
+ const { url, label } = resolveEmbed(raw, ctx);
984
+ const iframe =
985
+ `<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
986
+ `referrerpolicy="strict-origin-when-cross-origin" ` +
987
+ `allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ` +
988
+ `allowfullscreen style="width:100%;height:100%;border:0"></iframe>`;
989
+ return `<div class="wd-embed" style="aspect-ratio:16/9">${iframe}</div>`;
990
+ }
991
+
992
+ /**
993
+ * Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
994
+ * player; anything else is validated as a generic http(s) iframe source.
995
+ * @param {string} raw
996
+ * @param {Ctx} ctx
997
+ * @returns {{ url: string, label: string }}
998
+ */
999
+ function resolveEmbed(raw, ctx) {
1000
+ const youtube = raw.match(
1001
+ /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/
1002
+ );
1003
+ if (youtube)
1004
+ return { url: `https://www.youtube-nocookie.com/embed/${youtube[1]}`, label: "YouTube video" };
1005
+ const vimeo = raw.match(/vimeo\.com\/(?:video\/)?(\d+)/);
1006
+ if (vimeo) return { url: `https://player.vimeo.com/video/${vimeo[1]}`, label: "Vimeo video" };
1007
+ return { url: validateFetchUrl(raw, ctx, ":embed"), label: "Embedded content" };
1008
+ }
1009
+
814
1010
  /**
815
1011
  * @param {string} line
816
1012
  * @param {string[]} truthyLines