@zvndev/darkmown 2.0.0 → 2.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +31 -7
- package/SECURITY.md +4 -4
- package/docs/spec-alignment.md +19 -6
- package/package.json +5 -2
- package/site/_/footer.wd +1 -1
- package/site/_/nav.wd +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +18 -2
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +7 -3
- package/site/pages/playground/index.js +158 -0
- package/site/pages/playground/index.skin +128 -0
- package/site/pages/playground/index.wd +51 -0
- package/site/pages/showcase/index.wd +1 -1
- package/src/builder.js +10 -8
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +22 -4
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +25 -14
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +44 -7
- package/src/compiler/page.js +97 -17
- package/src/compiler/predicates.js +4 -12
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +16 -18
- package/src/compiler/structure.js +40 -10
- package/src/compiler.js +57 -6
- package/src/headers.js +15 -13
- package/src/index.js +1 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/src/templates/blog/site/_/nav.wd +6 -9
- package/src/templates/blog/site/pages/index.skin +7 -4
- package/src/templates/blog/site/pages/index.wd +2 -3
- package/src/templates/dashboard/site/_/nav.wd +6 -9
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -3
- package/src/templates/landing/site/_/nav.wd +6 -9
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -9
- package/src/templates/starter/site/_/nav.wd +7 -10
- package/src/templates/starter/site/pages/about.md +7 -18
- package/src/templates/starter/site/pages/about.skin +0 -25
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -3
- package/src/templates/store/site/_/nav.wd +6 -9
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -3
- package/types/compiler.d.ts +13 -0
- package/types/index.d.ts +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
tokens
|
|
2
|
+
pgink #eef2e6
|
|
3
|
+
pgbg #10160c
|
|
4
|
+
pgpanel #18210f
|
|
5
|
+
pgline #2c3a20
|
|
6
|
+
pgaccent #8fd694
|
|
7
|
+
pgmuted #9db089
|
|
8
|
+
pgradius 10px
|
|
9
|
+
|
|
10
|
+
page
|
|
11
|
+
margin 0
|
|
12
|
+
font system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
|
|
13
|
+
color $pgink
|
|
14
|
+
bg $pgbg
|
|
15
|
+
line-height 1.6
|
|
16
|
+
|
|
17
|
+
main
|
|
18
|
+
max-width 1100px
|
|
19
|
+
margin 0 auto
|
|
20
|
+
padding 2.5rem 1.5rem 4rem
|
|
21
|
+
|
|
22
|
+
.pg-intro h1
|
|
23
|
+
font-size 2.6rem
|
|
24
|
+
line-height 1.05
|
|
25
|
+
margin 0 0 .6rem
|
|
26
|
+
color $pgink
|
|
27
|
+
|
|
28
|
+
.pg-intro p
|
|
29
|
+
color $pgmuted
|
|
30
|
+
max-width 46rem
|
|
31
|
+
|
|
32
|
+
.pg-intro a
|
|
33
|
+
color $pgaccent
|
|
34
|
+
|
|
35
|
+
.pg-picker
|
|
36
|
+
display flex
|
|
37
|
+
flex-wrap wrap
|
|
38
|
+
gap .5rem
|
|
39
|
+
margin 1.4rem 0 1rem
|
|
40
|
+
|
|
41
|
+
.pg-picker button
|
|
42
|
+
font inherit
|
|
43
|
+
font-size .92rem
|
|
44
|
+
font-weight 560
|
|
45
|
+
padding .5rem 1rem
|
|
46
|
+
color $pgink
|
|
47
|
+
bg $pgpanel
|
|
48
|
+
border 1px solid $pgline
|
|
49
|
+
radius 999px
|
|
50
|
+
cursor pointer
|
|
51
|
+
transition background .14s ease, border-color .14s ease, transform .14s ease
|
|
52
|
+
|
|
53
|
+
.pg-picker button:hover
|
|
54
|
+
border-color $pgaccent
|
|
55
|
+
transform translateY(-1px)
|
|
56
|
+
|
|
57
|
+
.pg-picker button:focus-visible
|
|
58
|
+
outline 2px solid $pgaccent
|
|
59
|
+
outline-offset 2px
|
|
60
|
+
|
|
61
|
+
.pg-workbench
|
|
62
|
+
display grid
|
|
63
|
+
grid-template-columns 1fr 1fr
|
|
64
|
+
gap 1rem
|
|
65
|
+
margin-top 1.5rem
|
|
66
|
+
align-items stretch
|
|
67
|
+
|
|
68
|
+
.pg-pane
|
|
69
|
+
display flex
|
|
70
|
+
flex-direction column
|
|
71
|
+
min-width 0
|
|
72
|
+
|
|
73
|
+
.pg-label
|
|
74
|
+
font-size .78rem
|
|
75
|
+
font-weight 600
|
|
76
|
+
letter-spacing .04em
|
|
77
|
+
text-transform uppercase
|
|
78
|
+
color $pgmuted
|
|
79
|
+
margin-bottom .5rem
|
|
80
|
+
|
|
81
|
+
.pg-editor
|
|
82
|
+
width 100%
|
|
83
|
+
min-height 30rem
|
|
84
|
+
flex 1
|
|
85
|
+
resize vertical
|
|
86
|
+
padding 1rem 1.1rem
|
|
87
|
+
color $pgink
|
|
88
|
+
bg $pgpanel
|
|
89
|
+
border 1px solid $pgline
|
|
90
|
+
radius $pgradius
|
|
91
|
+
font ui-monospace, "SF Mono", Menlo, Consolas, monospace
|
|
92
|
+
font-size .9rem
|
|
93
|
+
line-height 1.55
|
|
94
|
+
tab-size 2
|
|
95
|
+
|
|
96
|
+
.pg-editor:focus-visible
|
|
97
|
+
outline none
|
|
98
|
+
border-color $pgaccent
|
|
99
|
+
box-shadow 0 0 0 3px rgba(143,214,148,.16)
|
|
100
|
+
|
|
101
|
+
.pg-preview
|
|
102
|
+
width 100%
|
|
103
|
+
min-height 30rem
|
|
104
|
+
flex 1
|
|
105
|
+
border 1px solid $pgline
|
|
106
|
+
radius $pgradius
|
|
107
|
+
bg #12190f
|
|
108
|
+
|
|
109
|
+
.pg-error
|
|
110
|
+
margin .75rem 0 0
|
|
111
|
+
padding .9rem 1.1rem
|
|
112
|
+
color #ffd9d0
|
|
113
|
+
bg #2a1712
|
|
114
|
+
border 1px solid #6b2c1e
|
|
115
|
+
radius $pgradius
|
|
116
|
+
font ui-monospace, "SF Mono", Menlo, Consolas, monospace
|
|
117
|
+
font-size .82rem
|
|
118
|
+
line-height 1.5
|
|
119
|
+
white-space pre-wrap
|
|
120
|
+
word-break break-word
|
|
121
|
+
|
|
122
|
+
@media (max-width: 820px)
|
|
123
|
+
.pg-workbench
|
|
124
|
+
grid-template-columns 1fr
|
|
125
|
+
.pg-editor
|
|
126
|
+
min-height 18rem
|
|
127
|
+
.pg-preview
|
|
128
|
+
min-height 22rem
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Playground — run Markdown in your browser
|
|
3
|
+
description: Edit a .wd file and watch it compile and run in your browser — the same compiler the Darkmown CLI uses, no install required.
|
|
4
|
+
html: true
|
|
5
|
+
transitions: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
@include /nav.wd
|
|
9
|
+
|
|
10
|
+
<main>
|
|
11
|
+
|
|
12
|
+
:state activeExample = "store"
|
|
13
|
+
|
|
14
|
+
::: section .pg-intro
|
|
15
|
+
|
|
16
|
+
# Playground
|
|
17
|
+
|
|
18
|
+
Edit the Markdown on the left. It compiles **in your browser** — the exact same
|
|
19
|
+
compiler the CLI ships — and runs live on the right. No install, no build step.
|
|
20
|
+
|
|
21
|
+
::: div .pg-picker
|
|
22
|
+
:button "Store + cart" -> activeExample = "store"
|
|
23
|
+
:button "Counter" -> activeExample = "counter"
|
|
24
|
+
:button "Plain Markdown" -> activeExample = "markdown"
|
|
25
|
+
:::
|
|
26
|
+
|
|
27
|
+
Now editing the **{ activeExample }** example. Change any text and the preview
|
|
28
|
+
re-renders as you type. Break the syntax on purpose — the real compiler error,
|
|
29
|
+
with `file:line` and a fix hint, shows up in the preview pane.
|
|
30
|
+
|
|
31
|
+
:::
|
|
32
|
+
|
|
33
|
+
::: section .pg-workbench
|
|
34
|
+
|
|
35
|
+
::: div .pg-pane .pg-pane-source
|
|
36
|
+
<label class="pg-label" for="pg-editor">Source</label>
|
|
37
|
+
|
|
38
|
+
<textarea id="pg-editor" class="pg-editor" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Markdown source"></textarea>
|
|
39
|
+
:::
|
|
40
|
+
|
|
41
|
+
::: div .pg-pane .pg-pane-preview
|
|
42
|
+
<label class="pg-label" for="pg-preview">Preview</label>
|
|
43
|
+
|
|
44
|
+
<iframe id="pg-preview" class="pg-preview" title="Live preview" sandbox="allow-scripts allow-same-origin"></iframe>
|
|
45
|
+
|
|
46
|
+
<pre id="pg-error" class="pg-error" role="alert" hidden></pre>
|
|
47
|
+
:::
|
|
48
|
+
|
|
49
|
+
:::
|
|
50
|
+
|
|
51
|
+
</main>
|
|
@@ -96,7 +96,7 @@ Darkmown's whole pitch is weight. A static page ships **zero** framework JavaScr
|
|
|
96
96
|
|
|
97
97
|
::: card .sc-scale
|
|
98
98
|
- **Static Darkmown page** — `0 KB` framework JS. Forever.
|
|
99
|
-
- **Reactive Darkmown page** — one shared `~7.
|
|
99
|
+
- **Reactive Darkmown page** — one shared `~7.7 KB` gzipped runtime, no per-component bundles.
|
|
100
100
|
- **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).
|
|
101
101
|
:::
|
|
102
102
|
|
package/src/builder.js
CHANGED
|
@@ -352,9 +352,11 @@ function buildRoute(route, cwd, paths, collections, feedLink, warned) {
|
|
|
352
352
|
* incremental builds keeps them consistent by construction.
|
|
353
353
|
*
|
|
354
354
|
* Cloudflare Pages reads dist/_headers for security headers (Vercel reads
|
|
355
|
-
* vercel.json; the local server adds them in src/statics.js).
|
|
356
|
-
*
|
|
357
|
-
*
|
|
355
|
+
* vercel.json; the local server adds them in src/statics.js). Since 2.1 both CSP
|
|
356
|
+
* variants are eval-free (the reactive runtime walks a validated AST, not
|
|
357
|
+
* `new Function`), so static and reactive routes resolve to the same policy; the
|
|
358
|
+
* per-route static override is kept as defense-in-depth. A catch-all keeps assets
|
|
359
|
+
* and any future path covered.
|
|
358
360
|
* @param {RouteManifestEntry[]} manifest
|
|
359
361
|
* @param {Paths} paths
|
|
360
362
|
* @returns {void}
|
|
@@ -924,10 +926,10 @@ export function stripRuntimeComments(source) {
|
|
|
924
926
|
*
|
|
925
927
|
* Cloudflare applies every matching block in order; when blocks set the same
|
|
926
928
|
* header, the later block wins. So we emit the catch-all `/*` first (baseline
|
|
927
|
-
* security headers + the
|
|
928
|
-
* one block per static route that
|
|
929
|
-
*
|
|
930
|
-
* already carries the
|
|
929
|
+
* security headers + the reactive CSP, which since 2.1 is eval-free and satisfies
|
|
930
|
+
* every page), then one block per static route that re-states the same eval-free
|
|
931
|
+
* CSP as defense-in-depth. Reactive routes need no override — the catch-all
|
|
932
|
+
* already carries the eval-free CSP.
|
|
931
933
|
*
|
|
932
934
|
* Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
|
|
933
935
|
* so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
|
|
@@ -941,7 +943,7 @@ export function renderCloudflareHeaders(manifest) {
|
|
|
941
943
|
const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
|
|
942
944
|
|
|
943
945
|
for (const entry of manifest) {
|
|
944
|
-
if (entry.assets.runtime) continue; // reactive routes keep the catch-all (
|
|
946
|
+
if (entry.assets.runtime) continue; // reactive routes keep the catch-all (eval-free) CSP
|
|
945
947
|
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
946
948
|
blocks.push([pattern, [` Content-Security-Policy: ${STATIC_CSP}`]]);
|
|
947
949
|
}
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// `scoped`/`draft`).
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
import fs from "node:fs";
|
|
19
18
|
import path from "node:path";
|
|
20
19
|
import { firstParagraph } from "../feeds.js";
|
|
21
20
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
21
|
+
import { fsReader } from "./fs-reader.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* @typedef {import("./context.js").Paths} Paths
|
|
@@ -47,20 +47,22 @@ const SCHEMA_TYPES = new Set(["string", "number", "boolean", "date", "string[]"]
|
|
|
47
47
|
*
|
|
48
48
|
* @param {Route[]} routes Post-draft-filter routes from `discoverRoutes`.
|
|
49
49
|
* @param {Paths} paths Resolved project paths (`routesRoot` for `_schema.wd`).
|
|
50
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by
|
|
51
|
+
* default; pass an in-memory reader to build collections without a filesystem).
|
|
50
52
|
* @returns {Map<string, CollectionRow[]>} Collection name → entry rows.
|
|
51
53
|
*/
|
|
52
|
-
export function buildCollections(routes, paths) {
|
|
54
|
+
export function buildCollections(routes, paths, reader = fsReader()) {
|
|
53
55
|
/** @type {Map<string, CollectionRow[]>} */
|
|
54
56
|
const collections = new Map();
|
|
55
57
|
for (const route of routes) {
|
|
56
58
|
const name = collectionNameOf(route.route);
|
|
57
59
|
if (!name) continue;
|
|
58
60
|
const rows = collections.get(name) || [];
|
|
59
|
-
rows.push(rowFor(route));
|
|
61
|
+
rows.push(rowFor(route, reader));
|
|
60
62
|
collections.set(name, rows);
|
|
61
63
|
}
|
|
62
64
|
for (const [name, rows] of collections) {
|
|
63
|
-
const schema = readSchema(name, paths);
|
|
65
|
+
const schema = readSchema(name, paths, reader);
|
|
64
66
|
if (schema) validateRows(rows, schema, name);
|
|
65
67
|
}
|
|
66
68
|
return collections;
|
|
@@ -87,9 +89,10 @@ function collectionNameOf(route) {
|
|
|
87
89
|
* are added AFTER the spread so a stray frontmatter `url:`/`slug:` can't shadow
|
|
88
90
|
* the real route data the loop relies on.
|
|
89
91
|
* @param {Route} route
|
|
92
|
+
* @param {import("./reader.js").Reader} reader Source reader for the excerpt body.
|
|
90
93
|
* @returns {CollectionRow}
|
|
91
94
|
*/
|
|
92
|
-
function rowFor(route) {
|
|
95
|
+
function rowFor(route, reader) {
|
|
93
96
|
const slug = path.basename(route.file, path.extname(route.file));
|
|
94
97
|
/** @type {Record<string, unknown>} */
|
|
95
98
|
const fields = {};
|
|
@@ -98,7 +101,7 @@ function rowFor(route) {
|
|
|
98
101
|
...fields,
|
|
99
102
|
url: route.route,
|
|
100
103
|
slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
|
|
101
|
-
excerpt: excerptFor(route)
|
|
104
|
+
excerpt: excerptFor(route, reader)
|
|
102
105
|
};
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -126,13 +129,14 @@ function coerceScalar(value) {
|
|
|
126
129
|
* (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
|
|
127
130
|
* description fallback so a collection card and the feed read the same snippet.
|
|
128
131
|
* @param {Route} route
|
|
132
|
+
* @param {import("./reader.js").Reader} reader Source reader for the `.md` body.
|
|
129
133
|
* @returns {string}
|
|
130
134
|
*/
|
|
131
|
-
function excerptFor(route) {
|
|
135
|
+
function excerptFor(route, reader) {
|
|
132
136
|
const fromMeta = route.meta.excerpt;
|
|
133
137
|
if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
|
|
134
138
|
if (path.extname(route.file) !== ".md") return "";
|
|
135
|
-
const { body } = parseFrontmatter(
|
|
139
|
+
const { body } = parseFrontmatter(reader.readText(route.file), route.file);
|
|
136
140
|
return firstParagraph(body);
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -153,12 +157,13 @@ function excerptFor(route) {
|
|
|
153
157
|
* schema file itself, pointing at the offending line.
|
|
154
158
|
* @param {string} name Collection name.
|
|
155
159
|
* @param {Paths} paths
|
|
160
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by default).
|
|
156
161
|
* @returns {SchemaField[] | null}
|
|
157
162
|
*/
|
|
158
|
-
export function readSchema(name, paths) {
|
|
163
|
+
export function readSchema(name, paths, reader = fsReader()) {
|
|
159
164
|
const file = path.join(paths.routesRoot, name, "_schema.wd");
|
|
160
|
-
if (!
|
|
161
|
-
const raw =
|
|
165
|
+
if (!reader.exists(file)) return null;
|
|
166
|
+
const raw = reader.readText(file);
|
|
162
167
|
return parseSchema(raw, file);
|
|
163
168
|
}
|
|
164
169
|
|
package/src/compiler/context.js
CHANGED
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
* dependency map that drives incremental rebuilds.
|
|
78
78
|
* @property {Set<string>} collectionsUsed Names of the collections this compile
|
|
79
79
|
* looped, so a change to any entry of a collection rebuilds its consumers.
|
|
80
|
+
* @property {import("./reader.js").Reader} reader The file reader every source
|
|
81
|
+
* read threads through — the real filesystem by default, or an in-memory map
|
|
82
|
+
* for `compileFromMemory`. Set once by `createCompilation` and reached via
|
|
83
|
+
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
80
84
|
*/
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -102,15 +106,24 @@
|
|
|
102
106
|
* @property {string[]} sections Active section-id scope chain.
|
|
103
107
|
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
104
108
|
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
109
|
+
* @property {number} [reactiveDepth] Number of enclosing REACTIVE `@loop` levels
|
|
110
|
+
* (0 at top level). The runtime reconciles at most two nested `data-wd-loop`
|
|
111
|
+
* levels — a third paints empty — so a third reactive level is rejected at
|
|
112
|
+
* compile time. Static (build-unrolled) loops flatten to markup and don't add
|
|
113
|
+
* a level, so they carry the count through without incrementing it.
|
|
114
|
+
* @property {{ at: string, line: string }} [loopOpener] The current `@loop`
|
|
115
|
+
* opener's `file:line` + raw text, set when descending into a loop body so the
|
|
116
|
+
* reactive-nesting-depth error can point at the offending opener.
|
|
105
117
|
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
106
118
|
* @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
|
|
107
119
|
* @property {(lines: string[], ctx: Ctx) => string} compileBody The body
|
|
108
120
|
* dispatcher, threaded through the context so block handlers (`:if`, `:::`,
|
|
109
121
|
* `:form`, `@loop`, …) recurse into nested bodies without importing the
|
|
110
122
|
* dispatcher back — the module graph stays an import-cycle-free DAG.
|
|
111
|
-
* @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null) => { meta: Meta, html: string }} compileFile
|
|
123
|
+
* @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null, reactiveDepth?: number, loopOpener?: { at: string, line: string } | null) => { meta: Meta, html: string }} compileFile
|
|
112
124
|
* The per-file compile, threaded for the same reason so `@include` can
|
|
113
|
-
* compile its target file.
|
|
125
|
+
* compile its target file. `reactiveDepth`/`loopOpener` carry the enclosing
|
|
126
|
+
* reactive-loop nesting across the include boundary so the depth guard holds.
|
|
114
127
|
*/
|
|
115
128
|
|
|
116
129
|
/**
|
|
@@ -193,9 +206,14 @@ export const LOOP_META = {
|
|
|
193
206
|
$count: "count"
|
|
194
207
|
};
|
|
195
208
|
|
|
196
|
-
/**
|
|
197
|
-
|
|
209
|
+
/**
|
|
210
|
+
* @param {import("./reader.js").Reader} reader The source reader for this
|
|
211
|
+
* compile (fs-backed by default; in-memory for `compileFromMemory`).
|
|
212
|
+
* @returns {Compilation}
|
|
213
|
+
*/
|
|
214
|
+
export function createCompilation(reader) {
|
|
198
215
|
return {
|
|
216
|
+
reader,
|
|
199
217
|
assets: {
|
|
200
218
|
skins: new Set(),
|
|
201
219
|
scripts: new Set(),
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Compiled-expression → compact AST.
|
|
3
|
+
//
|
|
4
|
+
// The predicate/computed compilers in `predicates.js` still do ALL the security
|
|
5
|
+
// validation — they map every operand through a fixed grammar to safe JS over the
|
|
6
|
+
// `I()`/`S()`/`C()`/`A()` readers, and reject anything outside the whitelist. This
|
|
7
|
+
// module takes that ALREADY-VALIDATED JS fragment and re-parses it into a compact
|
|
8
|
+
// serialized AST (nested arrays), so the runtime can WALK it with a tiny closed
|
|
9
|
+
// evaluator instead of constructing a `new Function`. That is the whole point:
|
|
10
|
+
// reactive pages no longer need `script-src 'unsafe-eval'`.
|
|
11
|
+
//
|
|
12
|
+
// The parsed sub-language is closed and small: the reader calls `S(k[,r])`,
|
|
13
|
+
// `I(p)`, `C(a,b)`, `A(name,list[,field])`; number / JSON-string / boolean / null
|
|
14
|
+
// / object / array literals; the operators `! - * / % + - < > <= >= == != && ||`;
|
|
15
|
+
// and grouping parens. Precedence and associativity match JavaScript exactly, so
|
|
16
|
+
// the walked AST evaluates identically to the prior `new Function` path. Anything
|
|
17
|
+
// unexpected is a hard parse error — never a silent fallthrough.
|
|
18
|
+
//
|
|
19
|
+
// Node shapes (array with a tag at [0]):
|
|
20
|
+
// ["L", value] literal (number | string | boolean | null | obj | arr)
|
|
21
|
+
// ["S", key] state read → getPath(state[key])
|
|
22
|
+
// ["S", key, rest] dotted state read → getPath(state[key], rest)
|
|
23
|
+
// ["I", path] loop-item read → getPath(item, path)
|
|
24
|
+
// ["C", a, b] contains(a, b)
|
|
25
|
+
// ["A", name, list] aggregate → AGG(name, list)
|
|
26
|
+
// ["A", name, list, fld] aggregate w/ field → AGG(name, list, fld)
|
|
27
|
+
// ["!", a] | ["u-", a] unary not / negate
|
|
28
|
+
// [op, a, b] binary op (op ∈ the operator set above)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import { FORMATTERS } from "./format.js";
|
|
32
|
+
import { getPath } from "./interpolation.js";
|
|
33
|
+
|
|
34
|
+
// Binary-operator precedence (higher binds tighter), matching JS.
|
|
35
|
+
/** @type {Record<string, number>} */
|
|
36
|
+
const PREC = {
|
|
37
|
+
"||": 1,
|
|
38
|
+
"&&": 2,
|
|
39
|
+
"==": 3,
|
|
40
|
+
"!=": 3,
|
|
41
|
+
"<": 4,
|
|
42
|
+
">": 4,
|
|
43
|
+
"<=": 4,
|
|
44
|
+
">=": 4,
|
|
45
|
+
"+": 5,
|
|
46
|
+
"-": 5,
|
|
47
|
+
"*": 6,
|
|
48
|
+
"/": 6,
|
|
49
|
+
"%": 6
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Re-parse a validated compiled expression (JS fragment over I()/S()/C()/A())
|
|
54
|
+
* into a compact AST. Throws on anything outside the closed grammar.
|
|
55
|
+
* @param {string} code
|
|
56
|
+
* @returns {any[]}
|
|
57
|
+
*/
|
|
58
|
+
export function astOf(code) {
|
|
59
|
+
let i = 0;
|
|
60
|
+
const n = code.length;
|
|
61
|
+
const ws = () => {
|
|
62
|
+
while (i < n && /\s/.test(code[i])) i++;
|
|
63
|
+
};
|
|
64
|
+
/** Peek a binary operator at the cursor without consuming; null if none. */
|
|
65
|
+
const peekOp = () => {
|
|
66
|
+
ws();
|
|
67
|
+
const two = code.slice(i, i + 2);
|
|
68
|
+
if (
|
|
69
|
+
two === "&&" ||
|
|
70
|
+
two === "||" ||
|
|
71
|
+
two === "==" ||
|
|
72
|
+
two === "!=" ||
|
|
73
|
+
two === "<=" ||
|
|
74
|
+
two === ">="
|
|
75
|
+
)
|
|
76
|
+
return two;
|
|
77
|
+
const one = code[i];
|
|
78
|
+
if (
|
|
79
|
+
one === "<" ||
|
|
80
|
+
one === ">" ||
|
|
81
|
+
one === "+" ||
|
|
82
|
+
one === "-" ||
|
|
83
|
+
one === "*" ||
|
|
84
|
+
one === "/" ||
|
|
85
|
+
one === "%"
|
|
86
|
+
)
|
|
87
|
+
return one;
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
/** @param {number} minPrec @returns {any[]} */
|
|
91
|
+
const parseExpr = (minPrec) => {
|
|
92
|
+
/** @type {any[]} */
|
|
93
|
+
let left = parseUnary();
|
|
94
|
+
for (;;) {
|
|
95
|
+
const op = peekOp();
|
|
96
|
+
if (op === null) break;
|
|
97
|
+
const prec = PREC[op];
|
|
98
|
+
if (prec < minPrec) break;
|
|
99
|
+
i += op.length;
|
|
100
|
+
const right = parseExpr(prec + 1);
|
|
101
|
+
left = [op, left, right];
|
|
102
|
+
}
|
|
103
|
+
return left;
|
|
104
|
+
};
|
|
105
|
+
/** @returns {any[]} */
|
|
106
|
+
const parseUnary = () => {
|
|
107
|
+
ws();
|
|
108
|
+
if (code[i] === "!" && code[i + 1] !== "=") {
|
|
109
|
+
i++;
|
|
110
|
+
return ["!", parseUnary()];
|
|
111
|
+
}
|
|
112
|
+
if (code[i] === "-") {
|
|
113
|
+
i++;
|
|
114
|
+
return ["u-", parseUnary()];
|
|
115
|
+
}
|
|
116
|
+
return parsePrimary();
|
|
117
|
+
};
|
|
118
|
+
/** @returns {any[]} */
|
|
119
|
+
const parsePrimary = () => {
|
|
120
|
+
ws();
|
|
121
|
+
const c = code[i];
|
|
122
|
+
if (c === "(") {
|
|
123
|
+
i++;
|
|
124
|
+
/** @type {any[]} */
|
|
125
|
+
const e = parseExpr(1);
|
|
126
|
+
ws();
|
|
127
|
+
if (code[i] !== ")") throw new Error(`expr-ast: expected ) at ${i} in ${code}`);
|
|
128
|
+
i++;
|
|
129
|
+
return e;
|
|
130
|
+
}
|
|
131
|
+
if (c === '"') return ["L", readString()];
|
|
132
|
+
if (c === "{" || c === "[") return ["L", readJson()];
|
|
133
|
+
if (c === "." || (c >= "0" && c <= "9")) return ["L", readNumber()];
|
|
134
|
+
if (/[A-Za-z_$]/.test(c)) return readIdent();
|
|
135
|
+
throw new Error(`expr-ast: unexpected "${c ?? "<end>"}" at ${i} in ${code}`);
|
|
136
|
+
};
|
|
137
|
+
const readString = () => {
|
|
138
|
+
let j = i + 1;
|
|
139
|
+
while (j < n) {
|
|
140
|
+
if (code[j] === "\\") j += 2;
|
|
141
|
+
else if (code[j] === '"') break;
|
|
142
|
+
else j++;
|
|
143
|
+
}
|
|
144
|
+
const lit = code.slice(i, j + 1);
|
|
145
|
+
i = j + 1;
|
|
146
|
+
return JSON.parse(lit);
|
|
147
|
+
};
|
|
148
|
+
const readJson = () => {
|
|
149
|
+
let depth = 0;
|
|
150
|
+
let j = i;
|
|
151
|
+
for (; j < n; j++) {
|
|
152
|
+
const ch = code[j];
|
|
153
|
+
if (ch === '"') {
|
|
154
|
+
j++;
|
|
155
|
+
while (j < n && code[j] !== '"') j += code[j] === "\\" ? 2 : 1;
|
|
156
|
+
} else if (ch === "{" || ch === "[") depth++;
|
|
157
|
+
else if (ch === "}" || ch === "]") {
|
|
158
|
+
depth--;
|
|
159
|
+
if (depth === 0) {
|
|
160
|
+
j++;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const lit = code.slice(i, j);
|
|
166
|
+
i = j;
|
|
167
|
+
return JSON.parse(lit);
|
|
168
|
+
};
|
|
169
|
+
const readNumber = () => {
|
|
170
|
+
let j = i;
|
|
171
|
+
while (j < n && /[0-9.]/.test(code[j])) j++;
|
|
172
|
+
const num = Number(code.slice(i, j));
|
|
173
|
+
i = j;
|
|
174
|
+
return num;
|
|
175
|
+
};
|
|
176
|
+
/** @returns {any[]} */
|
|
177
|
+
const readIdent = () => {
|
|
178
|
+
let j = i;
|
|
179
|
+
while (j < n && /[\w$]/.test(code[j])) j++;
|
|
180
|
+
const word = code.slice(i, j);
|
|
181
|
+
i = j;
|
|
182
|
+
if (word === "true") return ["L", true];
|
|
183
|
+
if (word === "false") return ["L", false];
|
|
184
|
+
if (word === "null") return ["L", null];
|
|
185
|
+
if (code[i] !== "(") throw new Error(`expr-ast: unexpected identifier "${word}" in ${code}`);
|
|
186
|
+
const args = parseArgs();
|
|
187
|
+
if (word === "S") return args.length > 1 ? ["S", args[0][1], args[1][1]] : ["S", args[0][1]];
|
|
188
|
+
if (word === "I") return ["I", args[0][1]];
|
|
189
|
+
if (word === "C") return ["C", args[0], args[1]];
|
|
190
|
+
if (word === "A")
|
|
191
|
+
return args.length > 2 ? ["A", args[0][1], args[1], args[2][1]] : ["A", args[0][1], args[1]];
|
|
192
|
+
throw new Error(`expr-ast: unknown call "${word}" in ${code}`);
|
|
193
|
+
};
|
|
194
|
+
/** @returns {any[][]} */
|
|
195
|
+
const parseArgs = () => {
|
|
196
|
+
i++; // consume "("
|
|
197
|
+
/** @type {any[][]} */
|
|
198
|
+
const args = [];
|
|
199
|
+
ws();
|
|
200
|
+
if (code[i] !== ")") {
|
|
201
|
+
args.push(parseExpr(1));
|
|
202
|
+
ws();
|
|
203
|
+
while (code[i] === ",") {
|
|
204
|
+
i++;
|
|
205
|
+
args.push(parseExpr(1));
|
|
206
|
+
ws();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
ws();
|
|
210
|
+
if (code[i] !== ")") throw new Error(`expr-ast: expected ) closing call in ${code}`);
|
|
211
|
+
i++;
|
|
212
|
+
return args;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const ast = parseExpr(1);
|
|
216
|
+
ws();
|
|
217
|
+
if (i !== n) throw new Error(`expr-ast: trailing input at ${i} in ${code}`);
|
|
218
|
+
return ast;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `JSON.stringify(astOf(code))` — the attribute payload the runtime reads.
|
|
223
|
+
* @param {string} code
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export const serializeExpr = (code) => JSON.stringify(astOf(code));
|
|
227
|
+
|
|
228
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
229
|
+
const contains = (a, b) =>
|
|
230
|
+
String(a ?? "")
|
|
231
|
+
.toLowerCase()
|
|
232
|
+
.includes(String(b ?? "").toLowerCase());
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build-time mirror of the runtime AST walker — same operators, so a `:computed`
|
|
236
|
+
* or `where`/`when` predicate folds to the SAME value the runtime will recompute.
|
|
237
|
+
* `state` reads come from the compilation's declared-state map. No eval.
|
|
238
|
+
* @param {any[]} node
|
|
239
|
+
* @param {unknown} item Loop row for I(); undefined otherwise.
|
|
240
|
+
* @param {{ state: Map<string, unknown> }} comp
|
|
241
|
+
* @returns {any}
|
|
242
|
+
*/
|
|
243
|
+
export function evalAst(node, item, comp) {
|
|
244
|
+
/** @param {any[]} n @returns {any} */
|
|
245
|
+
const ev = (n) => {
|
|
246
|
+
const t = n[0];
|
|
247
|
+
switch (t) {
|
|
248
|
+
case "L":
|
|
249
|
+
return n[1];
|
|
250
|
+
case "S":
|
|
251
|
+
return getPath(comp.state.get(n[1]), n[2] ? n[2].split(".") : []);
|
|
252
|
+
case "I":
|
|
253
|
+
return getPath(item, n[1] ? n[1].split(".") : []);
|
|
254
|
+
case "C":
|
|
255
|
+
return contains(ev(n[1]), ev(n[2]));
|
|
256
|
+
case "A": {
|
|
257
|
+
const f = FORMATTERS[n[1]];
|
|
258
|
+
return f ? f(ev(n[2]), n[3] == null ? [] : [n[3]]) : undefined;
|
|
259
|
+
}
|
|
260
|
+
case "!":
|
|
261
|
+
return !ev(n[1]);
|
|
262
|
+
case "u-":
|
|
263
|
+
return -ev(n[1]);
|
|
264
|
+
case "&&":
|
|
265
|
+
return ev(n[1]) && ev(n[2]);
|
|
266
|
+
case "||":
|
|
267
|
+
return ev(n[1]) || ev(n[2]);
|
|
268
|
+
case "==":
|
|
269
|
+
// biome-ignore lint/suspicious/noDoubleEquals: loose equality preserves prior `new Function` semantics exactly.
|
|
270
|
+
return ev(n[1]) == ev(n[2]);
|
|
271
|
+
case "!=":
|
|
272
|
+
// biome-ignore lint/suspicious/noDoubleEquals: loose inequality preserves prior `new Function` semantics exactly.
|
|
273
|
+
return ev(n[1]) != ev(n[2]);
|
|
274
|
+
case "<":
|
|
275
|
+
return ev(n[1]) < ev(n[2]);
|
|
276
|
+
case ">":
|
|
277
|
+
return ev(n[1]) > ev(n[2]);
|
|
278
|
+
case "<=":
|
|
279
|
+
return ev(n[1]) <= ev(n[2]);
|
|
280
|
+
case ">=":
|
|
281
|
+
return ev(n[1]) >= ev(n[2]);
|
|
282
|
+
case "+":
|
|
283
|
+
return ev(n[1]) + ev(n[2]);
|
|
284
|
+
case "-":
|
|
285
|
+
return ev(n[1]) - ev(n[2]);
|
|
286
|
+
case "*":
|
|
287
|
+
return ev(n[1]) * ev(n[2]);
|
|
288
|
+
case "/":
|
|
289
|
+
return ev(n[1]) / ev(n[2]);
|
|
290
|
+
case "%":
|
|
291
|
+
return ev(n[1]) % ev(n[2]);
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`expr-ast: unknown op "${t}"`);
|
|
294
|
+
};
|
|
295
|
+
return ev(node);
|
|
296
|
+
}
|