blume 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -8,6 +8,7 @@ import { askBackendRuntimeDep } from "../ai/ask.ts";
8
8
  import type { AskBackend } from "../ai/ask.ts";
9
9
  import { buildHomeLinkHeader } from "../ai/link-headers.ts";
10
10
  import { normalizeBasePath } from "../core/base-path.ts";
11
+ import { TOC_HIDDEN_KEY } from "../core/heading-markers.ts";
11
12
  import type { ResolvedConfig } from "../core/schema.ts";
12
13
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
13
14
  import { trimChar } from "../core/trim.ts";
@@ -16,7 +17,7 @@ import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
16
17
  import type { OgFont, OgFontFamilies } from "../og/card.ts";
17
18
  import { hasScalarReferences } from "../openapi/references.ts";
18
19
  import { searchProviderMeta } from "../search/providers.ts";
19
- import { buildFontEntries } from "../theme/fonts.ts";
20
+ import { buildFontEntries, fontLocaleCodes } from "../theme/fonts.ts";
20
21
  import type { ExampleSpec } from "./examples.ts";
21
22
  import type { BlumePageRoute } from "./integration.ts";
22
23
  import type { IslandSpec } from "./islands.ts";
@@ -307,35 +308,6 @@ const reactIntegration = (compilerPath: string | null | undefined): string =>
307
308
  ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
308
309
  : `react({ ${REACT_EXCLUDE} })`;
309
310
 
