@zvndev/darkmown 2.10.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +16 -0
- package/README.md +45 -5
- package/docs/cli.md +20 -0
- package/docs/errors.md +255 -0
- package/docs/spec-alignment.md +11 -0
- package/package.json +2 -1
- package/site/pages/docs/index.wd +10 -5
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +32 -33
- package/src/builder.js +118 -11
- package/src/catalog.js +8 -0
- package/src/cli.js +49 -4
- package/src/compiler/markdown.js +14 -2
- package/src/compiler/page.js +27 -15
- package/src/compiler.js +1 -1
- package/src/font-subset.js +335 -105
- package/src/mcp-tools.js +354 -0
- package/src/mcp.js +287 -0
- package/src/statics.js +172 -15
- package/types/builder.d.ts +5 -0
- package/types/compiler/page.d.ts +12 -3
- package/types/compiler.d.ts +4 -1
- package/types/font-subset.d.ts +54 -6
- package/types/statics.d.ts +42 -3
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.",
|
|
@@ -718,6 +724,8 @@ function inlineAttributesSection() {
|
|
|
718
724
|
"- Attach classes/an id to an inline element by following it with NO space:" +
|
|
719
725
|
" `[Get started](/start/){.btn}`, `{.brand}`, `*text*{.hl}`.",
|
|
720
726
|
"- Several at once: `[Go](/u/){.btn .lg #cta}`. A space before the block breaks it (stays literal).",
|
|
727
|
+
'- Images also take a quoted `sizes="…"`: `{sizes="(max-width: 640px) 100vw, 640px"}`.' +
|
|
728
|
+
" The srcset variant wiring never invents one; sizes passes through with or without a srcset.",
|
|
721
729
|
"- Works on links, images, and emphasis/strong. It does NOT work on inline `code`.",
|
|
722
730
|
""
|
|
723
731
|
];
|
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
|
|
@@ -456,11 +490,13 @@ function startDevServer({ cwd, log, warn, error }) {
|
|
|
456
490
|
* Start the static preview server for an existing `dist/`. Throws `CliError`
|
|
457
491
|
* when there is nothing built to serve.
|
|
458
492
|
*
|
|
459
|
-
* Preview mirrors production more closely than dev in
|
|
493
|
+
* Preview mirrors production more closely than dev in three ways: project
|
|
460
494
|
* `api/*.js` functions run on `/api/*` (same runner and `(request) => Response`
|
|
461
495
|
* contract as dev — a project without `api/` falls straight through to static),
|
|
462
|
-
*
|
|
463
|
-
* local weight checks see transfer sizes, not raw bytes
|
|
496
|
+
* compressible responses are brotli/gzip-negotiated off Accept-Encoding so
|
|
497
|
+
* local weight checks see transfer sizes, not raw bytes, and static files carry
|
|
498
|
+
* validators (ETag/Last-Modified/Cache-Control) with conditional-request 304s,
|
|
499
|
+
* so a reload revalidates instead of re-downloading every asset.
|
|
464
500
|
* @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
|
|
465
501
|
* @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
|
|
466
502
|
*/
|
|
@@ -484,7 +520,15 @@ function startPreviewServer({ cwd, log, error }) {
|
|
|
484
520
|
// but never echoed — a stack trace leaks paths and internals to the client.
|
|
485
521
|
handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors: false })
|
|
486
522
|
.then((handled) => {
|
|
487
|
-
if (!handled)
|
|
523
|
+
if (!handled) {
|
|
524
|
+
serve(distRoot, req.url || "/", res, {
|
|
525
|
+
acceptEncoding,
|
|
526
|
+
validators: {
|
|
527
|
+
ifNoneMatch: req.headers["if-none-match"],
|
|
528
|
+
ifModifiedSince: req.headers["if-modified-since"]
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
488
532
|
})
|
|
489
533
|
.catch((err) => {
|
|
490
534
|
error(err instanceof Error && err.stack ? err.stack : String(err));
|
|
@@ -588,6 +632,7 @@ Usage:
|
|
|
588
632
|
darkmown validate [path] Compile-check pages without building (exit 1 on errors)
|
|
589
633
|
darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
|
|
590
634
|
darkmown serve Preview the built dist locally
|
|
635
|
+
darkmown mcp Run the MCP server on stdio (catalog, grammar, validate, error codes, docs)
|
|
591
636
|
darkmown catalog [--llms|--llms-full] Print the .wd directive catalog (JSON, --llms cheatsheet, or --llms-full reference)
|
|
592
637
|
darkmown version Print the installed Darkmown version
|
|
593
638
|
darkmown help Show this help
|
package/src/compiler/markdown.js
CHANGED
|
@@ -788,6 +788,13 @@ function bindingPlugin(mdInstance) {
|
|
|
788
788
|
* wrapper container. The block must directly follow the element (no space), and
|
|
789
789
|
* `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
|
|
790
790
|
* Issue #18.
|
|
791
|
+
*
|
|
792
|
+
* One image-only extension: `sizes="…"` (quoted, any content). The srcset
|
|
793
|
+
* variant wiring emits no `sizes` of its own — inventing one guesses the
|
|
794
|
+
* layout — so the AUTHOR supplies it here when the image is not full-width:
|
|
795
|
+
* `{sizes="(max-width: 640px) 100vw, 640px"}`. It passes through
|
|
796
|
+
* with or without a srcset, and markdown-it escapes the value on render. On a
|
|
797
|
+
* non-image target the token is ignored like any other unrecognized part.
|
|
791
798
|
* @param {MarkdownIt} mdInstance
|
|
792
799
|
* @returns {void}
|
|
793
800
|
*/
|
|
@@ -799,13 +806,18 @@ function attrsPlugin(mdInstance) {
|
|
|
799
806
|
for (let i = 0; i < children.length; i++) {
|
|
800
807
|
const tok = children[i];
|
|
801
808
|
if (tok.type !== "text") continue;
|
|
802
|
-
const m = /^\{([.#][^}]*)\}/.exec(tok.content);
|
|
809
|
+
const m = /^\{((?:[.#]|sizes=")[^}]*)\}/.exec(tok.content);
|
|
803
810
|
if (!m) continue;
|
|
804
811
|
const target = attrTarget(children, i);
|
|
805
812
|
if (!target) continue;
|
|
806
|
-
|
|
813
|
+
// `sizes` is one quoted token that may itself hold spaces, so it must
|
|
814
|
+
// be matched whole before the whitespace split. Anything else that is
|
|
815
|
+
// not `.class` / `#id` keeps its long-standing behavior: skipped.
|
|
816
|
+
for (const part of m[1].match(/sizes="[^"]*"|\S+/g) ?? []) {
|
|
807
817
|
if (part[0] === ".") target.attrJoin("class", part.slice(1));
|
|
808
818
|
else if (part[0] === "#") target.attrSet("id", part.slice(1));
|
|
819
|
+
else if (target.type === "image" && part.startsWith('sizes="'))
|
|
820
|
+
target.attrSet("sizes", part.slice(7, -1));
|
|
809
821
|
}
|
|
810
822
|
tok.content = tok.content.slice(m[0].length);
|
|
811
823
|
}
|
package/src/compiler/page.js
CHANGED
|
@@ -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.
|
|
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.
|
|
@@ -172,24 +183,24 @@ export function compilePage(file, context, options = {}) {
|
|
|
172
183
|
// (graceful, transition-free fallback) and only applies to same-origin
|
|
173
184
|
// navigations where both the outgoing and incoming page opt in.
|
|
174
185
|
//
|
|
175
|
-
// The default
|
|
176
|
-
// incoming 0→1,
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
186
|
+
// The default is one simultaneous cross-fade: outgoing opacity 1→0 while
|
|
187
|
+
// incoming 0→1, same duration and easing both ways. An earlier default paired
|
|
188
|
+
// a fade-out with a fade-in of opposite translates and easings; measured on a
|
|
189
|
+
// real site that read as a flicker (the outgoing page appeared to come back
|
|
190
|
+
// mid-navigation). Opacity-only, no transform: the two snapshots overlap in
|
|
191
|
+
// place, so nothing ever moves backwards. Under prefers-reduced-motion the
|
|
192
|
+
// same fade runs at 1ms — a cut, keeping the block structure intact.
|
|
180
193
|
const wantTransitions =
|
|
181
194
|
compiled.meta.transitions === true || compiled.meta.transitions === "true";
|
|
182
195
|
const transitions = wantTransitions
|
|
183
196
|
? `\n <style>
|
|
184
197
|
@view-transition { navigation: auto; }
|
|
185
|
-
::view-transition-old(root) { animation: wd-nav-out
|
|
186
|
-
::view-transition-new(root) { animation: wd-nav-in
|
|
187
|
-
@keyframes wd-nav-out { to { opacity: 0;
|
|
188
|
-
@keyframes wd-nav-in { from { opacity: 0;
|
|
198
|
+
::view-transition-old(root) { animation: wd-nav-out 160ms cubic-bezier(0.4, 0, 0.2, 1) both; }
|
|
199
|
+
::view-transition-new(root) { animation: wd-nav-in 160ms cubic-bezier(0.4, 0, 0.2, 1) both; }
|
|
200
|
+
@keyframes wd-nav-out { to { opacity: 0; } }
|
|
201
|
+
@keyframes wd-nav-in { from { opacity: 0; } }
|
|
189
202
|
@media (prefers-reduced-motion: reduce) {
|
|
190
203
|
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
|
|
191
|
-
@keyframes wd-nav-out { to { opacity: 0; } }
|
|
192
|
-
@keyframes wd-nav-in { from { opacity: 0; } }
|
|
193
204
|
}
|
|
194
205
|
</style>`
|
|
195
206
|
: "";
|
|
@@ -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 = {}) {
|