@zvndev/darkmown 1.4.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 (106) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +114 -17
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +31 -4
  7. package/package.json +1 -1
  8. package/site/_/base.skin +239 -18
  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 +5 -1
  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.skin +27 -5
  17. package/site/pages/app.wd +1 -0
  18. package/site/pages/blog/_schema.wd +15 -0
  19. package/site/pages/blog/collections-and-pagination.md +38 -0
  20. package/site/pages/blog/collections-and-pagination.skin +127 -0
  21. package/site/pages/blog/drafts-that-never-leak.md +2 -0
  22. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  23. package/site/pages/blog/index.skin +144 -0
  24. package/site/pages/blog/index.wd +74 -0
  25. package/site/pages/blog/shipping-feeds.md +1 -0
  26. package/site/pages/blog/shipping-feeds.skin +127 -0
  27. package/site/pages/blog/typed-frontmatter.md +45 -0
  28. package/site/pages/blog/typed-frontmatter.skin +127 -0
  29. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  30. package/site/pages/blog/zero-js-by-default.md +31 -0
  31. package/site/pages/blog/zero-js-by-default.skin +127 -0
  32. package/site/pages/carousel/index.wd +1 -0
  33. package/site/pages/compass/index.wd +1 -0
  34. package/site/pages/data.skin +50 -9
  35. package/site/pages/data.wd +3 -0
  36. package/site/pages/docs/-relative-note.wd +4 -0
  37. package/site/pages/docs/index.skin +27 -0
  38. package/site/pages/docs/index.wd +115 -11
  39. package/site/pages/folio/-style.wd +4 -0
  40. package/site/pages/folio/cart.wd +1 -0
  41. package/site/pages/folio/index.wd +1 -0
  42. package/site/pages/forge/index.wd +1 -0
  43. package/site/pages/highlight/index.wd +1 -0
  44. package/site/pages/index.wd +1 -0
  45. package/site/pages/interactions/index.wd +1 -0
  46. package/site/pages/ledger/index.wd +1 -0
  47. package/site/pages/loops.skin +43 -6
  48. package/site/pages/loops.wd +1 -0
  49. package/site/pages/media/index.wd +1 -0
  50. package/site/pages/pulse/index.wd +1 -0
  51. package/site/pages/reactive.skin +38 -6
  52. package/site/pages/reactive.wd +1 -0
  53. package/site/pages/scoped/index.wd +1 -0
  54. package/site/pages/showcase/index.wd +1 -0
  55. package/site/pages/store.skin +43 -6
  56. package/site/pages/store.wd +1 -0
  57. package/site/pages/theme.wd +1 -0
  58. package/src/builder.js +535 -48
  59. package/src/cli.js +96 -18
  60. package/src/compiler/actions.js +303 -0
  61. package/src/compiler/body.js +36 -24
  62. package/src/compiler/collections.js +316 -0
  63. package/src/compiler/context.js +83 -6
  64. package/src/compiler/directives.js +31 -1470
  65. package/src/compiler/fetch.js +159 -0
  66. package/src/compiler/format.js +17 -4
  67. package/src/compiler/forms.js +456 -0
  68. package/src/compiler/frontmatter.js +10 -5
  69. package/src/compiler/includes.js +66 -1
  70. package/src/compiler/index.js +15 -3
  71. package/src/compiler/loops.js +181 -31
  72. package/src/compiler/markdown.js +65 -15
  73. package/src/compiler/media.js +133 -0
  74. package/src/compiler/page.js +78 -41
  75. package/src/compiler/state.js +168 -0
  76. package/src/compiler/structure.js +377 -0
  77. package/src/dev.js +76 -0
  78. package/src/headers.js +56 -17
  79. package/src/router.js +12 -1
  80. package/src/runtime.js +5 -3
  81. package/src/statics.js +63 -12
  82. package/src/templates/blog/README.md +13 -4
  83. package/src/templates/blog/site/_/nav.wd +4 -0
  84. package/src/templates/blog/site/pages/index.skin +87 -0
  85. package/src/templates/blog/site/pages/index.wd +10 -4
  86. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  87. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  88. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  89. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  90. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  91. package/src/templates/dashboard/site/_/nav.wd +4 -0
  92. package/src/templates/dashboard/site/pages/index.skin +84 -0
  93. package/src/templates/dashboard/site/pages/index.wd +1 -0
  94. package/src/templates/landing/site/_/nav.wd +4 -0
  95. package/src/templates/landing/site/pages/index.skin +113 -6
  96. package/src/templates/landing/site/pages/index.wd +1 -0
  97. package/src/templates/starter/site/_/nav.wd +4 -0
  98. package/src/templates/starter/site/pages/about.md +18 -1
  99. package/src/templates/starter/site/pages/about.skin +69 -0
  100. package/src/templates/starter/site/pages/index.skin +81 -3
  101. package/src/templates/starter/site/pages/index.wd +1 -0
  102. package/src/templates/store/site/_/nav.wd +4 -0
  103. package/src/templates/store/site/pages/index.skin +93 -11
  104. package/src/templates/store/site/pages/index.wd +1 -0
  105. package/site/pages/blog/index.md +0 -13
  106. package/src/templates/blog/site/_/posts.json +0 -16