310
- /**
311
- * The `server.watch` block for the generated dev config. Keeps the watcher out
312
- * of Astro's cache dir — but ONLY when the docs collection is rooted at a
313
- * directory containing the runtime dir (a migrated, `content.root: "."`
314
- * project). There, the glob loader's watcher match (`picomatch.isMatch(entry,
315
- * pattern)` with array-OR semantics, where any negated pattern matches
316
- * unrelated files) fires on every `.blume/.astro` write — "No entry type
317
- * found" noise, and a `data-store.json` event can re-ingest the store file as
318
- * a JSON entry and loop the sync. Everywhere else the watcher MUST see
319
- * `.astro/data-store.json`: its change events are the only trigger for
320
- * Astro's dev-time content invalidation (see vite-plugin-content-virtual-mod),
321
- * and `.md` bodies are rendered into the store at load time — so ignoring the
322
- * file serves stale `.md` HTML on every request until the server restarts,
323
- * even though the loader logs a reload.
324
- */
325
- const devWatchOption = (
326
- outDir: string,
327
- contentWatchesRuntimeDir: boolean | undefined
328
- ): string =>
329
- contentWatchesRuntimeDir
330
- ? `
331
- // Astro's cache dir sits inside the docs collection, whose watcher would
332
- // otherwise churn (and can loop) on Astro's own writes. Trade-off: .md
333
- // body edits need a dev-server restart in this layout.
334
- watch: {
335
- ignored: ${JSON.stringify([join(outDir, ".astro", "**")])},
336
- },`
337
- : "";
338
-
339
311
  interface IntegrationBridgeOptions {
340
312
  /** Config path relative to the generated Astro config. */
341
313
  configFile: string;
@@ -387,8 +359,11 @@ interface OptimizeDepsConfig {
387
359
  * the Vite root is the generated runtime, so user pages, convention islands,
388
360
  * and alias-reachable components all live outside it and are otherwise only
389
361
  * crawled when first requested. The compiler runtime rides the include list
390
- * because it is Babel-injected and no source scan can see it. See the
391
- * optimizeDeps comment in the generated config for the failure this prevents.
362
+ * because it is Babel-injected and no source scan can see it. @vitejs/plugin-react
363
+ * would add it itself, but only when the babel plugin is passed by its bare
364
+ * name (`getReactCompilerPlugin` is an exact string match) — Blume passes an
365
+ * absolute path (see `reactIntegration`), which that check never matches. See
366
+ * the optimizeDeps comment in the generated config for the failure this prevents.
392
367
  */
393
368
  const resolveOptimizeDeps = (options: {
394
369
  aliases: Record<string, string> | undefined;
@@ -445,12 +420,10 @@ export const astroConfigTemplate = (options: {
445
420
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
446
421
  aliases?: Record<string, string>;
447
422
  /**
448
- * Whether the filesystem `docs` collection is rooted at a directory that
449
- * contains the runtime dir (a migrated, `content.root: "."` project) — the
450
- * only layout where the dev watcher must be kept out of Astro's cache dir.
451
- * See {@link devWatchOption} for why this must stay scoped.
423
+ * The docs collection's content root; bounds `<include>` resolution in the
424
+ * processors and locates the include graph for dev-server invalidation.
452
425
  */
453
- contentWatchesRuntimeDir?: boolean;
426
+ contentRoot?: string;
454
427
  /** Bridge used to load configured integrations without serializing them. */
455
428
  integrationBridge?: IntegrationBridgeOptions;
456
429
  }): string => {
@@ -552,7 +525,12 @@ export const astroConfigTemplate = (options: {
552
525
  // `fontProviders` is only imported when at least one font is configured.
553
526
  // Local variant sources are emitted as absolute paths (the Astro root is
554
527
  // `.blume/`, not the user's project, so root-relative paths would miss).
555
- const fontEntries = buildFontEntries(config.theme.fonts);
528
+ // Subsets follow the configured locales (a Vietnamese site loads the
529
+ // `vietnamese` faces) unless a family pins its own.
530
+ const fontEntries = buildFontEntries(
531
+ config.theme.fonts,
532
+ fontLocaleCodes(config.i18n)
533
+ );
556
534
  const fontsOption = fontEntries.length
557
535
  ? `\n fonts: [${fontEntries
558
536
  .map((font) =>
@@ -588,6 +566,8 @@ export const astroConfigTemplate = (options: {
588
566
  font.cssVariable
589
567
  )}, weights: ${JSON.stringify(
590
568
  font.weights
569
+ )}, subsets: ${JSON.stringify(
570
+ font.subsets
591
571
  )}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
592
572
  )
593
573
  .join(", ")}],`
@@ -606,8 +586,8 @@ export const astroConfigTemplate = (options: {
606
586
  : "";
607
587
  const blumeImports = [
608
588
  "blumeIntegration",
589
+ "includeHmrPlugin",
609
590
  "prerenderDepsPlugin",
610
- "serverAppResolvePlugin",
611
591
  ...(adapterOption.includes("withAdapterRoot") ? ["withAdapterRoot"] : []),
612
592
  ];
613
593
  const blumeImport = `import { ${blumeImports.join(", ")} } from "blume/astro";\n`;
@@ -632,6 +612,7 @@ export const astroConfigTemplate = (options: {
632
612
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
633
613
  basePath: config.basePath,
634
614
  codeThemes: config.markdown.codeBlocks.theme,
615
+ contentRoot: options.contentRoot,
635
616
  deployBase,
636
617
  headingAnchors: config.markdown.headingAnchors,
637
618
  })}) })`,
@@ -657,10 +638,6 @@ export const astroConfigTemplate = (options: {
657
638
  })})`
658
639
  );
659
640
 
660
- const watchOption = devWatchOption(
661
- context.outDir,
662
- options.contentWatchesRuntimeDir
663
- );
664
641
  const {
665
642
  configSourceMarker,
666
643
  userConfigImports,
@@ -685,6 +662,7 @@ ${userConfigSetup}export default defineConfig({
685
662
  processor: blumeMarkdownProcessor(${JSON.stringify({
686
663
  basePath: config.basePath,
687
664
  codeThemes: config.markdown.codeBlocks.theme,
665
+ contentRoot: options.contentRoot,
688
666
  deployBase,
689
667
  headingAnchors: config.markdown.headingAnchors,
690
668
  })}),
@@ -706,7 +684,9 @@ ${userConfigSetup}export default defineConfig({
706
684
  // instantly.
707
685
  prefetch: { prefetchAll: true },
708
686
  vite: {
709
- plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
687
+ plugins: [tailwindcss(), includeHmrPlugin(${JSON.stringify(
688
+ `${context.outDir}/src/generated/includes.json`
689
+ )}), prerenderDepsPlugin()],
710
690
  // Everything hydration can reach must be part of the dev dep optimizer's
711
691
  // FIRST run. The Vite root is the generated runtime, so user pages,
712
692
  // islands, and aliased components live outside it and are only crawled
@@ -764,7 +744,7 @@ ${userConfigSetup}export default defineConfig({
764
744
  server: {
765
745
  fs: {
766
746
  allow: ${JSON.stringify(fsAllow)},
767
- },${watchOption}
747
+ },
768
748
  },
769
749
  },
770
750
  });
@@ -778,14 +758,11 @@ export const stagedContentDir = (outDir: string): string =>
778
758
  /**
779
759
  * The runtime dir relative to the docs collection `base` when it sits inside
780
760
  * it (a migrated, `content.root: "."` project) — null when it lives elsewhere.
781
- * Drives both the collection's negative glob (`contentConfigTemplate`) and
782
- * whether the dev watcher is kept out of Astro's cache dir (the
783
- * `contentWatchesRuntimeDir` option of `astroConfigTemplate`).
761
+ * Drives the collection's negative glob in `contentConfigTemplate`, which both
762
+ * keeps runtime-dir files out of the collection and (Astro's watcher honors
763
+ * negated patterns) keeps the content watcher off Astro's own `.astro` writes.
784
764
  */
785
- export const runtimeDirWithin = (
786
- base: string,
787
- outDir: string
788
- ): string | null => {
765
+ const runtimeDirWithin = (base: string, outDir: string): string | null => {
789
766
  const rel = relative(base, outDir);
790
767
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : null;
791
768
  };
@@ -836,15 +813,11 @@ export const contentConfigTemplate = (options: {
836
813
  const outDirRel = runtimeDirWithin(collectionBase, context.outDir);
837
814
  const outDirIgnore = outDirRel ? [`!${outDirRel}/**`] : [];
838
815
 
839
- // With no filesystem source, no route renders through `docs`, so glob nothing.
840
- // Beyond skipping wasted work, this is the only thing that keeps Astro's
841
- // content-layer *watcher* out of `.blume/`: an all-staged project roots the
842
- // collection at the project dir (which contains `.blume/.astro/fonts`, rewritten on every
843
- // request), and the watcher's match test is `picomatch.isMatch(path, pattern)`
844
- // — with array-OR semantics, any `!ignored/**` negation *matches* unrelated
845
- // files, so negative patterns can't exclude a subtree there. An empty pattern
846
- // matches nothing, so the watcher stays silent. The collection is still
847
- // declared below so `getCollection("docs")` / `getEntry` resolve (to empty).
816
+ // With no filesystem source, no route renders through `docs`, so glob
817
+ // nothing: an all-staged project roots the collection at the project dir,
818
+ // and a patterned glob would scan (and watch) the whole project for nothing.
819
+ // The collection is still declared below so `getCollection("docs")` /
820
+ // `getEntry` resolve (to empty).
848
821
  const filesystem = options.filesystem ?? true;
849
822
  const docsPattern = filesystem
850
823
  ? [
@@ -880,13 +853,17 @@ const staged = defineCollection({
880
853
  return `// Generated by Blume. Do not edit.
881
854
  import { defineCollection } from "astro:content";
882
855
  import { glob } from "astro/loaders";
856
+ import { withIncludeRefresh } from "blume/astro";
883
857
 
858
+ // withIncludeRefresh keeps <include>-bearing pages fresh: plain .md entries
859
+ // are rendered at sync time and digest-cached on the page file alone, so a
860
+ // partial edit (or a warm-cache rebuild after one) would serve stale HTML.
884
861
  const docs = defineCollection({
885
- loader: glob({
862
+ loader: withIncludeRefresh(glob({
886
863
  pattern: ${JSON.stringify(docsPattern)},
887
864
  base: ${JSON.stringify(astroGlobBase(collectionBase))},
888
865
  generateId: ({ entry }) => entry,
889
- }),
866
+ }), ${JSON.stringify(`${context.outDir}/src/generated/includes.json`)}),
890
867
  });
891
868
  ${stagedBlock}
892
869
  export const collections = { docs${options.staged ? ", staged" : ""} };
@@ -1560,10 +1537,11 @@ export function getStaticPaths() {
1560
1537
  return paths;
1561
1538
  }
1562
1539
 
1563
- // Footer branding shared by every card. The repo slug reuses the header link
1564
- // URL; the site text (host plus deployment base) is resolved at generate time.
1565
- const repoSlug = data.config.repoUrl
1566
- ? data.config.repoUrl.split("github.com/")[1]
1540
+ // Footer branding shared by every card. The slug comes from the configured
1541
+ // repo rather than the URL, so an Enterprise host reads the same as github.com;
1542
+ // the site text (host plus deployment base) is resolved at generate time.
1543
+ const repoSlug = data.config.github
1544
+ ? \`\${data.config.github.owner}/\${data.config.github.repo}\`
1567
1545
  : undefined;
1568
1546
 
1569
1547
  export async function GET({ props }: { props: { title: string } }) {
@@ -1786,7 +1764,18 @@ const entry = await getEntry(collection as CollectionKey, entryId);
1786
1764
  if (!entry) {
1787
1765
  return new Response(null, { status: 404 });
1788
1766
  }
1789
- const { Content, headings } = await render(entry);
1767
+ const { Content, headings: allHeadings, remarkPluginFrontmatter } = await render(entry);
1768
+ // \`[!toc]\`-marked headings render on the page but stay out of the table of
1769
+ // contents; the heading plugin reports their slugs through the render's
1770
+ // frontmatter (see markdown/heading-anchors.ts). Only the plugin's array
1771
+ // counts: \`frontmatter.extend\` can declare the same key, and on a page with
1772
+ // no headings that user-supplied value would pass straight through.
1773
+ const tocHiddenRaw = remarkPluginFrontmatter?.${TOC_HIDDEN_KEY};
1774
+ const tocHidden = new Set(Array.isArray(tocHiddenRaw) ? tocHiddenRaw : []);
1775
+ const headings =
1776
+ tocHidden.size > 0
1777
+ ? allHeadings.filter((heading) => !tocHidden.has(heading.slug))
1778
+ : allHeadings;
1790
1779
  const frontmatter = entry.data ?? {};
1791
1780
 
1792
1781
  const seo = frontmatter.seo ?? {};
@@ -2312,6 +2301,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2312
2301
  */
2313
2302
  export const notFoundPageTemplate = (): string => `---
2314
2303
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2304
+ import Icon from "blume/components/Icon.astro";
2315
2305
  import PageLayout from "blume/components/layout/PageLayout.astro";
2316
2306
  import { withBase } from "blume/components/islands/base-path.ts";
2317
2307
  import data from "blume:data";
@@ -2329,6 +2319,24 @@ const localeMeta = i18n
2329
2319
  : null;
2330
2320
  const dir = localeMeta?.dir ?? "ltr";
2331
2321
  const htmlLang = i18n ? i18n.defaultLocale : "en";
2322
+
2323
+ // Recovery links, so a reader — or an agent that followed a stale URL — can
2324
+ // get back on track without guessing: every top-level section, then the
2325
+ // machine-readable indexes the build emits (the sitemap only exists with a
2326
+ // \`deployment.site\`; llms.txt only when \`ai.llmsTxt\` is on). Tabs link to
2327
+ // their resolved target when the section has no index page of its own.
2328
+ const suggestions = [
2329
+ ...data.navigation.tabs.map((tab) => ({
2330
+ href: withBase(tab.href ?? tab.path),
2331
+ label: tab.label,
2332
+ })),
2333
+ ...(data.config.discovery.sitemap
2334
+ ? [{ href: withBase("/sitemap.xml"), label: nf.sitemap }]
2335
+ : []),
2336
+ ...(data.config.discovery.llmsTxt
2337
+ ? [{ href: withBase("/llms.txt"), label: nf.llms }]
2338
+ : []),
2339
+ ];
2332
2340
  ---
2333
2341
 
2334
2342
  <PageLayout
@@ -2349,15 +2357,56 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
2349
2357
  noindex={true}
2350
2358
  >
2351
2359
  <div
2352
- class="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center gap-4 px-6 py-24 text-center"
2360
+ class="mx-auto grid w-full max-w-5xl gap-12 px-6 py-20 sm:py-28 md:grid-cols-[3fr_2fr] md:gap-16 lg:gap-24 lg:py-36"
2353
2361
  >
2354
- <p class="text-6xl font-bold text-muted-foreground">404</p>
2355
- <h1 class="text-2xl font-semibold text-foreground">{nf.title}</h1>
2356
- <p class="text-muted-foreground">{nf.description}</p>
2357
- <a
2358
- class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
2359
- href={withBase("/")}>{nf.home}</a
2360
- >
2362
+ <div class="flex flex-col items-start">
2363
+ <p
2364
+ class="font-mono text-xs font-medium tracking-widest text-muted-foreground"
2365
+ >
2366
+ 404
2367
+ </p>
2368
+ <h1
2369
+ class="mt-4 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl"
2370
+ >
2371
+ {nf.title}
2372
+ </h1>
2373
+ <p class="mt-4 max-w-md text-pretty text-lg text-muted-foreground">
2374
+ {nf.description}
2375
+ </p>
2376
+ <a
2377
+ class="mt-8 inline-flex items-center gap-1.5 rounded-full bg-accent py-2 pe-4 ps-3.5 text-sm font-medium text-accent-foreground transition-opacity hover:opacity-90"
2378
+ href={withBase("/")}
2379
+ >
2380
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={14} />
2381
+ {nf.home}
2382
+ </a>
2383
+ </div>
2384
+ {
2385
+ suggestions.length > 0 && (
2386
+ <nav aria-label={nf.suggestions} class="md:border-s md:border-border md:ps-12 lg:ps-16">
2387
+ <h2 class="text-xs font-medium uppercase tracking-widest text-muted-foreground">
2388
+ {nf.suggestions}
2389
+ </h2>
2390
+ <ul class="mt-4 divide-y divide-border border-y border-border">
2391
+ {suggestions.map((link) => (
2392
+ <li>
2393
+ <a
2394
+ class="group flex items-center justify-between gap-4 py-3 text-sm font-medium text-foreground transition-colors hover:text-accent"
2395
+ href={link.href}
2396
+ >
2397
+ <span>{link.label}</span>
2398
+ <Icon
2399
+ class="shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:-scale-x-100 rtl:group-hover:-translate-x-0.5"
2400
+ name="arrow-right"
2401
+ size={14}
2402
+ />
2403
+ </a>
2404
+ </li>
2405
+ ))}
2406
+ </ul>
2407
+ </nav>
2408
+ )
2409
+ }
2361
2410
  </div>
2362
2411
  </PageLayout>
2363
2412
  `;
@@ -25,6 +25,7 @@ import {
25
25
  buildSkillsIndex,
26
26
  collectSkills,
27
27
  } from "../../ai/skills.ts";
28
+ import type { SkillArtifact } from "../../ai/skills.ts";
28
29
  import {
29
30
  buildSignaturesDirectory,
30
31
  SIGNATURES_DIRECTORY_PATH,
@@ -46,6 +47,11 @@ import {
46
47
  injectWorkerNegotiation,
47
48
  NEGOTIATION_WORKER_FILE,
48
49
  } from "../../deploy/cloudflare-negotiation.ts";
50
+ import {
51
+ auditVercelFunctions,
52
+ blumeDependencyNames,
53
+ functionBundleVerdict,
54
+ } from "../../deploy/function-bundle.ts";
49
55
  import { buildNetlifyHeaders } from "../../deploy/headers.ts";
50
56
  import {
51
57
  buildNetlifyRedirects,
@@ -195,29 +201,29 @@ export const emitHeaderFiles = async (
195
201
  };
196
202
 
197
203
  /**
198
- * Publish the configured Agent Skills: copy each skill artifact under
199
- * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
200
- * `public/.well-known/agent-skills/index.json` takes over the whole surface,
201
- * matching every other generated artifact.
204
+ * Collect the Agent Skills `ai.skills` publishes, once per build, so both the
205
+ * skills surface and llms.txt (which lists them) read the same set. Empty
206
+ * when the feature is off, the directory is missing, nothing in it is
207
+ * publishable (each with a warning), or a user-shipped
208
+ * `public/.well-known/agent-skills/index.json` already owns the surface.
202
209
  */
203
- const emitAgentSkills = async (
210
+ const collectConfiguredSkills = async (
204
211
  project: BlumeProject,
205
212
  distDir: string
206
- ): Promise<void> => {
213
+ ): Promise<SkillArtifact[]> => {
207
214
  const configured = project.config.ai.skills;
208
215
  if (!configured) {
209
- return;
216
+ return [];
210
217
  }
211
218
  const dir = resolve(project.context.root, configured);
212
219
  if (!existsSync(dir)) {
213
220
  logger.warn(
214
221
  `ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
215
222
  );
216
- return;
223
+ return [];
217
224
  }
218
- const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
219
- if (existsSync(join(outDir, "index.json"))) {
220
- return;
225
+ if (existsSync(join(distDir, AGENT_SKILLS_DIR.slice(1), "index.json"))) {
226
+ return [];
221
227
  }
222
228
  const { skills, warnings } = await collectSkills(dir);
223
229
  for (const warning of warnings) {
@@ -225,8 +231,26 @@ const emitAgentSkills = async (
225
231
  }
226
232
  if (skills.length === 0) {
227
233
  logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
234
+ }
235
+ return skills;
236
+ };
237
+
238
+ /**
239
+ * Publish the collected Agent Skills: copy each skill artifact under
240
+ * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
241
+ * `public/.well-known/agent-skills/index.json` takes over the whole surface
242
+ * (the collector returns nothing then), matching every other generated
243
+ * artifact.
244
+ */
245
+ const emitAgentSkills = async (
246
+ project: BlumeProject,
247
+ distDir: string,
248
+ skills: readonly SkillArtifact[]
249
+ ): Promise<void> => {
250
+ if (skills.length === 0) {
228
251
  return;
229
252
  }
253
+ const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
230
254
  await Promise.all(
231
255
  skills.map(async (skill) => {
232
256
  const target = join(outDir, skill.path);
@@ -329,6 +353,38 @@ const emitVercelNegotiation = async (
329
353
  );
330
354
  };
331
355
 
356
+ /**
357
+ * Refuse to ship a Vercel function bundle that would crash at runtime: a bare
358
+ * import the adapter's dependency trace silently dropped (see
359
+ * `deploy/function-bundle.ts`). A missing package that is one of Blume's own
360
+ * dependencies is fatal — the generated runtime imports it, so every request
361
+ * would die; a project's own external import is reported as a warning and left
362
+ * to the author.
363
+ */
364
+ const checkVercelFunctionBundles = async (
365
+ outputDir: string,
366
+ root: string
367
+ ): Promise<void> => {
368
+ const audits = await auditVercelFunctions(outputDir);
369
+ if (audits.length === 0) {
370
+ return;
371
+ }
372
+ const own = blumeDependencyNames();
373
+ let fatal = false;
374
+ for (const audit of audits) {
375
+ const verdict = functionBundleVerdict(audit, root, own);
376
+ if (verdict.fatal) {
377
+ fatal = true;
378
+ logger.error(verdict.message);
379
+ } else {
380
+ logger.warn(verdict.message);
381
+ }
382
+ }
383
+ if (fatal) {
384
+ process.exit(1);
385
+ }
386
+ };
387
+
332
388
  const warnCloudflareNegotiationSkipped = (): void =>
333
389
  logger.warn(
334
390
  "Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json — raw Markdown stays available at the .md URLs."
@@ -559,7 +615,8 @@ export const isolatedStaticDir = (
559
615
  */
560
616
  const publishLlmsFiles = async (
561
617
  project: BlumeProject,
562
- distDir: string
618
+ distDir: string,
619
+ skills: readonly SkillArtifact[]
563
620
  ): Promise<void> => {
564
621
  const indexPath = join(distDir, "llms.txt");
565
622
  const fullPath = join(distDir, "llms-full.txt");
@@ -568,7 +625,7 @@ const publishLlmsFiles = async (
568
625
  if (!(writeIndex || writeFull)) {
569
626
  return;
570
627
  }
571
- const { index, full } = await buildLlmsFiles(project);
628
+ const { index, full } = await buildLlmsFiles(project, { skills });
572
629
  const writes: Promise<void>[] = [];
573
630
  if (writeIndex) {
574
631
  writes.push(writeFile(indexPath, index, "utf-8"));
@@ -613,8 +670,10 @@ const publishBuildArtifacts = async (
613
670
  warn: (message) => logger.warn(message),
614
671
  });
615
672
 
673
+ // Collected once: llms.txt lists the skills the build publishes below.
674
+ const skills = await collectConfiguredSkills(project, distDir);
616
675
  if (project.config.ai.llmsTxt.enabled) {
617
- await publishLlmsFiles(project, distDir);
676
+ await publishLlmsFiles(project, distDir, skills);
618
677
  }
619
678
 
620
679
  // A user's own public/ file (copied into dist by Astro) always wins.
@@ -652,7 +711,7 @@ const publishBuildArtifacts = async (
652
711
  }
653
712
 
654
713
  await emitWellKnownFiles(project.config, distDir);
655
- await emitAgentSkills(project, distDir);
714
+ await emitAgentSkills(project, distDir, skills);
656
715
 
657
716
  await emitRedirectFiles(project.config, distDir);
658
717
  await emitHeaderFiles(project, distDir);
@@ -798,6 +857,15 @@ export const buildCommand = defineCommand({
798
857
  // 100` exiting 0 without measuring anything would be a silent false pass
799
858
  // in CI.
800
859
  if (runtimeDir) {
860
+ if (
861
+ project.config.deployment.output === "server" &&
862
+ project.config.deployment.adapter === "vercel"
863
+ ) {
864
+ await checkVercelFunctionBundles(
865
+ isolatedOutputDir(project.config, project.context),
866
+ root
867
+ );
868
+ }
801
869
  await runClientAssetChecks(
802
870
  isolatedStaticDir(project.config, project.context),
803
871
  args
@@ -831,6 +899,7 @@ export const buildCommand = defineCommand({
831
899
  }
832
900
 
833
901
  if (project.config.deployment.output === "server" && adapter === "vercel") {
902
+ await checkVercelFunctionBundles(join(root, ".vercel", "output"), root);
834
903
  await emitVercelNegotiation(project, markdownRoutePaths(project), root);
835
904
  }
836
905
 
@@ -111,6 +111,7 @@ export const collectAnswers = async (
111
111
  message: "Where does your content live?",
112
112
  options: [
113
113
  { hint: "Local .mdx files", label: "filesystem", value: "filesystem" },
114
+ { hint: "An Obsidian vault", label: "obsidian", value: "obsidian" },
114
115
  {
115
116
  hint: "Changelog from GitHub Releases",
116
117
  label: "github-releases",
@@ -15,6 +15,7 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
15
15
  /** The content-source kinds `init` can scaffold a config block for. */
16
16
  export const SOURCE_KINDS = [
17
17
  "filesystem",
18
+ "obsidian",
18
19
  "github-releases",
19
20
  "notion",
20
21
  "sanity",
@@ -209,8 +210,12 @@ export const titleize = (raw: string): string => {
209
210
  .join(" ");
210
211
  };
211
212
 
212
- /** True when any selected source is remote (everything except `filesystem`). */
213
- const hasRemoteSource = (sources: SourceKind[]): boolean =>
213
+ /**
214
+ * True when any selected source needs an explicit `content.sources` array —
215
+ * everything except `filesystem`, which is what the implicit default already
216
+ * desugars to.
217
+ */
218
+ const needsExplicitSources = (sources: SourceKind[]): boolean =>
214
219
  sources.some((source) => source !== "filesystem");
215
220
 
216
221
  /**
@@ -239,6 +244,14 @@ const SOURCE_SNIPPETS = {
239
244
  database: "your-database-id",
240
245
  prefix: "notion",
241
246
  },`,
247
+ obsidian: ` // An Obsidian vault, read in place. No export step, and no
248
+ // generated notes in your repo. Point \`vault\` at your vault directory,
249
+ // relative to this config file.
250
+ {
251
+ type: "obsidian",
252
+ vault: "vault",
253
+ prefix: "notes",
254
+ },`,
242
255
  sanity: ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
243
256
  // from the environment.
244
257
  {
@@ -258,7 +271,7 @@ const SOURCE_SNIPPETS = {
258
271
  const contentBlockFor = (answers: InitAnswers): string => {
259
272
  const sources =
260
273
  answers.sources.length === 0 ? ["filesystem" as const] : answers.sources;
261
- if (!hasRemoteSource(sources)) {
274
+ if (!needsExplicitSources(sources)) {
262
275
  return answers.contentDir === "docs"
263
276
  ? ""
264
277
  : `
@@ -267,7 +280,7 @@ const contentBlockFor = (answers: InitAnswers): string => {
267
280
  },`;
268
281
  }
269
282
  // Explicit sources replace the implicit filesystem desugar, so the local
270
- // content dir must be listed alongside the remote sources to stay included.
283
+ // content dir must be listed alongside the other sources to stay included.
271
284
  const entries = SOURCE_KINDS.filter((kind) => sources.includes(kind)).map(
272
285
  (kind) =>
273
286
  kind === "filesystem"
@@ -328,6 +341,16 @@ export const buildPlan = (
328
341
  .map((file) => ({ ...file, path: join(root, file.path) }))
329
342
  );
330
343
  }
344
+ // The obsidian snippet points at `vault/`; seed the directory with a first
345
+ // note so the scaffolded project passes the source's `validate()` and boots
346
+ // before the user has opened Obsidian at all.
347
+ if (answers.sources.includes("obsidian")) {
348
+ files.push({
349
+ content:
350
+ "# Welcome\n\nThis folder is read by Blume's `obsidian` source. Open it as a vault in Obsidian and write notes — `[[Wikilinks]]` become site links.\n",
351
+ path: join(root, "vault", "Welcome.md"),
352
+ });
353
+ }
331
354
  return files;
332
355
  };
333
356