blume 1.1.2 → 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 (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/index.js +284 -109
  3. package/dist/cli/index.js.map +33 -33
  4. package/dist/types/ai/component-markdown.d.ts +10 -0
  5. package/dist/types/core/config-input.d.ts +44 -0
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +24 -24
  8. package/dist/types/core/schema.d.ts +282 -114
  9. package/dist/types/core/types.d.ts +14 -0
  10. package/dist/types/openapi/references.d.ts +5 -0
  11. package/docs/advanced/api-reference.mdx +20 -0
  12. package/docs/configuration/index.mdx +27 -0
  13. package/package.json +1 -1
  14. package/src/ai/component-markdown.ts +28 -0
  15. package/src/ai/llms.ts +11 -2
  16. package/src/ai/markdown.ts +12 -6
  17. package/src/ai/mcp/server.ts +29 -6
  18. package/src/astro/examples.ts +13 -0
  19. package/src/astro/generate.ts +141 -58
  20. package/src/astro/templates.ts +65 -21
  21. package/src/audit/checks/duplicates.ts +15 -6
  22. package/src/audit/checks/indexability.ts +11 -2
  23. package/src/audit/checks/network.ts +22 -8
  24. package/src/audit/checks/sitemap.ts +42 -16
  25. package/src/audit/redirects.ts +12 -1
  26. package/src/audit/run.ts +13 -3
  27. package/src/audit/url.ts +21 -2
  28. package/src/cli/commands/audit.ts +21 -6
  29. package/src/cli/commands/dev.ts +19 -2
  30. package/src/components/content/Frame.astro +4 -1
  31. package/src/components/content/Prompt.astro +4 -1
  32. package/src/components/content/Tooltip.astro +4 -1
  33. package/src/components/content/Update.astro +45 -0
  34. package/src/components/islands/ask-ai.tsx +19 -2
  35. package/src/components/islands/hooks.ts +38 -11
  36. package/src/components/layout/Logo.astro +2 -2
  37. package/src/components/layout/RootLayout.astro +27 -7
  38. package/src/components/layout/Search.astro +5 -1
  39. package/src/components/layout/head-scripts.ts +22 -5
  40. package/src/components/openapi/ApiTagOperations.astro +17 -8
  41. package/src/core/config-input.ts +45 -0
  42. package/src/core/data.ts +2 -0
  43. package/src/core/date-format.ts +17 -0
  44. package/src/core/deployment-env.ts +7 -2
  45. package/src/core/graph.ts +7 -1
  46. package/src/core/i18n.ts +10 -2
  47. package/src/core/navigation.ts +7 -3
  48. package/src/core/project-graph.ts +9 -0
  49. package/src/core/schema.ts +64 -0
  50. package/src/core/sources/normalize.ts +69 -8
  51. package/src/core/sources/notion.ts +4 -2
  52. package/src/core/sources/sanity.ts +5 -3
  53. package/src/core/types.ts +16 -0
  54. package/src/markdown/code-title.ts +7 -1
  55. package/src/openapi/model.ts +31 -2
  56. package/src/openapi/references.ts +6 -0
  57. package/src/openapi/render-mdx.ts +12 -7
  58. package/src/openapi/scalar.ts +4 -0
  59. package/src/registry/eject.ts +6 -3
  60. package/src/theme/entry.ts +7 -0
  61. package/src/theme/twoslash.ts +10 -0
@@ -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";
@@ -58,7 +59,7 @@ import { buildThemeCss } from "../theme/palette.ts";
58
59
  import { twoslashCss } from "../theme/twoslash.ts";
59
60
  import { planComponentSlots } from "./component-slots.ts";
60
61
  import type { ComponentSlotPlan } from "./component-slots.ts";
61
- import { discoverExamples } from "./examples.ts";
62
+ import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
62
63
  import { discoverIslands } from "./islands.ts";
63
64
  import {
64
65
  customOgRoutes,
@@ -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
@@ -160,8 +163,9 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
160
163
  };
161
164
 
162
165
  /**
163
- * Realpath of the `astro` package reachable through the normal node_modules
164
- * ancestor walk from a generated runtime, or null when none resolves.
166
+ * The `astro` package reachable through the normal node_modules ancestor walk
167
+ * from a generated runtime its realpath'd `package.json` plus the
168
+ * `node_modules` directory the walk found it in — or null when none resolves.
165
169
  *
166
170
  * This deliberately does not use `createRequire().resolve()`. pnpm's generated
167
171
  * bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
@@ -170,13 +174,21 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
170
174
  * reachable skips the dependency link and makes `import "astro/config"` fail.
171
175
  * Walking the physical node_modules ancestors mirrors the lookup that config
172
176
  * actually gets.
177
+ *
178
+ * The containing directory matters as much as the package: under an isolated
179
+ * linker the walk can find a store-deduped astro in a directory that holds
180
+ * nothing else of Blume's, so "the right astro resolves" does not imply "the
181
+ * integrations resolve" — callers must check where the hit came from.
173
182
  */
174
- const resolvedAstroPath = (fromDir: string): string | null => {
183
+ const resolvedAstroHit = (
184
+ fromDir: string
185
+ ): { modulesDir: string; pkg: string } | null => {
175
186
  let dir = normalize(fromDir);
176
187
  while (true) {
177
- const resolved = resolveAstroPackageJson(join(dir, "node_modules"));
178
- if (resolved) {
179
- return resolved;
188
+ const modulesDir = join(dir, "node_modules");
189
+ const pkg = resolveAstroPackageJson(modulesDir);
190
+ if (pkg) {
191
+ return { modulesDir, pkg };
180
192
  }
181
193
  const parent = dirname(dir);
182
194
  if (parent === dir) {
@@ -186,6 +198,18 @@ const resolvedAstroPath = (fromDir: string): string | null => {
186
198
  }
187
199
  };
188
200
 
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 => {
206
+ try {
207
+ return realpathSync(a) === realpathSync(b);
208
+ } catch {
209
+ return false;
210
+ }
211
+ };
212
+
189
213
  /**
190
214
  * The two places an installer can put Blume's dependencies:
191
215
  * - `<blume>/node_modules` — deps nested under the package (workspace source,
@@ -263,6 +287,17 @@ const linkDepsJunction = async (
263
287
  if (!existing.isSymbolicLink()) {
264
288
  return;
265
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
+ }
266
301
  await rm(link, { force: true });
267
302
  }
268
303
  await mkdir(dirname(link), { recursive: true });
@@ -358,7 +393,11 @@ const dropStaleDepsLink = async (
358
393
  * install. An `overrides` pin plus an incremental `npm install` hoists
359
394
  * astro to the project root (deleting Blume's nested copy) but leaves
360
395
  * `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
361
- * upward walk from `.blume/` can't see them.
396
+ * upward walk from `.blume/` can't see them. The same shape arises under
397
+ * an isolated linker when the workspace itself declares astro at a version
398
+ * matching Blume's: the store dedupes both to one copy, so the walk finds
399
+ * the "correct" astro through the workspace's own direct-dep symlink — in
400
+ * a node_modules holding none of Blume's other deps.
362
401
  *
363
402
  * The repair is the same symlink: Blume's dependency directory linked in as
364
403
  * `.blume/node_modules` so the generated config's bare specifiers (`astro`,
@@ -387,12 +426,22 @@ export const ensureDepsLink = async (
387
426
  // that binds it to a superseded Blume — the probes below would otherwise
388
427
  // pass right through it (same astro, older blume) and leave it in place.
389
428
  await dropStaleDepsLink(join(outDir, "node_modules"), pkgDir);
390
- const outDirAstro = resolvedAstroPath(outDir);
429
+ const outDirHit = resolvedAstroHit(outDir);
391
430
  // `.blume/` resolves the very same astro Blume's deps provide.
392
- const astroCorrect = blumeAstro !== null && outDirAstro === blumeAstro;
393
- // Clean hoisted install: astro is correct and the integrations sit beside
394
- // it, so they resolve through the same walk — nothing to do.
395
- if (astroCorrect && mdxDir === astroDir) {
431
+ const astroCorrect = blumeAstro !== null && outDirHit?.pkg === blumeAstro;
432
+ // Clean hoisted install: astro is correct, found in Blume's own dependency
433
+ // directory, and the integrations sit beside it — the same walk resolves
434
+ // them too, so there is nothing to do. Requiring the walk to land in
435
+ // `astroDir` itself (not merely resolve an identical astro) matters under
436
+ // isolated linkers: a workspace that declares astro at a version matching
437
+ // Blume's gets a store-deduped symlink in its own node_modules, so the walk
438
+ // finds the "correct" astro in a directory holding only the workspace's
439
+ // direct deps — none of Blume's integrations (issue #103).
440
+ const walkLandsInDeps =
441
+ astroCorrect &&
442
+ outDirHit !== null &&
443
+ sameRealDir(outDirHit.modulesDir, astroDir);
444
+ if (walkLandsInDeps && mdxDir === astroDir) {
396
445
  return null;
397
446
  }
398
447
  // Linking the integrations' directory yields a consistent set when it also
@@ -406,7 +455,7 @@ export const ensureDepsLink = async (
406
455
  // Split layout: Blume's astro is nested (a conflicting astro took the root
407
456
  // spot) but @astrojs/mdx hoisted away from it, binding to the shadow. Only a
408
457
  // root pin fixes this — surface it.
409
- return astroConflictWarning(blumeAstro, outDirAstro);
458
+ return astroConflictWarning(blumeAstro, outDirHit?.pkg ?? null);
410
459
  };
411
460
 
412
461
  /**
@@ -555,6 +604,34 @@ const deploymentAdapterWarnings = (
555
604
  return [];
556
605
  };
557
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
+
558
635
  /** Absolute path to the configured `examples.css`, or null when unset. */
559
636
  const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
560
637
  config.examples.css ? join(root, config.examples.css) : null;
@@ -1014,6 +1091,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1014
1091
  basePath: config.basePath,
1015
1092
  codeThemes: config.markdown.codeBlocks.theme,
1016
1093
  codeWrap: config.markdown.code.wrap,
1094
+ dateFormat: config.dateFormat,
1017
1095
  description: config.description,
1018
1096
  favicon: resolveFavicon(project),
1019
1097
  feedback: config.feedback,
@@ -1236,6 +1314,15 @@ const writeNotFoundPage = async (
1236
1314
  await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
1237
1315
  };
1238
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
+
1239
1326
  export interface GenerateResult {
1240
1327
  /** Whether any structural file changed (config/page/content config). */
1241
1328
  structuralChange: boolean;
@@ -1272,6 +1359,20 @@ const buildComponentSlots = async (
1272
1359
  };
1273
1360
  };
1274
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
+
1275
1376
  /**
1276
1377
  * Write (or update) the generated `.blume/` Astro runtime for a project.
1277
1378
  * Only files whose content changed are rewritten so Vite HMR stays fast.
@@ -1335,6 +1436,9 @@ export const generateRuntime = async (
1335
1436
  tags: overrideTags,
1336
1437
  warnings: overrideWarnings,
1337
1438
  } = componentSlots;
1439
+ // Expose the discovered examples for agent-facing Markdown downleveling
1440
+ // (`<Component>` → source) before any consumer (raw `.md`, MCP, llms) runs.
1441
+ project.examples = exampleMarkdownLookup(exampleDiscovery.examples);
1338
1442
 
1339
1443
  // Each island/example framework enables its Astro renderer. React also
1340
1444
  // switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
@@ -1372,6 +1476,7 @@ export const generateRuntime = async (
1372
1476
  // sources, so the `docs` glob would otherwise scan (and watch) the whole
1373
1477
  // project root for nothing — see contentConfigTemplate.
1374
1478
  const hasFilesystemSource = project.sources.some((source) => !source.staged);
1479
+ const docsCollection = resolveDocsCollection(config, context);
1375
1480
 
1376
1481
  // All of these write to distinct generated paths and never read one another's
1377
1482
  // output, so the structural files, the per-convention hydration wrappers, and
@@ -1386,6 +1491,11 @@ export const generateRuntime = async (
1386
1491
  askPath,
1387
1492
  config,
1388
1493
  contentRoutes: project.manifest.routes.map((route) => route.path),
1494
+ contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1495
+ hasFilesystemSource,
1496
+ docsCollection.base,
1497
+ context
1498
+ ),
1389
1499
  context,
1390
1500
  dataPath,
1391
1501
  examplesPath,
@@ -1411,7 +1521,7 @@ export const generateRuntime = async (
1411
1521
  write(
1412
1522
  join(srcDir, "content.config.ts"),
1413
1523
  contentConfigTemplate({
1414
- collection: resolveDocsCollection(config, context),
1524
+ collection: docsCollection,
1415
1525
  config,
1416
1526
  context,
1417
1527
  filesystem: hasFilesystemSource,
@@ -1631,11 +1741,7 @@ export const generateRuntime = async (
1631
1741
  ...[
1632
1742
  ...validateNavTargets(project.graph.navigation, navTargetRoutes),
1633
1743
  ...validateSearchPopularIcons(config.search.popular),
1634
- ].map((diagnostic) =>
1635
- diagnostic.suggestion
1636
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1637
- : diagnostic.message
1638
- )
1744
+ ].map(diagnosticWarning)
1639
1745
  );
1640
1746
 
1641
1747
  // Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
@@ -1644,40 +1750,17 @@ export const generateRuntime = async (
1644
1750
  ...islandDiscovery.islands.map((island) => island.name),
1645
1751
  ...overrideTags,
1646
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.
1647
1757
  warnings.push(
1648
1758
  ...validateUsedComponents(
1649
1759
  project.graph.pages,
1650
1760
  knownComponentTags,
1651
1761
  new Set(registry.map((item) => item.name))
1652
- ).map((diagnostic) =>
1653
- diagnostic.suggestion
1654
- ? `${diagnostic.message} ${diagnostic.suggestion}`
1655
- : diagnostic.message
1656
- )
1657
- );
1658
-
1659
- // Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
1660
- // when the configured provider's package isn't installed. A dep is available
1661
- // if the project installed it (resolves from the root) OR Blume ships it
1662
- // (resolves from the Blume package — the same set the `.blume` deps link
1663
- // exposes to the build). Resolving from the project root alone falsely flagged
1664
- // a shipped SDK like Orama (the default provider) as missing whenever it
1665
- // wasn't hoisted into the project, e.g. under isolated linkers. We resolve
1666
- // from each package's real location rather than through the `.blume` junction,
1667
- // which can't be traversed reliably for store-symlinked deps.
1668
- for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
1669
- if (
1670
- !(canResolveFrom(context.root, dep) || canResolveFrom(packageRoot(), dep))
1671
- ) {
1672
- warnings.push(
1673
- `Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
1674
- );
1675
- }
1676
- }
1677
-
1678
- // React ships with Blume; Vue/Svelte islands need their Astro integration
1679
- // installed by the project. Warn early rather than let Vite fail to resolve it.
1680
- warnings.push(
1762
+ ).map(diagnosticWarning),
1763
+ ...searchProviderWarnings(config.search.provider, context.root),
1681
1764
  ...deploymentAdapterWarnings(config.deployment, context.root),
1682
1765
  ...islandFrameworkWarnings(frameworks, context.root)
1683
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
@@ -1445,6 +1489,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1445
1489
  page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
1446
1490
  headings={headings}
1447
1491
  toc={data.config.toc}
1492
+ dateFormat={data.config.dateFormat}
1448
1493
  themeMode={data.config.theme.mode}
1449
1494
  fontCssVars={data.fontCssVars}
1450
1495
  searchEnabled={data.config.search.enabled}
@@ -1502,6 +1547,7 @@ import RootLayout from "blume/components/layout/RootLayout.astro";
1502
1547
  import Update from "blume/components/content/Update.astro";
1503
1548
  import { withBase } from "blume/components/islands/base-path.ts";
1504
1549
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1550
+ import { resolveDateFormatOptions } from "blume/core/date-format.ts";
1505
1551
  import { layoutOverrides } from "../generated/components.ts";
1506
1552
  import data from "blume:data";
1507
1553
 
@@ -1529,8 +1575,9 @@ const localeMeta = i18n
1529
1575
  const dir = localeMeta?.dir ?? "ltr";
1530
1576
  const htmlLang = i18n ? i18n.defaultLocale : "en";
1531
1577
 
1532
- // Formatted in the same locale as the chrome, and in UTC, to match the
1533
- // per-page "last updated" stamp.
1578
+ // Formatted in the same locale as the chrome, and with the configured
1579
+ // \`dateFormat\` (UTC by default), to match the per-page "last updated" stamp.
1580
+ const dateFormatOptions = resolveDateFormatOptions(data.config.dateFormat);
1534
1581
  const formatDate = (value: string | null | undefined) => {
1535
1582
  if (!value) {
1536
1583
  return;
@@ -1538,10 +1585,7 @@ const formatDate = (value: string | null | undefined) => {
1538
1585
  const date = new Date(value);
1539
1586
  return Number.isNaN(date.getTime())
1540
1587
  ? undefined
1541
- : new Intl.DateTimeFormat(htmlLang, {
1542
- dateStyle: "long",
1543
- timeZone: "UTC",
1544
- }).format(date);
1588
+ : new Intl.DateTimeFormat(htmlLang, dateFormatOptions).format(date);
1545
1589
  };
1546
1590
 
1547
1591
  const slugify = (text: string) =>
@@ -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
  }