@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/compiler/state.js
CHANGED
|
@@ -174,6 +174,79 @@ function rejectStateSeed(value, raw, directive, ctx, index) {
|
|
|
174
174
|
);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// A SECRET-SHAPED SEED IS A HINT, NEVER AN ERROR.
|
|
179
|
+
//
|
|
180
|
+
// Every `:state`/`:store` seed is stored verbatim and inlined into the public
|
|
181
|
+
// HTML as a JSON script tag — that is the design, and it means a seed that is
|
|
182
|
+
// really an API key or token is published to every reader of the page. The
|
|
183
|
+
// documented pattern is to seed empty and fill from an api/ endpoint at
|
|
184
|
+
// runtime, but nothing stopped an author from pasting the key itself. This
|
|
185
|
+
// check is a heuristic over string seeds (and string values nested in
|
|
186
|
+
// array/object seeds), pushed onto the same `comp.warnings` channel as the
|
|
187
|
+
// other build hints: it can never fail a build, because a false positive must
|
|
188
|
+
// never break one, and it fires at most once per declaration.
|
|
189
|
+
//
|
|
190
|
+
// The shapes are deliberately tight — false positives are the enemy:
|
|
191
|
+
//
|
|
192
|
+
// - Known key prefixes (`sk-`, `ghp_`, `xoxb-`, `AKIA`, …) followed by a real
|
|
193
|
+
// token body, and the `-----BEGIN` private-key header.
|
|
194
|
+
// - A JWT: three base64url segments joined by dots, opening with `eyJ` (the
|
|
195
|
+
// base64 of `{"` — requiring it keeps dotted prose quiet).
|
|
196
|
+
// - A long unbroken token: 32+ chars from the base64/base64url alphabet with
|
|
197
|
+
// no spaces, requiring BOTH cases and a digit. Requiring the case mix keeps
|
|
198
|
+
// kebab-case slugs, hex hashes, and UUIDs (all lowercase) silent, and the
|
|
199
|
+
// charset excludes `:`, `.`, and space, so URLs, file paths, and prose
|
|
200
|
+
// never match.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
const SECRET_PREFIX = /^(?:sk-|sk_live_|sk_test_|ghp_|gho_|xoxb-|xoxp-|AKIA)/;
|
|
203
|
+
const SECRET_TOKEN_BODY = /^[A-Za-z0-9_-]{8,}$/;
|
|
204
|
+
const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
|
|
205
|
+
const LONG_TOKEN_SHAPE = /^[A-Za-z0-9/_+=-]{32,}$/;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Test one string for the secret shapes above.
|
|
209
|
+
* @param {string} text
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
function looksLikeSecret(text) {
|
|
213
|
+
if (text.startsWith("-----BEGIN")) return true;
|
|
214
|
+
const prefixed = text.match(SECRET_PREFIX);
|
|
215
|
+
if (prefixed && SECRET_TOKEN_BODY.test(text.slice(prefixed[0].length))) return true;
|
|
216
|
+
if (JWT_SHAPE.test(text)) return true;
|
|
217
|
+
return LONG_TOKEN_SHAPE.test(text) && /[a-z]/.test(text) && /[A-Z]/.test(text) && /\d/.test(text);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Walk a parsed seed for a secret-shaped string: the seed itself, or any
|
|
222
|
+
* string value nested in its arrays/objects.
|
|
223
|
+
* @param {unknown} value
|
|
224
|
+
* @returns {boolean}
|
|
225
|
+
*/
|
|
226
|
+
function seedContainsSecret(value) {
|
|
227
|
+
if (typeof value === "string") return looksLikeSecret(value);
|
|
228
|
+
if (Array.isArray(value)) return value.some(seedContainsSecret);
|
|
229
|
+
if (value && typeof value === "object") return Object.values(value).some(seedContainsSecret);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Push the secret-seed hint when a `:state`/`:store` seed carries one.
|
|
235
|
+
* @param {unknown} value The parsed seed.
|
|
236
|
+
* @param {string} name The declared name, for the message.
|
|
237
|
+
* @param {string} directive `:state` or `:store`, for the message.
|
|
238
|
+
* @param {Ctx} ctx
|
|
239
|
+
* @param {number} index 0-based line index for `file:line`.
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
function warnSecretSeed(value, name, directive, ctx, index) {
|
|
243
|
+
if (!seedContainsSecret(value)) return;
|
|
244
|
+
ctx.comp.warnings.push(
|
|
245
|
+
`${at(ctx, index)}: ${directive} "${name}" seed looks like a secret — it will be inlined into public HTML. ` +
|
|
246
|
+
`Seed empty and fill it from an api/ endpoint at runtime.`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
177
250
|
/**
|
|
178
251
|
* @param {string} line
|
|
179
252
|
* @param {Ctx} ctx
|
|
@@ -195,6 +268,7 @@ export function handleState(line, ctx, index) {
|
|
|
195
268
|
const flags = parseModifiers(match[3], ":state", ctx, index);
|
|
196
269
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
197
270
|
rejectStateSeed(value, match[2], ":state", ctx, index);
|
|
271
|
+
warnSecretSeed(value, match[1], ":state", ctx, index);
|
|
198
272
|
const key = declareState(match[1], value, ctx);
|
|
199
273
|
recordSymbol(ctx, index, { kind: "state", name: key, detail: `${key} = ${match[2].trim()}` });
|
|
200
274
|
const persistAttr = flags.persist ? ` data-wd-persist="${escapeHtml(key)}"` : "";
|
|
@@ -263,6 +337,7 @@ export function handleStore(line, ctx, index) {
|
|
|
263
337
|
const flags = parseModifiers(match[3], ":store", ctx, index);
|
|
264
338
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
265
339
|
rejectStateSeed(value, match[2], ":store", ctx, index);
|
|
340
|
+
warnSecretSeed(value, match[1], ":store", ctx, index);
|
|
266
341
|
const name = declareStore(match[1], value, ctx);
|
|
267
342
|
recordSymbol(ctx, index, { kind: "store", name, detail: `${name} = ${match[2].trim()}` });
|
|
268
343
|
const ephemeral = flags.ephemeral ? " data-wd-store-ephemeral" : "";
|
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 = {}) {
|
package/src/errors.js
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
IF_EXAMPLE,
|
|
77
77
|
INCLUDE_EXAMPLE
|
|
78
78
|
} from "./compiler/structure.js";
|
|
79
|
+
import { FONT_FACE_EXAMPLE, KEYFRAMES_EXAMPLE } from "./skin.js";
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* One subsystem block of the numbering scheme.
|
|
@@ -94,8 +95,9 @@ import {
|
|
|
94
95
|
* @property {string} title Short name for the mistake.
|
|
95
96
|
* @property {string} cause What the author wrote that triggers it.
|
|
96
97
|
* @property {string} fix The corrective action, in one line.
|
|
97
|
-
* @property {string} [example] A concrete, compilable line,
|
|
98
|
-
*
|
|
98
|
+
* @property {string} [example] A concrete, compilable line (or, for block
|
|
99
|
+
* constructs like the `.skin` at-rules, a minimal compilable block), sourced
|
|
100
|
+
* from the compiler's own `*_EXAMPLE` constants.
|
|
99
101
|
*/
|
|
100
102
|
|
|
101
103
|
/** The subsystem blocks, in code order. */
|
|
@@ -1167,6 +1169,36 @@ const ENTRIES = [
|
|
|
1167
1169
|
cause: "A top-level `page`/`body`/`html`/`*`/`::selection` selector escapes the scope.",
|
|
1168
1170
|
fix: "Move page-level styles to a global (non-scoped) skin."
|
|
1169
1171
|
},
|
|
1172
|
+
{
|
|
1173
|
+
code: "WD804",
|
|
1174
|
+
title: "Malformed `@font-face` block",
|
|
1175
|
+
cause:
|
|
1176
|
+
"`@font-face` was given a name, holds a nested block, or has no descriptors — it takes plain `prop value` declaration lines only.",
|
|
1177
|
+
fix: "Write a bare `@font-face` line and indent each descriptor beneath it.",
|
|
1178
|
+
example: FONT_FACE_EXAMPLE
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
code: "WD805",
|
|
1182
|
+
title: "Invalid `@keyframes` name",
|
|
1183
|
+
cause: "The name after `@keyframes` is missing or is not a plain CSS identifier.",
|
|
1184
|
+
fix: "Name the animation with a plain identifier, e.g. `@keyframes fade-in`.",
|
|
1185
|
+
example: KEYFRAMES_EXAMPLE
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
code: "WD806",
|
|
1189
|
+
title: "Invalid keyframe selector",
|
|
1190
|
+
cause: "A child of `@keyframes` is not `from`, `to`, or a percentage.",
|
|
1191
|
+
fix: "Use `from`, `to`, a percentage (`50%`), or a comma list of them (`50%, 75%`).",
|
|
1192
|
+
example: KEYFRAMES_EXAMPLE
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
code: "WD807",
|
|
1196
|
+
title: "Malformed `@keyframes` body",
|
|
1197
|
+
cause:
|
|
1198
|
+
"A keyframe's declarations nest deeper, a keyframe selector has no declarations, or the block has no keyframe selectors at all.",
|
|
1199
|
+
fix: "Give each keyframe selector a flat list of declarations indented one level beneath it.",
|
|
1200
|
+
example: KEYFRAMES_EXAMPLE
|
|
1201
|
+
},
|
|
1170
1202
|
|
|
1171
1203
|
// --- WD9xx: project, routing, CLI ----------------------------------------
|
|
1172
1204
|
{
|
|
@@ -1211,6 +1243,13 @@ const ENTRIES = [
|
|
|
1211
1243
|
cause: "The home page's `ai_crawlers:` is neither `allow` nor `deny`.",
|
|
1212
1244
|
fix: "Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow)."
|
|
1213
1245
|
},
|
|
1246
|
+
{
|
|
1247
|
+
code: "WD908",
|
|
1248
|
+
title: "Unsafe route name",
|
|
1249
|
+
cause:
|
|
1250
|
+
"A page filename produced a route containing whitespace or control characters, which would corrupt generated config files (dist/_headers, llms.txt).",
|
|
1251
|
+
fix: "Rename the file using only letters, digits, dashes, and underscores."
|
|
1252
|
+
},
|
|
1214
1253
|
{
|
|
1215
1254
|
code: "WD950",
|
|
1216
1255
|
title: "Invalid `rss_limit`",
|
|
@@ -1296,7 +1335,15 @@ export function errorsMarkdown() {
|
|
|
1296
1335
|
out.push("| Code | Error | Cause | Fix | Example |");
|
|
1297
1336
|
out.push("| --- | --- | --- | --- | --- |");
|
|
1298
1337
|
for (const row of rows) {
|
|
1299
|
-
|
|
1338
|
+
// A multi-line example (the `.skin` at-rule blocks are block constructs)
|
|
1339
|
+
// becomes one code span per line joined by <br>, keeping the table row
|
|
1340
|
+
// on a single markdown line.
|
|
1341
|
+
const example = row.example
|
|
1342
|
+
? row.example
|
|
1343
|
+
.split("\n")
|
|
1344
|
+
.map((line) => `\`${escapeCell(line)}\``)
|
|
1345
|
+
.join("<br>")
|
|
1346
|
+
: "";
|
|
1300
1347
|
out.push(
|
|
1301
1348
|
`| \`${row.code}\` | ${escapeCell(row.title)} | ${escapeCell(row.cause)} | ${escapeCell(row.fix)} | ${example} |`
|
|
1302
1349
|
);
|
package/src/font-subset.js
CHANGED
|
@@ -36,6 +36,8 @@ import fs from "node:fs";
|
|
|
36
36
|
import os from "node:os";
|
|
37
37
|
import path from "node:path";
|
|
38
38
|
import { fileURLToPath } from "node:url";
|
|
39
|
+
import { fsReader } from "./compiler/fs-reader.js";
|
|
40
|
+
import { isAllowedInclude } from "./compiler/includes.js";
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* @typedef {import("./config.js").Paths} Paths
|
|
@@ -287,21 +289,26 @@ export function subsetFonts(paths, options = {}) {
|
|
|
287
289
|
* build is synchronous; the wasm API is not), communicating through temp JSON
|
|
288
290
|
* files rather than stdio so a large payload never hits a pipe buffer. The
|
|
289
291
|
* worker always writes a results file — a missing or unparsable one means the
|
|
290
|
-
* child itself died,
|
|
291
|
-
* as
|
|
292
|
+
* child itself died (crashed, or killed by the spawn timeout), which is
|
|
293
|
+
* reported as `unavailable` (the build then behaves as if the toolchain were
|
|
294
|
+
* absent: a hint, no subsets, no failure).
|
|
292
295
|
* @param {{ charset: number[], jobs: SubsetJob[] }} payload
|
|
293
296
|
* @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
|
|
294
297
|
* Injectable for tests; the real one is `spawnSync`.
|
|
298
|
+
* @param {number} [timeoutMs] The worker is killed after this long (default
|
|
299
|
+
* two minutes), so a hung toolchain call cannot stall a build forever. The
|
|
300
|
+
* kill lands in the same `unavailable` path as any other dead child.
|
|
295
301
|
* @returns {SubsetResult}
|
|
296
302
|
*/
|
|
297
|
-
export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync)) {
|
|
303
|
+
export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
|
|
298
304
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-font-subset-"));
|
|
299
305
|
try {
|
|
300
306
|
const jobsFile = path.join(dir, "jobs.json");
|
|
301
307
|
const resultsFile = path.join(dir, "results.json");
|
|
302
308
|
fs.writeFileSync(jobsFile, JSON.stringify(payload));
|
|
303
309
|
spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
|
|
304
|
-
stdio: ["ignore", "ignore", "inherit"]
|
|
310
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
311
|
+
timeout: timeoutMs
|
|
305
312
|
});
|
|
306
313
|
if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
|
|
307
314
|
try {
|
|
@@ -391,9 +398,10 @@ function fontUrlsInCss(css) {
|
|
|
391
398
|
* Resolve a url() target from an emitted `@font-face` to the project font file
|
|
392
399
|
* it names, or null when it is not a subset candidate: anything not
|
|
393
400
|
* leading-slash (remote, protocol-relative, `data:`, relative), a non-subsettable
|
|
394
|
-
* extension (already-woff2 included), an un-decodable or escaping path,
|
|
395
|
-
*
|
|
396
|
-
*
|
|
401
|
+
* extension (already-woff2 included), an un-decodable or escaping path, a
|
|
402
|
+
* symlink (whose target could live anywhere on disk, whatever the link's own
|
|
403
|
+
* path says), or a file that does not exist. `/__wd/media/<rel>` maps to the
|
|
404
|
+
* shelf; every other absolute path maps to `site/pages`.
|
|
397
405
|
* @param {Paths} paths
|
|
398
406
|
* @param {string} url The raw url() target.
|
|
399
407
|
* @returns {FontRef | null}
|
|
@@ -416,6 +424,16 @@ function resolveFontUrl(paths, url) {
|
|
|
416
424
|
const source = path.normalize(path.join(base, rel));
|
|
417
425
|
if (!source.startsWith(base + path.sep)) return null;
|
|
418
426
|
if (!fs.existsSync(source)) return null;
|
|
427
|
+
// Symlinks are never subsetted, matching the asset-copy convention
|
|
428
|
+
// (`emitPageAssets` skips them entirely, and so does the image pre-pass):
|
|
429
|
+
// the lexical containment check above reads the path as WRITTEN, so a link
|
|
430
|
+
// sitting inside the project could point anywhere on disk and the target's
|
|
431
|
+
// bytes would be read and published as a subset. An ancestor directory can
|
|
432
|
+
// be a symlink with the file itself plain, so the canonical (realpath)
|
|
433
|
+
// containment check `@include` uses runs too — a font whose real path
|
|
434
|
+
// escapes site/pages and site/_ is simply "not project-local" here.
|
|
435
|
+
if (fs.lstatSync(source).isSymbolicLink()) return null;
|
|
436
|
+
if (!isAllowedInclude(source, paths, fsReader())) return null;
|
|
419
437
|
const subsetUrl = `${clean.slice(0, clean.length - ext.length)}-subset.woff2`;
|
|
420
438
|
return { url, source, subsetUrl, subsetDist: path.join(paths.distRoot, subsetUrl.slice(1)) };
|
|
421
439
|
}
|
|
@@ -46,7 +46,10 @@ export async function runJobsFile(jobsFile, resultsFile) {
|
|
|
46
46
|
// loses the EXIF tag with its pixels physically rotated, so it displays
|
|
47
47
|
// exactly like the tag-carrying original. `withoutEnlargement` is belt and
|
|
48
48
|
// braces — the pre-pass only ever queues widths below the intrinsic size.
|
|
49
|
-
|
|
49
|
+
// `limitInputPixels` caps the decompression-bomb surface: 100 MP is above
|
|
50
|
+
// any real photo, and an oversized input fails as a per-job error (a hint
|
|
51
|
+
// naming the file), never a build failure.
|
|
52
|
+
let pipeline = sharp(job.source, { limitInputPixels: 1e8 })
|
|
50
53
|
.rotate()
|
|
51
54
|
.resize({ width: job.width, withoutEnlargement: true });
|
|
52
55
|
if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });
|
package/src/image-variants.js
CHANGED
|
@@ -228,21 +228,26 @@ export function generateImageVariants(paths, options = {}) {
|
|
|
228
228
|
* build is synchronous; sharp is not), communicating through temp JSON files
|
|
229
229
|
* rather than stdio so a large job list never hits a pipe buffer. The worker
|
|
230
230
|
* always writes a results file — a missing or unparsable one means the child
|
|
231
|
-
* itself died,
|
|
232
|
-
* if sharp were absent: a hint, no
|
|
231
|
+
* itself died (crashed, or killed by the spawn timeout), which is reported as
|
|
232
|
+
* `unavailable` (the build then behaves as if sharp were absent: a hint, no
|
|
233
|
+
* variants, no failure).
|
|
233
234
|
* @param {ResizeJob[]} jobs
|
|
234
235
|
* @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
|
|
235
236
|
* Injectable for tests; the real one is `spawnSync`.
|
|
237
|
+
* @param {number} [timeoutMs] The worker is killed after this long (default
|
|
238
|
+
* two minutes), so a hung sharp call cannot stall a build forever. The kill
|
|
239
|
+
* lands in the same `unavailable` path as any other dead child.
|
|
236
240
|
* @returns {ResizeResult}
|
|
237
241
|
*/
|
|
238
|
-
export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync)) {
|
|
242
|
+
export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
|
|
239
243
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-img-variants-"));
|
|
240
244
|
try {
|
|
241
245
|
const jobsFile = path.join(dir, "jobs.json");
|
|
242
246
|
const resultsFile = path.join(dir, "results.json");
|
|
243
247
|
fs.writeFileSync(jobsFile, JSON.stringify(jobs));
|
|
244
248
|
spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
|
|
245
|
-
stdio: ["ignore", "ignore", "inherit"]
|
|
249
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
250
|
+
timeout: timeoutMs
|
|
246
251
|
});
|
|
247
252
|
if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
|
|
248
253
|
try {
|
package/src/mcp-tools.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The Darkmown MCP server's tools and resources.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here re-implements the framework: every tool is a thin adapter over
|
|
5
|
+
// a surface that already exists and is already drift-guarded by the test
|
|
6
|
+
// suite — the directive catalog (src/catalog.js), the agent grammar slice
|
|
7
|
+
// (src/tools/grammar.js), the GBNF generator (src/grammar.js), the in-memory
|
|
8
|
+
// validator (src/tools/validate.js), and the error-code registry
|
|
9
|
+
// (src/errors.js). An MCP client and the `darkmown` CLI therefore can never
|
|
10
|
+
// disagree about what compiles or what a WD code means.
|
|
11
|
+
//
|
|
12
|
+
// The five tools:
|
|
13
|
+
//
|
|
14
|
+
// catalog the machine-readable directive catalog (JSON, llms, llms-full)
|
|
15
|
+
// grammar the GBNF grammar, or a category slice of the directive cheatsheet
|
|
16
|
+
// validate compile .wd source in memory, answering with WD codes and fixes
|
|
17
|
+
// error one WDxxx code's cause, fix, and example (or the valid list)
|
|
18
|
+
// doc fuzzy-find a section of README.md / AGENTS.md by topic
|
|
19
|
+
//
|
|
20
|
+
// The resources expose the shipped markdown docs at darkmown://docs/* URIs,
|
|
21
|
+
// read from the installed package files at request time.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import { directiveCatalog, llmsFullText, llmsText } from "./catalog.js";
|
|
26
|
+
import { errorCatalog, errorForCode } from "./errors.js";
|
|
27
|
+
import { generateGrammar } from "./grammar.js";
|
|
28
|
+
import { grammar } from "./tools/grammar.js";
|
|
29
|
+
import { validate } from "./tools/validate.js";
|
|
30
|
+
|
|
31
|
+
/** The installed Darkmown version, read from the shipped package.json. */
|
|
32
|
+
function packageVersion() {
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
34
|
+
return String(pkg.version || "0.0.0");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read a file shipped in the package root (or under it), as UTF-8. */
|
|
38
|
+
function readPackageFile(/** @type {string} */ file) {
|
|
39
|
+
return fs.readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- catalog ---------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** @type {string[]} The render modes, mirroring the CLI's catalog flags. */
|
|
45
|
+
const CATALOG_MODES = ["json", "llms", "llms-full"];
|
|
46
|
+
|
|
47
|
+
/** @type {import("./mcp.js").McpTool} */
|
|
48
|
+
const catalogTool = {
|
|
49
|
+
name: "catalog",
|
|
50
|
+
description:
|
|
51
|
+
"The Darkmown .wd directive catalog: every directive, @loop clause, action op, " +
|
|
52
|
+
'format pipe, predicate operator, frontmatter key, and WDxxx error code. mode "json" ' +
|
|
53
|
+
'(default) is the full machine-readable catalog, "llms" the compact cheatsheet, ' +
|
|
54
|
+
'"llms-full" the complete reference.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
mode: {
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: CATALOG_MODES,
|
|
61
|
+
description: 'Render mode: "json" (default), "llms", or "llms-full".'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
run(args) {
|
|
66
|
+
const mode = args.mode ?? "json";
|
|
67
|
+
if (typeof mode !== "string" || !CATALOG_MODES.includes(mode)) {
|
|
68
|
+
return {
|
|
69
|
+
text: `unknown catalog mode ${JSON.stringify(mode)}. Valid modes: ${CATALOG_MODES.join(", ")}`,
|
|
70
|
+
isError: true
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (mode === "llms") return { text: llmsText() };
|
|
74
|
+
if (mode === "llms-full") return { text: llmsFullText() };
|
|
75
|
+
return { text: JSON.stringify(directiveCatalog(), null, 2) };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// --- grammar ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** @type {import("./mcp.js").McpTool} */
|
|
82
|
+
const grammarTool = {
|
|
83
|
+
name: "grammar",
|
|
84
|
+
description:
|
|
85
|
+
"The .wd directive syntax. Pass gbnf:true for the full GBNF directive-line grammar " +
|
|
86
|
+
'(for constrained decoding), or categories like ["state", "actions"] for just the ' +
|
|
87
|
+
"cheatsheet rows covering those directives. Categories: state, loops, actions, " +
|
|
88
|
+
"forms, fetch, structure, media. No arguments returns every category.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
gbnf: {
|
|
93
|
+
type: "boolean",
|
|
94
|
+
description: "Return the full GBNF grammar instead of a cheatsheet slice."
|
|
95
|
+
},
|
|
96
|
+
categories: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: { type: "string" },
|
|
99
|
+
description:
|
|
100
|
+
"Directive categories to include: state, loops, actions, forms, fetch, structure, media."
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
run(args) {
|
|
105
|
+
if (args.gbnf === true) return { text: generateGrammar() };
|
|
106
|
+
const categories = Array.isArray(args.categories)
|
|
107
|
+
? args.categories.filter((c) => typeof c === "string")
|
|
108
|
+
: undefined;
|
|
109
|
+
const result = grammar(categories);
|
|
110
|
+
return { text: result.text, data: result.data, ...(result.ok ? {} : { isError: true }) };
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// --- validate ---------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** @type {import("./mcp.js").McpTool} */
|
|
117
|
+
const validateTool = {
|
|
118
|
+
name: "validate",
|
|
119
|
+
description:
|
|
120
|
+
"Compile .wd (or .md) source with the real Darkmown compiler, in memory. On success " +
|
|
121
|
+
"answers whether the page is static or reactive and lists any warnings; on failure " +
|
|
122
|
+
"answers with the stable WD error code, the file:line, a Use: hint, and a concrete " +
|
|
123
|
+
"example. Prove a page compiles before writing it.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
source: { type: "string", description: "The full .wd/.md page source." },
|
|
128
|
+
filename: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: 'Name the source is compiled under (default "index.wd").'
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
required: ["source"]
|
|
134
|
+
},
|
|
135
|
+
run(args) {
|
|
136
|
+
if (typeof args.source !== "string") {
|
|
137
|
+
return { text: 'validate needs a "source" string.', isError: true };
|
|
138
|
+
}
|
|
139
|
+
const filename =
|
|
140
|
+
typeof args.filename === "string" && args.filename.trim()
|
|
141
|
+
? args.filename.replace(/^\/+/, "")
|
|
142
|
+
: "index.wd";
|
|
143
|
+
const key = filename.startsWith("site/") ? filename : `site/pages/${filename}`;
|
|
144
|
+
const result = validate({ [key]: args.source }, key);
|
|
145
|
+
return { text: result.text, data: { ok: result.ok, ...result.data } };
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// --- error -----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** @type {import("./mcp.js").McpTool} */
|
|
152
|
+
const errorTool = {
|
|
153
|
+
name: "error",
|
|
154
|
+
description:
|
|
155
|
+
"Look up one Darkmown compile-error code (e.g. WD201): its title, what causes it, " +
|
|
156
|
+
"how to fix it, and a concrete compilable example. An unknown code answers with " +
|
|
157
|
+
"the list of valid codes.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
code: { type: "string", description: 'The WDxxx code, e.g. "WD201".' }
|
|
162
|
+
},
|
|
163
|
+
required: ["code"]
|
|
164
|
+
},
|
|
165
|
+
run(args) {
|
|
166
|
+
const code = typeof args.code === "string" ? args.code.trim().toUpperCase() : "";
|
|
167
|
+
const entry = errorForCode(code);
|
|
168
|
+
if (!entry) {
|
|
169
|
+
const codes = errorCatalog().map((e) => e.code);
|
|
170
|
+
return {
|
|
171
|
+
text:
|
|
172
|
+
`unknown error code ${JSON.stringify(code || args.code)}. ` +
|
|
173
|
+
`Valid codes: ${codes.join(", ")}`,
|
|
174
|
+
data: { codes },
|
|
175
|
+
isError: true
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const lines = [`${entry.code} ${entry.title}`, `cause: ${entry.cause}`, `fix: ${entry.fix}`];
|
|
179
|
+
if (entry.example) lines.push(`e.g. ${entry.example}`);
|
|
180
|
+
return { text: lines.join("\n"), data: entry };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// --- doc -------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** The shipped long-form docs the `doc` tool searches, in search order. */
|
|
187
|
+
const DOC_FILES = ["README.md", "AGENTS.md"];
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One searchable section of a shipped doc.
|
|
191
|
+
* @typedef {object} DocSection
|
|
192
|
+
* @property {string} topic The heading text, e.g. "Includes & sections".
|
|
193
|
+
* @property {string} file The doc the section lives in.
|
|
194
|
+
* @property {string} body The section's markdown, heading excluded.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Split a doc into sections on `#`/`##`/`###` headings, ignoring heading-shaped
|
|
199
|
+
* lines inside fenced code blocks (README's example robots.txt and sample pages
|
|
200
|
+
* carry lines that start with `#`).
|
|
201
|
+
* @returns {DocSection[]}
|
|
202
|
+
*/
|
|
203
|
+
export function docSections() {
|
|
204
|
+
/** @type {{ topic: string, file: string, lines: string[] }[]} */
|
|
205
|
+
const raw = [];
|
|
206
|
+
for (const file of DOC_FILES) {
|
|
207
|
+
let fenced = false;
|
|
208
|
+
/** @type {{ topic: string, file: string, lines: string[] } | null} */
|
|
209
|
+
let current = null;
|
|
210
|
+
for (const line of readPackageFile(file).split("\n")) {
|
|
211
|
+
if (/^(```|~~~)/.test(line.trimStart())) fenced = !fenced;
|
|
212
|
+
const heading = !fenced && /^#{1,3} (.+)$/.exec(line);
|
|
213
|
+
if (heading) {
|
|
214
|
+
current = { topic: heading[1].trim(), file, lines: [] };
|
|
215
|
+
raw.push(current);
|
|
216
|
+
} else if (current) {
|
|
217
|
+
current.lines.push(line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return raw.map(({ topic, file, lines }) => ({ topic, file, body: lines.join("\n").trim() }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Score a section heading against a query, higher is better. Exact match wins,
|
|
226
|
+
* then containment either way, then shared-word count. Normalisation folds
|
|
227
|
+
* case and strips punctuation, so "raw html" matches
|
|
228
|
+
* "⚠️ Raw HTML is escaped by default (since 2.0.0)".
|
|
229
|
+
* @param {string} topic
|
|
230
|
+
* @param {string} query
|
|
231
|
+
* @returns {number}
|
|
232
|
+
*/
|
|
233
|
+
function scoreSection(topic, query) {
|
|
234
|
+
const normalize = (/** @type {string} */ s) =>
|
|
235
|
+
s
|
|
236
|
+
.toLowerCase()
|
|
237
|
+
.replace(/[^a-z0-9 ]/g, " ")
|
|
238
|
+
.replace(/\s+/g, " ")
|
|
239
|
+
.trim();
|
|
240
|
+
const heading = normalize(topic);
|
|
241
|
+
const wanted = normalize(query);
|
|
242
|
+
if (!wanted) return 0;
|
|
243
|
+
if (heading === wanted) return 100;
|
|
244
|
+
if (heading.includes(wanted) || wanted.includes(heading)) return 60;
|
|
245
|
+
const words = new Set(heading.split(" "));
|
|
246
|
+
let overlap = 0;
|
|
247
|
+
for (const word of wanted.split(" ")) if (words.has(word)) overlap++;
|
|
248
|
+
return overlap;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @type {import("./mcp.js").McpTool} */
|
|
252
|
+
const docTool = {
|
|
253
|
+
name: "doc",
|
|
254
|
+
description:
|
|
255
|
+
"Fuzzy-find a section of the shipped Darkmown docs (README.md and AGENTS.md) by " +
|
|
256
|
+
'topic, e.g. "includes", "forms", "dark mode", and return that section\'s markdown. ' +
|
|
257
|
+
"Call with no topic to list every available section.",
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
topic: { type: "string", description: "What to look up, in a word or two." }
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
run(args) {
|
|
265
|
+
const topic = typeof args.topic === "string" ? args.topic.trim() : "";
|
|
266
|
+
const sections = docSections();
|
|
267
|
+
const list = sections.map((s) => `- ${s.topic} (${s.file})`).join("\n");
|
|
268
|
+
if (!topic) {
|
|
269
|
+
return {
|
|
270
|
+
text: `Available doc sections:\n${list}`,
|
|
271
|
+
data: { topics: sections.map((s) => ({ topic: s.topic, file: s.file })) }
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
let best = /** @type {DocSection | null} */ (null);
|
|
275
|
+
let bestScore = 0;
|
|
276
|
+
for (const section of sections) {
|
|
277
|
+
const score = scoreSection(section.topic, topic);
|
|
278
|
+
if (score > bestScore) {
|
|
279
|
+
best = section;
|
|
280
|
+
bestScore = score;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!best) {
|
|
284
|
+
return {
|
|
285
|
+
text: `no doc section matches ${JSON.stringify(topic)}. Available sections:\n${list}`,
|
|
286
|
+
isError: true
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
text: `# ${best.topic} (${best.file})\n\n${best.body}`,
|
|
291
|
+
data: { topic: best.topic, file: best.file }
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// --- resources ---------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The shipped docs exposed as MCP resources, read from the installed package
|
|
300
|
+
* at request time so a long-running server sees the files the user actually
|
|
301
|
+
* has installed.
|
|
302
|
+
* @type {{ uri: string, name: string, file: string, description: string }[]}
|
|
303
|
+
*/
|
|
304
|
+
const DOC_RESOURCES = [
|
|
305
|
+
{
|
|
306
|
+
uri: "darkmown://docs/readme",
|
|
307
|
+
name: "README.md",
|
|
308
|
+
file: "README.md",
|
|
309
|
+
description: "The Darkmown README: authoring model, directive reference, commands."
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
uri: "darkmown://docs/agents",
|
|
313
|
+
name: "AGENTS.md",
|
|
314
|
+
file: "AGENTS.md",
|
|
315
|
+
description: "The agent authoring guide: the complete .wd directive set and the hard rules."
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
uri: "darkmown://docs/changelog",
|
|
319
|
+
name: "CHANGELOG.md",
|
|
320
|
+
file: "CHANGELOG.md",
|
|
321
|
+
description: "Darkmown release history."
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
uri: "darkmown://docs/errors",
|
|
325
|
+
name: "docs/errors.md",
|
|
326
|
+
file: "docs/errors.md",
|
|
327
|
+
description: "Every stable WDxxx compile-error code with cause, fix, and example."
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
uri: "darkmown://docs/cli",
|
|
331
|
+
name: "docs/cli.md",
|
|
332
|
+
file: "docs/cli.md",
|
|
333
|
+
description: "The Darkmown CLI command reference."
|
|
334
|
+
}
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The server configuration: identity, the five tools, and the doc resources.
|
|
339
|
+
* Built fresh on each call so tests can mutate nothing shared.
|
|
340
|
+
* @returns {import("./mcp.js").McpConfig}
|
|
341
|
+
*/
|
|
342
|
+
export function mcpConfig() {
|
|
343
|
+
return {
|
|
344
|
+
serverInfo: { name: "darkmown", version: packageVersion() },
|
|
345
|
+
tools: [catalogTool, grammarTool, validateTool, errorTool, docTool],
|
|
346
|
+
resources: DOC_RESOURCES.map(({ uri, name, file, description }) => ({
|
|
347
|
+
uri,
|
|
348
|
+
name,
|
|
349
|
+
description,
|
|
350
|
+
mimeType: "text/markdown",
|
|
351
|
+
read: () => readPackageFile(file)
|
|
352
|
+
}))
|
|
353
|
+
};
|
|
354
|
+
}
|