@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
package/src/builder.js CHANGED
@@ -11,11 +11,12 @@ import {
11
11
  aiCrawlerPolicy,
12
12
  buildRobots,
13
13
  buildRss,
14
- buildSitemap,
15
14
  lastmodFor,
16
- RSS_ITEM_LIMIT,
17
15
  rfc822,
18
- rssDescription
16
+ rssDescription,
17
+ rssItemLimit,
18
+ sitemapDate,
19
+ sitemapDocuments
19
20
  } from "./feeds.js";
20
21
  import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
21
22
  import { HIGHLIGHT_CSS } from "./highlight.js";
@@ -58,7 +59,7 @@ export const DEP_MAP_FILE = ".wd-dev-deps.json";
58
59
 
59
60
  // Bumped whenever the map's shape changes, so a stale map from an older
60
61
  // Darkmown falls back to a full rebuild instead of being misread.
61
- const DEP_MAP_VERSION = 1;
62
+ const DEP_MAP_VERSION = 2;
62
63
 
63
64
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
64
65
 
@@ -154,7 +155,7 @@ function buildFull(cwd, paths, options) {
154
155
  manifest.push(...built.mapEntry.pages);
155
156
  if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
156
157
  for (const entry of built.mapEntry.pages) {
157
- if (entry.route !== route.route) extraRoutes.push({ ...route, route: entry.route });
158
+ if (entry.route !== route.route) extraRoutes.push(paginatedRoute(route, entry.route));
158
159
  }
159
160
  mapRoutes[route.route] = built.mapEntry;
160
161
  }
@@ -169,8 +170,9 @@ function buildFull(cwd, paths, options) {
169
170
  // Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
170
171
  // need the home `site_url` (else a loud, actionable build hint). Drafts are
171
172
  // already filtered out of `routes`, so neither feed can ever see one. The
172
- // paginated 2+ routes are indexable HTML too, so they join the sitemap (RSS is
173
- // dated-posts-only, which they are not, so it's unaffected).
173
+ // paginated 2+ routes are indexable HTML too, so they join the sitemap; RSS is
174
+ // dated-posts-only and `paginatedRoute` drops the listing's `date:`, so a
175
+ // dated listing syndicates once instead of once per generated page.
174
176
  const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
175
177
 
176
178
  // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
@@ -187,7 +189,7 @@ function buildFull(cwd, paths, options) {
187
189
  emitCloudflareWorker(paths, apiDir, apiRoutes);
188
190
  }
189
191
 
190
- if (options.depMap) writeDepMap(paths, feedLink, mapRoutes);
192
+ if (options.depMap) writeDepMap(paths, feedLink, siteInputs(identity, titles), mapRoutes);
191
193
 
192
194
  return { routes: manifest, distRoot: paths.distRoot, feeds };
193
195
  }
