dogsbay 0.2.0-beta.34 → 0.2.0-beta.36

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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Walk one or more content directories collecting absolute-path
3
+ * asset references that the build's `copyAssets` would emit to
4
+ * `public/`. The audit rule `structure/asset-refs` cross-checks
5
+ * `<img src>` / `<a href>` references against this set so a
6
+ * misnamed file shows up as a structured Issue.
7
+ *
8
+ * Mirrors the extension set used by `copyAssets` in
9
+ * `@dogsbay/format-astro/src/project.ts`. Keep in sync — drift
10
+ * would either flag valid refs as broken or miss real misses.
11
+ */
12
+ import { existsSync, readdirSync, statSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
+ // Same sets as format-astro's copyAssets. Keep in sync.
15
+ const OPTIMIZABLE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
16
+ const PASSTHROUGH_EXTS = new Set([".svg", ".ico", ".pdf"]);
17
+ /**
18
+ * Return the set of absolute asset paths (leading slash) that
19
+ * `copyAssets` would emit for the given content directories.
20
+ *
21
+ * @param contentDirs Absolute paths, one per source.
22
+ */
23
+ export function collectAssets(contentDirs) {
24
+ const out = new Set();
25
+ for (const dir of contentDirs) {
26
+ if (!existsSync(dir))
27
+ continue;
28
+ walk(dir, dir, out);
29
+ }
30
+ return out;
31
+ }
32
+ function walk(rootDir, dir, out) {
33
+ let entries;
34
+ try {
35
+ entries = readdirSync(dir);
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ for (const entry of entries) {
41
+ const full = join(dir, entry);
42
+ let isDir = false;
43
+ try {
44
+ isDir = statSync(full).isDirectory();
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (isDir) {
50
+ walk(rootDir, full, out);
51
+ continue;
52
+ }
53
+ const dot = entry.lastIndexOf(".");
54
+ if (dot < 0)
55
+ continue;
56
+ const ext = entry.slice(dot).toLowerCase();
57
+ if (!OPTIMIZABLE_EXTS.has(ext) && !PASSTHROUGH_EXTS.has(ext))
58
+ continue;
59
+ const rel = relative(rootDir, full);
60
+ // Public URL is "/<rel>" with forward slashes — copyAssets
61
+ // copies rel-to-source verbatim into public/.
62
+ out.add("/" + rel.replace(/\\/g, "/"));
63
+ }
64
+ }
@@ -0,0 +1,132 @@
1
+ const EXTERNAL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
2
+ const ANCHOR_ONLY_RE = /^#/;
3
+ const DATA_URI_RE = /^data:/i;
4
+ const ASSET_EXT_RE = /\.(png|jpe?g|gif|webp|svg|avif|pdf|zip|csv|tsv|json|yml|yaml|toml|mp4|webm|mp3|ogg|woff2?|ttf|otf)$/i;
5
+ /**
6
+ * Heuristic for "looks like an asset reference," used to decide
7
+ * whether an `<a href>` should be validated against the asset
8
+ * set or the page set. Image src is always asset; href can be
9
+ * either. Anything ending in a known asset extension is treated
10
+ * as asset.
11
+ */
12
+ function looksLikeAsset(href) {
13
+ // Strip query + fragment before checking extension
14
+ const noFragment = href.split(/[?#]/)[0];
15
+ return ASSET_EXT_RE.test(noFragment);
16
+ }
17
+ export function validateRefs(pages, options) {
18
+ const basePath = options.basePath.replace(/\/$/, "");
19
+ const knownAssets = options.knownAssets;
20
+ // Page slug set — multiple match forms so the validator
21
+ // accepts the common ways authors write the same slug.
22
+ // For slug "tutorials/quickstart" any of these match:
23
+ // /tutorials/quickstart
24
+ // /tutorials/quickstart/
25
+ // /tutorials/quickstart.md
26
+ // /<basePath>/tutorials/quickstart (and the above three)
27
+ const pageSlugs = new Set();
28
+ for (const p of pages) {
29
+ pageSlugs.add(p.slug);
30
+ // Index pages can be referenced as their parent dir
31
+ if (p.slug === "index")
32
+ pageSlugs.add("");
33
+ if (p.slug.endsWith("/index")) {
34
+ pageSlugs.add(p.slug.slice(0, -"/index".length));
35
+ }
36
+ }
37
+ const result = { linkMisses: [], assetMisses: [] };
38
+ for (const page of pages) {
39
+ walkTree(page.tree, (href, kind) => {
40
+ check(href, kind, page.slug, basePath, pageSlugs, knownAssets, result);
41
+ });
42
+ }
43
+ return result;
44
+ }
45
+ /** Internal walk — invokes `visit` for every href/src in the tree. */
46
+ function walkTree(nodes, visit) {
47
+ for (const node of nodes) {
48
+ if (node.inline)
49
+ walkInline(node.inline, visit);
50
+ if (node.html)
51
+ walkHtml(node.html, visit);
52
+ if (node.props?.href && typeof node.props.href === "string") {
53
+ visit(node.props.href, "link");
54
+ }
55
+ if (node.props?.src && typeof node.props.src === "string") {
56
+ visit(node.props.src, "image");
57
+ }
58
+ if (node.children)
59
+ walkTree(node.children, visit);
60
+ }
61
+ }
62
+ function walkInline(nodes, visit) {
63
+ for (const node of nodes) {
64
+ if (node.type === "link" && typeof node.href === "string") {
65
+ visit(node.href, "link");
66
+ if (node.children)
67
+ walkInline(node.children, visit);
68
+ }
69
+ else if (node.type === "image" && typeof node.src === "string") {
70
+ visit(node.src, "image");
71
+ }
72
+ else if (node.type === "highlight" && node.children) {
73
+ walkInline(node.children, visit);
74
+ }
75
+ }
76
+ }
77
+ function walkHtml(html, visit) {
78
+ // Pre-rendered HTML in `node.html` (Starlight importer output,
79
+ // MkDocs raw HTML, etc.) — scan with regexes. Cheap; the audit
80
+ // doesn't need a full HTML parser for this.
81
+ const aRe = /<a\b[^>]*\shref="([^"]+)"/gi;
82
+ let m;
83
+ while ((m = aRe.exec(html)) !== null)
84
+ visit(m[1], "link");
85
+ const imgRe = /<img\b[^>]*\ssrc="([^"]+)"/gi;
86
+ while ((m = imgRe.exec(html)) !== null)
87
+ visit(m[1], "image");
88
+ }
89
+ function check(href, kind, pageSlug, basePath, pageSlugs, knownAssets, result) {
90
+ if (!href || EXTERNAL_RE.test(href))
91
+ return;
92
+ if (ANCHOR_ONLY_RE.test(href))
93
+ return;
94
+ if (DATA_URI_RE.test(href))
95
+ return;
96
+ if (!href.startsWith("/"))
97
+ return; // relative — out of scope for v1
98
+ // Strip query and fragment for the comparison.
99
+ const cleanHref = href.split(/[?#]/)[0];
100
+ // Strip basePath if the author prefixed it. We compare against
101
+ // bare slugs, so `/docs/intro` and `/intro` should both match
102
+ // page slug "intro" (basePath="/docs").
103
+ let path = cleanHref;
104
+ if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) {
105
+ path = path.slice(basePath.length);
106
+ }
107
+ // Strip leading / and trailing /, plus the `.md` extension.
108
+ let trimmed = path.replace(/^\//, "").replace(/\/$/, "");
109
+ trimmed = trimmed.replace(/\.md$/i, "");
110
+ // Image src — always asset.
111
+ // Anchor href — asset if it has an asset extension, else page.
112
+ const wantsAsset = kind === "image" || looksLikeAsset(cleanHref);
113
+ if (wantsAsset) {
114
+ // Asset paths are absolute under the public root.
115
+ // knownAssets stores entries like "/_assets/foo.png".
116
+ const lookup = cleanHref.startsWith("/") && basePath && cleanHref.startsWith(`${basePath}/`)
117
+ ? cleanHref.slice(basePath.length)
118
+ : cleanHref;
119
+ if (!knownAssets.has(lookup)) {
120
+ result.assetMisses.push({
121
+ pageSlug,
122
+ href,
123
+ resolvedAgainst: "asset",
124
+ });
125
+ }
126
+ return;
127
+ }
128
+ // Page lookup
129
+ if (!pageSlugs.has(trimmed)) {
130
+ result.linkMisses.push({ pageSlug, href, resolvedAgainst: "page" });
131
+ }
132
+ }
@@ -123,7 +123,7 @@ export const sitemapComplete = {
123
123
  severity: "warning",
124
124
  description: "Every built HTML page is listed in the sitemap.",
125
125
  run(ctx) {
126
- const { file, distRoot, allFiles } = ctx;
126
+ const { file, distRoot, allFiles, config } = ctx;
127
127
  const state = getState(distRoot);
128
128
  // Emit once per audit run — we have access to allFiles here.
129
129
  if (state.emitted.has("seo/sitemap-complete"))
@@ -134,13 +134,22 @@ export const sitemapComplete = {
134
134
  // to add here.
135
135
  return [];
136
136
  }
137
+ // urlBase comes from the path component of site.url. When the
138
+ // site is mounted at a subpath (typical GH Pages project deploy:
139
+ // `site.url: https://user.github.io/repo`), sitemap entries
140
+ // carry the urlBase prefix (the emitter writes absolute URLs),
141
+ // but the filesystem-derived urlPath below doesn't — files
142
+ // live at `dist/<page>/index.html` regardless of urlBase. Pass
143
+ // urlBase into the comparison so the suffix-strip step
144
+ // accounts for it. See plans/sitemap-audit-urlbase.md.
145
+ const urlBase = urlBaseFromSiteUrl(config?.siteUrl);
137
146
  // Map each html file to its likely "page URL" form. Astro
138
147
  // emits `docs/intro/index.html` for the URL `/docs/intro/`,
139
148
  // so we strip `/index.html` and ensure a leading slash.
140
149
  const issues = [];
141
150
  for (const f of allFiles) {
142
151
  const expectedPath = htmlPathToUrlPath(f.path);
143
- const found = pathListedInSitemap(expectedPath, state.sitemapLocs);
152
+ const found = pathListedInSitemap(expectedPath, state.sitemapLocs, urlBase);
144
153
  if (!found) {
145
154
  issues.push({
146
155
  ruleId: "seo/sitemap-complete",
@@ -216,9 +225,18 @@ function htmlPathToUrlPath(htmlPath) {
216
225
  * Sitemap entries can be absolute (`https://example.com/foo/`)
217
226
  * or relative (`/foo/`); we match either by suffix.
218
227
  *
228
+ * `urlBase` is the path component of `site.url` — e.g. `/repo` for
229
+ * a GH Pages project deploy at `https://user.github.io/repo`. When
230
+ * non-empty, the platform's sitemap emitter writes absolute URLs
231
+ * including that segment (`https://user.github.io/repo/intro/`),
232
+ * but the filesystem-derived `urlPath` doesn't have it (files live
233
+ * at `dist/intro/index.html`). Strip the urlBase off the sitemap
234
+ * suffix before comparing so the audit doesn't flag every page as
235
+ * missing on every subpath-mounted deploy.
236
+ *
219
237
  * Trailing slash tolerant: `/foo/` matches `/foo` too.
220
238
  */
221
- function pathListedInSitemap(urlPath, locs) {
239
+ function pathListedInSitemap(urlPath, locs, urlBase) {
222
240
  for (const loc of locs) {
223
241
  if (loc === urlPath)
224
242
  return true;
@@ -230,6 +248,16 @@ function pathListedInSitemap(urlPath, locs) {
230
248
  if (afterHost >= 0)
231
249
  suffix = loc.slice(afterHost);
232
250
  }
251
+ // Strip the urlBase prefix when the sitemap entry carries one
252
+ // and the comparison target doesn't.
253
+ if (urlBase) {
254
+ if (suffix === urlBase) {
255
+ suffix = "/";
256
+ }
257
+ else if (suffix.startsWith(`${urlBase}/`)) {
258
+ suffix = suffix.slice(urlBase.length);
259
+ }
260
+ }
233
261
  if (suffix === urlPath)
234
262
  return true;
235
263
  // Trailing-slash flexibility
@@ -238,3 +266,21 @@ function pathListedInSitemap(urlPath, locs) {
238
266
  }
239
267
  return false;
240
268
  }
269
+ /**
270
+ * Extract the path component of `site.url` for use as the urlBase
271
+ * stripping prefix. Returns `""` when site.url is missing, has no
272
+ * path, or doesn't parse — comparison falls back to today's
273
+ * behaviour.
274
+ */
275
+ function urlBaseFromSiteUrl(siteUrl) {
276
+ if (!siteUrl)
277
+ return "";
278
+ try {
279
+ const u = new URL(siteUrl);
280
+ const path = u.pathname.replace(/\/$/, "");
281
+ return path === "/" ? "" : path;
282
+ }
283
+ catch {
284
+ return "";
285
+ }
286
+ }
@@ -0,0 +1,41 @@
1
+ import { validateRefs } from "../../lib/validate-refs.js";
2
+ export const assetRefs = {
3
+ id: "structure/asset-refs",
4
+ category: "structure",
5
+ stage: "source-corpus",
6
+ severity: "error",
7
+ description: "Every absolute `<img src>` and asset-style `<a href>` resolves to a file under the content tree.",
8
+ run(rawCtx) {
9
+ const ctx = rawCtx;
10
+ if (!ctx.pages || ctx.pages.length === 0)
11
+ return [];
12
+ if (!ctx.knownAssets) {
13
+ // No asset set available — typically a unit-test ctx that
14
+ // didn't bother. No-op rather than false-positive.
15
+ return [];
16
+ }
17
+ const { assetMisses } = validateRefs(ctx.pages, {
18
+ basePath: ctx.basePath ?? "",
19
+ knownAssets: ctx.knownAssets,
20
+ });
21
+ return assetMisses.map((m) => {
22
+ // Where the user should drop the file to satisfy THIS ref —
23
+ // strip optional basePath, prepend `content` for the
24
+ // suggestion. (We can't know the exact content dir layout
25
+ // for multi-source sites without more plumbing; keep it
26
+ // intuitive.)
27
+ const cleanHref = m.href.split(/[?#]/)[0];
28
+ const expected = `content${cleanHref}`;
29
+ return {
30
+ ruleId: "structure/asset-refs",
31
+ severity: "error",
32
+ file: `${m.pageSlug}.md`,
33
+ message: `Asset reference ${m.href} doesn't match any file in the ` +
34
+ `content tree. To fix: correct the path, rename the ` +
35
+ `existing file to match, or drop the asset at ` +
36
+ `${expected} to satisfy this exact reference.`,
37
+ context: m.href,
38
+ };
39
+ });
40
+ },
41
+ };
@@ -11,8 +11,11 @@
11
11
  * - structure/duplicate-slugs (corpus; two sources produced the same URL)
12
12
  */
13
13
  import { registerRule } from "../../registry.js";
14
+ import { assetRefs } from "./asset-refs.js";
15
+ import { internalLinks } from "./internal-links.js";
14
16
  import { localeCoherence } from "./locale-coherence.js";
15
17
  import { namespaceCoherence } from "./namespace-coherence.js";
18
+ import { navTargetExists } from "./nav-target-exists.js";
16
19
  import { versionCoherence } from "./version-coherence.js";
17
20
  let registered = false;
18
21
  /**
@@ -26,6 +29,9 @@ export function registerStructureRules() {
26
29
  registerRule(namespaceCoherence);
27
30
  registerRule(versionCoherence);
28
31
  registerRule(localeCoherence);
32
+ registerRule(navTargetExists);
33
+ registerRule(internalLinks);
34
+ registerRule(assetRefs);
29
35
  }
30
36
  /**
31
37
  * Test-only: reset the "registered" flag so unit tests can
@@ -0,0 +1,28 @@
1
+ import { validateRefs } from "../../lib/validate-refs.js";
2
+ export const internalLinks = {
3
+ id: "structure/internal-links",
4
+ category: "structure",
5
+ stage: "source-corpus",
6
+ severity: "error",
7
+ description: "Every absolute internal `<a href>` resolves to a built page in the corpus.",
8
+ run(rawCtx) {
9
+ const ctx = rawCtx;
10
+ if (!ctx.pages || ctx.pages.length === 0)
11
+ return [];
12
+ const { linkMisses } = validateRefs(ctx.pages, {
13
+ basePath: ctx.basePath ?? "",
14
+ knownAssets: ctx.knownAssets ?? new Set(),
15
+ });
16
+ return linkMisses.map((m) => ({
17
+ ruleId: "structure/internal-links",
18
+ severity: "error",
19
+ file: `${m.pageSlug}.md`,
20
+ message: `Internal link to ${m.href} doesn't resolve to any built ` +
21
+ `page. To fix: correct the path, rename the target page ` +
22
+ `to match, or remove the link. (Common cause: page renamed ` +
23
+ `without updating the body text. ` +
24
+ `Use \`dogsbay site check\` to surface every miss.)`,
25
+ context: m.href,
26
+ }));
27
+ },
28
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `structure/nav-target-exists` — every `file:` reference in a
3
+ * declared nav file resolves to a content file on disk.
4
+ *
5
+ * Re-runs the same resolution `loadNavFile` does at build time,
6
+ * but in `collect` mode: instead of `console.warn` + label-only
7
+ * fallback (the build-pipeline behaviour), the audit surfaces
8
+ * every miss as a structured Issue. This is what makes nav-file
9
+ * typos:
10
+ *
11
+ * - count in the `dogsbay site check` summary
12
+ * - bump the exit code (error severity)
13
+ * - get categorised under `structure` for selective opt-out
14
+ *
15
+ * Without this rule, a misnamed file in nav.yml emitted a
16
+ * `[dogsbay] Nav file references missing file: ...` line to
17
+ * stderr during build but never entered the audit catalog, so
18
+ * CI couldn't catch it. Reported by the docs team against
19
+ * dogsbay-docs-platform.
20
+ *
21
+ * Source content + nav heuristic: matches `buildNavFromDirectory`
22
+ * — an explicit `source.nav` path wins, otherwise we look for
23
+ * `nav.yml` / `nav.yaml` / `nav.json` in the resolved content
24
+ * dir. If no nav file is present, the rule is a no-op (the site
25
+ * uses directory-scan nav, which has its own validation in the
26
+ * importer).
27
+ */
28
+ import { existsSync } from "node:fs";
29
+ import { isAbsolute, join, resolve } from "node:path";
30
+ import { loadNavFile } from "@dogsbay/format-dogsbay-md";
31
+ const HEURISTIC_NAV_FILES = ["nav.yml", "nav.yaml", "nav.json"];
32
+ export const navTargetExists = {
33
+ id: "structure/nav-target-exists",
34
+ category: "structure",
35
+ stage: "source-corpus",
36
+ severity: "error",
37
+ description: "Every `file:` reference in nav files (nav.yml / nav.yaml / nav.json) resolves to a content file that exists on disk.",
38
+ run(rawCtx) {
39
+ const ctx = rawCtx;
40
+ const auditSources = ctx.auditSources;
41
+ const siteRoot = ctx.siteRoot;
42
+ if (!auditSources || auditSources.length === 0 || !siteRoot) {
43
+ // No way to find nav files — typically a unit-test run with
44
+ // pre-imported nav.
45
+ return [];
46
+ }
47
+ const issues = [];
48
+ for (const src of auditSources) {
49
+ const { contentDir, navOverride } = src;
50
+ const navPath = resolveNavFile(navOverride, contentDir, siteRoot);
51
+ if (!navPath)
52
+ continue; // directory-scan source → skip
53
+ // Use `collect` mode so the loader pushes structured
54
+ // findings to onMissingTarget instead of console.warn'ing.
55
+ const misses = [];
56
+ try {
57
+ loadNavFile(navPath, contentDir, {
58
+ missingFile: "collect",
59
+ onMissingTarget: (m) => misses.push(m),
60
+ });
61
+ }
62
+ catch (err) {
63
+ // Nav file itself is unreadable / malformed — emit as a
64
+ // distinct issue. The build's heuristic-loader warns and
65
+ // falls back to directory scan; the audit makes the
66
+ // condition visible.
67
+ issues.push({
68
+ ruleId: "structure/nav-target-exists",
69
+ severity: "error",
70
+ file: navPath,
71
+ message: `Nav file ${navPath} failed to load: ${err.message}`,
72
+ });
73
+ continue;
74
+ }
75
+ for (const miss of misses) {
76
+ issues.push({
77
+ ruleId: "structure/nav-target-exists",
78
+ severity: "error",
79
+ file: navPath,
80
+ message: `Nav references missing file: ${miss.file} ` +
81
+ `(resolved to ${miss.resolvedAbs}). To fix: rename the ` +
82
+ `file to match, correct the path in the nav file, or ` +
83
+ `remove the entry.`,
84
+ context: miss.file,
85
+ });
86
+ }
87
+ }
88
+ return issues;
89
+ },
90
+ };
91
+ /**
92
+ * Mirror the resolution `buildNavFromDirectory` uses:
93
+ * 1. Explicit `source.nav` wins (relative to siteRoot)
94
+ * 2. Auto-detect nav.{yml,yaml,json} in the resolved content dir
95
+ * 3. Otherwise undefined (directory-scan nav — no nav file to audit)
96
+ */
97
+ function resolveNavFile(navOverride, contentDir, siteRoot) {
98
+ if (navOverride) {
99
+ return isAbsolute(navOverride) ? navOverride : resolve(siteRoot, navOverride);
100
+ }
101
+ for (const fileName of HEURISTIC_NAV_FILES) {
102
+ const candidate = join(contentDir, fileName);
103
+ if (existsSync(candidate))
104
+ return candidate;
105
+ }
106
+ return undefined;
107
+ }
package/dist/audit/run.js CHANGED
@@ -28,6 +28,10 @@ export function runAudit(inputs) {
28
28
  namespaces: inputs.namespaces,
29
29
  versions: inputs.versions,
30
30
  locales: inputs.locales,
31
+ auditSources: inputs.auditSources,
32
+ siteRoot: inputs.siteRoot,
33
+ basePath: inputs.basePath,
34
+ knownAssets: inputs.knownAssets,
31
35
  };
32
36
  for (const issue of rule.run(ctx)) {
33
37
  issues.push(issue);
@@ -22,6 +22,8 @@ import { collectPassthroughEntries } from "../passthrough-astro.js";
22
22
  import { configToAstroOptions, findConfig, loadConfig, resolveOutputDir, } from "../config/index.js";
23
23
  import { filterSourcesForMode, importContent, primaryModeFiltersAnything, } from "../import-content.js";
24
24
  import { resolveSource } from "../source-resolver.js";
25
+ import { collectAssets } from "../audit/lib/collect-assets.js";
26
+ import { validateRefs } from "../audit/lib/validate-refs.js";
25
27
  import { loadPlugins, runExtendConfig, runOnContentImported, runTransformNav, runEmitFiles, collectClientModules, collectStyles, collectClientConfigs, collectComponentWrappers, } from "../plugins/index.js";
26
28
  export async function siteBuild(cwd, options) {
27
29
  const startDir = resolve(cwd || ".");
@@ -137,7 +139,15 @@ export async function siteBuild(cwd, options) {
137
139
  console.log(` Output: ${outputDir}`);
138
140
  }
139
141
  // 7. Import content
140
- const importResult = await importContent(resolvedSources, config);
142
+ //
143
+ // `--strict` promotes nav-target misses to fatal at build time
144
+ // (default: warn + label-only fallback, soft). Plumbed through
145
+ // importContent → plugin → buildNavFromDirectory → loadNavFile
146
+ // via the `missingNavTargets: "fail"` import option. See
147
+ // plans/site-build-strict.md.
148
+ const importResult = await importContent(resolvedSources, config, {
149
+ missingNavTargets: options.strict ? "fail" : undefined,
150
+ });
141
151
  const { importer } = importResult;
142
152
  // Filter status: draft pages from production builds. dogsbay site
143
153
  // dev passes includeDrafts: true so drafts stay visible during
@@ -161,6 +171,38 @@ export async function siteBuild(cwd, options) {
161
171
  : draftCount > 0
162
172
  ? ` (including ${draftCount} draft${draftCount === 1 ? "" : "s"})`
163
173
  : ""));
174
+ // 7d. --strict ref validation. Phase 2 of plans/site-build-strict.md.
175
+ // Same logic as the structure/internal-links + structure/asset-refs
176
+ // audit rules — single source of truth in validateRefs. Under
177
+ // --strict, the first miss throws; without it, site check is the
178
+ // surface for these (build stays soft to preserve the dev loop).
179
+ if (options.strict === true) {
180
+ const knownAssets = collectAssets(resolvedSources);
181
+ const { linkMisses, assetMisses } = validateRefs(pages, {
182
+ basePath: config.site.basePath ?? "",
183
+ knownAssets,
184
+ });
185
+ if (linkMisses.length > 0) {
186
+ const m = linkMisses[0];
187
+ throw new Error(`Internal link to ${m.href} from ${m.pageSlug}.md doesn't ` +
188
+ `resolve to any built page. To fix: correct the path, rename ` +
189
+ `the target page to match, or remove the link.` +
190
+ (linkMisses.length > 1
191
+ ? ` (${linkMisses.length - 1} more found — run \`dogsbay site check\` for the full list.)`
192
+ : ""));
193
+ }
194
+ if (assetMisses.length > 0) {
195
+ const m = assetMisses[0];
196
+ const cleanHref = m.href.split(/[?#]/)[0];
197
+ throw new Error(`Asset reference ${m.href} from ${m.pageSlug}.md doesn't ` +
198
+ `match any file in the content tree. To fix: correct the ` +
199
+ `path, rename the existing file to match, or drop the asset ` +
200
+ `at content${cleanHref} to satisfy this exact reference.` +
201
+ (assetMisses.length > 1
202
+ ? ` (${assetMisses.length - 1} more found — run \`dogsbay site check\` for the full list.)`
203
+ : ""));
204
+ }
205
+ }
164
206
  // 7. Emit content / config-derived / agent-readiness tiers.
165
207
  // sourceDir drives asset copying + relative extraCss resolution.
166
208
  // PR 1 ships a single-source pipeline; the namespace PR will
@@ -23,6 +23,7 @@ import pc from "picocolors";
23
23
  import { configToAstroOptions, findConfig, loadConfig, resolveOutputDir, } from "../config/index.js";
24
24
  import { effectiveLocale, effectiveVersion, importContent, isLocaleAxisActive, isNamespaceAxisActive, isVersionAxisActive, } from "../import-content.js";
25
25
  import { resolveSource } from "../source-resolver.js";
26
+ import { collectAssets } from "../audit/lib/collect-assets.js";
26
27
  import { filterRules, registerRule } from "../audit/registry.js";
27
28
  import { reportExitCode, runAudit } from "../audit/run.js";
28
29
  import { loadPlugins, collectAuditRules, runOnContentImported, runTransformNav, } from "../plugins/index.js";
@@ -179,6 +180,22 @@ export async function siteCheck(cwd, options) {
179
180
  .map((s) => effectiveLocale(s, config.content.defaultLocale))
180
181
  .filter((l) => l !== undefined))
181
182
  : undefined;
183
+ // Re-resolve content sources for `structure/nav-target-exists`.
184
+ // The rule walks each source's nav file and re-runs the
185
+ // `file:` resolution that `loadNavFile` does at build time,
186
+ // surfacing misses as structured audit issues (the build-time
187
+ // `console.warn` doesn't enter the summary or exit code).
188
+ const auditSources = [];
189
+ for (const src of config.content.sources) {
190
+ const r = await resolveSource(src, siteRoot);
191
+ auditSources.push({ contentDir: r.path, navOverride: src.nav });
192
+ }
193
+ // Asset set for `structure/asset-refs` — walks every source's
194
+ // content tree for files matching `copyAssets`'s extension
195
+ // list. Keep `collectAssets` in sync with that helper or refs
196
+ // either false-positive (real assets flagged) or false-negative
197
+ // (real misses missed).
198
+ const knownAssets = collectAssets(auditSources.map((s) => s.contentDir));
182
199
  const report = runAudit({
183
200
  rules,
184
201
  pages,
@@ -189,6 +206,10 @@ export async function siteCheck(cwd, options) {
189
206
  namespaces,
190
207
  versions,
191
208
  locales,
209
+ auditSources,
210
+ siteRoot,
211
+ basePath: config.site.basePath,
212
+ knownAssets,
192
213
  });
193
214
  // ── Output ─────────────────────────────────────────────────────
194
215
  if (!options.quiet) {
@@ -180,25 +180,7 @@ export function primaryModeFiltersAnything(sources) {
180
180
  primaryCount++;
181
181
  return primaryCount > 0 && primaryCount < sources.length;
182
182
  }
183
- /**
184
- * Run the import pipeline for a Dogsbay site.
185
- *
186
- * Loops over `config.content.sources[]`, resolving each to a
187
- * filesystem path, picking its importer, and calling its
188
- * `import()`. Pages and nav are concatenated in declaration order.
189
- *
190
- * In PR 1 only the `path:` origin is supported and only single-
191
- * element `sources[]` is exercised in practice; the loop shape sets
192
- * up multi-source aggregation for the namespace / versioning /
193
- * i18n PRs.
194
- *
195
- * @param resolvedSources - absolute paths to each source's content
196
- * directory, in the same order as `config.content.sources[]`. The
197
- * caller is responsible for resolving relative source paths
198
- * against the config-file dir before invoking.
199
- * @param config - resolved DogsbayConfig (defaults applied)
200
- */
201
- export async function importContent(resolvedSources, config) {
183
+ export async function importContent(resolvedSources, config, options = {}) {
202
184
  const sources = config.content.sources;
203
185
  if (sources.length === 0) {
204
186
  throw new Error("config.content.sources must contain at least one source");
@@ -228,7 +210,7 @@ export async function importContent(resolvedSources, config) {
228
210
  }
229
211
  if (!firstImporter)
230
212
  firstImporter = importer;
231
- const opts = buildImportOptions(config, sourceCfg);
213
+ const opts = buildImportOptions(config, sourceCfg, options);
232
214
  const { pages, nav } = await importer.import(resolved, opts);
233
215
  const anyAxisActive = axes.version || axes.namespace || axes.locale;
234
216
  const prefixSegs = getPrefixSegments(sourceCfg, axes, defaults);
@@ -374,7 +356,7 @@ function resolveImporter(source, fromName) {
374
356
  * top-level fields (`section`, `codeBlockTitle`) apply to all
375
357
  * sources.
376
358
  */
377
- function buildImportOptions(config, source) {
359
+ function buildImportOptions(config, source, extra = {}) {
378
360
  const opts = {};
379
361
  if (source.nav)
380
362
  opts.nav = source.nav;
@@ -386,6 +368,9 @@ function buildImportOptions(config, source) {
386
368
  if (config.content.diagrams !== undefined) {
387
369
  opts.diagrams = config.content.diagrams;
388
370
  }
371
+ if (extra.missingNavTargets !== undefined) {
372
+ opts.missingNavTargets = extra.missingNavTargets;
373
+ }
389
374
  // MkDocs-specific
390
375
  if (source.mkdocs?.partialsDir) {
391
376
  opts.partialsDir = source.mkdocs.partialsDir;
package/dist/index.js CHANGED
@@ -117,6 +117,10 @@ site
117
117
  "Kept as a no-op for compatibility with older scripts.")
118
118
  .option("--refresh", "Wipe the per-source git cache before resolving — forces re-clone of every git: source. " +
119
119
  "Useful when a tracked branch's HEAD has moved.")
120
+ .option("--strict", "Fail the build on structural breakage — missing nav targets, " +
121
+ "malformed nav files. Recommended for CI. One flag, growing " +
122
+ "coverage; release notes call out additions. See " +
123
+ "plans/site-build-strict.md.")
120
124
  .action((dir, options) => siteBuild(dir, options));
121
125
  site
122
126
  .command("dev")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.34",
3
+ "version": "0.2.0-beta.36",
4
4
  "description": "CLI for Dogsbay — scaffold, build, and serve documentation sites with markdown / MkDocs / Obsidian / OpenAPI sources",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,14 +32,14 @@
32
32
  "picocolors": "^1.1.0",
33
33
  "prompts": "^2.4.2",
34
34
  "yaml": "^2.8.3",
35
- "@dogsbay/format-mkdocs": "0.2.0-beta.34",
36
- "@dogsbay/format-astro": "0.2.0-beta.34",
37
- "@dogsbay/format-obsidian": "0.2.0-beta.34",
38
- "@dogsbay/format-mdx": "0.2.0-beta.34",
39
- "@dogsbay/format-starlight": "0.2.0-beta.34",
40
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.34",
41
- "@dogsbay/format-openapi": "0.2.0-beta.34",
42
- "@dogsbay/types": "0.2.0-beta.34"
35
+ "@dogsbay/format-mkdocs": "0.2.0-beta.36",
36
+ "@dogsbay/format-obsidian": "0.2.0-beta.36",
37
+ "@dogsbay/format-astro": "0.2.0-beta.36",
38
+ "@dogsbay/format-mdx": "0.2.0-beta.36",
39
+ "@dogsbay/format-starlight": "0.2.0-beta.36",
40
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.36",
41
+ "@dogsbay/format-openapi": "0.2.0-beta.36",
42
+ "@dogsbay/types": "0.2.0-beta.36"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.0.0",