@zvndev/darkmown 2.10.0 → 2.11.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 (45) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +37 -3
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +20 -1
  6. package/docs/errors.md +255 -0
  7. package/docs/spec-alignment.md +14 -1
  8. package/package.json +3 -2
  9. package/site/pages/benchmarks/index.wd +1 -1
  10. package/site/pages/docs/index.wd +6 -1
  11. package/site/pages/index.wd +1 -0
  12. package/site/pages/playground/index.js +59 -54
  13. package/src/api-runner.js +80 -8
  14. package/src/builder.js +122 -13
  15. package/src/catalog.js +6 -0
  16. package/src/cli.js +48 -5
  17. package/src/compiler/page.js +16 -4
  18. package/src/compiler/state.js +75 -0
  19. package/src/compiler.js +1 -1
  20. package/src/errors.js +50 -3
  21. package/src/font-subset.js +25 -7
  22. package/src/image-variant-resize.js +4 -1
  23. package/src/image-variants.js +9 -4
  24. package/src/mcp-tools.js +354 -0
  25. package/src/mcp.js +287 -0
  26. package/src/router.js +16 -0
  27. package/src/skin.js +111 -45
  28. package/src/statics.js +1 -0
  29. package/src/templates/dashboard/README.md +3 -1
  30. package/src/templates/landing/site/pages/index.skin +7 -0
  31. package/src/templates/landing/site/pages/index.wd +10 -3
  32. package/src/templates/store/README.md +5 -2
  33. package/src/templates/store/site/_/card.wd +19 -0
  34. package/src/templates/store/site/_/products.json +6 -6
  35. package/src/templates/store/site/pages/index.skin +17 -2
  36. package/src/templates/store/site/pages/index.wd +3 -12
  37. package/types/api-runner.d.ts +20 -4
  38. package/types/builder.d.ts +5 -0
  39. package/types/compiler/page.d.ts +12 -3
  40. package/types/compiler.d.ts +4 -1
  41. package/types/errors.d.ts +6 -4
  42. package/types/font-subset.d.ts +7 -3
  43. package/types/image-variants.d.ts +7 -3
  44. package/types/router.d.ts +5 -0
  45. package/types/skin.d.ts +9 -4
