@zvndev/darkmown 2.10.1 → 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.
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
 
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
@@ -588,6 +622,7 @@ Usage:
588
622
  darkmown validate [path] Compile-check pages without building (exit 1 on errors)
589
623
  darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
590
624
  darkmown serve Preview the built dist locally
625
+ darkmown mcp Run the MCP server on stdio (catalog, grammar, validate, error codes, docs)
591
626
  darkmown catalog [--llms|--llms-full] Print the .wd directive catalog (JSON, --llms cheatsheet, or --llms-full reference)
592
627
  darkmown version Print the installed Darkmown version
593
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,
package/src/compiler.js CHANGED
@@ -43,7 +43,7 @@ export {
43
43
  * a different source (see `compileFromMemory`).
44
44
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
45
45
  * @param {import("./config.js").Paths} context Resolved project paths.
46
- * @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
46
+ * @param {{ feed?: { href: string, title: string }, mdSource?: { href: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
47
47
  * @returns {CompiledPage}
48
48
  */
49
49
  export function compilePage(file, context, options = {}) {