@dogsbay/commands 0.2.0-beta.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +137 -0
  2. package/dist/commands/content.d.ts +78 -0
  3. package/dist/commands/content.d.ts.map +1 -0
  4. package/dist/commands/content.js +634 -0
  5. package/dist/commands/content.js.map +1 -0
  6. package/dist/commands/examples.d.ts +49 -0
  7. package/dist/commands/examples.d.ts.map +1 -0
  8. package/dist/commands/examples.js +173 -0
  9. package/dist/commands/examples.js.map +1 -0
  10. package/dist/commands/keys.d.ts +14 -0
  11. package/dist/commands/keys.d.ts.map +1 -0
  12. package/dist/commands/keys.js +87 -0
  13. package/dist/commands/keys.js.map +1 -0
  14. package/dist/commands/link-retarget.d.ts +18 -0
  15. package/dist/commands/link-retarget.d.ts.map +1 -0
  16. package/dist/commands/link-retarget.js +104 -0
  17. package/dist/commands/link-retarget.js.map +1 -0
  18. package/dist/commands/refactor-files.d.ts +27 -0
  19. package/dist/commands/refactor-files.d.ts.map +1 -0
  20. package/dist/commands/refactor-files.js +264 -0
  21. package/dist/commands/refactor-files.js.map +1 -0
  22. package/dist/commands/refactor-keys.d.ts +21 -0
  23. package/dist/commands/refactor-keys.d.ts.map +1 -0
  24. package/dist/commands/refactor-keys.js +224 -0
  25. package/dist/commands/refactor-keys.js.map +1 -0
  26. package/dist/commands/refactor-structure.d.ts +19 -0
  27. package/dist/commands/refactor-structure.d.ts.map +1 -0
  28. package/dist/commands/refactor-structure.js +314 -0
  29. package/dist/commands/refactor-structure.js.map +1 -0
  30. package/dist/commands/reindex.d.ts +10 -0
  31. package/dist/commands/reindex.d.ts.map +1 -0
  32. package/dist/commands/reindex.js +23 -0
  33. package/dist/commands/reindex.js.map +1 -0
  34. package/dist/commands/topicmap-edit.d.ts +65 -0
  35. package/dist/commands/topicmap-edit.d.ts.map +1 -0
  36. package/dist/commands/topicmap-edit.js +194 -0
  37. package/dist/commands/topicmap-edit.js.map +1 -0
  38. package/dist/commands/topicmap.d.ts +13 -0
  39. package/dist/commands/topicmap.d.ts.map +1 -0
  40. package/dist/commands/topicmap.js +69 -0
  41. package/dist/commands/topicmap.js.map +1 -0
  42. package/dist/commands/validate.d.ts +13 -0
  43. package/dist/commands/validate.d.ts.map +1 -0
  44. package/dist/commands/validate.js +753 -0
  45. package/dist/commands/validate.js.map +1 -0
  46. package/dist/commands/where-used.d.ts +16 -0
  47. package/dist/commands/where-used.d.ts.map +1 -0
  48. package/dist/commands/where-used.js +83 -0
  49. package/dist/commands/where-used.js.map +1 -0
  50. package/dist/edits.d.ts +54 -0
  51. package/dist/edits.d.ts.map +1 -0
  52. package/dist/edits.js +158 -0
  53. package/dist/edits.js.map +1 -0
  54. package/dist/engine.d.ts +103 -0
  55. package/dist/engine.d.ts.map +1 -0
  56. package/dist/engine.js +115 -0
  57. package/dist/engine.js.map +1 -0
  58. package/dist/flags.d.ts +31 -0
  59. package/dist/flags.d.ts.map +1 -0
  60. package/dist/flags.js +73 -0
  61. package/dist/flags.js.map +1 -0
  62. package/dist/frontmatter-schema.d.ts +97 -0
  63. package/dist/frontmatter-schema.d.ts.map +1 -0
  64. package/dist/frontmatter-schema.js +240 -0
  65. package/dist/frontmatter-schema.js.map +1 -0
  66. package/dist/index.d.ts +35 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +70 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/indexes.d.ts +175 -0
  71. package/dist/indexes.d.ts.map +1 -0
  72. package/dist/indexes.js +536 -0
  73. package/dist/indexes.js.map +1 -0
  74. package/dist/json-schema.d.ts +10 -0
  75. package/dist/json-schema.d.ts.map +1 -0
  76. package/dist/json-schema.js +50 -0
  77. package/dist/json-schema.js.map +1 -0
  78. package/dist/storage.d.ts +68 -0
  79. package/dist/storage.d.ts.map +1 -0
  80. package/dist/storage.js +193 -0
  81. package/dist/storage.js.map +1 -0
  82. package/dist/workspace.d.ts +30 -0
  83. package/dist/workspace.d.ts.map +1 -0
  84. package/dist/workspace.js +63 -0
  85. package/dist/workspace.js.map +1 -0
  86. package/package.json +47 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * `validate` — the workspace health gate.
