@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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- 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 =
|
|
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(
|
|
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
|
|
173
|
-
// dated-posts-only
|
|
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.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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(
|
|
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)
|
|
785
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
1058
|
-
*
|
|
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
|
-
|
|
1125
|
-
fs.
|
|
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 (
|
|
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);
|