@zvndev/darkmown 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +44 -16
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +19 -6
  7. package/package.json +1 -1
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +4 -0
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +1 -0
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.skin +127 -0
  24. package/site/pages/carousel/index.wd +1 -0
  25. package/site/pages/compass/index.wd +1 -0
  26. package/site/pages/data.wd +3 -0
  27. package/site/pages/docs/-relative-note.wd +4 -0
  28. package/site/pages/docs/index.skin +27 -0
  29. package/site/pages/docs/index.wd +70 -13
  30. package/site/pages/folio/-style.wd +4 -0
  31. package/site/pages/folio/cart.wd +1 -0
  32. package/site/pages/folio/index.wd +1 -0
  33. package/site/pages/forge/index.wd +1 -0
  34. package/site/pages/highlight/index.wd +1 -0
  35. package/site/pages/index.wd +1 -0
  36. package/site/pages/interactions/index.wd +1 -0
  37. package/site/pages/ledger/index.wd +1 -0
  38. package/site/pages/loops.wd +1 -0
  39. package/site/pages/media/index.wd +1 -0
  40. package/site/pages/pulse/index.wd +1 -0
  41. package/site/pages/reactive.wd +1 -0
  42. package/site/pages/scoped/index.wd +1 -0
  43. package/site/pages/showcase/index.wd +1 -0
  44. package/site/pages/store.wd +1 -0
  45. package/site/pages/theme.wd +1 -0
  46. package/src/builder.js +444 -65
  47. package/src/cli.js +96 -18
  48. package/src/compiler/actions.js +303 -0
  49. package/src/compiler/body.js +36 -24
  50. package/src/compiler/context.js +56 -6
  51. package/src/compiler/directives.js +31 -1470
  52. package/src/compiler/fetch.js +159 -0
  53. package/src/compiler/format.js +17 -4
  54. package/src/compiler/forms.js +456 -0
  55. package/src/compiler/frontmatter.js +10 -5
  56. package/src/compiler/includes.js +66 -1
  57. package/src/compiler/index.js +15 -3
  58. package/src/compiler/loops.js +53 -21
  59. package/src/compiler/markdown.js +65 -15
  60. package/src/compiler/media.js +133 -0
  61. package/src/compiler/page.js +59 -75
  62. package/src/compiler/state.js +168 -0
  63. package/src/compiler/structure.js +377 -0
  64. package/src/dev.js +76 -0
  65. package/src/headers.js +56 -17
  66. package/src/router.js +12 -1
  67. package/src/runtime.js +5 -3
  68. package/src/statics.js +63 -12
  69. package/src/templates/blog/README.md +13 -4
  70. package/src/templates/blog/site/_/nav.wd +4 -0
  71. package/src/templates/blog/site/pages/index.skin +3 -0
  72. package/src/templates/blog/site/pages/index.wd +10 -4
  73. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  74. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  75. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  76. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  77. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  78. package/src/templates/dashboard/site/_/nav.wd +4 -0
  79. package/src/templates/dashboard/site/pages/index.wd +1 -0
  80. package/src/templates/landing/site/_/nav.wd +4 -0
  81. package/src/templates/landing/site/pages/index.wd +1 -0
  82. package/src/templates/starter/site/_/nav.wd +4 -0
  83. package/src/templates/starter/site/pages/about.md +18 -1
  84. package/src/templates/starter/site/pages/about.skin +69 -0
  85. package/src/templates/starter/site/pages/index.wd +1 -0
  86. package/src/templates/store/site/_/nav.wd +4 -0
  87. package/src/templates/store/site/pages/index.wd +1 -0
  88. package/src/templates/blog/site/_/posts.json +0 -16
package/src/builder.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { discoverApiRoutes } from "./api-runner.js";
5
5
  import { buildCollections } from "./compiler/collections.js";
6
6
  import { compilePage, parseFrontmatter } from "./compiler.js";