3
+ *
4
+ * Every check is an index lookup: broken relative links, missing link
5
+ * anchors, missing include targets, undefined keys, conflicting key
6
+ * definitions, malformed keys files, topic-map parse errors, and
7
+ * topic-map entries pointing at missing files. Docs that fail to parse
8
+ * are surfaced too — a doc the indexer can't read can hide any of the
9
+ * above.
10
+ */
11
+ import { z } from "zod";
12
+ import { dirname as dirnameOf, joinPath } from "@dogsbay/format-topicmap";
13
+ import { defineCommand } from "../engine.js";
14
+ import { anchorAliases, anchorAliasTargets } from "../indexes.js";
15
+ import { WELL_KNOWN_FIELDS, matchesType, isSafeAutoFix, nearMisses, contentRelativePath, requiredFieldsFor, typeOf, AGGREGATOR_OWNED, } from "../frontmatter-schema.js";
16
+ import { dirname as dirOf, relative as relPath } from "node:path/posix";
17
+ import { load as loadYaml } from "js-yaml";
18
+ const input = z.object({}).strict();
19
+ async function siteLayout(storage) {
20
+ const out = { roots: [""], prefixes: [], checkable: true };
21
+ for (const name of ["dogsbay.config.yml", "dogsbay.config.yaml", "dogsbay.config.json"]) {
22
+ if (!(await storage.exists(name)))
23
+ continue;
24
+ try {
25
+ const text = await storage.read(name);
26
+ const cfg = (name.endsWith(".json") ? JSON.parse(text) : loadYaml(text));
27
+ if (!cfg || typeof cfg !== "object")
28
+ return out;
29
+ const site = (cfg.site ?? {});
30
+ if (typeof site.basePath === "string" && site.basePath.replace(/\//g, ""))
31
+ out.prefixes.push(site.basePath.replace(/^\/+|\/+$/g, ""));
32
+ if (typeof site.url === "string") {
33
+ try {
34
+ const u = new URL(site.url);
35
+ const pth = u.pathname.replace(/^\/+|\/+$/g, "");
36
+ if (pth)
37
+ out.prefixes.push(pth);
38
+ }
39
+ catch { /* not a URL */ }
40
+ }
41
+ const content = (cfg.content ?? {});
42
+ const sources = Array.isArray(content.sources) ? content.sources : [];
43
+ const roots = [];
44
+ for (const src of sources) {
45
+ if (!src || typeof src !== "object")
46
+ continue;
47
+ const rec = src;
48
+ if (typeof rec.git === "string" || typeof rec.repo === "string") {
49
+ out.checkable = false;
50
+ continue;
51
+ }
52
+ if (typeof rec.namespace === "string" || typeof rec.version === "string" || typeof rec.locale === "string")
53
+ out.checkable = false;
54
+ if (typeof rec.path === "string") {
55
+ const root = rec.path.replace(/^\.\/+/, "").replace(/^\/+|\/+$/g, "");
56
+ if (root.startsWith("..")) {
57
+ out.checkable = false;
58
+ continue;
59
+ } // above the workspace: unknowable
60
+ roots.push(root);
61
+ }
62
+ }
63
+ if (typeof content.path === "string")
64
+ roots.push(content.path.replace(/^\.\/+/, "").replace(/^\/+|\/+$/g, ""));
65
+ if (roots.length)
66
+ out.roots = [...new Set(roots)];
67
+ }
68
+ catch {
69
+ /* unreadable config: resolve against the workspace root */
70
+ }
71
+ break;
72
+ }
73
+ return out;
74
+ }
75
+ /** js-yaml's own complaint about the frontmatter block, with the line it points at. */
76
+ function yamlErrorOf(source) {
77
+ const lines = source.split("\n");
78
+ if (lines[0]?.trim() !== "---")
79
+ return undefined;
80
+ let end = -1;
81
+ for (let i = 1; i < lines.length; i++)
82
+ if (/^(---|\.\.\.)[ \t]*\r?$/.test(lines[i])) {
83
+ end = i;
84
+ break;
85
+ }
86
+ if (end === -1)
87
+ return undefined;
88
+ try {
89
+ loadYaml(lines.slice(1, end).join("\n"));
90
+ return undefined;
91
+ }
92
+ catch (err) {
93
+ const e = err;
94
+ const message = String(e.message ?? err).split("\n")[0];
95
+ return { message, line: (e.mark?.line ?? 0) + 2 }; // +1 for the opener, +1 for 1-based
96
+ }
97
+ }
98
+ /**
99
+ * The frontmatter policy, read from the same config file as the site layout:
100
+ * declared `taxonomies:` (their `values`/`hierarchical` ARE the check) plus
101
+ * the opt-in `content.frontmatter` strictness. Absent config → the
102
+ * well-known-field type checks only.
103
+ */
104
+ async function frontmatterPolicy(storage, layout) {
105
+ const out = { taxonomies: {}, propSchema: {}, required: [], unknown: "ignore", roots: layout.roots };
106
+ for (const name of ["dogsbay.config.yml", "dogsbay.config.yaml", "dogsbay.config.json"]) {
107
+ if (!(await storage.exists(name)))
108
+ continue;
109
+ try {
110
+ const text = await storage.read(name);
111
+ const cfg = (name.endsWith(".json") ? JSON.parse(text) : loadYaml(text));
112
+ if (!cfg || typeof cfg !== "object")
113
+ return out;
114
+ const taxonomies = (cfg.taxonomies ?? {});
115
+ for (const [field, raw] of Object.entries(taxonomies)) {
116
+ if (!raw || typeof raw !== "object")
117
+ continue;
118
+ const rec = raw;
119
+ out.taxonomies[field] = {
120
+ values: Array.isArray(rec.values) ? rec.values.filter((v) => typeof v === "string") : undefined,
121
+ hierarchical: rec.hierarchical === true,
122
+ };
123
+ }
124
+ // `propSchema` shorthand is a bare type name; the long form adds an enum
125
+ const propSchema = (cfg.propSchema ?? {});
126
+ for (const [key, raw] of Object.entries(propSchema)) {
127
+ if (typeof raw === "string" && ["string", "number", "boolean"].includes(raw)) {
128
+ out.propSchema[key] = { type: raw };
129
+ }
130
+ else if (raw && typeof raw === "object") {
131
+ const rec = raw;
132
+ if (typeof rec.type === "string" && ["string", "number", "boolean"].includes(rec.type)) {
133
+ out.propSchema[key] = {
134
+ type: rec.type,
135
+ enum: Array.isArray(rec.enum) ? rec.enum.map((v) => String(v)) : undefined,
136
+ };
137
+ }
138
+ }
139
+ }
140
+ const content = (cfg.content ?? {});
141
+ const fm = (content.frontmatter ?? {});
142
+ if (fm.unknown === "warn" || fm.unknown === "error")
143
+ out.unknown = fm.unknown;
144
+ else if (fm.unknown !== undefined && fm.unknown !== "ignore") {
145
+ // the CLI throws on this; the IDE reads the config directly, so say so rather than
146
+ // silently applying no policy at all
147
+ out.invalid = `content.frontmatter.unknown must be "ignore" | "warn" | "error"; got ${JSON.stringify(fm.unknown)}`;
148
+ }
149
+ const required = fm.required;
150
+ if (required && typeof required === "object") {
151
+ for (const [glob, fields] of Object.entries(required)) {
152
+ const list = Array.isArray(fields) ? fields.filter((f) => typeof f === "string") : typeof fields === "string" ? [fields] : [];
153
+ if (list.length)
154
+ out.required.push({ glob, fields: list });
155
+ }
156
+ }
157
+ }
158
+ catch {
159
+ /* unreadable config: no policy rather than a guessed one */
160
+ }
161
+ break;
162
+ }
163
+ return out;
164
+ }
165
+ /**
166
+ * Frontmatter findings for one document. Pure: the caller supplies the raw
167
+ * source (for key lines) and the policy.
168
+ */
169
+ function frontmatterIssues(doc, keyLines, policy) {
170
+ const out = [];
171
+ const fm = doc.frontmatter ?? {};
172
+ const at = (key) => (keyLines.has(key) ? { line: keyLines.get(key) } : {});
173
+ // the fix is the EXISTING props-set verb (span-aware, re-parses what it wrote, expect-gated) —
174
+ // `expect` is the hash of the bytes this finding was computed from
175
+ const setFix = (key, value, label) => ({
176
+ fix: { verb: "props-set", args: { file: doc.path, set: { [key]: value }, expect: doc.hash }, label },
177
+ });
178
+ for (const [key, value] of Object.entries(fm)) {
179
+ const taxonomy = policy.taxonomies[key];
180
+ const spec = WELL_KNOWN_FIELDS[key];
181
+ if (AGGREGATOR_OWNED.includes(key))
182
+ continue; // reported below, once, whatever its type
183
+ if (!spec && !taxonomy) {
184
+ // an unknown-key policy applies to CONTENT only: a README or a plan in the same
185
+ // workspace is not a page, and reporting its keys is noise
186
+ if (policy.unknown !== "ignore" && contentRelativePath(policy, doc.path) !== null) {
187
+ out.push({
188
+ severity: policy.unknown === "error" ? "error" : "warning",
189
+ code: "frontmatter-unknown",
190
+ message: `unknown frontmatter key "${key}" (not a well-known field or a declared taxonomy)`,
191
+ file: doc.path,
192
+ ...at(key),
193
+ });
194
+ }
195
+ continue;
196
+ }
197
+ if (value === null || value === undefined)
198
+ continue; // an empty key is a stub, not a wrong type
199
+ // A DECLARED taxonomy wins over the built-in field spec: taxonomy terms are strings (the
200
+ // collector drops anything else), so `product: 3` must be reported even though the chunker's
201
+ // own `product` facet would have taken a number.
202
+ const effective = taxonomy ? { type: "string|string[]" } : spec;
203
+ if (effective && !matchesType(value, effective.type)) {
204
+ out.push({
205
+ severity: "error",
206
+ code: "frontmatter-type",
207
+ message: `"${key}" should be ${effective.type}, got ${typeOf(value)}`,
208
+ file: doc.path,
209
+ ...at(key),
210
+ });
211
+ continue;
212
+ }
213
+ if (spec && !matchesType(value, spec.type)) {
214
+ out.push({
215
+ severity: "error",
216
+ code: "frontmatter-type",
217
+ message: `"${key}" should be ${spec.type}, got ${typeOf(value)}`,
218
+ file: doc.path,
219
+ ...at(key),
220
+ });
221
+ continue; // an enum check on the wrong type says nothing useful
222
+ }
223
+ // `tags: api, guides` is ONE tag called "api, guides" — the coercion wraps the scalar, it
224
+ // does not split it, so the writer silently gets a tag nobody will ever match
225
+ // ONLY where the coercion wraps rather than splits: the taxonomy/meta fields. The chunker's
226
+ // facet axes split a string themselves, so a comma there is fine.
227
+ const wrapsScalar = taxonomy !== undefined || key === "tags" || key === "audience" || key === "category";
228
+ if (typeof value === "string" && /,\s*\S/.test(value) && wrapsScalar) {
229
+ out.push({
230
+ severity: "warning",
231
+ code: "frontmatter-comma-scalar",
232
+ message: `"${key}: ${value}" is ONE value containing a comma, not a list — write it as a YAML list (\`[a, b]\`) if you meant several`,
233
+ file: doc.path,
234
+ ...at(key),
235
+ });
236
+ }
237
+ // a value the pipeline discards: an empty string or an empty list is not "no value", it is a
238
+ // value that silently disappears (parseMeta drops both)
239
+ if ((typeof value === "string" && value.trim() === "") || (Array.isArray(value) && value.length === 0)) {
240
+ out.push({
241
+ severity: "warning",
242
+ code: "frontmatter-empty",
243
+ message: `"${key}" is empty, so it is dropped — remove the key or give it a value`,
244
+ file: doc.path,
245
+ ...at(key),
246
+ });
247
+ continue;
248
+ }
249
+ // a closed set: the well-known `status`, or a taxonomy that declared `values`
250
+ const allowed = spec?.values ?? taxonomy?.values;
251
+ const terms = typeof value === "string" ? [value] : Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
252
+ if (allowed) {
253
+ for (const term of terms) {
254
+ const head = taxonomy?.hierarchical ? term.split("/")[0] : term;
255
+ if (allowed.includes(term) || (taxonomy?.hierarchical && allowed.includes(head)))
256
+ continue;
257
+ // a hierarchical taxonomy declares its HEAD terms, so `apu/rest` is a near miss of `api`
258
+ const probe = taxonomy?.hierarchical ? head : term;
259
+ const near = nearMisses(probe, allowed);
260
+ // a hierarchical fix keeps the sub-term: `apu/rest` → `api/rest`, never `api`
261
+ const replacement = near.length === 1
262
+ ? (taxonomy?.hierarchical && term !== head ? `${near[0]}${term.slice(head.length)}` : near[0])
263
+ : undefined;
264
+ out.push({
265
+ severity: "error",
266
+ code: "frontmatter-enum",
267
+ message: taxonomy?.hierarchical && term !== head
268
+ ? `"${key}: ${term}" starts with "${head}", which is not one of ${allowed.join(", ")}`
269
+ : `"${key}: ${term}" is not one of ${allowed.join(", ")}`,
270
+ file: doc.path,
271
+ ...at(key),
272
+ ...(near.length ? { candidates: near } : {}),
273
+ // ONLY a case slip is applied with one click: at edit distance 2 the single
274
+ // candidate for `unstable` is `stable`, and inverting a writer's meaning on a
275
+ // click is worse than making them choose
276
+ ...(replacement && typeof value === "string" && isSafeAutoFix(probe, near[0])
277
+ ? setFix(key, replacement, `Use "${replacement}"`)
278
+ : {}),
279
+ });
280
+ }
281
+ }
282
+ if (taxonomy && !taxonomy.hierarchical) {
283
+ for (const term of terms) {
284
+ if (!term.includes("/"))
285
+ continue;
286
+ out.push({
287
+ severity: "warning",
288
+ code: "frontmatter-nesting",
289
+ message: `"${key}: ${term}" uses slash nesting, but the ${key} taxonomy is not hierarchical — it will be one flat term`,
290
+ file: doc.path,
291
+ ...at(key),
292
+ });
293
+ }
294
+ }
295
+ }
296
+ // the free-form `props:` bag, against the config's `propSchema`
297
+ const props = fm.props;
298
+ if (props && typeof props === "object" && !Array.isArray(props)) {
299
+ for (const [key, value] of Object.entries(props)) {
300
+ const spec = policy.propSchema[key];
301
+ if (!spec || value === null || value === undefined)
302
+ continue;
303
+ if (typeof value !== spec.type) {
304
+ out.push({
305
+ severity: "error",
306
+ code: "frontmatter-prop-type",
307
+ message: `"props.${key}" should be ${spec.type}, got ${typeOf(value)}`,
308
+ file: doc.path,
309
+ ...at("props"),
310
+ });
311
+ continue;
312
+ }
313
+ if (spec.enum && !spec.enum.includes(String(value))) {
314
+ const near = nearMisses(String(value), spec.enum);
315
+ out.push({
316
+ severity: "error",
317
+ code: "frontmatter-prop-enum",
318
+ message: `"props.${key}: ${String(value)}" is not one of ${spec.enum.join(", ")}`,
319
+ file: doc.path,
320
+ ...at("props"),
321
+ ...(near.length ? { candidates: near } : {}),
322
+ });
323
+ }
324
+ }
325
+ }
326
+ // keys the multi-source build writes: a writer setting one by hand routes the page wrong
327
+ for (const key of AGGREGATOR_OWNED) {
328
+ if (fm[key] === undefined)
329
+ continue;
330
+ out.push({
331
+ severity: "warning",
332
+ code: "frontmatter-aggregator-owned",
333
+ message: `"${key}" is written by the multi-source build, not by hand — the build overwrites it, so setting it here has no effect`,
334
+ file: doc.path,
335
+ ...at(key),
336
+ });
337
+ }
338
+ for (const field of requiredFieldsFor(policy, doc.path)) {
339
+ const value = fm[field];
340
+ if (value === undefined || value === null || value === "") {
341
+ out.push({
342
+ severity: "error",
343
+ code: "frontmatter-required",
344
+ message: `"${field}" is required here but is ${value === undefined ? "missing" : "empty"}`,
345
+ file: doc.path,
346
+ ...at(field),
347
+ });
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+ /** A site path is a page when a content root has it as `x.md`, `x/index.md`, `x.mdx` — or an existing FILE (an asset); `_`-prefixed segments are never routed (except `_assets`). */
353
+ function resolveSitePath(index, layout, sitePath) {
354
+ const candidates = [];
355
+ let p = sitePath;
356
+ for (const pre of layout.prefixes)
357
+ if (p === pre || p.startsWith(pre + "/")) {
358
+ p = p.slice(pre.length).replace(/^\/+/, "");
359
+ break;
360
+ }
361
+ // `_`-prefixed segments are never routed by the build (fragments, includes) — except under `_assets/`, which is served
362
+ const hidden = (path) => {
363
+ if (path.startsWith("_assets/") || path.includes("/_assets/"))
364
+ return false;
365
+ return path.split("/").some((seg) => seg.startsWith("_"));
366
+ };
367
+ for (const root of layout.roots) {
368
+ const base = root ? `${root}/${p}` : p;
369
+ for (const c of [`${base}.md`, `${base}/index.md`, `${base}.mdx`]) {
370
+ candidates.push(c);
371
+ if (!hidden(c) && (index.docs.has(c) || index.maps.has(c)))
372
+ return { doc: index.docs.has(c) ? c : undefined, file: c, candidates };
373
+ }
374
+ candidates.push(base);
375
+ if (!hidden(base) && index.files.has(base))
376
+ return { file: base, candidates };
377
+ }
378
+ return { candidates };
379
+ }
380
+ export const validateCommand = defineCommand({
381
+ name: "validate",
382
+ writes: "none",
383
+ description: "Validate the workspace: links, anchors, includes, keys, and topic maps",
384
+ input,
385
+ async run(ctx) {
386
+ const index = await ctx.workspace.index();
387
+ const issues = [];
388
+ const layout = await siteLayout(ctx.workspace.storage);
389
+ const policy = await frontmatterPolicy(ctx.workspace.storage, layout);
390
+ // basename → workspace files with that name (a moved target is usually the same file elsewhere)
391
+ const byBase = new Map();
392
+ for (const f of index.files) {
393
+ const b = f.slice(f.lastIndexOf("/") + 1);
394
+ const list = byBase.get(b) ?? [];
395
+ list.push(f);
396
+ byBase.set(b, list);
397
+ }
398
+ // Basenames that say nothing about WHICH file was meant: a dead `old/index.md` must not be
399
+ // auto-pointed at whatever single other index.md exists. Listed as candidates, never fixed.
400
+ const GENERIC = new Set(["index.md", "_index.md", "readme.md", "overview.md", "introduction.md", "intro.md", "getting-started.md", "changelog.md"]);
401
+ const candidatesFor = (target, fromDoc) => (byBase.get(target.slice(target.lastIndexOf("/") + 1)) ?? []).filter((f) => f !== target && f !== fromDoc);
402
+ const autoFixable = (target, cands) => cands.length === 1 && !GENERIC.has(target.slice(target.lastIndexOf("/") + 1).toLowerCase());
403
+ /** A fix that rewrites one destination in `docPath`, when exactly one candidate exists. */
404
+ // `expect` = the hash of the bytes the problem was computed from: the verb refuses (stale-expect)
405
+ // if the file changed since, instead of retargeting whatever is there now
406
+ const retargetFix = (doc, href, line, newDest, label) => ({
407
+ fix: { verb: "link-retarget", args: { file: doc.path, from: href, to: newDest, ...(line !== undefined ? { line } : {}), expect: doc.hash }, label },
408
+ });
409
+ // The candidate is a REAL file name (decoded); a bare `](…)` destination cannot hold whitespace,
410
+ // parens or angle brackets, so the rewritten destination is percent-encoded the way the source
411
+ // href was (or, if the file name needs it, regardless) — a raw space would un-link the reference.
412
+ const destEncode = (dest, likeHref) => {
413
+ const needs = /[\s()<>]/.test(dest);
414
+ if (!needs && !/%[0-9A-Fa-f]{2}/.test(likeHref))
415
+ return dest;
416
+ return dest.split("/").map((seg) => encodeURIComponent(seg).replace(/%2F/g, "/")).join("/");
417
+ };
418
+ const relDest = (fromDoc, toFile, likeHref = "") => destEncode(relPath(dirOf(fromDoc) || ".", toFile), likeHref);
419
+ if (policy.invalid) {
420
+ issues.push({ severity: "error", code: "frontmatter-policy-invalid", message: policy.invalid, file: "dogsbay.config.yml" });
421
+ }
422
+ for (const doc of index.docs.values()) {
423
+ if (doc.parseError) {
424
+ issues.push({
425
+ severity: "error",
426
+ code: "doc-parse-failed",
427
+ message: `cannot parse: ${doc.parseError}`,
428
+ file: doc.path,
429
+ });
430
+ continue;
431
+ }
432
+ {
433
+ const fm = doc.frontmatter;
434
+ const isMapping = !!fm && typeof fm === "object" && !Array.isArray(fm);
435
+ const hasKeys = isMapping && Object.keys(fm).length > 0;
436
+ // no second read: the index carries the block's shape and key lines from its own pass
437
+ // A block that is not a MAPPING — a list, a scalar, tab-indented YAML — yields an array or
438
+ // `{}`, and EVERY field on the page is then ignored with no other symptom.
439
+ if (doc.frontmatterBlock === "unterminated") {
440
+ // the parser treats the whole file as an unterminated block: no tree, no headings,
441
+ // no links — the page renders EMPTY, and nothing else would report it
442
+ issues.push({
443
+ severity: "error",
444
+ code: "frontmatter-unterminated",
445
+ message: "the frontmatter block is never closed (`---` on its own line), so the entire page is read as frontmatter and renders empty",
446
+ file: doc.path,
447
+ line: 1,
448
+ });
449
+ }
450
+ else if (!isMapping || (!hasKeys && !doc.frontmatterEmpty)) {
451
+ // a YAML SYNTAX error (duplicate key, bad quoting) also lands here with `{}` —
452
+ // the parser's own message and line say far more than "it is not a mapping"
453
+ const yamlError = yamlErrorOf(await ctx.workspace.storage.read(doc.path).catch(() => ""));
454
+ if (yamlError) {
455
+ issues.push({
456
+ severity: "error",
457
+ code: "frontmatter-yaml-error",
458
+ message: `the frontmatter is not valid YAML: ${yamlError.message}`,
459
+ file: doc.path,
460
+ line: yamlError.line,
461
+ });
462
+ }
463
+ else
464
+ issues.push({
465
+ severity: "error",
466
+ code: "frontmatter-not-a-mapping",
467
+ message: `the frontmatter block is ${Array.isArray(fm) ? "a list" : "not a YAML mapping"} (it must be \`key: value\` pairs), so every field on this page is ignored`,
468
+ file: doc.path,
469
+ line: 1,
470
+ });
471
+ }
472
+ else if (hasKeys || requiredFieldsFor(policy, doc.path).length) {
473
+ issues.push(...frontmatterIssues(doc, doc.frontmatterKeyLines, policy));
474
+ }
475
+ }
476
+ for (const link of doc.links) {
477
+ const at = link.line !== undefined ? { line: link.line } : {};
478
+ if (link.siteAbsolute) {
479
+ // `/guides/x` is resolved the way the SITE BUILD routes it: under the content roots
480
+ // (dogsbay.config `content.sources[].path`, else the workspace root), basePath stripped,
481
+ // as `x.md` / `x/index.md` / `x.mdx` or an existing asset file — the other half of link
482
+ // checking (validate used to skip these; native corpora write every link this way)
483
+ if (!layout.checkable)
484
+ continue;
485
+ const sitePath = link.sitePath ?? "";
486
+ if (!sitePath)
487
+ continue; // "/" — the home page
488
+ const hit = resolveSitePath(index, layout, sitePath);
489
+ if (!hit.file) {
490
+ const cands = candidatesFor(`${sitePath}.md`);
491
+ issues.push({
492
+ severity: "warning",
493
+ code: "link-site-absolute-unresolved",
494
+ message: `${link.kind} "${link.href}" — no page or file at ${hit.candidates.slice(0, 3).join(", ")}${layout.roots[0] ? "" : " (workspace root taken as the content root)"}${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
495
+ file: doc.path,
496
+ ...at,
497
+ ...(cands.length ? { candidates: cands } : {}),
498
+ });
499
+ continue;
500
+ }
501
+ const foundDoc = hit.doc ? index.docs.get(hit.doc) : undefined;
502
+ if (link.fragment && foundDoc && !foundDoc.anchors.includes(link.fragment)) {
503
+ const mismatch = anchorAliases(foundDoc.headings).has(link.fragment);
504
+ const want = mismatch ? anchorAliasTargets(foundDoc.headings, foundDoc.anchors).get(link.fragment) : undefined;
505
+ const pathPart = link.href.split("#")[0] ?? "";
506
+ issues.push({
507
+ severity: "warning",
508
+ code: mismatch ? "link-anchor-rule-mismatch" : "link-anchor-missing",
509
+ message: mismatch
510
+ ? `link "${link.href}" — #${link.fragment} exists under another tool's heading-slug rule, but Dogsbay renders ${want ? `#${want}` : "a different anchor"} in ${hit.doc}`
511
+ : `link "${link.href}" — no anchor #${link.fragment} in ${hit.doc}`,
512
+ file: doc.path,
513
+ ...at,
514
+ ...(want ? retargetFix(doc, link.href, link.line, `${pathPart}#${want}`, `Use #${want}`) : {}),
515
+ });
516
+ }
517
+ continue;
518
+ }
519
+ if (!link.target)
520
+ continue; // external: not ours to check
521
+ if (link.escapes) {
522
+ // it may well exist one level up — that is a different fact from "missing",
523
+ // and the right fix (open the parent as the project, or move the file) differs
524
+ issues.push({
525
+ severity: "warning",
526
+ code: "link-escapes-workspace",
527
+ message: `${link.kind} "${link.href}" resolves outside the workspace root (${link.target}) — not checked`,
528
+ file: doc.path,
529
+ ...at,
530
+ });
531
+ continue;
532
+ }
533
+ const targetDoc = index.docs.get(link.target);
534
+ const targetExists = targetDoc !== undefined ||
535
+ index.maps.has(link.target) ||
536
+ (await ctx.workspace.storage.exists(link.target));
537
+ if (!targetExists) {
538
+ const cands = candidatesFor(link.target, doc.path);
539
+ const fragment = link.fragment ? `#${link.fragment}` : "";
540
+ issues.push({
541
+ severity: "error",
542
+ code: link.kind === "image" ? "image-target-missing" : "link-target-missing",
543
+ message: `${link.kind} "${link.href}" → ${link.target} does not exist${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
544
+ file: doc.path,
545
+ ...at,
546
+ ...(cands.length ? { candidates: cands } : {}),
547
+ ...(autoFixable(link.target, cands) ? retargetFix(doc, link.href, link.line, relDest(doc.path, cands[0], link.href) + fragment, `Point at ${cands[0]}`) : {}),
548
+ });
549
+ continue;
550
+ }
551
+ if (link.fragment && targetDoc && !targetDoc.anchors.includes(link.fragment)) {
552
+ // the anchor may exist under ANOTHER renderer's slug rule (GitHub, mkdocs): a
553
+ // migration-era mismatch the author's old toolchain accepted — say so, rather
554
+ // than reporting a dead link that is not dead in their tool
555
+ const aliases = anchorAliases(targetDoc.headings);
556
+ if (aliases.has(link.fragment)) {
557
+ const want = anchorAliasTargets(targetDoc.headings, targetDoc.anchors).get(link.fragment);
558
+ const pathPart = link.href.split("#")[0] ?? "";
559
+ issues.push({
560
+ severity: "warning",
561
+ code: "link-anchor-rule-mismatch",
562
+ message: `link "${link.href}" — #${link.fragment} exists under another tool's heading-slug rule, but Dogsbay renders ${want ? `#${want}` : "a different anchor"} in ${link.target}`,
563
+ file: doc.path,
564
+ ...at,
565
+ ...(want ? retargetFix(doc, link.href, link.line, `${pathPart}#${want}`, `Use #${want}`) : {}),
566
+ });
567
+ }
568
+ else {
569
+ issues.push({
570
+ severity: "warning",
571
+ code: "link-anchor-missing",
572
+ message: `link "${link.href}" — no anchor #${link.fragment} in ${link.target}`,
573
+ file: doc.path,
574
+ ...at,
575
+ });
576
+ }
577
+ }
578
+ }
579
+ for (const include of doc.includes) {
580
+ // Out-of-root include (#027): the build's Minja pass REFUSES these
581
+ // (path containment), so flag them here where the failure is cheap
582
+ // to see — before a build dies on it. `..`-escapes keep their
583
+ // leading segments through normalizePath; absolute raws never
584
+ // resolve workspace-relative at all. Deliberately CONSERVATIVE:
585
+ // every absolute raw is flagged, including one whose filesystem
586
+ // path happens to sit inside the build root (which the build
587
+ // would allow) — validate has no filesystem-root context, and an
588
+ // absolute include is non-portable anyway. `..\`-style raws are
589
+ // caught for Windows-authored corpora (normalizePath splits on
590
+ // `/` only, so they never surface in `target`).
591
+ if (include.target.startsWith("../") || include.raw.startsWith("/") ||
592
+ /^[A-Za-z]:[\\/]/.test(include.raw) || include.raw.startsWith("..\\")) {
593
+ issues.push({
594
+ severity: "error",
595
+ code: "include-escapes-workspace",
596
+ message: `{% include "${include.raw}" %} resolves outside the workspace — the build refuses out-of-root includes`,
597
+ file: doc.path,
598
+ ...(include.line !== undefined ? { line: include.line } : {}),
599
+ });
600
+ continue;
601
+ }
602
+ if (!(await ctx.workspace.storage.exists(include.target))) {
603
+ const cands = candidatesFor(include.target, doc.path);
604
+ issues.push({
605
+ severity: "error",
606
+ code: "include-target-missing",
607
+ message: `{% include "${include.raw}" %} → ${include.target} does not exist${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
608
+ file: doc.path,
609
+ ...(include.line !== undefined ? { line: include.line } : {}),
610
+ ...(cands.length ? { candidates: cands } : {}),
611
+ ...(autoFixable(include.target, cands) ? retargetFix(doc, include.raw, include.line, relDest(doc.path, cands[0], include.raw), `Point at ${cands[0]}`) : {}),
612
+ });
613
+ }
614
+ }
615
+ for (const name of doc.keyUses) {
616
+ if (!index.keys.has(name)) {
617
+ issues.push({
618
+ severity: "error",
619
+ code: "key-undefined",
620
+ message: `key "${name}" is referenced but defined in no keys file`,
621
+ file: doc.path,
622
+ });
623
+ }
624
+ }
625
+ }
626
+ for (const [name, defs] of index.keys) {
627
+ if (defs.length > 1) {
628
+ issues.push({
629
+ severity: "warning",
630
+ code: "key-conflict",
631
+ message: `key "${name}" defined ${defs.length} times (${defs.map((d) => d.file).join(", ")}) — first wins`,
632
+ file: defs[0].file,
633
+ });
634
+ }
635
+ }
636
+ for (const err of index.keyFileErrors) {
637
+ issues.push({
638
+ severity: "error",
639
+ code: "keys-file-invalid",
640
+ message: err.message,
641
+ file: err.file,
642
+ });
643
+ }
644
+ for (const map of index.maps.values()) {
645
+ for (const err of map.errors) {
646
+ issues.push({
647
+ severity: "error",
648
+ code: `topicmap-${err.code}`,
649
+ message: err.message,
650
+ file: err.mapPath || map.path,
651
+ });
652
+ }
653
+ }
654
+ // Root-aware walk over the map include graph. "/"-rooted hrefs
655
+ // resolve against the ROOT map's directory (the topicmap loader
656
+ // convention), so target-existence and cycle checks must carry the
657
+ // root dir through the graph — a per-map loop can't see them.
658
+ // Maps unreachable from any root still get their RELATIVE refs
659
+ // checked; their "/"-rooted refs are skipped (no root, no meaning).
660
+ const includedByRelative = new Set();
661
+ for (const map of index.maps.values()) {
662
+ for (const ref of map.refs) {
663
+ if (ref.isMap && ref.target)
664
+ includedByRelative.add(ref.target);
665
+ }
666
+ }
667
+ const roots = [...index.maps.keys()].filter((path) => !includedByRelative.has(path));
668
+ const visited = new Set();
669
+ const reportedCycles = new Set();
670
+ const reportedMissing = new Set();
671
+ const walkMaps = async (path, rootDir, trail, resolveRootRelative) => {
672
+ if (trail.includes(path)) {
673
+ const cycle = [...trail.slice(trail.indexOf(path)), path];
674
+ const key = [...cycle].sort().join("→");
675
+ if (!reportedCycles.has(key)) {
676
+ reportedCycles.add(key);
677
+ issues.push({
678
+ severity: "error",
679
+ code: "topicmap-cycle",
680
+ message: `circular topic map include: ${cycle.join(" → ")}`,
681
+ file: path,
682
+ });
683
+ }
684
+ return;
685
+ }
686
+ const wasVisited = visited.has(path);
687
+ visited.add(path);
688
+ const entry = index.maps.get(path);
689
+ for (const ref of entry?.refs ?? []) {
690
+ let target = ref.target;
691
+ if (!target && ref.rootRelative && resolveRootRelative) {
692
+ target = joinPath(rootDir, ref.href.replace(/^\/+/, ""));
693
+ }
694
+ if (!target)
695
+ continue; // external, or root-relative with no known root
696
+ if (!wasVisited) {
697
+ const missingKey = `${path}|${ref.href}`;
698
+ if (!reportedMissing.has(missingKey) &&
699
+ !(await ctx.workspace.storage.exists(target))) {
700
+ reportedMissing.add(missingKey);
701
+ issues.push({
702
+ severity: "error",
703
+ code: "topicmap-target-missing",
704
+ message: `entry "${ref.href}" → ${target} does not exist`,
705
+ file: path,
706
+ });
707
+ }
708
+ }
709
+ // Recurse into unvisited maps; ALSO re-enter a map already on
710
+ // the current trail so the cycle gets reported (visited alone
711
+ // would skip it — that's only correct for diamonds)
712
+ if (ref.isMap &&
713
+ index.maps.has(target) &&
714
+ (!visited.has(target) || trail.includes(target))) {
715
+ await walkMaps(target, rootDir, [...trail, path], resolveRootRelative);
716
+ }
717
+ }
718
+ };
719
+ for (const root of roots) {
720
+ await walkMaps(root, dirnameOf(root), [], true);
721
+ }
722
+ for (const path of index.maps.keys()) {
723
+ if (!visited.has(path))
724
+ await walkMaps(path, dirnameOf(path), [], false);
725
+ }
726
+ // the same href twice in one file is one finding (70% of rows on a real corpus were duplicates)
727
+ const seen = new Set();
728
+ const unique = issues.filter((i) => {
729
+ const k = `${i.severity}|${i.code}|${i.file ?? ""}|${i.line ?? ""}|${i.message}`;
730
+ if (seen.has(k))
731
+ return false;
732
+ seen.add(k);
733
+ return true;
734
+ });
735
+ issues.length = 0;
736
+ issues.push(...unique);
737
+ const errors = issues.filter((issue) => issue.severity === "error").length;
738
+ const warnings = issues.length - errors;
739
+ return {
740
+ data: {
741
+ checked: index.stats,
742
+ errors,
743
+ warnings,
744
+ issues,
745
+ },
746
+ issues,
747
+ summary: issues.length === 0
748
+ ? `workspace clean — ${index.stats.docs} doc(s), ${index.stats.maps} map(s), ${index.stats.keys} key(s)`
749
+ : `${errors} error(s), ${warnings} warning(s) across ${index.stats.docs} doc(s)`,
750
+ };
751
+ },
752
+ });
753
+ //# sourceMappingURL=validate.js.map