@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.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +23 -0
- package/README.md +37 -3
- package/SECURITY.md +1 -0
- package/docs/cli.md +20 -1
- package/docs/errors.md +255 -0
- package/docs/spec-alignment.md +14 -1
- package/package.json +3 -2
- package/site/pages/benchmarks/index.wd +1 -1
- package/site/pages/docs/index.wd +6 -1
- package/site/pages/index.wd +1 -0
- package/site/pages/playground/index.js +59 -54
- package/src/api-runner.js +80 -8
- package/src/builder.js +122 -13
- package/src/catalog.js +6 -0
- package/src/cli.js +48 -5
- package/src/compiler/page.js +16 -4
- package/src/compiler/state.js +75 -0
- package/src/compiler.js +1 -1
- package/src/errors.js +50 -3
- package/src/font-subset.js +25 -7
- package/src/image-variant-resize.js +4 -1
- package/src/image-variants.js +9 -4
- package/src/mcp-tools.js +354 -0
- package/src/mcp.js +287 -0
- package/src/router.js +16 -0
- package/src/skin.js +111 -45
- package/src/statics.js +1 -0
- package/src/templates/dashboard/README.md +3 -1
- package/src/templates/landing/site/pages/index.skin +7 -0
- package/src/templates/landing/site/pages/index.wd +10 -3
- package/src/templates/store/README.md +5 -2
- package/src/templates/store/site/_/card.wd +19 -0
- package/src/templates/store/site/_/products.json +6 -6
- package/src/templates/store/site/pages/index.skin +17 -2
- package/src/templates/store/site/pages/index.wd +3 -12
- package/types/api-runner.d.ts +20 -4
- package/types/builder.d.ts +5 -0
- package/types/compiler/page.d.ts +12 -3
- package/types/compiler.d.ts +4 -1
- package/types/errors.d.ts +6 -4
- package/types/font-subset.d.ts +7 -3
- package/types/image-variants.d.ts +7 -3
- package/types/router.d.ts +5 -0
- 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
|
-
|
|
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
|
|
110
|
-
* avoids the `duplex: "half"` streaming
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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).
|
|
477
|
-
//
|
|
478
|
-
|
|
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(
|
|
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
|
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.
|
|
@@ -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,
|