package/src/api-runner.js CHANGED
@@ -78,13 +78,17 @@ function dynamicCount(segments) {
78
78
  /**
79
79
  * Match a request pathname against discovered routes. The pathname is expected
80
80
  * to start with `/api`; anything else returns `null` (caller serves static).
81
+ * A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
82
+ * `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
83
+ * caller answers a clean 400 instead of crashing the request handler.
81
84
  * @param {ApiRoute[]} routes
82
85
  * @param {string} pathname Request pathname (no query string).
83
- * @returns {{ file: string, params: Record<string, string> } | null}
86
+ * @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
84
87
  */
85
88
  export function matchApiRoute(routes, pathname) {
86
89
  if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
87
90
  const parts = pathname.split("/").filter(Boolean).slice(1); // drop leading "api"
91
+ let sawMalformed = false;
88
92
  for (const route of routes) {
89
93
  if (route.segments.length !== parts.length) continue;
90
94
  /** @type {Record<string, string>} */
@@ -93,7 +97,15 @@ export function matchApiRoute(routes, pathname) {
93
97
  for (let i = 0; i < parts.length; i++) {
94
98
  const seg = route.segments[i];
95
99
  if (seg.startsWith("[") && seg.endsWith("]")) {
96
- params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
100
+ try {
101
+ params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
102
+ } catch {
103
+ // Malformed here does not preclude a static match on a later route,
104
+ // so keep scanning and report only if nothing else claimed the path.
105
+ sawMalformed = true;
106
+ ok = false;
107
+ break;
108
+ }
97
109
  } else if (seg !== parts[i]) {
98
110
  ok = false;
99
111
  break;
@@ -101,13 +113,15 @@ export function matchApiRoute(routes, pathname) {
101
113
  }
102
114
  if (ok) return { file: route.file, params };
103
115
  }
104
- return null;
116
+ return sawMalformed ? { malformed: true } : null;
105
117
  }
106
118
 
107
119
  /**
108
120
  * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
109
- * methods that carry one) is buffered fully — dev endpoints are small and this
110
- * avoids the `duplex: "half"` streaming dance.
121
+ * methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
122
+ * preview endpoints are small and this avoids the `duplex: "half"` streaming
123
+ * dance. A larger body rejects with {@link BodyTooLargeError}, which
124
+ * `handleApiRequest` answers as a 413.
111
125
  * @param {import("node:http").IncomingMessage} req
112
126
  * @returns {Promise<Request>}
113
127
  */
@@ -125,6 +139,15 @@ export async function nodeRequestToWeb(req) {
125
139
  return new Request(url, init);
126
140
  }
127
141
 
142
+ /** Bodies larger than this are rejected with a 413 rather than buffered without bound. */
143
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
144
+
145
+ /**
146
+ * Rejection marker for an over-cap request body, so `handleApiRequest` answers
147
+ * 413 (a client error) instead of a generic 500.
148
+ */
149
+ class BodyTooLargeError extends Error {}
150
+
128
151
  /**
129
152
  * @param {import("node:http").IncomingMessage} req
130
153
  * @returns {Promise<Buffer>}
@@ -133,7 +156,22 @@ function readBody(req) {
133
156
  return new Promise((resolve, reject) => {
134
157
  /** @type {Buffer[]} */
135
158
  const chunks = [];
136
- req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
159
+ let received = 0;
160
+ let over = false;
161
+ req.on("data", (chunk) => {
162
+ received += chunk.length;
163
+ if (received > MAX_BODY_BYTES) {
164
+ // Stop accumulating, but keep draining the socket: destroying the
165
+ // request here would kill the connection before the 413 answer can
166
+ // reach the client (verified: `req.destroy()` mid-upload → EPIPE).
167
+ if (!over) {
168
+ over = true;
169
+ reject(new BodyTooLargeError(`Request body too large (over ${MAX_BODY_BYTES} bytes)`));
170
+ }
171
+ return;
172
+ }
173
+ chunks.push(Buffer.from(chunk));
174
+ });
137
175
  req.on("end", () => resolve(Buffer.concat(chunks)));
138
176
  req.on("error", reject);
139
177
  });
@@ -189,18 +227,41 @@ export async function writeWebResponse(response, res, acceptEncoding) {
189
227
  * the handler succeeded or threw), `false` when nothing matched so the caller
190
228
  * should fall through to static serving.
191
229
  *
230
+ * Error disclosure: a handler throw is answered as JSON either way, but the
231
+ * `error` field carries the real message only when `discloseErrors` is set
232
+ * (the dev server's default — a documented dev convenience). The preview
233
+ * server passes `false` and the client gets the generic "Internal error",
234
+ * matching the shape the generated Cloudflare worker ships.
235
+ *
192
236
  * @param {object} io
193
237
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
194
238
  * @param {import("node:http").IncomingMessage} io.req
195
239
  * @param {import("node:http").ServerResponse} io.res
196
240
  * @param {string | string[] | undefined} [io.acceptEncoding] The request's
197
241
  * Accept-Encoding header, when the caller negotiates compression (preview).
242
+ * @param {boolean} [io.discloseErrors] Echo the thrown message to the client
243
+ * (default true, the dev behavior). Pass false on any production-shaped path.
198
244
  * @returns {Promise<boolean>}
199
245
  */
200
- export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
246
+ export async function handleApiRequest({
247
+ apiDir,
248
+ req,
249
+ res,
250
+ acceptEncoding,
251
+ discloseErrors = true
252
+ }) {
201
253
  const pathname = new URL(req.url || "/", "http://localhost").pathname;
202
254
  const match = matchApiRoute(discoverApiRoutes(apiDir), pathname);
203
255
  if (!match) return false;
256
+ if ("malformed" in match) {
257
+ // A malformed percent-encoded dynamic segment: the URL itself is at fault,
258
+ // so this is a 400, not a 500 — and it must never reach the caller's catch.
259
+ await writeWebResponse(
260
+ Response.json({ ok: false, error: "Malformed URL encoding" }, { status: 400 }),
261
+ res
262
+ );
263
+ return true;
264
+ }
204
265
 
205
266
  try {
206
267
  const handler = await importHandler(match.file);
@@ -215,7 +276,18 @@ export async function handleApiRequest({ apiDir, req, res, acceptEncoding }) {
215
276
  }
216
277
  await writeWebResponse(response, res, acceptEncoding);
217
278
  } catch (err) {
218
- const message = err instanceof Error ? err.message : String(err);
279
+ if (err instanceof BodyTooLargeError) {
280
+ await writeWebResponse(
281
+ Response.json({ ok: false, error: "Request body too large" }, { status: 413 }),
282
+ res
283
+ );
284
+ return true;
285
+ }
286
+ const message = discloseErrors
287
+ ? err instanceof Error
288
+ ? err.message
289
+ : String(err)
290
+ : "Internal error";
219
291
  await writeWebResponse(Response.json({ ok: false, error: message }, { status: 500 }), res);
220
292
  }
221
293
  return true;
package/src/builder.js CHANGED
@@ -66,8 +66,9 @@ export const DEP_MAP_FILE = ".wd-dev-deps.json";
66
66
  // Bumped whenever the map's shape changes, so a stale map from an older
67
67
  // Darkmown falls back to a full rebuild instead of being misread. (3: manifest
68
68
  // entries gained `assets.embedHosts` — a v2 map's entries would silently drop
69
- // them from a reassembled `_headers`/`routes.json`.)
70
- const DEP_MAP_VERSION = 3;
69
+ // them from a reassembled `_headers`/`routes.json`. 4: the site record gained
70
+ // `mdSource` — a v3 map has no answer for the incremental md_source check.)
71
+ const DEP_MAP_VERSION = 4;
71
72
 
72
73
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
73
74
 
@@ -290,6 +291,16 @@ function buildIncremental(cwd, paths, options, changed) {
290
291
  "site_url changed, and every page's canonical URL is built from it"
291
292
  );
292
293
  }
294
+ // The home page's `md_source` flag is the same kind of whole-site value: it
295
+ // decides every page's markdown alternate link AND which `<route>.md` files
296
+ // dist holds, so flipping it can never be a partial rebuild (a full rebuild
297
+ // also wipes the now-unwanted source copies).
298
+ if (Boolean(map.site.mdSource) !== identity.mdSource) {
299
+ return fullFallback(
300
+ changed,
301
+ "md_source changed, and every page's markdown alternate link is built from it"
302
+ );
303
+ }
293
304
 
294
305
  /** @type {Set<string>} */
295
306
  const affected = new Set();
@@ -351,11 +362,13 @@ function buildIncremental(cwd, paths, options, changed) {
351
362
  images.variants
352
363
  );
353
364
  // A shrunk pagination leaves stale `/page/N/` outputs behind — remove any
354
- // previously-emitted page this rebuild no longer produces.
365
+ // previously-emitted page this rebuild no longer produces, along with its
366
+ // markdown-source copy when the site publishes those.
355
367
  const kept = new Set(built.mapEntry.pages.map((entry) => entry.route));
356
368
  for (const page of map.routes[routePath].pages) {
357
369
  if (!kept.has(page.route)) {
358
370
  fs.rmSync(outputPathForRoute(paths.distRoot, page.route), { force: true });
371
+ fs.rmSync(mdSourcePathForRoute(paths.distRoot, page.route), { force: true });
359
372
  }
360
373
  }
361
374
  if (built.notFoundHtml !== undefined) {
@@ -425,6 +438,9 @@ function buildRoute(
425
438
  titles,
426
439
  imageVariants
427
440
  ) {
441
+ // Markdown-source publishing (2.11): gated site-wide by the home page's
442
+ // `md_source: true`, opted out per page with `md_source: false`.
443
+ const mdSource = identity.mdSource && !mdSourceOptOut(route.meta);
428
444
  const compiled = compileRoutePages(
429
445
  route,
430
446
  paths,
@@ -432,7 +448,8 @@ function buildRoute(
432
448
  feedLink,
433
449
  identity,
434
450
  titles,
435
- imageVariants
451
+ imageVariants,
452
+ mdSource
436
453
  );
437
454
  /** @type {RouteManifestEntry[]} */
438
455
  const entries = [];
@@ -447,6 +464,7 @@ function buildRoute(
447
464
  const outFile = outputPathForRoute(paths.distRoot, page.route);
448
465
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
449
466
  fs.writeFileSync(outFile, page.html);
467
+ emitMdSource(route, page.route, paths, mdSource);
450
468
  if (page.route === "/404/") notFoundHtml = page.html;
451
469
  emitAssets(page.assets, paths, page.html, warned);
452
470
  if (page.assets.runtime) emitRuntime(paths);
@@ -624,7 +642,7 @@ function relPosix(cwd, file) {
624
642
  * unreadable, or from an incompatible version — every one of which means the
625
643
  * incremental build cannot trust its picture of the site and must go full.
626
644
  * @param {Paths} paths
627
- * @returns {{ version: number, feed: { href: string, title: string } | null, site: { url: string, titles: Record<string, string> }, routes: Record<string, DepMapEntry> } | null}
645
+ * @returns {{ version: number, feed: { href: string, title: string } | null, site: { url: string, titles: Record<string, string>, mdSource: boolean }, routes: Record<string, DepMapEntry> } | null}
628
646
  */
629
647
  function readDepMap(paths) {
630
648
  try {
@@ -640,7 +658,7 @@ function readDepMap(paths) {
640
658
  * Persist the dependency map for the next incremental build.
641
659
  * @param {Paths} paths
642
660
  * @param {{ href: string, title: string } | undefined} feedLink
643
- * @param {{ url: string, titles: Record<string, string> }} site {@link siteInputs}.
661
+ * @param {{ url: string, titles: Record<string, string>, mdSource: boolean }} site {@link siteInputs}.
644
662
  * @param {Record<string, DepMapEntry>} mapRoutes
645
663
  * @returns {void}
646
664
  */
@@ -657,15 +675,17 @@ function writeDepMap(paths, feedLink, site, mapRoutes) {
657
675
  * never appear in `routesAffectedBy`.
658
676
  *
659
677
  * `url` is `site_url` (every page's canonical + `og:url`); `titles` is every
660
- * route's title, because a nested page's breadcrumb JSON-LD names its ANCESTORS.
678
+ * route's title, because a nested page's breadcrumb JSON-LD names its ANCESTORS;
679
+ * `mdSource` is the home page's source-publishing flag, baked into every page's
680
+ * head as the `<link rel="alternate" type="text/markdown">`.
661
681
  * The next incremental build diffs both against the site it discovers, which is
662
682
  * the only way it can know that editing page A invalidated page B.
663
683
  * @param {SiteIdentity} identity
664
684
  * @param {Map<string, string>} titles
665
- * @returns {{ url: string, titles: Record<string, string> }}
685
+ * @returns {{ url: string, titles: Record<string, string>, mdSource: boolean }}
666
686
  */
667
687
  function siteInputs(identity, titles) {
668
- return { url: identity.siteUrl, titles: Object.fromEntries(titles) };
688
+ return { url: identity.siteUrl, titles: Object.fromEntries(titles), mdSource: identity.mdSource };
669
689
  }
670
690
 
671
691
  /**
@@ -792,9 +812,21 @@ function copyChangedAsset(file, cwd, paths) {
792
812
  * @param {Map<string, string>} titles Route path → page title, for breadcrumbs.
793
813
  * @param {Map<string, number[]>} imageVariants Generated responsive variants
794
814
  * (original's absolute source path → widths) for the srcset wiring.
815
+ * @param {boolean} mdSource Whether this route publishes its markdown source
816
+ * (site opted in AND the page did not opt out) — every emitted page then
817
+ * advertises `<route>.md` with a `<link rel="alternate" type="text/markdown">`.
795
818
  * @returns {{ pages: EmittedPage[], deps: Set<string>, collections: Set<string> }}
796
819
  */
797
- function compileRoutePages(route, paths, collections, feedLink, identity, titles, imageVariants) {
820
+ function compileRoutePages(
821
+ route,
822
+ paths,
823
+ collections,
824
+ feedLink,
825
+ identity,
826
+ titles,
827
+ imageVariants,
828
+ mdSource
829
+ ) {
798
830
  /** @type {Set<string>} */
799
831
  const deps = new Set();
800
832
  /** @type {Set<string>} */
@@ -813,10 +845,15 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
813
845
  route: routePath,
814
846
  breadcrumbs: breadcrumbTrail(routePath, titles, identity.siteUrl)
815
847
  });
848
+ // The markdown-source alternate every emitted page of this route advertises
849
+ // when publishing is on: `<route>.md`, where buildRoute copies the source.
850
+ const mdFor = (/** @type {string} */ routePath) =>
851
+ mdSource ? { href: mdSourceHrefFor(routePath) } : undefined;
816
852
  // First compile (discovery): page 1 with a placeholder pager. If the page has
817
853
  // no `paginate`, this is the only compile and we emit it as-is.
818
854
  const first = compilePage(route.file, paths, {
819
855
  feed: feedLink,
856
+ mdSource: mdFor(route.route),
820
857
  site: siteFor(route.route),
821
858
  collections,
822
859
  vars: { page: pagerVars(route.route, 1, 1) },
@@ -843,6 +880,7 @@ function compileRoutePages(route, paths, collections, feedLink, identity, titles
843
880
  // would ask Google to drop pages 2..N from the index entirely.
844
881
  const page = compilePage(route.file, paths, {
845
882
  feed: feedLink,
883
+ mdSource: mdFor(pageRoute(route.route, n)),
846
884
  site: siteFor(pageRoute(route.route, n)),
847
885
  collections,
848
886
  vars: { page: pagerVars(route.route, n, total) },
@@ -889,6 +927,69 @@ function pageRoute(baseRoute, n) {
889
927
  return n === 1 ? baseRoute : `${baseRoute}page/${n}/`;
890
928
  }
891
929
 
930
+ /**
931
+ * Whether a page opts OUT of markdown-source publishing (`md_source: false`).
932
+ * Mirrors the router's `isDraft`: the scalar parser yields a boolean or the
933
+ * string `"false"`, and only those opt out. Any other value — including a
934
+ * page-level `md_source: true` — is not an opt-out; publishing is gated
935
+ * site-wide by the HOME page's `md_source: true`, never per page.
936
+ * @param {Meta} meta
937
+ * @returns {boolean}
938
+ */
939
+ function mdSourceOptOut(meta) {
940
+ return meta.md_source === false || meta.md_source === "false";
941
+ }
942
+
943
+ /**
944
+ * The dist path a route's markdown source publishes to: `/` → `dist/index.md`,
945
+ * `/docs/` → `dist/docs.md`, `/blog/post/` → `dist/blog/post.md`. The file sits
946
+ * at the folder level next to the route's `<route>/index.html`, so by
947
+ * construction it can never collide with a route output — and being dist-side
948
+ * only, route discovery (which reads `site/pages`) never mistakes it for a page.
949
+ * @param {string} distRoot Absolute path to the build output directory.
950
+ * @param {string} route Public route path (trailing-slashed).
951
+ * @returns {string}
952
+ */
953
+ function mdSourcePathForRoute(distRoot, route) {
954
+ const clean = route === "/" ? "index" : route.replace(/^\/|\/$/g, "");
955
+ return path.join(distRoot, `${clean}.md`);
956
+ }
957
+
958
+ /**
959
+ * The public URL a route's markdown source serves at: `/` → `/index.md`,
960
+ * `/docs/` → `/docs.md`. Matches {@link mdSourcePathForRoute} exactly.
961
+ * @param {string} route Public route path (trailing-slashed).
962
+ * @returns {string}
963
+ */
964
+ function mdSourceHrefFor(route) {
965
+ return route === "/" ? "/index.md" : `${route.slice(0, -1)}.md`;
966
+ }
967
+
968
+ /**
969
+ * Publish (or withdraw) one route's markdown source beside its HTML output.
970
+ * The copy is BYTE-EXACT — `fs.copyFileSync`, no transformation, no header
971
+ * injection — so the file an agent fetches is the file the author wrote,
972
+ * frontmatter and line endings included. When publishing is off for the route
973
+ * (site flag unset, or the page opted out) any previously emitted copy is
974
+ * removed instead: an incremental rebuild does not wipe dist, so a stale file
975
+ * would otherwise keep serving after the opt-out.
976
+ * @param {Route} route The source route (its `file` is what gets copied).
977
+ * @param {string} routePath The emitted page's public route (a paginated page
978
+ * publishes the same source under its own `<route>.md`).
979
+ * @param {Paths} paths
980
+ * @param {boolean} enabled
981
+ * @returns {void}
982
+ */
983
+ function emitMdSource(route, routePath, paths, enabled) {
984
+ const out = mdSourcePathForRoute(paths.distRoot, routePath);
985
+ if (!enabled) {
986
+ fs.rmSync(out, { force: true });
987
+ return;
988
+ }
989
+ fs.mkdirSync(path.dirname(out), { recursive: true });
990
+ fs.copyFileSync(route.file, out);
991
+ }
992
+
892
993
  /**
893
994
  * Build the `{ page.* }` pager exposed to the template scope of a paginated
894
995
  * listing: `current`/`total` numbers and `prev`/`next` URLs (empty string at the
@@ -916,6 +1017,8 @@ function pagerVars(baseRoute, current, total) {
916
1017
  * @property {string} description Site description (also the RSS channel description).
917
1018
  * @property {string} aiCrawlers `"allow"` or `"deny"`, from `ai_crawlers:`.
918
1019
  * @property {number} rssLimit Most-recent-first RSS item cap, from `rss_limit:`.
1020
+ * @property {boolean} mdSource Publish every page's markdown source at
1021
+ * `<route>.md`, from the home page's `md_source: true` (2.11).
919
1022
  */
920
1023
 
921
1024
  /**
@@ -940,7 +1043,11 @@ function siteIdentity(routes) {
940
1043
  title: read("title") || "Darkmown",
941
1044
  description: read("description"),
942
1045
  aiCrawlers: aiCrawlerPolicy(meta, home ? home.file : undefined),
943
- rssLimit: rssItemLimit(meta, home ? home.file : undefined)
1046
+ rssLimit: rssItemLimit(meta, home ? home.file : undefined),
1047
+ // `md_source: true` opts the whole site into publishing sources; a page
1048
+ // opts back out with `md_source: false` (see mdSourceOptOut). Accepts the
1049
+ // boolean and the string, like `isDraft`/`isNoindex`.
1050
+ mdSource: meta.md_source === true || meta.md_source === "true"
944
1051
  };
945
1052
  }
946
1053
 
@@ -1464,15 +1571,17 @@ function emitAssets(assets, paths, pageHtml, warned) {
1464
1571
  fs.mkdirSync(path.dirname(out), { recursive: true });
1465
1572
  if (source.endsWith(".skin")) {
1466
1573
  const src = fs.readFileSync(source, "utf8");
1574
+ // `file` threads the source path into compileSkin so a skin authoring
1575
+ // error reports `file:line` like every other compile error.
1467
1576
  if (assets.scopedSkins.has(source)) {
1468
1577
  const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
1469
1578
  const scope = scopeIdFor(rel);
1470
1579
  /** @type {Set<string>} */
1471
1580
  const subjects = new Set();
1472
- fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
1581
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects, file: source }));
1473
1582
  warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
1474
1583
  } else {
1475
- fs.writeFileSync(out, compileSkin(src));
1584
+ fs.writeFileSync(out, compileSkin(src, { file: source }));
1476
1585
  }
1477
1586
  } else {
1478
1587
  fs.copyFileSync(source, out);
package/src/catalog.js CHANGED
@@ -581,6 +581,12 @@ const FRONTMATTER_KEYS = [
581
581
  description: "HOME PAGE ONLY: how many newest posts rss.xml carries (default 20).",
582
582
  example: "rss_limit: 50"
583
583
  },
584
+ {
585
+ name: "md_source",
586
+ description:
587
+ 'HOME PAGE ONLY: publish every page\'s markdown source at <route>.md, advertised with a <link rel="alternate" type="text/markdown"> in the head. A page opts out with md_source: false.',
588
+ example: "md_source: true"
589
+ },
584
590
  {
585
591
  name: "date",
586
592
  description: "Marks the page a post: RSS item, sitemap lastmod, og:type=article.",
package/src/cli.js CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  devEventsPath,
19
19
  injectDevClient
20
20
  } from "./dev.js";
21
+ import { createDispatcher, serveMcp } from "./mcp.js";
22
+ import { mcpConfig } from "./mcp-tools.js";
21
23
  import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
22
24
  import { availableTemplates, initProject } from "./scaffold.js";
23
25
  import { contentType, isServableFile, pipeFile, resolvePublicFile, serve } from "./statics.js";
@@ -109,6 +111,9 @@ export async function run(argv, env = {}) {
109
111
  const target = argv.slice(1).find((arg) => !arg.startsWith("-"));
110
112
  return runValidate({ cwd, target, log, error });
111
113
  }
114
+ if (command === "mcp") {
115
+ return runMcpCommand({ argv, error });
116
+ }
112
117
  if (command === "dev") {
113
118
  return startDevServer({ cwd, log, warn, error });
114
119
  }
@@ -301,6 +306,35 @@ function plural(n, word) {
301
306
  return n === 1 ? word : `${word}s`;
302
307
  }
303
308
 
309
+ /**
310
+ * `darkmown mcp`: run the Model Context Protocol server on stdio, so an agent
311
+ * (Claude Code, Codex, Cursor, …) can query the directive catalog, grammar,
312
+ * error codes, and docs, and validate `.wd` source, with zero extra
313
+ * dependencies. stdout carries ONLY protocol frames; the startup line and any
314
+ * diagnostics go to stderr (the `error` sink), because one stray byte on
315
+ * stdout corrupts the JSON-RPC stream. Resolves when the host closes stdin.
316
+ * @param {{ argv: string[], error: typeof console.error }} io
317
+ * @returns {Promise<{ command: "mcp" }>}
318
+ */
319
+ function runMcpCommand({ argv, error }) {
320
+ const extra = argv.slice(1);
321
+ if (extra.length > 0) {
322
+ error(`Unknown argument for mcp: ${extra[0]}`);
323
+ error("Usage: darkmown mcp (speaks MCP over stdio; takes no arguments)");
324
+ throw new CliError(`Unknown argument: ${extra[0]}`, { silent: true });
325
+ }
326
+ const config = mcpConfig();
327
+ error(
328
+ `Darkmown MCP server v${config.serverInfo.version} on stdio. ` +
329
+ "Point your agent's MCP config at this command; stop with Ctrl-C or by closing stdin."
330
+ );
331
+ return serveMcp({
332
+ input: process.stdin,
333
+ output: process.stdout,
334
+ dispatcher: createDispatcher(config)
335
+ });
336
+ }
337
+
304
338
  /**
305
339
  * Start the live-compiler dev server. Builds once (recording, not crashing on, a
306
340
  * broken initial build), watches `site/` and `src/`, and serves dist with the
@@ -413,7 +447,10 @@ function startDevServer({ cwd, log, warn, error }) {
413
447
  }
414
448
  // Serverless parity: run a project `api/*.js` function if the path matches,
415
449
  // exactly as Vercel/Cloudflare would in production; otherwise serve static. A
416
- // throw on either path (e.g. a stat/read error) becomes a clean 500.
450
+ // throw on either path (e.g. a stat/read error) becomes a clean 500. Dev
451
+ // DELIBERATELY echoes the stack and the api runner keeps real error messages
452
+ // (the `discloseErrors` default) — this is the author's own machine; `serve`
453
+ // is the production-shaped path and discloses nothing.
417
454
  handleApiRequest({ apiDir, req, res })
418
455
  .then((handled) => {
419
456
  if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd), () => lastBuildError);
@@ -473,15 +510,20 @@ function startPreviewServer({ cwd, log, error }) {
473
510
  const server = http.createServer((req, res) => {
474
511
  const acceptEncoding = req.headers["accept-encoding"];
475
512
  // Serverless parity, exactly as in dev: run a project api/*.js function when
476
- // the path matches; otherwise serve static (compressed when negotiated). A
477
- // throw on either path (e.g. a malformed dynamic segment) becomes a clean 500.
478
- handleApiRequest({ apiDir, req, res, acceptEncoding })
513
+ // the path matches; otherwise serve static (compressed when negotiated).
514
+ // Unlike dev, serve mirrors PRODUCTION disclosure rules: the api runner gets
515
+ // `discloseErrors: false` (the client sees "Internal error", same shape as
516
+ // the generated Cloudflare worker), a malformed dynamic segment is a clean
517
+ // 400 inside the runner, and an unexpected throw here is logged server-side
518
+ // but never echoed — a stack trace leaks paths and internals to the client.
519
+ handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors: false })
479
520
  .then((handled) => {
480
521
  if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
481
522
  })
482
523
  .catch((err) => {
524
+ error(err instanceof Error && err.stack ? err.stack : String(err));
483
525
  res.writeHead(500, { "content-type": "text/plain" });
484
- res.end((err instanceof Error && err.stack) || String(err));
526
+ res.end("Internal Server Error");
485
527
  });
486
528
  });
487
529
  return new Promise((resolve) => {
@@ -580,6 +622,7 @@ Usage:
580
622
  darkmown validate [path] Compile-check pages without building (exit 1 on errors)
581
623
  darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
582
624
  darkmown serve Preview the built dist locally
625
+ darkmown mcp Run the MCP server on stdio (catalog, grammar, validate, error codes, docs)
583
626
  darkmown catalog [--llms|--llms-full] Print the .wd directive catalog (JSON, --llms cheatsheet, or --llms-full reference)
584
627
  darkmown version Print the installed Darkmown version
585
628
  darkmown help Show this help
@@ -46,10 +46,13 @@ const SKIP_LINK_STYLE =
46
46
  * Compile a page source file into a full HTML document plus its assets.
47
47
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
48
48
  * @param {Paths} context Resolved project paths.
49
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
49
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
50
50
  * When a site-wide RSS feed is emitted (the home page set `site_url` and the
51
51
  * site has dated posts), `feed` carries its absolute href + title so every page
52
- * links it. `site` carries the origin + this page's own route (and its resolved
52
+ * links it. When the site publishes markdown sources (the home page set
53
+ * `md_source: true` and this page did not opt out), `mdSource` carries the
54
+ * page's own `<route>.md` href for the alternate link. `site` carries the
55
+ * origin + this page's own route (and its resolved
53
56
  * breadcrumb trail) so the shell can state a canonical URL. `collections` is the
54
57
  * build-time collection index a bare-name `@loop` resolves against; `vars` seeds
55
58
  * the document scope (the pager `page` object on a paginated route). `reader` is
@@ -154,6 +157,14 @@ export function compilePage(file, context, options = {}) {
154
157
  const feedLink = options.feed
155
158
  ? `\n <link rel="alternate" type="application/rss+xml" title="${escapeHtml(options.feed.title)}" href="${escapeHtml(options.feed.href)}">`
156
159
  : "";
160
+ // Markdown-source discovery (2.11): when the site publishes its sources (home
161
+ // `md_source: true`, and this page did not opt out), the builder copies the
162
+ // page's source to `<route>.md` and the page advertises it here, so an agent
163
+ // can autodiscover and fetch the plain markdown instead of the HTML.
164
+ // Build-time only — no client JS.
165
+ const mdSourceLink = options.mdSource
166
+ ? `\n <link rel="alternate" type="text/markdown" href="${escapeHtml(options.mdSource.href)}" title="Markdown source">`
167
+ : "";
157
168
  // Pay-for-what-you-use behavior modules (slider is compile-time and never here;
158
169
  // sortable/carousel each emit one) load after the runtime — sortable depends on
159
170
  // `window.wd` — and before any colocated page script.
@@ -239,7 +250,7 @@ export function compilePage(file, context, options = {}) {
239
250
  <meta charset="utf-8">
240
251
  <meta name="viewport" content="width=device-width, initial-scale=1">
241
252
  <title>${escapeHtml(title)}</title>${descriptionTag}
242
- <link rel="icon" href="${icon.href}"${icon.type ? ` type="${icon.type}"` : ""}>${feedLink}
253
+ <link rel="icon" href="${icon.href}"${icon.type ? ` type="${icon.type}"` : ""}>${feedLink}${mdSourceLink}
243
254
  ${SKIP_LINK_STYLE}
244
255
  ${cssLinks}${transitions}${speculation}
245
256
  </head>
@@ -735,7 +746,7 @@ function foldBuildBindingsLine(line, values) {
735
746
  * @param {Record<string, string> | Map<string, string>} files Project-relative
736
747
  * path → source content.
737
748
  * @param {string} entryPath Project-relative path of the page to compile.
738
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
749
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
739
750
  * Same shell options as {@link compilePage}; `cwd` overrides the virtual
740
751
  * project root the relative keys resolve against (defaults to `/`).
741
752
  * @returns {CompiledPage}
@@ -747,6 +758,7 @@ export function compileFromMemory(files, entryPath, options = {}) {
747
758
  const abs = path.resolve(cwd, entryPath);
748
759
  return compilePage(abs, context, {
749
760
  feed: options.feed,
761
+ mdSource: options.mdSource,
750
762
  site: options.site,
751
763
  collections: options.collections,
752
764
  vars: options.vars,