7
- import { createPaths } from "./config.js";
7
+ import { createPaths, routesDir, shelfDir } from "./config.js";
8
8
  import {
9
9
  absoluteUrl,
10
10
  buildRobots,
@@ -35,6 +35,29 @@ import { compileSkin, scopeIdFor } from "./skin.js";
35
35
  * @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
36
36
  */
37
37
 
38
+ /**
39
+ * One source route's record in the dev dependency map (`dist/.wd-dev-deps.json`),
40
+ * written by dev builds so a later `--changed` build can rebuild only the routes
41
+ * whose dependency graph contains the changed file.
42
+ * @typedef {object} DepMapEntry
43
+ * @property {string} file Source file, project-relative, POSIX-separated.
44
+ * @property {string[]} deps Every source file this route's compile read (the page
45
+ * itself, includes, loop JSON data files, colocated `.skin`/`.js`) —
46
+ * project-relative, POSIX-separated, sorted.
47
+ * @property {string[]} collections Collection names the route loops, so a change
48
+ * to any entry of the collection rebuilds this route.
49
+ * @property {RouteManifestEntry[]} pages The manifest entries this route emitted
50
+ * (one, or one per pagination page), so `routes.json`/`_headers`/the sitemap
51
+ * can be reassembled globally without recompiling untouched routes.
52
+ */
53
+
54
+ /** The dev dependency map's filename under `dist/`. */
55
+ export const DEP_MAP_FILE = ".wd-dev-deps.json";
56
+
57
+ // Bumped whenever the map's shape changes, so a stale map from an older
58
+ // Darkmown falls back to a full rebuild instead of being misread.
59
+ const DEP_MAP_VERSION = 1;
60
+
38
61
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
39
62
 
40
63
  /**
@@ -48,14 +71,47 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
48
71
  * `/api/*` to those same functions and serves everything else as a static asset.
49
72
  *
50
73
  * @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
51
- * @param {{ target?: string, includeDrafts?: boolean }} [options] `target:
52
- * "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps `draft:
53
- * true` pages in the build + feeds (dev / `build --drafts`); default excludes
54
- * them at route discovery so feeds never see a draft.
55
- * @returns {{ routes: RouteManifestEntry[], distRoot: string, feeds: { sitemap: number | null, rss: number | null } }}
74
+ * @param {{ target?: string, includeDrafts?: boolean, depMap?: boolean, changed?: string[], quietFeedHints?: boolean }} [options]
75
+ * `target: "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps
76
+ * `draft: true` pages in the build + feeds (dev / `build --drafts`); default
77
+ * excludes them at route discovery so feeds never see a draft. `depMap: true`
78
+ * (dev builds) writes the per-route dependency map to `dist/.wd-dev-deps.json`.
79
+ * `changed` (project-relative paths, dev only; implies `depMap`) attempts an
80
+ * incremental rebuild of just the affected routes, falling back to a full
81
+ * rebuild on ANY uncertainty — a stale page is worse than a slow rebuild.
82
+ * @returns {BuildResult}
56
83
  */
57
84
  export function buildSite(cwd = process.cwd(), options = {}) {
58
85
  const paths = createPaths(cwd);
86
+ const changed = (options.changed ?? []).map((file) => file.replaceAll(path.sep, "/"));
87
+ if (changed.length > 0) {
88
+ const partial = buildIncremental(cwd, paths, options, changed);
89
+ if (partial) return partial;
90
+ // A `changed` build that fell back still writes the map, so the NEXT change
91
+ // gets to be incremental again.
92
+ return buildFull(cwd, paths, { ...options, depMap: true });
93
+ }
94
+ return buildFull(cwd, paths, options);
95
+ }
96
+
97
+ /**
98
+ * The value `buildSite` returns. `incremental` is present only when a `changed`
99
+ * build actually rebuilt a subset of routes (absent on every full rebuild).
100
+ * @typedef {object} BuildResult
101
+ * @property {RouteManifestEntry[]} routes
102
+ * @property {string} distRoot
103
+ * @property {{ sitemap: number | null, rss: number | null }} feeds
104
+ * @property {{ changed: string[], rebuilt: string[], total: number }} [incremental]
105
+ */
106
+
107
+ /**
108
+ * The full site build: wipe `dist`, compile every route, emit every global file.
109
+ * @param {string} cwd
110
+ * @param {Paths} paths
111
+ * @param {{ target?: string, includeDrafts?: boolean, depMap?: boolean, quietFeedHints?: boolean }} options
112
+ * @returns {BuildResult}
113
+ */
114
+ function buildFull(cwd, paths, options) {
59
115
  fs.rmSync(paths.distRoot, { recursive: true, force: true });
60
116
  fs.mkdirSync(paths.distRoot, { recursive: true });
61
117
  emitShelfAssets(paths);
@@ -71,13 +127,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
71
127
  // as the RSS channel fields. Resolved once, up front, so every page can link
72
128
  // the feed during the loop.
73
129
  const identity = siteIdentity(routes);
74
- // Advertise the feed in every page's <head> only when rss.xml will actually be
75
- // emitted: the home set `site_url` AND there is at least one dated post. A
76
- // stale `<link>` to a missing feed would 404 for aggregators.
77
- const feedLink =
78
- identity.siteUrl && hasDatedPost(routes)
79
- ? { href: absoluteUrl(identity.siteUrl, "/rss.xml"), title: identity.title }
80
- : undefined;
130
+ const feedLink = feedLinkFor(identity, routes);
81
131
  /** @type {RouteManifestEntry[]} */
82
132
  const manifest = [];
83
133
 
@@ -93,40 +143,17 @@ export function buildSite(cwd = process.cwd(), options = {}) {
93
143
  /** @type {Route[]} */
94
144
  const extraRoutes = [];
95
145
 
146
+ /** @type {Record<string, DepMapEntry>} */
147
+ const mapRoutes = {};
148
+
96
149
  for (const route of routes) {
97
- // Compile each route's page(s). A normal route yields one page; a route whose
98
- // `@loop … paginate N` slices a collection yields page 1 at the route plus
99
- // `/<route>/page/<n>/` for pages 2..N — every one static HTML.
100
- for (const page of compileRoutePages(route, paths, collections, feedLink)) {
101
- if (page.route !== route.route) extraRoutes.push({ ...route, route: page.route });
102
- for (const warning of page.warnings || []) {
103
- if (warned.has(warning)) continue;
104
- warned.add(warning);
105
- console.warn(`hint: ${warning}`);
106
- }
107
- const outFile = outputPathForRoute(paths.distRoot, page.route);
108
- fs.mkdirSync(path.dirname(outFile), { recursive: true });
109
- fs.writeFileSync(outFile, page.html);
110
- if (page.route === "/404/") notFoundHtml = page.html;
111
- emitAssets(page.assets, paths, page.html, warned);
112
- if (page.assets.runtime) emitRuntime(paths);
113
- emitBehaviors(page.assets, paths);
114
- if (page.assets.hasCode) emitHighlight(paths);
115
- const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
116
- manifest.push({
117
- route: page.route,
118
- file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
119
- assets: {
120
- skins: [...page.assets.skins],
121
- scripts: [
122
- ...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
123
- ...behaviorSrcs,
124
- ...page.assets.scripts
125
- ],
126
- runtime: page.assets.runtime
127
- }
128
- });
150
+ const built = buildRoute(route, cwd, paths, collections, feedLink, warned);
151
+ manifest.push(...built.mapEntry.pages);
152
+ if (built.notFoundHtml !== undefined) notFoundHtml = built.notFoundHtml;
153
+ for (const entry of built.mapEntry.pages) {
154
+ if (entry.route !== route.route) extraRoutes.push({ ...route, route: entry.route });
129
155
  }
156
+ mapRoutes[route.route] = built.mapEntry;
130
157
  }
131
158
 
132
159
  // Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
@@ -134,20 +161,14 @@ export function buildSite(cwd = process.cwd(), options = {}) {
134
161
  // minimal page so a 404 is always available.
135
162
  fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
136
163
 
137
- fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
138
-
139
- // Cloudflare Pages reads dist/_headers for security headers (Vercel reads
140
- // vercel.json; the local server adds them in src/statics.js). Static routes
141
- // get the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed
142
- // one. A catch-all keeps assets and any future path covered.
143
- fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
164
+ emitGlobalFiles(manifest, paths);
144
165
 
145
166
  // Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
146
167
  // need the home `site_url` (else a loud, actionable build hint). Drafts are
147
168
  // already filtered out of `routes`, so neither feed can ever see one. The
148
169
  // paginated 2+ routes are indexable HTML too, so they join the sitemap (RSS is
149
170
  // dated-posts-only, which they are not, so it's unaffected).
150
- const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths);
171
+ const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
151
172
 
152
173
  // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
153
174
  // sees every emitted route/framework file already on disk.
@@ -163,9 +184,334 @@ export function buildSite(cwd = process.cwd(), options = {}) {
163
184
  emitCloudflareWorker(paths, apiDir, apiRoutes);
164
185
  }
165
186
 
187
+ if (options.depMap) writeDepMap(paths, feedLink, mapRoutes);
188
+
166
189
  return { routes: manifest, distRoot: paths.distRoot, feeds };
167
190
  }
168
191
 
192
+ /**
193
+ * The incremental (dev) build: rebuild only the routes whose dependency graph
194
+ * contains a changed file, reusing everything else from the previous build.
195
+ * Correctness first — returns null (→ full rebuild) on ANY uncertainty: no/stale
196
+ * dependency map, a route added/removed/renamed, a deleted changed file, a
197
+ * changed file no dependency graph accounts for, or a change to the site-wide
198
+ * feed link every page embeds. The global outputs (`routes.json`, `_headers`,
199
+ * robots/sitemap/rss, the dep map) are re-emitted from the merged manifest every
200
+ * time so a partial rebuild can never leave them inconsistent.
201
+ * @param {string} cwd
202
+ * @param {Paths} paths
203
+ * @param {{ target?: string, includeDrafts?: boolean, quietFeedHints?: boolean }} options
204
+ * @param {string[]} changed Project-relative POSIX paths of the changed files.
205
+ * @returns {BuildResult | null} null → caller runs the full build.
206
+ */
207
+ function buildIncremental(cwd, paths, options, changed) {
208
+ if (options.target) return fullFallback(changed, "targeted builds always run full");
209
+ const map = readDepMap(paths);
210
+ if (!map) return fullFallback(changed, "no dependency map from a previous dev build");
211
+
212
+ const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
213
+ const current = new Map(routes.map((route) => [route.route, relPosix(cwd, route.file)]));
214
+ const prevRoutes = Object.keys(map.routes);
215
+ if (
216
+ prevRoutes.length !== current.size ||
217
+ prevRoutes.some((key) => map.routes[key].file !== current.get(key))
218
+ ) {
219
+ return fullFallback(changed, "a route was added, removed, or renamed");
220
+ }
221
+
222
+ const collections = buildCollections(routes, paths);
223
+ const identity = siteIdentity(routes);
224
+ const feedLink = feedLinkFor(identity, routes);
225
+ // The feed <link> is embedded in EVERY page's head, so a change to it
226
+ // (site_url/title set or cleared, the first dated post appearing) fans out to
227
+ // the whole site — that is a full rebuild, not a partial one.
228
+ if (JSON.stringify(feedLink ?? null) !== JSON.stringify(map.feed ?? null)) {
229
+ return fullFallback(changed, "the site feed link changed");
230
+ }
231
+
232
+ /** @type {Set<string>} */
233
+ const affected = new Set();
234
+ for (const file of changed) {
235
+ if (!fs.existsSync(path.join(cwd, file))) {
236
+ return fullFallback(changed, `"${file}" was removed or renamed`);
237
+ }
238
+ const hits = routesAffectedBy(file, map.routes, collections);
239
+ if (!hits) return fullFallback(changed, `"${file}" is not in the dependency map`);
240
+ for (const routePath of hits) affected.add(routePath);
241
+ copyChangedAsset(file, cwd, paths);
242
+ }
243
+
244
+ const byRoute = new Map(routes.map((route) => [route.route, route]));
245
+ /** @type {Set<string>} */
246
+ const warned = new Set();
247
+ const rebuilt = [...affected].sort();
248
+ for (const routePath of rebuilt) {
249
+ const route = /** @type {Route} */ (byRoute.get(routePath));
250
+ const built = buildRoute(route, cwd, paths, collections, feedLink, warned);
251
+ // A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
252
+ // previously-emitted page this rebuild no longer produces.
253
+ const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
254
+ for (const page of map.routes[routePath].pages) {
255
+ if (!kept.has(page.route)) {
256
+ fs.rmSync(outputPathForRoute(paths.distRoot, page.route), { force: true });
257
+ }
258
+ }
259
+ if (built.notFoundHtml !== undefined) {
260
+ fs.writeFileSync(path.join(paths.distRoot, "404.html"), built.notFoundHtml);
261
+ }
262
+ map.routes[routePath] = built.mapEntry;
263
+ }
264
+
265
+ // Reassemble the GLOBAL outputs from the merged map (fresh entries for rebuilt
266
+ // routes, previous entries for untouched ones) in discovery order, so
267
+ // routes.json, _headers, and the feeds stay whole-site consistent.
268
+ /** @type {RouteManifestEntry[]} */
269
+ const manifest = [];
270
+ /** @type {Route[]} */
271
+ const extraRoutes = [];
272
+ for (const route of routes) {
273
+ for (const entry of map.routes[route.route].pages) {
274
+ manifest.push(entry);
275
+ if (entry.route !== route.route) extraRoutes.push({ ...route, route: entry.route });
276
+ }
277
+ }
278
+ emitGlobalFiles(manifest, paths);
279
+ const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
280
+ writeDepMap(paths, feedLink, map.routes);
281
+
282
+ return {
283
+ routes: manifest,
284
+ distRoot: paths.distRoot,
285
+ feeds,
286
+ incremental: { changed, rebuilt, total: routes.length }
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Compile one source route and emit its page(s) + assets. A normal route emits
292
+ * one page; a paginated listing emits page 1 at the route and `/page/<n>/` for
293
+ * the rest. Shared verbatim by the full and incremental builds so a partially
294
+ * rebuilt route is byte-identical to a fully rebuilt one.
295
+ * @param {Route} route
296
+ * @param {string} cwd
297
+ * @param {Paths} paths
298
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} collections
299
+ * @param {{ href: string, title: string } | undefined} feedLink
300
+ * @param {Set<string>} warned Cross-route dedupe set for emitted hints.
301
+ * @returns {{ mapEntry: DepMapEntry, notFoundHtml: string | undefined }}
302
+ */
303
+ function buildRoute(route, cwd, paths, collections, feedLink, warned) {
304
+ const compiled = compileRoutePages(route, paths, collections, feedLink);
305
+ /** @type {RouteManifestEntry[]} */
306
+ const entries = [];
307
+ /** @type {string | undefined} */
308
+ let notFoundHtml;
309
+ for (const page of compiled.pages) {
310
+ for (const warning of page.warnings || []) {
311
+ if (warned.has(warning)) continue;
312
+ warned.add(warning);
313
+ console.warn(`hint: ${warning}`);
314
+ }
315
+ const outFile = outputPathForRoute(paths.distRoot, page.route);
316
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
317
+ fs.writeFileSync(outFile, page.html);
318
+ if (page.route === "/404/") notFoundHtml = page.html;
319
+ emitAssets(page.assets, paths, page.html, warned);
320
+ if (page.assets.runtime) emitRuntime(paths);
321
+ emitBehaviors(page.assets, paths);
322
+ if (page.assets.hasCode) emitHighlight(paths);
323
+ const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
324
+ entries.push({
325
+ route: page.route,
326
+ file: relPosix(cwd, route.file),
327
+ assets: {
328
+ skins: [...page.assets.skins],
329
+ scripts: [
330
+ ...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
331
+ ...behaviorSrcs,
332
+ ...page.assets.scripts
333
+ ],
334
+ runtime: page.assets.runtime
335
+ }
336
+ });
337
+ }
338
+ return {
339
+ notFoundHtml,
340
+ mapEntry: {
341
+ file: relPosix(cwd, route.file),
342
+ deps: [...compiled.deps].map((dep) => relPosix(cwd, dep)).sort(),
343
+ collections: [...compiled.collections].sort(),
344
+ pages: entries
345
+ }
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Write the global manifest files derived from the (whole-site) route manifest:
351
+ * `routes.json` and the Cloudflare `_headers`. One emit path for full and
352
+ * incremental builds keeps them consistent by construction.
353
+ *
354
+ * Cloudflare Pages reads dist/_headers for security headers (Vercel reads
355
+ * vercel.json; the local server adds them in src/statics.js). Static routes get
356
+ * the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed one. A
357
+ * catch-all keeps assets and any future path covered.
358
+ * @param {RouteManifestEntry[]} manifest
359
+ * @param {Paths} paths
360
+ * @returns {void}
361
+ */
362
+ function emitGlobalFiles(manifest, paths) {
363
+ fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
364
+ fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
365
+ }
366
+
367
+ /**
368
+ * The site-wide RSS `<link rel="alternate">` every page embeds — present only
369
+ * when rss.xml will actually be emitted: the home set `site_url` AND there is at
370
+ * least one dated post. A stale `<link>` to a missing feed would 404 for
371
+ * aggregators.
372
+ * @param {{ siteUrl: string, title: string, description: string }} identity
373
+ * @param {Route[]} routes
374
+ * @returns {{ href: string, title: string } | undefined}
375
+ */
376
+ function feedLinkFor(identity, routes) {
377
+ return identity.siteUrl && hasDatedPost(routes)
378
+ ? { href: absoluteUrl(identity.siteUrl, "/rss.xml"), title: identity.title }
379
+ : undefined;
380
+ }
381
+
382
+ /**
383
+ * A project-relative, POSIX-separated path — the canonical file-path form in
384
+ * `routes.json` and the dev dependency map (and what the dev watcher reports).
385
+ * @param {string} cwd
386
+ * @param {string} file Absolute path.
387
+ * @returns {string}
388
+ */
389
+ function relPosix(cwd, file) {
390
+ return path.relative(cwd, file).replaceAll(path.sep, "/");
391
+ }
392
+
393
+ /**
394
+ * Read the previous dev build's dependency map, or null when it is absent,
395
+ * unreadable, or from an incompatible version — every one of which means the
396
+ * incremental build cannot trust its picture of the site and must go full.
397
+ * @param {Paths} paths
398
+ * @returns {{ version: number, feed: { href: string, title: string } | null, routes: Record<string, DepMapEntry> } | null}
399
+ */
400
+ function readDepMap(paths) {
401
+ try {
402
+ const map = JSON.parse(fs.readFileSync(path.join(paths.distRoot, DEP_MAP_FILE), "utf8"));
403
+ if (map && map.version === DEP_MAP_VERSION && map.routes) return map;
404
+ } catch {
405
+ /* absent or corrupt — full rebuild */
406
+ }
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * Persist the dependency map for the next incremental build.
412
+ * @param {Paths} paths
413
+ * @param {{ href: string, title: string } | undefined} feedLink
414
+ * @param {Record<string, DepMapEntry>} mapRoutes
415
+ * @returns {void}
416
+ */
417
+ function writeDepMap(paths, feedLink, mapRoutes) {
418
+ fs.writeFileSync(
419
+ path.join(paths.distRoot, DEP_MAP_FILE),
420
+ JSON.stringify({ version: DEP_MAP_VERSION, feed: feedLink ?? null, routes: mapRoutes })
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Log why an incremental build is falling back to a full rebuild, and return
426
+ * null so the caller runs one. The reason lands on stderr, which the dev server
427
+ * surfaces alongside the rebuild output.
428
+ * @param {string[]} changed
429
+ * @param {string} reason
430
+ * @returns {null}
431
+ */
432
+ function fullFallback(changed, reason) {
433
+ console.warn(`hint: full rebuild — ${reason} (changed: ${changed.join(", ")})`);
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * The source routes a changed file affects: every route whose dependency set
439
+ * contains it, plus — when the file is a page inside a collection directory
440
+ * (an entry or its `_schema.wd`) — every route that loops that collection.
441
+ * Returns null when NO dependency graph accounts for the file (a brand-new
442
+ * include, an asset whose consumers aren't tracked, …) — the uncertainty signal
443
+ * that forces a full rebuild.
444
+ * @param {string} file Project-relative POSIX path of the changed file.
445
+ * @param {Record<string, DepMapEntry>} mapRoutes
446
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} collections
447
+ * @returns {Set<string> | null}
448
+ */
449
+ function routesAffectedBy(file, mapRoutes, collections) {
450
+ /** @type {Set<string>} */
451
+ const affected = new Set();
452
+ let known = false;
453
+ for (const [routePath, entry] of Object.entries(mapRoutes)) {
454
+ if (entry.deps.includes(file)) {
455
+ affected.add(routePath);
456
+ known = true;
457
+ }
458
+ }
459
+ const collection = collectionMemberOf(file);
460
+ if (collection && collections.has(collection)) {
461
+ known = true;
462
+ for (const [routePath, entry] of Object.entries(mapRoutes)) {
463
+ if (entry.collections.includes(collection)) affected.add(routePath);
464
+ }
465
+ }
466
+ return known ? affected : null;
467
+ }
468
+
469
+ /**
470
+ * The collection a changed page file belongs to: `site/pages/blog/hello.md` →
471
+ * `"blog"`. Only `.md`/`.wd` files nested under a `site/pages/<name>/` directory
472
+ * qualify — any other file type there (an image a page might measure) is not a
473
+ * collection membership signal and must go through the deps/full-rebuild path.
474
+ * @param {string} file Project-relative POSIX path.
475
+ * @returns {string | null}
476
+ */
477
+ function collectionMemberOf(file) {
478
+ const prefix = `${routesDir}/`;
479
+ if (!file.startsWith(prefix)) return null;
480
+ if (![".md", ".wd"].includes(path.posix.extname(file))) return null;
481
+ const parts = file.slice(prefix.length).split("/");
482
+ return parts.length >= 2 ? parts[0] : null;
483
+ }
484
+
485
+ /**
486
+ * Re-copy a changed non-page asset to its published location during an
487
+ * incremental build (the full build's `emitShelfAssets`/`emitPageAssets` sweep
488
+ * doesn't run there). Only files a dependency graph already vouched for reach
489
+ * this point — in practice `@loop` JSON data files, published under
490
+ * `/__wd/data/` (shelf) or beside their page (site/pages). Page/skin/script
491
+ * sources have their own emit paths and hidden segments are never published, so
492
+ * both are skipped.
493
+ * @param {string} file Project-relative POSIX path of the changed file.
494
+ * @param {string} cwd
495
+ * @param {Paths} paths
496
+ * @returns {void}
497
+ */
498
+ function copyChangedAsset(file, cwd, paths) {
499
+ const ext = path.posix.extname(file).toLowerCase();
500
+ if ([".md", ".wd", ".skin", ".js"].includes(ext)) return;
501
+ /** @type {string} */
502
+ let out;
503
+ if (file.startsWith(`${shelfDir}/`)) {
504
+ const rel = file.slice(shelfDir.length + 1);
505
+ out = path.join(paths.distRoot, ext === ".json" ? "__wd/data" : "__wd/media", rel);
506
+ } else {
507
+ const rel = file.slice(routesDir.length + 1);
508
+ if (rel.split("/").some((segment) => /^[._-]/.test(segment))) return;
509
+ out = path.join(paths.distRoot, rel);
510
+ }
511
+ fs.mkdirSync(path.dirname(out), { recursive: true });
512
+ fs.copyFileSync(path.join(cwd, file), out);
513
+ }
514
+
169
515
  /**
170
516
  * A compiled page bound for a concrete output route. Mirrors `CompiledPage` plus
171
517
  * the public `route` it's written to — for a paginated listing, page 1 keeps the
@@ -178,9 +524,11 @@ export function buildSite(cwd = process.cwd(), options = {}) {
178
524
  */
179
525
 
180
526
  /**
181
- * Compile a route into the one-or-more static pages it emits. A normal route is
182
- * one page. A route whose body paginates a collection (`@loop … paginate N`) is
183
- * compiled once to discover the page count, then recompiled per page with the
527
+ * Compile a route into the one-or-more static pages it emits, plus the union of
528
+ * every source file the compiles read (`deps`) and every collection they looped
529
+ * (`collections`) — the raw material of the dev dependency map. A normal route
530
+ * is one page. A route whose body paginates a collection (`@loop … paginate N`)
531
+ * is compiled once to discover the page count, then recompiled per page with the
184
532
  * correct `page` pager seeded into scope — page 1 at the route, pages 2..total at
185
533
  * `/<route>/page/<n>/`. Every emitted page is static HTML (`runtime: false` for a
186
534
  * pure listing).
@@ -188,9 +536,18 @@ export function buildSite(cwd = process.cwd(), options = {}) {
188
536
  * @param {Paths} paths
189
537
  * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} collections
190
538
  * @param {{ href: string, title: string } | undefined} feedLink
191
- * @returns {EmittedPage[]}
539
+ * @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
192
540
  */
193
541
  function compileRoutePages(route, paths, collections, feedLink) {
542
+ /** @type {Set<string>} */
543
+ const deps = new Set();
544
+ /** @type {Set<string>} */
545
+ const used = new Set();
546
+ const track = (/** @type {import("./compiler.js").CompiledPage} */ page) => {
547
+ for (const dep of page.deps) deps.add(dep);
548
+ for (const name of page.collectionsUsed) used.add(name);
549
+ for (const source of page.assets.files.keys()) deps.add(source);
550
+ };
194
551
  // First compile (discovery): page 1 with a placeholder pager. If the page has
195
552
  // no `paginate`, this is the only compile and we emit it as-is.
196
553
  const first = compilePage(route.file, paths, {
@@ -198,10 +555,15 @@ function compileRoutePages(route, paths, collections, feedLink) {
198
555
  collections,
199
556
  vars: { page: pagerVars(route.route, 1, 1) }
200
557
  });
558
+ track(first);
201
559
  if (!first.pagination || first.pagination.total <= 1) {
202
- return [
203
- { route: route.route, html: first.html, assets: first.assets, warnings: first.warnings }
204
- ];
560
+ return {
561
+ pages: [
562
+ { route: route.route, html: first.html, assets: first.assets, warnings: first.warnings }
563
+ ],
564
+ deps,
565
+ collections: used
566
+ };
205
567
  }
206
568
  // Paginated: recompile every page (including page 1, whose pager `total`/`next`
207
569
  // were unknown on the discovery pass) with the correct pager.
@@ -214,6 +576,7 @@ function compileRoutePages(route, paths, collections, feedLink) {
214
576
  collections,
215
577
  vars: { page: pagerVars(route.route, n, total) }
216
578
  });
579
+ track(page);
217
580
  pages.push({
218
581
  route: pageRoute(route.route, n),
219
582
  html: page.html,
@@ -221,7 +584,7 @@ function compileRoutePages(route, paths, collections, feedLink) {
221
584
  warnings: page.warnings
222
585
  });
223
586
  }
224
- return pages;
587
+ return { pages, deps, collections: used };
225
588
  }
226
589
 
227
590
  /**
@@ -298,17 +661,33 @@ function hasDatedPost(routes) {
298
661
  * @param {Route[]} routes Emitted (post-draft-filter) routes.
299
662
  * @param {{ siteUrl: string, title: string, description: string }} identity
300
663
  * @param {Paths} paths
664
+ * @param {boolean} [quietHints] Suppress the site_url/placeholder feed hints — set
665
+ * on dev-server rebuilds so they print once per session (at the initial build)
666
+ * instead of on every incremental rebuild. A production `darkmown build` never
667
+ * sets it, so the hints always show there.
301
668
  * @returns {{ sitemap: number | null, rss: number | null }}
302
669
  */
303
- function emitFeeds(routes, identity, paths) {
670
+ function emitFeeds(routes, identity, paths, quietHints = false) {
304
671
  fs.writeFileSync(path.join(paths.distRoot, "robots.txt"), buildRobots(identity.siteUrl));
305
672
 
306
673
  if (!identity.siteUrl) {
674
+ if (!quietHints) {
675
+ console.warn(
676
+ "hint: set site_url in site/pages/index frontmatter to emit sitemap.xml + rss.xml " +
677
+ "(e.g. `site_url: https://example.com`). robots.txt was still written."
678
+ );
679
+ }
680
+ return { sitemap: null, rss: null };
681
+ }
682
+
683
+ // The blog template ships `site_url: https://example.com` as a placeholder — a
684
+ // real build against it would point every feed/sitemap URL at example.com.
685
+ // Flag it so the author swaps in their real origin before deploying.
686
+ if (identity.siteUrl === "https://example.com" && !quietHints) {
307
687
  console.warn(
308
- "hint: set site_url in site/pages/index frontmatter to emit sitemap.xml + rss.xml " +
309
- "(e.g. `site_url: https://example.com`). robots.txt was still written."
688
+ "hint: site_url is the placeholder https://example.com — sitemap.xml and rss.xml will " +
689
+ "point at example.com. Set it to your real origin before deploying."
310
690
  );
311
- return { sitemap: null, rss: null };
312
691
  }
313
692
 
314
693
  // The `/404/` route is never a feed entry — it's not an indexable page and