package/src/builder.js CHANGED
@@ -2,8 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { discoverApiRoutes } from "./api-runner.js";
5
+ import { buildCollections } from "./compiler/collections.js";
5
6
  import { compilePage, parseFrontmatter } from "./compiler.js";
6
- import { createPaths } from "./config.js";
7
+ import { createPaths, routesDir, shelfDir } from "./config.js";
7
8
  import {
8
9
  absoluteUrl,
9
10
  buildRobots,
@@ -34,6 +35,29 @@ import { compileSkin, scopeIdFor } from "./skin.js";
34
35
  * @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
35
36
  */
36
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
+
37
61
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
38
62
 
39
63
  /**
@@ -47,30 +71,63 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
47
71
  * `/api/*` to those same functions and serves everything else as a static asset.
48
72
  *
49
73
  * @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
50
- * @param {{ target?: string, includeDrafts?: boolean }} [options] `target:
51
- * "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps `draft:
52
- * true` pages in the build + feeds (dev / `build --drafts`); default excludes
53
- * them at route discovery so feeds never see a draft.
54
- * @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}
55
83
  */
56
84
  export function buildSite(cwd = process.cwd(), options = {}) {
57
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) {
58
115
  fs.rmSync(paths.distRoot, { recursive: true, force: true });
59
116
  fs.mkdirSync(paths.distRoot, { recursive: true });
60
117
  emitShelfAssets(paths);
61
118
  const routes = discoverRoutes(paths.routesRoot, { includeDrafts: options.includeDrafts });
119
+ // Build the collection index from the (post-draft-filter) routes — every
120
+ // site/pages/<name>/ subdirectory's entries become queryable rows a bare-name
121
+ // `@loop blog into post` resolves against. Drafts are already excluded, so a
122
+ // draft entry can never appear in a listing. Validates each collection's
123
+ // `_schema.wd` here (throwing on a bad entry with file:line).
124
+ const collections = buildCollections(routes, paths);
62
125
  // Site identity lives in the HOME page frontmatter (no config loader): the
63
126
  // `site_url` origin triggers + prefixes the feeds, reusing `title`/`description`
64
127
  // as the RSS channel fields. Resolved once, up front, so every page can link
65
128
  // the feed during the loop.
66
129
  const identity = siteIdentity(routes);
67
- // Advertise the feed in every page's <head> only when rss.xml will actually be
68
- // emitted: the home set `site_url` AND there is at least one dated post. A
69
- // stale `<link>` to a missing feed would 404 for aggregators.
70
- const feedLink =
71
- identity.siteUrl && hasDatedPost(routes)
72
- ? { href: absoluteUrl(identity.siteUrl, "/rss.xml"), title: identity.title }
73
- : undefined;
130
+ const feedLink = feedLinkFor(identity, routes);
74
131
  /** @type {RouteManifestEntry[]} */
75
132
  const manifest = [];
76
133
 
@@ -80,25 +137,193 @@ export function buildSite(cwd = process.cwd(), options = {}) {
80
137
  /** @type {string | undefined} HTML of the `/404/` route, copied to dist/404.html. */
81
138
  let notFoundHtml;
82
139
 
140
+ // Extra (paginated 2+) routes the build generates beyond the discovered set —
141
+ // each is indexable static HTML, so it belongs in the sitemap (it has no own
142
+ // frontmatter `date:`; it reuses its listing source file for `<lastmod>`).
143
+ /** @type {Route[]} */
144
+ const extraRoutes = [];
145
+
146
+ /** @type {Record<string, DepMapEntry>} */
147
+ const mapRoutes = {};
148
+
149
+ for (const route of routes) {
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 });
155
+ }
156
+ mapRoutes[route.route] = built.mapEntry;
157
+ }
158
+
159
+ // Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
160
+ // dist/404.html for misses. Prefer the compiled 404 route; fall back to a
161
+ // minimal page so a 404 is always available.
162
+ fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
163
+
164
+ emitGlobalFiles(manifest, paths);
165
+
166
+ // Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
167
+ // need the home `site_url` (else a loud, actionable build hint). Drafts are
168
+ // already filtered out of `routes`, so neither feed can ever see one. The
169
+ // paginated 2+ routes are indexable HTML too, so they join the sitemap (RSS is
170
+ // dated-posts-only, which they are not, so it's unaffected).
171
+ const feeds = emitFeeds([...routes, ...extraRoutes], identity, paths, options.quietFeedHints);
172
+
173
+ // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
174
+ // sees every emitted route/framework file already on disk.
175
+ emitPageAssets(paths);
176
+
177
+ // Surface api/ routing footguns on every build (target-independent): two files
178
+ // that resolve to the same /api path mean one is silently unreachable.
179
+ const apiDir = path.join(cwd, "api");
180
+ const apiRoutes = discoverApiRoutes(apiDir);
181
+ warnApiRouteCollisions(apiRoutes, apiDir);
182
+
183
+ if (options.target === "cloudflare") {
184
+ emitCloudflareWorker(paths, apiDir, apiRoutes);
185
+ }
186
+
187
+ if (options.depMap) writeDepMap(paths, feedLink, mapRoutes);
188
+
189
+ return { routes: manifest, distRoot: paths.distRoot, feeds };
190
+ }
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 = [];
83
272
  for (const route of routes) {
84
- const page = compilePage(route.file, paths, { feed: feedLink });
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) {
85
310
  for (const warning of page.warnings || []) {
86
311
  if (warned.has(warning)) continue;
87
312
  warned.add(warning);
88
313
  console.warn(`hint: ${warning}`);
89
314
  }
90
- const outFile = outputPathForRoute(paths.distRoot, route.route);
315
+ const outFile = outputPathForRoute(paths.distRoot, page.route);
91
316
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
92
317
  fs.writeFileSync(outFile, page.html);
93
- if (route.route === "/404/") notFoundHtml = page.html;
318
+ if (page.route === "/404/") notFoundHtml = page.html;
94
319
  emitAssets(page.assets, paths, page.html, warned);
95
320
  if (page.assets.runtime) emitRuntime(paths);
96
321
  emitBehaviors(page.assets, paths);
97
322
  if (page.assets.hasCode) emitHighlight(paths);
98
323
  const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
99
- manifest.push({
100
- route: route.route,
101
- file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
324
+ entries.push({
325
+ route: page.route,
326
+ file: relPosix(cwd, route.file),
102
327
  assets: {
103
328
  skins: [...page.assets.skins],
104
329
  scripts: [
@@ -110,40 +335,286 @@ export function buildSite(cwd = process.cwd(), options = {}) {
110
335
  }
111
336
  });
112
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
+ }
113
348
 
114
- // Hosts (Vercel, Cloudflare Pages) and the framework's own server auto-serve
115
- // dist/404.html for misses. Prefer the compiled 404 route; fall back to a
116
- // minimal page so a 404 is always available.
117
- fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
118
-
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) {
119
363
  fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
120
-
121
- // Cloudflare Pages reads dist/_headers for security headers (Vercel reads
122
- // vercel.json; the local server adds them in src/statics.js). Static routes
123
- // get the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed
124
- // one. A catch-all keeps assets and any future path covered.
125
364
  fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
365
+ }
126
366
 
127
- // Crawler files + feeds. robots.txt is ALWAYS emitted; sitemap.xml + rss.xml
128
- // need the home `site_url` (else a loud, actionable build hint). Drafts are
129
- // already filtered out of `routes`, so neither feed can ever see one.
130
- const feeds = emitFeeds(routes, identity, paths);
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
+ }
131
381
 
132
- // Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
133
- // sees every emitted route/framework file already on disk.
134
- emitPageAssets(paths);
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
+ }
135
392
 
136
- // Surface api/ routing footguns on every build (target-independent): two files
137
- // that resolve to the same /api path mean one is silently unreachable.
138
- const apiDir = path.join(cwd, "api");
139
- const apiRoutes = discoverApiRoutes(apiDir);
140
- warnApiRouteCollisions(apiRoutes, apiDir);
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
+ }
141
409
 
