@zvndev/darkmown 0.11.0 → 0.11.2

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 CHANGED
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
7
7
  - `.md` files are plain CommonMark. Directives stay inert text.
8
8
  - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
9
  - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
- - Static pages ship **zero** JavaScript; a page loads the ~3.2 KB runtime only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~4.7 KB gzipped runtime (CI-enforced under 5 KB) only if it declares reactive behavior.
11
11
 
12
12
  ## Project layout & routing
13
13
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 0.11.2 — 2026-06-17
6
+
7
+ Product trust and release-hardening sprint.
8
+
9
+ - **Public demos fixed.** `/app/` now renders feature bullets from the current data shape, and deploy/form copy clearly distinguishes static hosting from server-backed demo endpoints.
10
+ - **Preview servers safer by default.** `darkmown dev` and `darkmown serve` bind to `127.0.0.1` unless `HOST` is set, and malformed preview URLs no longer crash the server.
11
+ - **Directive hardening.** Demo `:try` links reject dangerous URL schemes; `:input` and `:bind` now carry escaped accessibility attributes.
12
+ - **Release gates tightened.** Tag releases verify Node 20/22/24 before publishing, keep the runtime-size budget enforced, and prepublish checks cover typecheck, coverage, build, size, audit, and consumer smoke.
13
+ - **Runtime guidance synced.** Packaged guidance and public copy now describe the measured ~4.7 KB gzipped runtime with the under-5 KB CI budget.
14
+
15
+ ## 0.11.1 — 2026-06-17
16
+
17
+ Maintenance release. No functional changes since 0.11.0.
18
+
19
+ - **Fully comment-free shipped runtime.** The last two inline comments in `src/runtime.js` are converted to a strippable form, so the emitted `runtime.js` now contains zero comments (the source stays fully documented). The reactive runtime is ~4.7 KB gzipped.
20
+
5
21
  ## 0.11.0 — 2026-06-16
6
22
 
7
23
  Three big `.wd` capabilities for building real apps — global state, a full fetch lifecycle, and complete loop ergonomics — plus a leaner runtime.
@@ -30,7 +46,7 @@ The "proof + full test pyramid" release: prove the moat on the homepage, test ev
30
46
  - **Full test pyramid.** Unit + integration backfill (grammar whitelists, skin compiler, interpolation, router, every compile-error path), a zero-dependency CLI/pipeline e2e (`darkmown init` → build → serve → fetch → assert), real-browser e2e via Playwright (`npm run test:e2e`), and a seeded property/fuzz suite (5,000 cases/run) for the parser and expression validators. The zero-dep `npm test` suite grew from 98 to ~200 checks; coverage is ~96% lines, gated at 80% in CI.
31
47
  - **The fuzzer earned its keep:** it found two real bugs, now fixed — two `:button` action errors that omitted the file path, and bare object interpolation (`{ meta }`) emitting `[object Object]` instead of an actionable error. Arrays now join with `, ` consistently.
32
48
  - **Type safety.** Source stays plain JavaScript but is fully type-checked via JSDoc + `checkJs` (`npm run typecheck`, gated in CI), and ships `.d.ts` declarations. `typescript` is a dev-only dependency; the runtime stays zero-dependency.
33
- - **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still downloads ~3.2 KB gzipped even though the source carries full type annotations. The size budget now measures the shipped artifact.
49
+ - **Lean shipped runtime.** JSDoc is stripped from the runtime on emit, so the browser still stays under the shared runtime budget; the current runtime is ~4.7 KB gzipped even as the source carries full type annotations. The size budget now measures the shipped artifact.
34
50
  - **Gold-standard hardening (46.5/50 audit).** Release pack-smoke test before publish, `prepublishOnly` guard, `dependabot` (npm + GitHub Actions), CI `concurrency` cancellation, `.github/` PR + issue templates, `CODEOWNERS`, a short `CODE_OF_CONDUCT.md`, an exposed/documented `window.wd.debug`, and repo-root tidy-up.
