@zvndev/darkmown 0.20.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +3 -3
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/compiler/body.js +26 -1
- package/src/compiler/context.js +1 -1
- package/src/compiler/directives.js +199 -3
- package/src/compiler/format.js +244 -0
- package/src/compiler/interpolation.js +35 -1
- package/src/compiler/loops.js +95 -24
- package/src/compiler/markdown.js +16 -7
- package/src/compiler/predicates.js +35 -2
- package/src/headers.js +5 -0
- package/src/runtime.js +168 -14
- 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
|
package/src/compiler/body.js
CHANGED
|
@@ -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";
|
|
@@ -117,6 +121,27 @@ export function compileBody(lines, ctx) {
|
|
|
117
121
|
out.push(handleEffect(line, ctx, i));
|
|
118
122
|
continue;
|
|
119
123
|
}
|
|
124
|
+
if (/^:every\s/.test(line)) {
|
|
125
|
+
flush();
|
|
126
|
+
out.push(handleEvery(line, ctx, i));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^:theme(?:\s|$)/.test(line)) {
|
|
130
|
+
flush();
|
|
131
|
+
out.push(handleTheme(line, ctx, i));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const media = line.match(/^:(video|audio)\s/);
|
|
135
|
+
if (media) {
|
|
136
|
+
flush();
|
|
137
|
+
out.push(handleMedia(line, /** @type {"video" | "audio"} */ (media[1]), ctx, i));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/^:embed\s/.test(line)) {
|
|
141
|
+
flush();
|
|
142
|
+
out.push(handleEmbed(line, ctx, i));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
120
145
|
if (/^:form\s/.test(line)) {
|
|
121
146
|
flush();
|
|
122
147
|
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
@@ -217,7 +242,7 @@ export function compileBody(lines, ctx) {
|
|
|
217
242
|
// narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
|
|
218
243
|
// followed by punctuation do not match).
|
|
219
244
|
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|$)/;
|
|
245
|
+
/^(?:@(?: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
246
|
/**
|
|
222
247
|
* @param {string} line
|
|
223
248
|
* @param {Ctx} ctx
|
package/src/compiler/context.js
CHANGED
|
@@ -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
|
-
|
|
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
|