blume 1.1.3 → 1.1.4

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 (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli/index.js +189 -88
  3. package/dist/cli/index.js.map +22 -22
  4. package/dist/types/core/data.d.ts +2 -0
  5. package/package.json +1 -1
  6. package/src/ai/mcp/server.ts +29 -6
  7. package/src/astro/generate.ts +94 -46
  8. package/src/astro/templates.ts +59 -15
  9. package/src/audit/checks/duplicates.ts +15 -6
  10. package/src/audit/checks/indexability.ts +11 -2
  11. package/src/audit/checks/network.ts +22 -8
  12. package/src/audit/checks/sitemap.ts +42 -16
  13. package/src/audit/redirects.ts +12 -1
  14. package/src/audit/run.ts +13 -3
  15. package/src/audit/url.ts +21 -2
  16. package/src/cli/commands/audit.ts +21 -6
  17. package/src/cli/commands/dev.ts +19 -2
  18. package/src/components/content/Frame.astro +4 -1
  19. package/src/components/content/Prompt.astro +4 -1
  20. package/src/components/content/Tooltip.astro +4 -1
  21. package/src/components/content/Update.astro +45 -0
  22. package/src/components/islands/ask-ai.tsx +19 -2
  23. package/src/components/islands/hooks.ts +38 -11
  24. package/src/components/layout/RootLayout.astro +13 -2
  25. package/src/components/layout/Search.astro +5 -1
  26. package/src/components/layout/head-scripts.ts +22 -5
  27. package/src/core/data.ts +2 -0
  28. package/src/core/deployment-env.ts +7 -2
  29. package/src/core/graph.ts +7 -1
  30. package/src/core/i18n.ts +10 -2
  31. package/src/core/navigation.ts +7 -3
  32. package/src/core/sources/normalize.ts +69 -8
  33. package/src/core/sources/notion.ts +4 -2
  34. package/src/core/sources/sanity.ts +5 -3
  35. package/src/markdown/code-title.ts +7 -1
  36. package/src/openapi/model.ts +31 -2
  37. package/src/openapi/render-mdx.ts +12 -7
@@ -104,6 +104,8 @@ export interface BlumeDataConfig {
104
104
  codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
105
105
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
106
106
  codeWrap: boolean;
107
+ /** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
108
+ dateFormat: ResolvedConfig["dateFormat"];
107
109
  description: string | undefined;
108
110
  favicon: BlumeFavicon;
109
111
  feedback: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -5,7 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
- import { withBasePath } from "../../core/base-path.ts";
8
+ import { stripBasePath, withBasePath } from "../../core/base-path.ts";
9
9
  import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
10
10
  import type { OramaDoc } from "../../search/orama-index.ts";
11
11
  import type { McpData } from "./data.ts";
@@ -81,12 +81,32 @@ const asLimit = (value: unknown): number => {
81
81
  return Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT);
82
82
  };
83
83
 
84
- /** Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix). */
85
- const normalizeRoute = (input: string): string => {
86
- const noTrailing = input.trim().replace(/\/+$/u, "");
84
+ /**
85
+ * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
86
+ * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
87
+ * `site` + `deployment.base`, and an agent following "pass a route from
88
+ * `search_docs`" will hand one straight back — reducing it to its base-less,
89
+ * percent-decoded path.
90
+ */
91
+ const normalizeRoute = (input: string, data: McpData): string => {
92
+ let value = input.trim();
93
+ if (/^https?:\/\//iu.test(value)) {
94
+ try {
95
+ value = new URL(value).pathname;
96
+ } catch {
97
+ // Not parseable as a URL after all; treat it as a path.
98
+ }
99
+ }
100
+ try {
101
+ value = decodeURI(value);
102
+ } catch {
103
+ // Malformed percent sequence — compare it as written.
104
+ }
105
+ const noTrailing = value.replace(/\/+$/u, "");
87
106
  const noSuffix = noTrailing.replace(/\.mdx?$/u, "");
88
107
  const withSlash = noSuffix.startsWith("/") ? noSuffix : `/${noSuffix}`;
89
- return withSlash === "" ? "/" : withSlash;
108
+ const based = stripBasePath(data.base, withSlash);
109
+ return based === "" ? "/" : based;
90
110
  };
91
111
 
92
112
  /** Build the absolute (or root-relative) URL for a route. */
@@ -141,8 +161,11 @@ const buildServer = (
141
161
  asString(args.query),
142
162
  asLimit(args.limit)
143
163
  );
164
+ // `route` is the key `get_page` takes (the tool descriptions promise
165
+ // it); `url` is where the page is served.
144
166
  const results = hits.map((doc: OramaDoc) => ({
145
167
  excerpt: excerptFor(doc),
168
+ route: doc.route,
146
169
  title: doc.title,
147
170
  url: urlFor(doc.route, data),
148
171
  }));
@@ -150,7 +173,7 @@ const buildServer = (
150
173
  }
151
174
 
152
175
  if (name === "get_page") {
153
- const key = normalizeRoute(asString(args.route));
176
+ const key = normalizeRoute(asString(args.route), data);
154
177
  const markdown = data.pages[key];
155
178
  if (markdown === undefined) {
156
179
  return text(
@@ -3,6 +3,7 @@ import {
3
3
  lstat,
4
4
  mkdir,
5
5
  readFile,
6
+ readlink,
6
7
  realpath,
7
8
  rename,
8
9
  rm,
@@ -12,7 +13,7 @@ import {
12
13
  import { createRequire } from "node:module";
13
14
  import { pathToFileURL } from "node:url";
14
15
 
15
- import { basename, dirname, join, normalize, relative } from "pathe";
16
+ import { basename, dirname, join, normalize, relative, resolve } from "pathe";
16
17
  import { glob } from "tinyglobby";
17
18
 
18
19
  import { buildAskData } from "../ai/ask-data.ts";
@@ -39,7 +40,7 @@ import type { BlumeProject } from "../core/project-graph.ts";
39
40
  import type { ResolvedConfig } from "../core/schema.ts";
40
41
  import { resolveDocsCollection } from "../core/sources/resolve.ts";
41
42
  import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
42
- import type { Navigation } from "../core/types.ts";
43
+ import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
43
44
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
44
45
  import { resolveOgLogo } from "../og/logo.ts";
45
46
  import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
@@ -87,6 +88,7 @@ import {
87
88
  ogEndpointTemplate,
88
89
  rawMarkdownEndpointTemplate,
89
90
  rssEndpointTemplate,
91
+ runtimeDirWithin,
90
92
  staticJsonEndpointTemplate,
91
93
  runtimeDependencies,
92
94
  runtimePackageTemplate,
@@ -118,17 +120,18 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
118
120
  * that never installed the plugin directly. Resolving from `packageRoot()` binds
119
121
  * to Blume's shipped copy regardless of the user's package manager or hoisting.
120
122
  */
121
- const resolveReactCompiler = (
123
+ export const resolveReactCompiler = (
122
124
  config: ResolvedConfig,
123
- needsReact: boolean
125
+ needsReact: boolean,
126
+ pkgDir: string = packageRoot()
124
127
  ): string | null => {
125
128
  if (!(needsReact && config.react.compiler)) {
126
129
  return null;
127
130
  }
128
131
  try {
129
- return createRequire(
130
- pathToFileURL(join(packageRoot(), "_.js")).href
131
- ).resolve("babel-plugin-react-compiler");
132
+ return createRequire(pathToFileURL(join(pkgDir, "_.js")).href).resolve(
133
+ "babel-plugin-react-compiler"
134
+ );
132
135
  } catch {
133
136
  return null;
134
137
  }
@@ -137,9 +140,9 @@ const resolveReactCompiler = (
137
140
  /**
138
141
  * Warning (as a spreadable list) for the case where the React Compiler was
139
142
  * requested but its plugin couldn't be resolved — so the build silently drops
140
- * to uncompiled output rather than failing.
143
+ * to uncompiled output rather than failing. Exported for testing.
141
144
  */
142
- const reactCompilerWarnings = (
145
+ export const reactCompilerWarnings = (
143
146
  config: ResolvedConfig,
144
147
  needsReact: boolean,
145
148
  compilerPath: string | null
@@ -195,8 +198,11 @@ const resolvedAstroHit = (
195
198
  }
196
199
  };
197
200
 
198
- /** Whether two paths name the same physical directory (realpath equality). */
199
- const sameRealDir = (a: string, b: string): boolean => {
201
+ /**
202
+ * Whether two paths name the same physical directory (realpath equality).
203
+ * Exported for testing.
204
+ */
205
+ export const sameRealDir = (a: string, b: string): boolean => {
200
206
  try {
201
207
  return realpathSync(a) === realpathSync(b);
202
208
  } catch {
@@ -281,6 +287,17 @@ const linkDepsJunction = async (
281
287
  if (!existing.isSymbolicLink()) {
282
288
  return;
283
289
  }
290
+ // Already pointing at the right target — leave it alone. This runs on
291
+ // every dev regeneration, and an unconditional rm+recreate opens a window
292
+ // in which the Vite server's module resolution races a missing
293
+ // `node_modules` and 500s intermittently.
294
+ try {
295
+ if (resolve(dirname(link), await readlink(link)) === resolve(depsDir)) {
296
+ return;
297
+ }
298
+ } catch {
299
+ // Unreadable link — replace it below.
300
+ }
284
301
  await rm(link, { force: true });
285
302
  }
286
303
  await mkdir(dirname(link), { recursive: true });
@@ -587,6 +604,34 @@ const deploymentAdapterWarnings = (
587
604
  return [];
588
605
  };
589
606
 
607
+ /**
608
+ * Warn when the configured search provider's SDK is missing. Provider SDKs are
609
+ * optional peers; warn (rather than fail opaquely in Vite) when the package
610
+ * isn't installed. A dep is available if the project installed it (resolves
611
+ * from the root) OR Blume ships it (resolves from the Blume package — the same
612
+ * set the `.blume` deps link exposes to the build). Resolving from the project
613
+ * root alone falsely flagged a shipped SDK like Orama (the default provider)
614
+ * as missing whenever it wasn't hoisted into the project, e.g. under isolated
615
+ * linkers. We resolve from each package's real location rather than through
616
+ * the `.blume` junction, which can't be traversed reliably for store-symlinked
617
+ * deps. `pkgDir` is injectable for testing.
618
+ */
619
+ export const searchProviderWarnings = (
620
+ provider: ResolvedConfig["search"]["provider"],
621
+ root: string,
622
+ pkgDir: string = packageRoot()
623
+ ): string[] => {
624
+ const warnings: string[] = [];
625
+ for (const dep of searchProviderMeta(provider).runtimeDeps) {
626
+ if (!(canResolveFrom(root, dep) || canResolveFrom(pkgDir, dep))) {
627
+ warnings.push(
628
+ `Search provider "${provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
629
+ );
630
+ }
631
+ }
632
+ return warnings;
633
+ };
634
+
590
635
  /** Absolute path to the configured `examples.css`, or null when unset. */
591
636
  const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
592
637
  config.examples.css ? join(root, config.examples.css) : null;
@@ -1046,6 +1091,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1046
1091
  basePath: config.basePath,
1047
1092
  codeThemes: config.markdown.codeBlocks.theme,
1048
1093
  codeWrap: config.markdown.code.wrap,
1094
+ dateFormat: config.dateFormat,
1049
1095
  description: config.description,
1050
1096
  favicon: resolveFavicon(project),
1051
1097
  feedback: config.feedback,
@@ -1268,6 +1314,15 @@ const writeNotFoundPage = async (
1268
1314
  await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1269
1315
  };
1270
1316
 
1317
+ /**
1318
+ * Flatten a diagnostic to a single warning line, appending the suggestion when
1319
+ * one exists. Exported for testing.
1320
+ */
1321
+ export const diagnosticWarning = (diagnostic: Diagnostic): string =>
1322
+ diagnostic.suggestion
1323
+ ? `${diagnostic.message} ${diagnostic.suggestion}`
1324
+ : diagnostic.message;
1325
+
1271
1326
  export interface GenerateResult {
1272
1327
  /** Whether any structural file changed (config/page/content config). */
1273
1328
  structuralChange: boolean;
@@ -1304,6 +1359,20 @@ const buildComponentSlots = async (
1304
1359
  };
1305
1360
  };
1306
1361
 
1362
+ /**
1363
+ * Whether the docs glob-loader's watcher observes the runtime dir: a
1364
+ * filesystem collection whose base contains it (a migrated, `content.root:
1365
+ * "."` project) — the one layout where the dev watcher must be kept out of
1366
+ * Astro's cache dir. See `devWatchOption` in templates.ts.
1367
+ */
1368
+ const contentWatchesRuntimeDir = (
1369
+ hasFilesystemSource: boolean,
1370
+ collectionBase: string,
1371
+ context: ProjectContext
1372
+ ): boolean =>
1373
+ hasFilesystemSource &&
1374
+ runtimeDirWithin(collectionBase, context.outDir) !== null;
1375
+
1307
1376
  /**
1308
1377
  * Write (or update) the generated `.blume/` Astro runtime for a project.
1309
1378
  * Only files whose content changed are rewritten so Vite HMR stays fast.
@@ -1407,6 +1476,7 @@ export const generateRuntime = async (
1407
1476
  // sources, so the `docs` glob would otherwise scan (and watch) the whole
1408
1477
  // project root for nothing — see contentConfigTemplate.
1409
1478
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
1479
+ const docsCollection = resolveDocsCollection(config, context);
1410
1480
 
1411
1481
  // All of these write to distinct generated paths and never read one another's
1412
1482
  // output, so the structural files, the per-convention hydration wrappers, and
@@ -1421,6 +1491,11 @@ export const generateRuntime = async (
1421
1491
  askPath,
1422
1492
  config,
1423
1493
  contentRoutes: project.manifest.routes.map((route) => route.path),
1494
+ contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1495
+ hasFilesystemSource,
1496
+ docsCollection.base,
1497
+ context
1498
+ ),
1424
1499
  context,
1425
1500
  dataPath,
1426
1501
  examplesPath,
@@ -1446,7 +1521,7 @@ export const generateRuntime = async (
1446
1521
  write(
1447
1522
  join(srcDir, "content.config.ts"),
1448
1523
  contentConfigTemplate({
1449
- collection: resolveDocsCollection(config, context),
1524
+ collection: docsCollection,
1450
1525
  config,
1451
1526
  context,
1452
1527
  filesystem: hasFilesystemSource,
@@ -1666,11 +1741,7 @@ export const generateRuntime = async (
1666
1741
  ...[
1667
1742
  ...validateNavTargets(project.graph.navigation, navTargetRoutes),
1668
1743
  ...validateSearchPopularIcons(config.search.popular),
1669
- ].map((diagnostic) =>
1670
- diagnostic.suggestion
1671
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1672
- : diagnostic.message
1673
- )
1744
+ ].map(diagnosticWarning)
1674
1745
  );
1675
1746
 
1676
1747
  // Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
@@ -1679,40 +1750,17 @@ export const generateRuntime = async (
1679
1750
  ...islandDiscovery.islands.map((island) => island.name),
1680
1751
  ...overrideTags,
1681
1752
  ]);
1753
+ // Missing-dependency preflights: the search provider's SDK, the deployment
1754
+ // adapter's package, and — since React ships with Blume while Vue/Svelte
1755
+ // don't — any island framework's Astro integration. Warn early rather than
1756
+ // let Vite fail to resolve them opaquely.
1682
1757
  warnings.push(
1683
1758
  ...validateUsedComponents(
1684
1759
  project.graph.pages,
1685
1760
  knownComponentTags,
1686
1761
  new Set(registry.map((item) => item.name))
1687
- ).map((diagnostic) =>
1688
- diagnostic.suggestion
1689
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1690
- : diagnostic.message
1691
- )
1692
- );
1693
-
1694
- // Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
1695
- // when the configured provider's package isn't installed. A dep is available
1696
- // if the project installed it (resolves from the root) OR Blume ships it
1697
- // (resolves from the Blume package — the same set the `.blume` deps link
1698
- // exposes to the build). Resolving from the project root alone falsely flagged
1699
- // a shipped SDK like Orama (the default provider) as missing whenever it
1700
- // wasn't hoisted into the project, e.g. under isolated linkers. We resolve
1701
- // from each package's real location rather than through the `.blume` junction,
1702
- // which can't be traversed reliably for store-symlinked deps.
1703
- for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
1704
- if (
1705
- !(canResolveFrom(context.root, dep) || canResolveFrom(packageRoot(), dep))
1706
- ) {
1707
- warnings.push(
1708
- `Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
1709
- );
1710
- }
1711
- }
1712
-
1713
- // React ships with Blume; Vue/Svelte islands need their Astro integration
1714
- // installed by the project. Warn early rather than let Vite fail to resolve it.
1715
- warnings.push(
1762
+ ).map(diagnosticWarning),
1763
+ ...searchProviderWarnings(config.search.provider, context.root),
1716
1764
  ...deploymentAdapterWarnings(config.deployment, context.root),
1717
1765
  ...islandFrameworkWarnings(frameworks, context.root)
1718
1766
  );
@@ -261,6 +261,35 @@ const reactIntegration = (compilerPath: string | null | undefined): string =>
261
261
  ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
262
262
  : `react({ ${REACT_EXCLUDE} })`;
263
263
 
264
+ /**
265
+ * The `server.watch` block for the generated dev config. Keeps the watcher out
266
+ * of Astro's cache dir — but ONLY when the docs collection is rooted at a
267
+ * directory containing the runtime dir (a migrated, `content.root: "."`
268
+ * project). There, the glob loader's watcher match (`picomatch.isMatch(entry,
269
+ * pattern)` with array-OR semantics, where any negated pattern matches
270
+ * unrelated files) fires on every `.blume/.astro` write — "No entry type
271
+ * found" noise, and a `data-store.json` event can re-ingest the store file as
272
+ * a JSON entry and loop the sync. Everywhere else the watcher MUST see
273
+ * `.astro/data-store.json`: its change events are the only trigger for
274
+ * Astro's dev-time content invalidation (see vite-plugin-content-virtual-mod),
275
+ * and `.md` bodies are rendered into the store at load time — so ignoring the
276
+ * file serves stale `.md` HTML on every request until the server restarts,
277
+ * even though the loader logs a reload.
278
+ */
279
+ const devWatchOption = (
280
+ outDir: string,
281
+ contentWatchesRuntimeDir: boolean | undefined
282
+ ): string =>
283
+ contentWatchesRuntimeDir
284
+ ? `
285
+ // Astro's cache dir sits inside the docs collection, whose watcher would
286
+ // otherwise churn (and can loop) on Astro's own writes. Trade-off: .md
287
+ // body edits need a dev-server restart in this layout.
288
+ watch: {
289
+ ignored: ${JSON.stringify([join(outDir, ".astro", "**")])},
290
+ },`
291
+ : "";
292
+
264
293
  export const astroConfigTemplate = (options: {
265
294
  context: ProjectContext;
266
295
  config: ResolvedConfig;
@@ -286,6 +315,13 @@ export const astroConfigTemplate = (options: {
286
315
  reactCompilerPath?: string | null;
287
316
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
288
317
  aliases?: Record<string, string>;
318
+ /**
319
+ * Whether the filesystem `docs` collection is rooted at a directory that
320
+ * contains the runtime dir (a migrated, `content.root: "."` project) — the
321
+ * only layout where the dev watcher must be kept out of Astro's cache dir.
322
+ * See {@link devWatchOption} for why this must stay scoped.
323
+ */
324
+ contentWatchesRuntimeDir?: boolean;
289
325
  }): string => {
290
326
  const { context, config, needsReact, pages, dataPath, themePath } = options;
291
327
  const {
@@ -446,6 +482,11 @@ export const astroConfigTemplate = (options: {
446
482
  `blumeIntegration(${JSON.stringify({ base: deployment.base, contentRoutes, pages })})`
447
483
  );
448
484
 
485
+ const watchOption = devWatchOption(
486
+ context.outDir,
487
+ options.contentWatchesRuntimeDir
488
+ );
489
+
449
490
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
450
491
  ${defineConfigImport}
451
492
  import mdx from "@astrojs/mdx";
@@ -522,16 +563,7 @@ export default defineConfig({
522
563
  server: {
523
564
  fs: {
524
565
  allow: ${JSON.stringify(fsAllow)},
525
- },
526
- // Keep the file watcher out of Astro's own cache dir. In a migrated
527
- // (root-rooted) project the docs collection is rooted at the project dir,
528
- // so its glob-loader watcher would otherwise fire on every write Astro
529
- // makes under .blume/.astro (data-store.json, content module manifests,
530
- // self-hosted fonts) -- pure noise the loader logs as "No entry type
531
- // found". Vite appends this to its default ignores.
532
- watch: {
533
- ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
534
- },
566
+ },${watchOption}
535
567
  },
536
568
  },
537
569
  });
@@ -542,6 +574,21 @@ export default defineConfig({
542
574
  export const stagedContentDir = (outDir: string): string =>
543
575
  join(outDir, "content");
544
576
 
577
+ /**
578
+ * The runtime dir relative to the docs collection `base` when it sits inside
579
+ * it (a migrated, `content.root: "."` project) — null when it lives elsewhere.
580
+ * Drives both the collection's negative glob (`contentConfigTemplate`) and
581
+ * whether the dev watcher is kept out of Astro's cache dir (the
582
+ * `contentWatchesRuntimeDir` option of `astroConfigTemplate`).
583
+ */
584
+ export const runtimeDirWithin = (
585
+ base: string,
586
+ outDir: string
587
+ ): string | null => {
588
+ const rel = relative(base, outDir);
589
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : null;
590
+ };
591
+
545
592
  /**
546
593
  * Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
547
594
  * Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
@@ -585,11 +632,8 @@ export const contentConfigTemplate = (options: {
585
632
  // collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
586
633
  // staged bodies under `.blume/content`, …) as entries. This matters when
587
634
  // the collection base is the project root (a migrated `.`-rooted project).
588
- const outDirRel = relative(collectionBase, context.outDir);
589
- const outDirIgnore =
590
- outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
591
- ? [`!${outDirRel}/**`]
592
- : [];
635
+ const outDirRel = runtimeDirWithin(collectionBase, context.outDir);
636
+ const outDirIgnore = outDirRel ? [`!${outDirRel}/**`] : [];
593
637
 
594
638
  // With no filesystem source, no route renders through `docs`, so glob nothing.
595
639
  // Beyond skipping wasted work, this is the only thing that keeps Astro's
@@ -1,17 +1,24 @@
1
+ import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
1
2
  import type { Diagnostic } from "../../core/types.ts";
2
3
  import { finding } from "../catalog.ts";
3
4
  import type { CheckId } from "../catalog.ts";
4
5
  import { pageSite } from "../locate.ts";
5
6
  import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
7
+ import { decodePath } from "../url.ts";
6
8
 
7
- const isNonCanonical = (page: PageSnapshot): boolean => {
9
+ const isNonCanonical = (page: PageSnapshot, deployBase: string): boolean => {
8
10
  if (!page.canonical) {
9
11
  return false;
10
12
  }
11
13
  try {
14
+ // Canonicals are emitted as `site + base + route`; page URLs carry no
15
+ // deployment base — without stripping it, every page of a subpath
16
+ // deployment would look non-canonical and escape these checks entirely.
12
17
  return (
13
- new URL(page.canonical).pathname.replace(/\/$/u, "") !==
14
- page.url.replace(/\/$/u, "")
18
+ stripBasePath(
19
+ deployBase,
20
+ decodePath(new URL(page.canonical).pathname)
21
+ ).replace(/\/$/u, "") !== page.url.replace(/\/$/u, "")
15
22
  );
16
23
  } catch {
17
24
  return false;
@@ -19,8 +26,9 @@ const isNonCanonical = (page: PageSnapshot): boolean => {
19
26
  };
20
27
 
21
28
  /** Pages that can meaningfully be compared against each other for duplication. */
22
- const comparable = (context: AuditContext): PageSnapshot[] =>
23
- context.pages.filter(
29
+ const comparable = (context: AuditContext): PageSnapshot[] => {
30
+ const deployBase = normalizeBasePath(context.project.config.deployment.base);
31
+ return context.pages.filter(
24
32
  (page) =>
25
33
  page.indexable &&
26
34
  // A fallback page renders the default locale's content at a localized URL.
@@ -29,8 +37,9 @@ const comparable = (context: AuditContext): PageSnapshot[] =>
29
37
  !page.route?.fallback &&
30
38
  // A page that points its canonical elsewhere has already declared itself a
31
39
  // duplicate; that's the mechanism working, not a finding.
32
- !isNonCanonical(page)
40
+ !isNonCanonical(page, deployBase)
33
41
  );
42
+ };
34
43
 
35
44
  /**
36
45
  * Group pages by a value and report every group with more than one member.
@@ -1,10 +1,11 @@
1
+ import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
1
2
  import { SITE_INFERRING_ADAPTERS } from "../../core/deployment-env.ts";
2
3
  import type { Diagnostic } from "../../core/types.ts";
3
4
  import { finding } from "../catalog.ts";
4
5
  import { pageSite } from "../locate.ts";
5
6
  import { ERROR_ROUTES } from "../types.ts";
6
7
  import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
7
- import { normalizePath, siteOrigin } from "../url.ts";
8
+ import { decodePath, normalizePath, siteOrigin } from "../url.ts";
8
9
 
9
10
  /** The canonical URL parsed, or null when it isn't a usable absolute URL. */
10
11
  const parseCanonical = (page: PageSnapshot): URL | null => {
@@ -76,7 +77,15 @@ const canonicalChecks = (
76
77
  return found;
77
78
  }
78
79
 
79
- const target = normalizePath(canonical.pathname);
80
+ // Canonicals are emitted as `site + base + route`; page URLs and `byUrl`
81
+ // keys carry no deployment base, so strip it (and percent-encoding) before
82
+ // comparing.
83
+ const target = normalizePath(
84
+ stripBasePath(
85
+ normalizeBasePath(context.project.config.deployment.base),
86
+ decodePath(canonical.pathname)
87
+ )
88
+ );
80
89
  if (target === normalizePath(page.url)) {
81
90
  return found;
82
91
  }
@@ -12,9 +12,17 @@ const SERVER_ERROR = 500;
12
12
  /** Past this, a page is slow enough that it costs you crawl budget and readers. */
13
13
  const SLOW_MS = 1500;
14
14
 
15
- /** The live URL a built page is served at, under the `--url` origin. */
16
- const liveUrl = (origin: string, page: PageSnapshot): string =>
17
- new URL(page.url, origin).toString();
15
+ /**
16
+ * The live URL a built page is served at, under the `--url` origin. Page URLs
17
+ * come from the built file tree and carry no `deployment.base`, but the live
18
+ * site serves everything under it — probing without the base would 4xx every
19
+ * page of a healthy subpath deployment.
20
+ */
21
+ const liveUrl = (
22
+ origin: string,
23
+ page: PageSnapshot,
24
+ deployBase: string
25
+ ): string => new URL(`${deployBase}${page.url}`, origin).toString();
18
26
 
19
27
  /**
20
28
  * Whether the response failed outright, and how. Null when the page is served.
@@ -144,17 +152,23 @@ export const networkChecks: CheckModule = {
144
152
  }
145
153
 
146
154
  const found: Diagnostic[] = [];
147
- const targets = context.pages.map((page) => liveUrl(origin, page));
155
+ const deployBase = normalizeBasePath(
156
+ context.project.config.deployment.base
157
+ );
158
+ const targets = context.pages.map((page) =>
159
+ liveUrl(origin, page, deployBase)
160
+ );
148
161
  // robots.txt and sitemap.xml are fetched alongside the pages: they're the
149
162
  // two files a crawler asks for first, and a deploy that hides them silently
150
- // undoes everything else the audit checks.
151
- const robotsUrl = new URL("/robots.txt", origin).toString();
152
- const sitemapUrl = new URL("/sitemap.xml", origin).toString();
163
+ // undoes everything else the audit checks. They sit at the root of the
164
+ // build output, which the host serves under the deployment base.
165
+ const robotsUrl = new URL(`${deployBase}/robots.txt`, origin).toString();
166
+ const sitemapUrl = new URL(`${deployBase}/sitemap.xml`, origin).toString();
153
167
 
154
168
  const results = await probeAll([...targets, robotsUrl, sitemapUrl]);
155
169
 
156
170
  for (const page of context.pages) {
157
- const result = results.get(liveUrl(origin, page));
171
+ const result = results.get(liveUrl(origin, page, deployBase));
158
172
  if (!result) {
159
173
  continue;
160
174
  }