35
51
 
36
52
  ## 0.8.2 — 2026-06-14
@@ -41,7 +57,7 @@ Honesty, hardening, and polish pass — every claim in the docs is now backed by
41
57
  - **Runtime:** state changes now batch into a single render per tick (`requestAnimationFrame`, falling back to a microtask), so rapid `:bind` input no longer re-runs the reconciler on every keystroke; the final state always renders and the public `wd.render()` stays synchronous. Opt-in `window.wd.debug` surfaces warnings for failed `:computed` / `where` expressions that were previously silent.
42
58
  - **Tests:** first runtime/DOM test suite — keyed loop reconcile (node reuse on add/remove/reorder), `getPath` prototype-pollution rejection, loop-key collisions, and render batching — loading the real runtime via `node:vm`, no new dependencies. Suite grew 83 → 98.
43
59
  - **Compiler:** unterminated frontmatter now throws an actionable error with the file path instead of silently swallowing the page; empty `---`/`---` blocks parse cleanly.
44
- - **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size corrected to the true ~3.1 KB gzipped across all surfaces; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
60
+ - **Docs & claims:** view transitions are now correctly described as parked/disabled (they were advertised but hardcoded off); the "no eval" wording is corrected (`:computed` and `where` compile to a whitelisted grammar run via `new Function`, never raw user content); runtime size copy corrected across all surfaces; the current runtime is ~4.7 KB gzipped and CI-enforced under 5 KB; `:note`/`:try`/`:sprint` documented as demo-only; `docs/cli.md` scaffold output synced; VS Code install instructions corrected (build from source; Marketplace listing pending).
45
61
  - **Site & tooling:** landing-page whitespace fixed (removed the forced `60vh` hero band); mobile nav no longer overflows; tagline locked to "Markdown that runs."; regenerated `package-lock.json` identity (`@zvndev/darkmown`).
46
62
 
47
63
  ## 0.8.1 — 2026-06-14
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
4
4
 
5
- Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~3.2 KB gzipped (CI-enforced under 5 KB).
5
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~4.7 KB gzipped, CI-enforced under 5 KB.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -47,7 +47,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
47
47
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
48
48
  - `site/_` is the include shelf for `@include /name.wd`.
49
49
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
50
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~3.2 KB gzipped, CI-enforced under 5 KB).
50
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~4.7 KB gzipped, CI-enforced under 5 KB).
51
51
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
52
52
 
53
53
  ## Interpolation