142
- if (options.target === "cloudflare") {
143
- emitCloudflareWorker(paths, apiDir, apiRoutes);
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
+ }
144
465
  }
466
+ return known ? affected : null;
467
+ }
145
468
 
146
- return { routes: manifest, distRoot: paths.distRoot, feeds };
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
+
515
+ /**
516
+ * A compiled page bound for a concrete output route. Mirrors `CompiledPage` plus
517
+ * the public `route` it's written to — for a paginated listing, page 1 keeps the
518
+ * listing route and pages 2+ get `/<route>/page/<n>/`.
519
+ * @typedef {object} EmittedPage
520
+ * @property {string} route Public route path this page is written to.
521
+ * @property {string} html
522
+ * @property {import("./compiler.js").Assets} assets
523
+ * @property {string[]} warnings
524
+ */
525
+
526
+ /**
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
532
+ * correct `page` pager seeded into scope — page 1 at the route, pages 2..total at
533
+ * `/<route>/page/<n>/`. Every emitted page is static HTML (`runtime: false` for a
534
+ * pure listing).
535
+ * @param {Route} route
536
+ * @param {Paths} paths
537
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} collections
538
+ * @param {{ href: string, title: string } | undefined} feedLink
539
+ * @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
540
+ */
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
+ };
551
+ // First compile (discovery): page 1 with a placeholder pager. If the page has
552
+ // no `paginate`, this is the only compile and we emit it as-is.
553
+ const first = compilePage(route.file, paths, {
554
+ feed: feedLink,
555
+ collections,
556
+ vars: { page: pagerVars(route.route, 1, 1) }
557
+ });
558
+ track(first);
559
+ if (!first.pagination || first.pagination.total <= 1) {
560
+ return {
561
+ pages: [
562
+ { route: route.route, html: first.html, assets: first.assets, warnings: first.warnings }
563
+ ],
564
+ deps,
565
+ collections: used
566
+ };
567
+ }
568
+ // Paginated: recompile every page (including page 1, whose pager `total`/`next`
569
+ // were unknown on the discovery pass) with the correct pager.
570
+ const total = first.pagination.total;
571
+ /** @type {EmittedPage[]} */
572
+ const pages = [];
573
+ for (let n = 1; n <= total; n++) {
574
+ const page = compilePage(route.file, paths, {
575
+ feed: feedLink,
576
+ collections,
577
+ vars: { page: pagerVars(route.route, n, total) }
578
+ });
579
+ track(page);
580
+ pages.push({
581
+ route: pageRoute(route.route, n),
582
+ html: page.html,
583
+ assets: page.assets,
584
+ warnings: page.warnings
585
+ });
586
+ }
587
+ return { pages, deps, collections: used };
588
+ }
589
+
590
+ /**
591
+ * The output route for page `n` of a paginated listing: page 1 keeps the
592
+ * listing's own clean route; pages 2+ live at `/<route>/page/<n>/`.
593
+ * @param {string} baseRoute Listing route (trailing-slashed).
594
+ * @param {number} n 1-based page number.
595
+ * @returns {string}
596
+ */
597
+ function pageRoute(baseRoute, n) {
598
+ return n === 1 ? baseRoute : `${baseRoute}page/${n}/`;
599
+ }
600
+
601
+ /**
602
+ * Build the `{ page.* }` pager exposed to the template scope of a paginated
603
+ * listing: `current`/`total` numbers and `prev`/`next` URLs (empty string at the
604
+ * ends, so `{ page.prev }` renders nothing on page 1). Plain links to the
605
+ * generated routes — zero JS.
606
+ * @param {string} baseRoute Listing route.
607
+ * @param {number} current 1-based current page.
608
+ * @param {number} total Total page count.
609
+ * @returns {{ current: number, total: number, prev: string, next: string }}
610
+ */
611
+ function pagerVars(baseRoute, current, total) {
612
+ return {
613
+ current,
614
+ total,
615
+ prev: current > 1 ? pageRoute(baseRoute, current - 1) : "",
616
+ next: current < total ? pageRoute(baseRoute, current + 1) : ""
617
+ };
147
618
  }
148
619
 
149
620
  /**
@@ -190,17 +661,33 @@ function hasDatedPost(routes) {
190
661
  * @param {Route[]} routes Emitted (post-draft-filter) routes.
191
662
  * @param {{ siteUrl: string, title: string, description: string }} identity
192
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.
193
668
  * @returns {{ sitemap: number | null, rss: number | null }}
194
669
  */
195
- function emitFeeds(routes, identity, paths) {
670
+ function emitFeeds(routes, identity, paths, quietHints = false) {
196
671
  fs.writeFileSync(path.join(paths.distRoot, "robots.txt"), buildRobots(identity.siteUrl));
197
672
 
198
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) {
199
687
  console.warn(
200
- "hint: set site_url in site/pages/index frontmatter to emit sitemap.xml + rss.xml " +
201
- "(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."
202
690
  );
203
- return { sitemap: null, rss: null };
204
691
  }
205
692
 
206
693
  // The `/404/` route is never a feed entry — it's not an indexable page and