@@ -198,9 +200,11 @@ function buildFull(cwd, paths, options) {
198
200
  * Correctness first — returns null (→ full rebuild) on ANY uncertainty: no/stale
199
201
  * dependency map, a route added/removed/renamed, a deleted changed file, a
200
202
  * changed file no dependency graph accounts for, or a change to the site-wide
201
- * feed link every page embeds. The global outputs (`routes.json`, `_headers`,
202
- * robots/sitemap/rss, the dep map) are re-emitted from the merged manifest every
203
- * time so a partial rebuild can never leave them inconsistent.
203
+ * feed link or `site_url` every page embeds. A renamed route does not fall back:
204
+ * the pages whose breadcrumbs name it simply join the rebuild set. The global
205
+ * outputs (`routes.json`, `_headers`, robots/sitemap/rss, the dep map) are
206
+ * re-emitted from the merged manifest every time so a partial rebuild can never
207
+ * leave them inconsistent.
204
208
  * @param {string} cwd
205
209
  * @param {Paths} paths
206
210
  * @param {{ target?: string, includeDrafts?: boolean, quietFeedHints?: boolean }} options
@@ -232,6 +236,17 @@ function buildIncremental(cwd, paths, options, changed) {
232
236
  if (JSON.stringify(feedLink ?? null) !== JSON.stringify(map.feed ?? null)) {
233
237
  return fullFallback(changed, "the site feed link changed");
234
238
  }
239
+ // `site_url` is the second whole-site value baked into every page (its
240
+ // `<link rel="canonical">` and `og:url`), and it is read from the HOME page's
241
+ // frontmatter — so editing one file invalidates every other one. Rebuilding a
242
+ // subset would leave `dist/` internally contradictory: a sitemap re-emitted at
243
+ // the new origin over canonicals still pointing at the old one.
244
+ if (identity.siteUrl !== map.site.url) {
245
+ return fullFallback(
246
+ changed,
247
+ "site_url changed, and every page's canonical URL is built from it"
248
+ );
249
+ }
235
250
 
236
251
  /** @type {Set<string>} */
237
252
  const affected = new Set();
@@ -245,6 +260,29 @@ function buildIncremental(cwd, paths, options, changed) {
245
260
  copyChangedAsset(file, cwd, paths);
246
261
  }
247
262
 
263
+ // The third whole-site value: page TITLES. A nested page's breadcrumb JSON-LD
264
+ // names its ANCESTORS, so renaming `/guides/` rewrites `/guides/setup/`'s
265
+ // markup while only `/guides/` is ever in the changed set — the one way this
266
+ // build could ship a stale page and still report success.
267
+ //
268
+ // Which pages name a renamed route is ASKED OF `breadcrumbTrail` rather than
269
+ // re-derived here, so the fan-out cannot drift from what the compile actually
270
+ // emits: no trail (a top-level route, or a site with no `site_url`) means
271
+ // there is nothing to invalidate, and the rebuild set stays minimal.
272
+ /** @type {Set<string>} */
273
+ const renamedCrumbs = new Set();
274
+ for (const [routePath, title] of titles) {
275
+ if (title === map.site.titles[routePath]) continue;
276
+ renamedCrumbs.add(absoluteUrl(identity.siteUrl, routePath));
277
+ affected.add(routePath);
278
+ }
279
+ if (renamedCrumbs.size > 0) {
280
+ for (const candidate of Object.keys(map.routes)) {
281
+ const trail = breadcrumbTrail(candidate, titles, identity.siteUrl);
282
+ if (trail.some((crumb) => renamedCrumbs.has(crumb.url))) affected.add(candidate);
283
+ }
284
+ }
285
+
248
286
  const byRoute = new Map(routes.map((route) => [route.route, route]));
249
287
  /** @type {Set<string>} */
250
288
  const warned = new Set();
@@ -276,12 +314,12 @@ function buildIncremental(cwd, paths, options, changed) {
276
314
  for (const route of routes) {
277
315
  for (const entry of map.routes[route.route].pages) {
278
316
  manifest.push(entry);
279
- if (entry.route !== route.route) extraRoutes.push({ ...route, route: entry.route });
317
+ if (entry.route !== route.route) extraRoutes.push(paginatedRoute(route, entry.route));
280
318
  }
281
319
  }
282
320
  emitGlobalFiles(manifest, paths, routes, identity);
283
321
  const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
284
- writeDepMap(paths, feedLink, map.routes);
322
+ writeDepMap(paths, feedLink, siteInputs(identity, titles), map.routes);
285
323
 
286
324
  return {
287
325
  routes: manifest,
@@ -492,12 +530,12 @@ function relPosix(cwd, file) {
492
530
  * unreadable, or from an incompatible version — every one of which means the
493
531
  * incremental build cannot trust its picture of the site and must go full.
494
532
  * @param {Paths} paths
495
- * @returns {{ version: number, feed: { href: string, title: string } | null, routes: Record<string, DepMapEntry> } | null}
533
+ * @returns {{ version: number, feed: { href: string, title: string } | null, site: { url: string, titles: Record<string, string> }, routes: Record<string, DepMapEntry> } | null}
496
534
  */
497
535
  function readDepMap(paths) {
498
536
  try {
499
537
  const map = JSON.parse(fs.readFileSync(path.join(paths.distRoot, DEP_MAP_FILE), "utf8"));
500
- if (map && map.version === DEP_MAP_VERSION && map.routes) return map;
538
+ if (map && map.version === DEP_MAP_VERSION && map.routes && map.site) return map;
501
539
  } catch {
502
540
  /* absent or corrupt — full rebuild */
503
541
  }
@@ -508,16 +546,34 @@ function readDepMap(paths) {
508
546
  * Persist the dependency map for the next incremental build.
509
547
  * @param {Paths} paths
510
548
  * @param {{ href: string, title: string } | undefined} feedLink
549
+ * @param {{ url: string, titles: Record<string, string> }} site {@link siteInputs}.
511
550
  * @param {Record<string, DepMapEntry>} mapRoutes
512
551
  * @returns {void}
513
552
  */
514
- function writeDepMap(paths, feedLink, mapRoutes) {
553
+ function writeDepMap(paths, feedLink, site, mapRoutes) {
515
554
  fs.writeFileSync(
516
555
  path.join(paths.distRoot, DEP_MAP_FILE),
517
- JSON.stringify({ version: DEP_MAP_VERSION, feed: feedLink ?? null, routes: mapRoutes })
556
+ JSON.stringify({ version: DEP_MAP_VERSION, feed: feedLink ?? null, site, routes: mapRoutes })
518
557
  );
519
558
  }
520
559
 
560
+ /**
561
+ * The WHOLE-SITE compile inputs recorded in the dependency map: the values baked
562
+ * into a page that do NOT come from that page's own dependency graph, and so can
563
+ * never appear in `routesAffectedBy`.
564
+ *
565
+ * `url` is `site_url` (every page's canonical + `og:url`); `titles` is every
566
+ * route's title, because a nested page's breadcrumb JSON-LD names its ANCESTORS.
567
+ * The next incremental build diffs both against the site it discovers, which is
568
+ * the only way it can know that editing page A invalidated page B.
569
+ * @param {SiteIdentity} identity
570
+ * @param {Map<string, string>} titles
571
+ * @returns {{ url: string, titles: Record<string, string> }}
572
+ */
573
+ function siteInputs(identity, titles) {
574
+ return { url: identity.siteUrl, titles: Object.fromEntries(titles) };
575
+ }
576
+
521
577
  /**
522
578
  * Log why an incremental build is falling back to a full rebuild, and return
523
579
  * null so the caller runs one. The reason lands on stderr, which the dev server
@@ -700,6 +756,25 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
700
756
  return { pages, deps, collections: used };
701
757
  }
702
758
 
759
+ /**
760
+ * A generated `/page/<n>/` route, for the feed pass: the same source route at
761
+ * its own public path, with the listing's frontmatter MINUS `date:`.
762
+ *
763
+ * Dropping the date is the whole point. A listing page is not a post; cloning
764
+ * its `date:` onto every generated page republished the listing as N duplicate
765
+ * items in `rss.xml` (a `paginate 1` over three entries produced three
766
+ * identical feed items). The sitemap still dates these pages — from the listing
767
+ * SOURCE FILE via `lastmodFor`, which is what they actually are.
768
+ * @param {Route} route The listing's source route.
769
+ * @param {string} routePath The generated page's public route path.
770
+ * @returns {Route}
771
+ */
772
+ function paginatedRoute(route, routePath) {
773
+ const meta = { ...route.meta };
774
+ delete meta.date;
775
+ return { ...route, route: routePath, meta };
776
+ }
777
+
703
778
  /**
704
779
  * The output route for page `n` of a paginated listing: page 1 keeps the
705
780
  * listing's own clean route; pages 2+ live at `/<route>/page/<n>/`.
@@ -737,6 +812,7 @@ function pagerVars(baseRoute, current, total) {
737
812
  * @property {string} title Site title (also the RSS channel title).
738
813
  * @property {string} description Site description (also the RSS channel description).
739
814
  * @property {string} aiCrawlers `"allow"` or `"deny"`, from `ai_crawlers:`.
815
+ * @property {number} rssLimit Most-recent-first RSS item cap, from `rss_limit:`.
740
816
  */
741
817
 
742
818
  /**
@@ -760,7 +836,8 @@ function siteIdentity(routes) {
760
836
  siteUrl: read("site_url").replace(/\/$/, ""),
761
837
  title: read("title") || "Darkmown",
762
838
  description: read("description"),
763
- aiCrawlers: aiCrawlerPolicy(meta, home ? home.file : undefined)
839
+ aiCrawlers: aiCrawlerPolicy(meta, home ? home.file : undefined),
840
+ rssLimit: rssItemLimit(meta, home ? home.file : undefined)
764
841
  };
765
842
  }
766
843
 
@@ -780,9 +857,11 @@ function hasDatedPost(routes) {
780
857
  * (every non-404 route) and rss.xml (dated posts only). Returns the URL/item
781
858
  * counts (`null` when a feed was skipped) for the CLI build summary.
782
859
  *
783
- * The sitemap `<lastmod>` for each route is the frontmatter `date:` if present,
784
- * else `lastmodFor(file)` (git last-commit date → file mtime). RSS items are the
785
- * routes with a `date:`, newest first, capped at {@link RSS_ITEM_LIMIT}.
860
+ * The sitemap `<lastmod>` for each route is the frontmatter `date:` if present
861
+ * AND a real date, else `lastmodFor(file)` (git last-commit date → file mtime);
862
+ * see {@link routeLastmod}. Over 50,000 URLs the sitemap shards behind an index
863
+ * ({@link writeSitemap}). RSS items are the routes with a `date:`, newest first,
864
+ * capped at the home page's `rss_limit:` (default 20).
786
865
  * @param {Route[]} routes Emitted (post-draft-filter) routes.
787
866
  * @param {SiteIdentity} identity
788
867
  * @param {Paths} paths
@@ -822,15 +901,14 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
822
901
  // not a post. Filter it out once; both feeds work off the result.
823
902
  const feedable = routes.filter((route) => route.route !== "/404/");
824
903
 
825
- // Sitemap: one <url> per emitted route (404 excluded above).
904
+ // Sitemap: one <url> per emitted route (404 excluded above). A frontmatter
905
+ // `date:` wins, but only when it IS a date — an unparseable one warns and
906
+ // omits `<lastmod>` rather than emitting garbage that invalidates the file.
826
907
  const sitemapEntries = feedable.map((route) => ({
827
908
  loc: absoluteUrl(identity.siteUrl, route.route),
828
- lastmod:
829
- typeof route.meta.date === "string" && route.meta.date.trim()
830
- ? String(route.meta.date).trim().slice(0, 10)
831
- : lastmodFor(route.file)
909
+ lastmod: routeLastmod(route)
832
910
  }));
833
- fs.writeFileSync(path.join(paths.distRoot, "sitemap.xml"), buildSitemap(sitemapEntries));
911
+ writeSitemap(paths, sitemapEntries, identity.siteUrl);
834
912
 
835
913
  // RSS: dated posts only, newest first, capped. The `date:` is the "this is a
836
914
  // post" signal. With no dated post there is nothing to syndicate, so rss.xml
@@ -839,7 +917,7 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
839
917
  const posts = feedable
840
918
  .filter((route) => typeof route.meta.date === "string" && route.meta.date.trim())
841
919
  .sort((a, b) => String(b.meta.date).localeCompare(String(a.meta.date)))
842
- .slice(0, RSS_ITEM_LIMIT);
920
+ .slice(0, identity.rssLimit);
843
921
  if (posts.length === 0) return { sitemap: sitemapEntries.length, rss: null };
844
922
 
845
923
  const items = posts.map((route) => {
@@ -867,6 +945,52 @@ function emitFeeds(routes, identity, paths, quietHints = false) {
867
945
  return { sitemap: sitemapEntries.length, rss: items.length };
868
946
  }
869
947
 
948
+ /**
949
+ * The sitemap `<lastmod>` for one route: its frontmatter `date:` when that is a
950
+ * real `yyyy-mm-dd` date, else the source file's git/mtime date. A `date:` the
951
+ * sitemap protocol cannot accept is NOT coerced — `Jan 5, 2026` used to ship as
952
+ * `Jan 5, 202`, and a `&` or `<` in the value made the entire document
953
+ * unparseable, so nothing was indexed at all. It warns with the route and the
954
+ * offending value, and the entry goes out without a `<lastmod>` (which is valid).
955
+ * @param {Route} route
956
+ * @returns {string} `yyyy-mm-dd`, or "" to omit the element.
957
+ */
958
+ function routeLastmod(route) {
959
+ const raw = typeof route.meta.date === "string" ? route.meta.date.trim() : "";
960
+ if (!raw) return lastmodFor(route.file);
961
+ const valid = sitemapDate(raw);
962
+ if (valid) return valid;
963
+ console.warn(
964
+ `hint: sitemap <lastmod> omitted for ${route.route} — date: "${raw}" is not a date. ` +
965
+ "Use: date: 2026-01-05 (yyyy-mm-dd)."
966
+ );
967
+ return "";
968
+ }
969
+
970
+ /**
971
+ * Write `sitemap.xml` and, over the protocol's 50,000-URL cap, the numbered
972
+ * shards it indexes. Any shard a PREVIOUS build wrote and this one does not is
973
+ * deleted first: a site that shrinks back under the cap (or loses a shard's
974
+ * worth of pages) must not leave an orphan `sitemap-3.xml` behind, still listed
975
+ * nowhere and still crawlable.
976
+ * @param {Paths} paths
977
+ * @param {import("./feeds.js").DatedRoute[]} entries
978
+ * @param {string} siteUrl
979
+ * @returns {void}
980
+ */
981
+ function writeSitemap(paths, entries, siteUrl) {
982
+ const documents = sitemapDocuments(entries, siteUrl);
983
+ const fresh = new Set(documents.map((doc) => doc.file));
984
+ for (const name of fs.readdirSync(paths.distRoot)) {
985
+ if (/^sitemap-\d+\.xml$/.test(name) && !fresh.has(name)) {
986
+ fs.rmSync(path.join(paths.distRoot, name), { force: true });
987
+ }
988
+ }
989
+ for (const doc of documents) {
990
+ fs.writeFileSync(path.join(paths.distRoot, doc.file), doc.xml);
991
+ }
992
+ }
993
+
870
994
  /**
871
995
  * Warn when more than one handler resolves to the same `/api` path, since only
872
996
  * one is ever reachable and readdir order silently decides which. Two collisions
@@ -1054,8 +1178,8 @@ export function stripRuntimeComments(source) {
1054
1178
  * header, the later block wins. So we emit the catch-all `/*` first (baseline
1055
1179
  * security headers + the reactive CSP, which since 2.1 is eval-free and satisfies
1056
1180
  * every page), then one block per static route that re-states the same eval-free
1057
- * CSP as defense-in-depth. Reactive routes need no override — the catch-all
1058
- * already carries the eval-free CSP.
1181
+ * CSP as defense-in-depth, and finally a block for any reactive route a static
1182
+ * route's glob would otherwise shadow (see below).
1059
1183
  *
1060
1184
  * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
1061
1185
  * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
@@ -1075,6 +1199,26 @@ export function renderCloudflareHeaders(manifest) {
1075
1199
  }
1076
1200
  }
1077
1201
 
1202
+ // A static route's `/docs/*` glob also matches every route NESTED under it,
1203
+ // including a reactive `/docs/guide/` — and later blocks win, so that route
1204
+ // was silently resolving to the static policy from its parent. It is a no-op
1205
+ // only while the two policies happen to be identical. A shadowed reactive
1206
+ // route therefore re-states its own policy AFTER the static globs, so the
1207
+ // route always lands on the policy its own manifest entry declares.
1208
+ const shadowingRoutes = manifest
1209
+ .filter((entry) => !entry.assets.runtime && entry.route !== "/")
1210
+ .map((entry) => entry.route);
1211
+ for (const entry of manifest) {
1212
+ if (!entry.assets.runtime) continue;
1213
+ const shadowed = shadowingRoutes.some(
1214
+ (parent) => entry.route !== parent && entry.route.startsWith(parent)
1215
+ );
1216
+ if (!shadowed) continue;
1217
+ for (const pattern of cloudflarePathPatterns(entry.route)) {
1218
+ blocks.push([pattern, [` Content-Security-Policy: ${REACTIVE_CSP}`]]);
1219
+ }
1220
+ }
1221
+
1078
1222
  return `${blocks.map(([pattern, lines]) => [pattern, ...lines].join("\n")).join("\n\n")}\n`;
1079
1223
  }
1080
1224
 
@@ -1115,14 +1259,37 @@ function defaultNotFoundHtml() {
1115
1259
  }
1116
1260
 
1117
1261
  /**
1262
+ * Emit the shipped reactive runtime as `/__wd/runtime.js`, plus its sourcemap as
1263
+ * `/__wd/runtime.js.map`.
1264
+ *
1265
+ * What ships is `src/runtime.min.js`, the COMMITTED esbuild artifact built by
1266
+ * `scripts/build-runtime.mjs` — copied byte for byte, never re-derived here.
1267
+ * Two reasons it is a copy and not a build step:
1268
+ *
1269
+ * 1. `darkmown build` runs on a consumer's machine from the published tarball,
1270
+ * where esbuild does not exist. The bytes have to be generated in the repo,
1271
+ * committed, and shipped.
1272
+ * 2. The gzip budget in `.size-snapshot.json` measures `src/runtime.min.js`. If
1273
+ * the build could regenerate it, the number the budget gates and the number
1274
+ * in git could differ and nobody would notice.
1275
+ *
1276
+ * So a change to `src/runtime.js` reaches a page only after `npm run
1277
+ * build:runtime`. That is enforced, not trusted: `tests/runtime-min.test.js`
1278
+ * rebuilds in memory and fails on any difference, and CI runs
1279
+ * `build-runtime.mjs --check`.
1280
+ *
1281
+ * The map is emitted alongside because the file the browser downloads is now
1282
+ * minified: without it a stack trace from a real page is unreadable. It carries
1283
+ * the original source inline (`sourcesContent`) and is fetched only when devtools
1284
+ * is open, so it costs a page nothing.
1118
1285
  * @param {Paths} paths
1119
1286
  * @returns {void}
1120
1287
  */
1121
1288
  function emitRuntime(paths) {
1122
1289
  const out = path.join(paths.distRoot, "__wd/runtime.js");
1123
1290
  fs.mkdirSync(path.dirname(out), { recursive: true });
1124
- const source = fs.readFileSync(path.join(moduleDir, "runtime.js"), "utf8");
1125
- fs.writeFileSync(out, stripRuntimeComments(source));
1291
+ fs.copyFileSync(path.join(moduleDir, "runtime.min.js"), out);
1292
+ fs.copyFileSync(path.join(moduleDir, "runtime.min.js.map"), `${out}.map`);
1126
1293
  }
1127
1294
 
1128
1295
  /**
@@ -1248,16 +1415,37 @@ function subjectMatchesTag(subject, tag) {
1248
1415
  return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
1249
1416
  }
1250
1417
 
1418
+ /**
1419
+ * Whether a `.skin`/`.js` file is the colocated asset of a page or include: a
1420
+ * `.wd` or `.md` with the same basename sits beside it.
1421
+ * @param {string} file Absolute path of the asset.
1422
+ * @returns {boolean}
1423
+ */
1424
+ function hasColocatedPage(file) {
1425
+ const base = file.slice(0, -path.extname(file).length);
1426
+ return fs.existsSync(`${base}.wd`) || fs.existsSync(`${base}.md`);
1427
+ }
1428
+
1251
1429
  /**
1252
1430
  * Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
1431
+ *
1432
+ * `.md`/`.wd` are include SOURCES. A `.skin`/`.js` that sits next to a `.wd`/`.md`
1433
+ * of the same basename is a COLOCATED compiler input with its own emit path
1434
+ * (`emitAssets` writes the compiled CSS to `/__wd/styles/` and the script to
1435
+ * `/__wd/scripts/`); publishing that source verbatim to `/__wd/media/` shipped a
1436
+ * second, unreferenced copy that no incremental rebuild ever refreshed. A
1437
+ * STANDALONE `.skin`/`.js` (no sibling page) is a plain shelf asset the docs
1438
+ * promise at `/__wd/media/<path>` (`<script src="/__wd/media/lib/helper.js">`),
1439
+ * so it is still published.
1253
1440
  * @param {Paths} paths
1254
1441
  * @returns {void}
1255
1442
  */
1256
1443
  function emitShelfAssets(paths) {
1257
1444
  if (!fs.existsSync(paths.shelfRoot)) return;
1258
1445
  for (const file of walk(paths.shelfRoot)) {
1259
- const ext = path.extname(file);
1260
- if ([".md", ".wd"].includes(ext)) continue;
1446
+ const ext = path.extname(file).toLowerCase();
1447
+ if (ext === ".md" || ext === ".wd") continue;
1448
+ if ((ext === ".skin" || ext === ".js") && hasColocatedPage(file)) continue;
1261
1449
  const rel = path.relative(paths.shelfRoot, file);
1262
1450
  const folder = ext === ".json" ? "__wd/data" : "__wd/media";
1263
1451
  const out = path.join(paths.distRoot, folder, rel);