package/docs/cli.md CHANGED
@@ -73,3 +73,9 @@ npm run smoke
73
73
  ```
74
74
 
75
75
  The smoke script packs the local tarball, installs the packed CLI in a temporary driver project, scaffolds a consumer app through that installed bin, installs the same tarball into the app, builds it, verifies the reactive home route, and verifies the plain `.md` about route stays zero-JS.
76
+
77
+ ## Deployment notes
78
+
79
+ ### Cloudflare Pages
80
+
81
+ `wrangler.toml` builds and deploys the static `dist/` output for Cloudflare Pages. That configuration does **not** provide the demo `/__wd/echo` endpoint used by server-backed form examples; Vercel handles that route separately through `vercel.json` and `api/echo.js`. Add a Cloudflare Pages Function before advertising that demo endpoint on Cloudflare.
@@ -6,7 +6,7 @@ This audit compares the current Darkmown implementation with the original MARROW
6
6
 
7
7
  - **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
8
8
  - **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
9
- - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~3.2 KB gzipped, CI-enforced below the 5 KB target).
9
+ - **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~4.7 KB gzipped, CI-enforced below the 5 KB target).
10
10
  - **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
11
11
  - **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
12
12
  - **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -13,16 +13,18 @@
13
13
  "types": "./types/index.d.ts",
14
14
  "scripts": {
15
15
  "build": "node src/cli.js build",
16
+ "runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 5120)'); if (size >= 5120) process.exit(1)\"",
16
17
  "dev": "node src/cli.js dev",
17
18
  "test": "node --test tests/*.test.js",
18
- "test:cov": "node --test --experimental-test-coverage tests/*.test.js",
19
+ "test:cov": "node scripts/test-coverage-gate.mjs 80",
19
20
  "test:e2e": "playwright test",
20
21
  "test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
21
22
  "pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
22
23
  "typecheck": "tsc --noEmit",
23
24
  "types": "tsc -p tsconfig.types.json",
24
- "prepublishOnly": "npm test",
25
- "smoke": "node scripts/smoke-consumer.mjs"
25
+ "smoke": "node scripts/smoke-consumer.mjs",
26
+ "prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
27
+ "prepublishOnly": "npm run prepublish:check"
26
28
  },
27
29
  "files": [
28
30
  "src",
package/site/pages/app.wd CHANGED
@@ -16,10 +16,10 @@ Everything on this page — the prose, the fetched list, the badges, the form, t
16
16
  This whole section is plain Markdown. The list below is unrolled **at build time** from a JSON file — it renders into HTML with no runtime at all:
17
17
 
18
18
  @loop /features.json into feature
19
- - **{ feature.name }** — { feature.detail }
19
+ - **{ feature.title }** — { feature.body }
20
20
  @endloop
21
21
 
22
- <aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~2&nbsp;KB runtime loads — and only because the document asks for it.</aside>
22
+ <aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~4.7&nbsp;KB gzipped runtime loads (CI-enforced under 5 KB) — and only because the document asks for it.</aside>
23
23
 
24
24
  ## Fetched data, looped, with nested conditions
25
25
 
@@ -68,7 +68,7 @@ Nice — you are tracking the whole near-term roadmap.
68
68
 
69
69
  ## A form that talks to a real server
70
70
 
71
- This form posts to a live serverless function and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST — Darkmown adapts to your backend instead of owning one:
71
+ This form posts to the demo echo endpoint and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST. On Vercel/dev the sample endpoint replies; on static-only hosts, point `action=` at your own backend — Darkmown adapts instead of owning one:
72
72
 
73
73
  :form action="/__wd/echo" into reply
74
74
  :input note placeholder="Tell us what you'd build with this"
@@ -68,7 +68,7 @@ The reply will appear here.
68
68
  The request failed: { reply_error }
69
69
  :endif
70
70
 
71
- In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
71
+ In `darkmown dev` the echo endpoint is built into the dev server; on darkmown.com it is a real serverless function behind the same URL. On static-only hosts such as a plain Cloudflare Pages deploy, replace `action="/__wd/echo"` with your own API endpoint. Either way, Darkmown itself stays static — sessions ride on `:fetch` plus ordinary cookies against your real API.
72
72
 
73
73
  ## Persistent state survives reload
74
74
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Darkmown docs
3
+ description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
3
4
  ---
4
5
 
5
6
  @include /nav.wd
@@ -169,7 +170,7 @@ tags: [sales, revenue, "q1, q2"]
169
170
 
170
171
  ## Reactive directives
171
172
 
172
- Reactive pages opt into `/__wd/runtime.js` (~3.2 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
173
+ Reactive pages opt into `/__wd/runtime.js` (~4.7 KB gzipped, under a CI-enforced 5 KB budget). Static pages do not.
173
174
 
174
175
  ```wd
175
176
  :state count = 0
