@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.
- package/AGENTS.md +17 -3
- package/CHANGELOG.md +38 -0
- package/README.md +114 -17
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +31 -4
- package/package.json +1 -1
- package/site/_/base.skin +239 -18
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +5 -1
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.skin +27 -5
- package/site/pages/app.wd +1 -0
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +74 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +115 -11
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.skin +38 -6
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +1 -0
- package/site/pages/store.skin +43 -6
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +535 -48
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +83 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +66 -1
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +181 -31
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +78 -41
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +377 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +4 -0
- package/src/templates/blog/site/pages/index.skin +87 -0
- package/src/templates/blog/site/pages/index.wd +10 -4
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +4 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/landing/site/pages/index.wd +1 -0
- package/src/templates/starter/site/_/nav.wd +4 -0
- package/src/templates/starter/site/pages/about.md +18 -1
- package/src/templates/starter/site/pages/about.skin +69 -0
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/starter/site/pages/index.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.skin +93 -11
- package/src/templates/store/site/pages/index.wd +1 -0
- package/site/pages/blog/index.md +0 -13
- 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]
|
|
51
|
-
* "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps
|
|
52
|
-
* true` pages in the build + feeds (dev / `build --drafts`); default
|
|
53
|
-
* them at route discovery so feeds never see a draft.
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
100
|
-
route:
|
|
101
|
-
file:
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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:
|
|
201
|
-
"
|
|
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
|