@zvndev/darkmown 1.5.0 → 2.0.1
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 +18 -4
- package/CHANGELOG.md +40 -0
- package/README.md +49 -19
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +21 -7
- package/package.json +3 -2
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +5 -1
- package/site/_/nav.wd +4 -0
- 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.wd +2 -1
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -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 +1 -1
- 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.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 +85 -14
- 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.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +6 -3
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- 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.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +2 -1
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +65 -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 +75 -4
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +85 -23
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +88 -77
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +399 -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 +7 -6
- package/src/templates/blog/site/pages/index.skin +10 -4
- package/src/templates/blog/site/pages/index.wd +11 -6
- 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 +7 -6
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -2
- package/src/templates/landing/site/_/nav.wd +7 -6
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -8
- package/src/templates/starter/site/_/nav.wd +8 -7
- package/src/templates/starter/site/pages/about.md +8 -2
- package/src/templates/starter/site/pages/about.skin +44 -0
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -2
- package/src/templates/store/site/_/nav.wd +7 -6
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -2
- 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]
|
|
52
|
-
* "cloudflare"` emits the Pages worker. `includeDrafts: true` keeps
|
|
53
|
-
* true` pages in the build + feeds (dev / `build --drafts`); default
|
|
54
|
-
* them at route discovery so feeds never see a draft.
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
for (const
|
|
101
|
-
if (
|
|
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
|
-
|
|
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
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
-
|
|
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:
|
|
309
|
-
"
|
|
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
|