@@ -96,14 +96,14 @@ Markdoc.renderers.react(content, React, {
96
96
 
97
97
  </section>
98
98
 
99
- :note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~3.2 KB runtime loads, never before. JS optional, CSS optional, always."
99
+ :note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~4.7 KB gzipped runtime loads (CI-enforced under 5 KB), never before. JS optional, CSS optional, always."
100
100
 
101
101
  <section class="props">
102
102
 
103
103
  ## Three rules, no escape hatch
104
104
 
105
105
  - **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
106
- - **One runtime.** Every reactive page shares the same ~3.2 KB runtime. No per-component bundles, no hydration tax that scales with your UI.
106
+ - **One runtime.** Every reactive page shares the same ~4.7 KB gzipped runtime, CI-enforced under 5 KB. No per-component bundles, no hydration tax that scales with your UI.
107
107
  - **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
108
108
 
109
109
  </section>
@@ -151,18 +151,10 @@ This button is powered by `site/pages/index.js`, discovered by matching the page
151
151
 
152
152
  ## Deploy in one click
153
153
 
154
- `npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build:
154
+ `npm run build` emits a plain static `dist/` — no server, no special runtime — so it deploys anywhere that serves files. This very site is a Darkmown build. The demo form posts to Darkmown's dev/Vercel echo endpoint; on static-only hosts, point `action=` at your own backend or remove that demo.
155
155
 
156
156
  <a class="btn primary" href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Vercel</a>
157
- <a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy to Cloudflare</a>
157
+ <a class="btn" href="https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FZVN-DEV%2Fdarkmown">Deploy static to Cloudflare</a>
158
158
  <a class="btn" href="https://github.com/ZVN-DEV/darkmown">Star on GitHub</a>
159
159
 
160
- ## Built in the open
161
-
162
- Darkmown itself was built by a small agent team. The board below is a live `@loop` over real data — itself a demo of the framework rendering this page:
163
-
164
- :sprint min=4 max=6 roles="PM, Development, Research, Testing, Review"
165
-
166
- @include /sprint-loop.wd
167
-
168
160
  </main>
package/src/cli.js CHANGED
@@ -28,6 +28,7 @@ if (command === "help" || command === "--help" || command === "-h") {
28
28
  console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
29
29
  } else if (command === "dev") {
30
30
  const port = Number(process.env.PORT || 5173);
31
+ const host = process.env.HOST || "127.0.0.1";
31
32
  const distRoot = path.join(process.cwd(), "dist");
32
33
  buildSite();
33
34
  /** @type {Set<import("node:http").ServerResponse>} */
@@ -100,19 +101,20 @@ if (command === "help" || command === "--help" || command === "-h") {
100
101
  res.end((error instanceof Error && error.stack) || String(error));
101
102
  }
102
103
  });
103
- server.listen(port, () => {
104
- console.log(`Darkmown dev server ready at http://localhost:${port}`);
104
+ server.listen(port, host, () => {
105
+ console.log(`Darkmown dev server ready at http://${host}:${port}`);
105
106
  console.log(`Live compiler watching site/ and src/`);
106
107
  });
107
108
  } else if (command === "serve") {
108
109
  const port = Number(process.env.PORT || 4173);
110
+ const host = process.env.HOST || "127.0.0.1";
109
111
  const distRoot = path.join(process.cwd(), "dist");
110
112
  if (!fs.existsSync(distRoot)) {
111
113
  console.error("No dist directory found. Run `darkmown build` first.");
112
114
  process.exit(1);
113
115
  }
114
- http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, () => {
115
- console.log(`Darkmown preview of dist at http://localhost:${port}`);
116
+ http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, host, () => {
117
+ console.log(`Darkmown preview of dist at http://${host}:${port}`);
116
118
  });
117
119
  } else {
118
120
  console.error(`Unknown command: ${command}`);
package/src/compiler.js CHANGED
@@ -400,7 +400,7 @@ function compileBody(lines, ctx) {
400
400
  i = block.end;
401
401
  continue;
402
402
  }
403
- const demo = renderDemoDirective(line);
403
+ const demo = renderDemoDirective(line, ctx);
404
404
  if (demo) {
405
405
  flush();
406
406
  out.push(demo);
@@ -924,7 +924,7 @@ function handleInput(line, ctx) {
924
924
  type = value;
925
925
  continue;
926
926
  }
927
- if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete"].includes(token[1])) {
927
+ if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
928
928
  throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
929
929
  }
930
930
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
@@ -959,7 +959,7 @@ function handleBind(line, ctx) {
959
959
  }
960
960
  const value = stripQuotes(token[2]);
961
961
  if (token[1] === "type") { type = value; continue; }
962
- if (!["placeholder", "autocomplete"].includes(token[1])) {
962
+ if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
963
963
  throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
964
964
  }
965
965
  attrs.push(`${token[1]}="${escapeHtml(value)}"`);
@@ -1571,11 +1571,15 @@ export function loopKeyOf(item, counts) {
1571
1571
  /**
1572
1572
  * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
1573
1573
  * @param {string} line
1574
+ * @param {Ctx} ctx
1574
1575
  * @returns {string}
1575
1576
  */
1576
- function renderDemoDirective(line) {
1577
+ function renderDemoDirective(line, ctx) {
1577
1578
  const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
1578
- if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
1579
+ if (tryMatch) {
1580
+ const href = validateDemoHref(tryMatch[2], ctx);
1581
+ return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
1582
+ }
1579
1583
  const note = line.match(/^:note\s+"([^"]+)"$/);
1580
1584
  if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
1581
1585
  const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
@@ -1586,6 +1590,30 @@ function renderDemoDirective(line) {
1586
1590
  return "";
1587
1591
  }
1588
1592
 
1593
+ /**
1594
+ * Validate demo-card links without adding arbitrary URL passthrough.
1595
+ * @param {string} href
1596
+ * @param {Ctx} ctx
1597
+ * @returns {string}
1598
+ */
1599
+ function validateDemoHref(href, ctx) {
1600
+ const value = href.trim();
1601
+ if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
1602
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
1603
+ }
1604
+ if (value.startsWith("//")) {
1605
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`);
1606
+ }
1607
+ if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("#")) {
1608
+ return value;
1609
+ }
1610
+ const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
1611
+ if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
1612
+ return value;
1613
+ }
1614
+ throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
1615
+ }
1616
+
1589
1617
  // ---------------------------------------------------------------------------
1590
1618
  // Interpolation: one syntax, { name } / { name.path }
1591
1619
  // ---------------------------------------------------------------------------
package/src/runtime.js CHANGED
@@ -45,7 +45,7 @@ for (const key of persistKeys) {
45
45
  for (const name of storeKeys) {
46
46
  const stored = localStorage.getItem(`wd:store:${name}`);
47
47
  if (stored === null) localStorage.setItem(`wd:store:${name}`, JSON.stringify(state[name] ?? null));
48
- else try { state[name] = JSON.parse(stored); } catch { /* keep seed */ }
48
+ else try { state[name] = JSON.parse(stored); } catch { /** keep seed */ }
49
49
  }
50
50
 
51
51
  function savePersisted() {
@@ -347,7 +347,7 @@ function renderNow() {
347
347
  }
348
348
  used.add(key);
349
349
  fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
350
- node.__wdItem = item; // let per-row actions resolve which row was clicked
350
+ node.__wdItem = item; /** let per-row actions resolve which row was clicked */
351
351
  out.appendChild(node);
352
352
  }
353
353
  for (const [key, node] of existing) {
package/src/statics.js CHANGED
@@ -28,7 +28,12 @@ export function serve(distRoot, url, res) {
28
28
  * @returns {string | null}
29
29
  */
30
30
  export function resolvePublicFile(distRoot, url) {
31
- const cleanUrl = decodeURIComponent(url.split("?")[0]);
31
+ let cleanUrl;
32
+ try {
33
+ cleanUrl = decodeURIComponent(url.split("?")[0]);
34
+ } catch {
35
+ return null;
36
+ }
32
37
  const base = cleanUrl.startsWith("/__wd/")
33
38
  ? cleanUrl
34
39
  : path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");