blume 0.6.7 → 0.7.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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -315,11 +315,16 @@ export interface NavSelectorItem {
315
315
  * A header dropdown for switching context — versions, languages, products, or a
316
316
  * generic dropdown. `kind` drives the icon and a11y labeling.
317
317
  */
318
+ /** Context-partition selector kinds (a versioned/localized/multi-product site). */
319
+ type NavSelectorContextKind = "product" | "version";
320
+ /** What a header selector switches between. */
321
+ type NavSelectorKind = "dropdown" | "language" | NavSelectorContextKind;
322
+
318
323
  export interface NavSelector {
319
324
  /** The options shown in the dropdown. */
320
325
  items?: NavSelectorItem[];
321
326
  /** What the selector switches between. */
322
- kind: "dropdown" | "language" | "product" | "version";
327
+ kind: NavSelectorKind;
323
328
  /** Selector label / current value. */
324
329
  label: string;
325
330
  }
@@ -385,6 +390,11 @@ export interface FontsConfig {
385
390
  }
386
391
 
387
392
  /** Colors, fonts, radius, and color-mode behavior. */
393
+ /** Corner radius scale (`none`/`sm` tighter, `md`/`lg` rounder). */
394
+ type RadiusScaleTight = "none" | "sm";
395
+ type RadiusScaleRound = "md" | "lg";
396
+ type RadiusScale = RadiusScaleTight | RadiusScaleRound;
397
+
388
398
  export interface ThemeConfig {
389
399
  /**
390
400
  * Accent color. A palette name (`blue`, `violet`, `green`, …) or any CSS
@@ -405,7 +415,7 @@ export interface ThemeConfig {
405
415
  /** Initial color mode. Defaults to `system`. */
406
416
  mode?: "system" | "light" | "dark";
407
417
  /** Corner radius scale. Defaults to `md`. */
408
- radius?: "none" | "sm" | "md" | "lg";
418
+ radius?: RadiusScale;
409
419
  }
410
420
 
411
421
  // ---------------------------------------------------------------------------
@@ -477,6 +487,10 @@ export interface AskSuggestion {
477
487
  }
478
488
 
479
489
  /** The Ask AI chat assistant. */
490
+ /** Backends that can route an Ask AI request. */
491
+ type AskProviderGateway = "gateway" | "openrouter" | "llmgateway";
492
+ type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible";
493
+
480
494
  export interface AskConfig {
481
495
  /**
482
496
  * Name of the env var holding the provider API key. Each provider has a
@@ -493,12 +507,7 @@ export interface AskConfig {
493
507
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
494
508
  model?: string;
495
509
  /** Which backend routes the request. Defaults to `gateway`. */
496
- provider?:
497
- | "gateway"
498
- | "openrouter"
499
- | "llmgateway"
500
- | "inkeep"
501
- | "openai-compatible";
510
+ provider?: AskProvider;
502
511
  /** Starter prompts shown before the first question. */
503
512
  suggestions?: AskSuggestion[];
504
513
  }
@@ -603,9 +612,13 @@ export interface I18nConfig {
603
612
  * Where and how the site deploys. `site` (and `adapter`) are auto-detected from
604
613
  * the platform env on Vercel, Netlify, and Cloudflare.
605
614
  */
615
+ /** Astro server-output adapters, by hosting platform. */
616
+ type CloudDeploymentAdapter = "netlify" | "cloudflare";
617
+ type DeploymentAdapter = "vercel" | "node" | CloudDeploymentAdapter;
618
+
606
619
  export interface DeploymentConfig {
607
620
  /** Astro adapter for server output. `null` (default) keeps a static build. */
608
- adapter?: "vercel" | "node" | "netlify" | "cloudflare" | null;
621
+ adapter?: DeploymentAdapter | null;
609
622
  /** Base path when the site is served from a subdirectory. */
610
623
  base?: string;
611
624
  /** Build output mode. Defaults to `static`. */
@@ -617,12 +630,17 @@ export interface DeploymentConfig {
617
630
  site?: string;
618
631
  }
619
632
 
633
+ /** HTTP redirect status codes: permanent (301/308) and temporary (302/307). */
634
+ type RedirectStatusPermanent = 301 | 308;
635
+ type RedirectStatusTemporary = 302 | 307;
636
+ type RedirectStatus = RedirectStatusPermanent | RedirectStatusTemporary;
637
+
620
638
  /** A URL redirect rule. */
621
639
  export interface RedirectConfig {
622
640
  /** Path to redirect from. */
623
641
  from: string;
624
642
  /** HTTP status. Defaults to `301`. */
625
- status?: 301 | 302 | 307 | 308;
643
+ status?: RedirectStatus;
626
644
  /** Path or URL to redirect to. */
627
645
  to: string;
628
646
  }
@@ -717,7 +735,10 @@ export interface CodeConfig {
717
735
  export interface MarkdownConfig {
718
736
  /** Code-block rendering: language icons, line wrap. */
719
737
  code?: CodeConfig;
720
- /** Syntax-highlighting themes for fenced code blocks. */
738
+ /**
739
+ * Syntax-highlighting themes for every code surface — fenced blocks, inline
740
+ * `` `code`{:lang} ``, `<CodeBlock>`, and `<Diff>`.
741
+ */
721
742
  codeBlocks?: {
722
743
  /** Shiki theme names per color mode. */
723
744
  theme?: {
@@ -736,6 +757,16 @@ export interface MarkdownConfig {
736
757
  imageZoom?: boolean;
737
758
  }
738
759
 
760
+ /** React island behavior. */
761
+ export interface ReactConfig {
762
+ /**
763
+ * Auto-memoize React components/hooks with the React Compiler
764
+ * (`babel-plugin-react-compiler`). On by default whenever React is enabled;
765
+ * set to `false` to skip the compiler's babel pass. Defaults to `true`.
766
+ */
767
+ compiler?: boolean;
768
+ }
769
+
739
770
  // ---------------------------------------------------------------------------
740
771
  // OpenAPI / AsyncAPI
741
772
  // ---------------------------------------------------------------------------
@@ -787,6 +818,25 @@ export interface AsyncApiConfig {
787
818
  // Misc top-level unions
788
819
  // ---------------------------------------------------------------------------
789
820
 
821
+ /** `<Component />` example previews (the object form of `examples`). */
822
+ export interface ExamplesConfig {
823
+ /**
824
+ * A stylesheet, relative to the project root, injected into every preview
825
+ * frame after Blume's default tokens. Previews render inside an isolated
826
+ * iframe the docs styles never reach, so design tokens for the previewed
827
+ * components — shadcn variables, `@theme` mappings, custom fonts — live
828
+ * here. Tailwind is already provided in the frame; the file should hold
829
+ * tokens and styles, not another `@import "tailwindcss"`.
830
+ */
831
+ css?: string;
832
+ /**
833
+ * Where example files live, relative to the project root. Defaults to
834
+ * `examples`; may be a glob to target a registry that colocates component
835
+ * sources with their examples (e.g. `registry/<pkg>/**\/examples/*`).
836
+ */
837
+ source?: string;
838
+ }
839
+
790
840
  /**
791
841
  * Reader-facing "Export" page actions. A boolean toggles both formats; the
792
842
  * object form enables each individually. Defaults to `false`.
@@ -843,6 +893,16 @@ export interface BlumeConfig {
843
893
  asyncapi?: AsyncApiConfig;
844
894
  /** Site-wide announcement banner shown above the header. */
845
895
  banner?: BannerConfig;
896
+ /**
897
+ * Site-wide mount point prepended to every generated route (e.g. `/docs`) —
898
+ * pages, links, redirects, sitemap, OG images, `llms.txt`, and the search
899
+ * index — while staying invisible to the sidebar/nav tree (no wrapper group).
900
+ * Distinct from a per-source `prefix` (which namespaces one source *and*
901
+ * creates a group) and from `deployment.base` (Astro's host-subdirectory
902
+ * base, for serving the whole site — root included — from a subpath). The two
903
+ * compose: with both set, a page lands at `{deployment.base}/{basePath}/page`.
904
+ */
905
+ basePath?: string;
846
906
  /** Where content lives and how it's discovered. */
847
907
  content?: ContentConfig;
848
908
  /** Where and how the site deploys (site URL, adapter, output mode). */
@@ -850,11 +910,15 @@ export interface BlumeConfig {
850
910
  /** Default meta description, used where a page sets none. */
851
911
  description?: string;
852
912
  /**
853
- * Where `<Component path>` resolves live previews and their source from,
854
- * relative to the project root. Defaults to `examples`. May be a glob to
855
- * target a registry that colocates component sources with their examples.
913
+ * `<Component path>` example previews. A string is shorthand for
914
+ * `{ source }`: where examples live, relative to the project root (defaults
915
+ * to `examples`; may be a glob to target a registry that colocates
916
+ * component sources with their examples). The object form adds `css` — a
917
+ * stylesheet injected into every preview frame (previews render in an
918
+ * iframe the docs theme never reaches), for the previewed components'
919
+ * design tokens, e.g. shadcn variables.
856
920
  */
857
- examples?: string;
921
+ examples?: string | ExamplesConfig;
858
922
  /** Reader-facing PDF/EPUB export actions. Defaults to `false`. */
859
923
  export?: ExportConfig;
860
924
  /** Show the per-page "Was this helpful?" widget. Defaults to `true`. */
@@ -875,6 +939,8 @@ export interface BlumeConfig {
875
939
  navigation?: NavigationConfig;
876
940
  /** Native OpenAPI reference. */
877
941
  openapi?: OpenApiConfig;
942
+ /** React island behavior (compiler auto-memoization). */
943
+ react?: ReactConfig;
878
944
  /** URL redirect rules. */
879
945
  redirects?: RedirectConfig[];
880
946
  /** Search backend and credentials. */
@@ -91,8 +91,9 @@ import type { Diagnostic } from "./types.ts";
91
91
  * - `i18n` — opt-in multi-locale: `locales`, `defaultLocale`, `parser`
92
92
  * (`dir` vs filename `dot` suffix), and per-locale UI overrides.
93
93
  *
94
- * - `examples` — where `<Component path>` previews resolve their source from
95
- * (defaults to `examples/`; supports a glob for colocated registries).
94
+ * - `examples` — `<Component path>` previews: `source` (default `examples/`;
95
+ * supports a glob for colocated registries) and `css`, a stylesheet injected
96
+ * into the isolated preview frames (e.g. shadcn variables).
96
97
  *
97
98
  * @example Zero-config — just render the Markdown under `docs/`.
98
99
  * ```ts
@@ -197,11 +198,12 @@ export const loadConfig = async (
197
198
  };
198
199
  // Surface every issue in one failing run — reporting only the first turns
199
200
  // a three-mistake config into three fix-rerun-fail loops.
201
+ const moreIssues = rest.map((d) => ` - ${d.message}`).join("\n");
200
202
  throw new BlumeError(
201
203
  rest.length > 0
202
204
  ? {
203
205
  ...primary,
204
- message: `${primary.message}\n${rest.length} more config issue(s):\n${rest.map((d) => ` - ${d.message}`).join("\n")}`,
206
+ message: `${primary.message}\n${rest.length} more config issue(s):\n${moreIssues}`,
205
207
  }
206
208
  : primary
207
209
  );
@@ -22,6 +22,7 @@ export const discoverContent = async (options: {
22
22
  include: string[];
23
23
  exclude: string[];
24
24
  defaultType: string;
25
+ basePath?: string;
25
26
  i18n?: ResolvedI18nConfig;
26
27
  }): Promise<{ pages: PageRecord[]; diagnostics: Diagnostic[] }> => {
27
28
  const source = filesystemSource({
@@ -38,6 +39,7 @@ export const discoverContent = async (options: {
38
39
 
39
40
  for (const entry of entries) {
40
41
  const normalized = normalizeEntry(entry, {
42
+ basePath: options.basePath ?? "",
41
43
  defaultType: options.defaultType,
42
44
  i18n: options.i18n,
43
45
  source: { name: source.name, prefix: source.prefix, staged: false },
package/src/core/data.ts CHANGED
@@ -93,6 +93,8 @@ export interface BlumeDataConfig {
93
93
  suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
94
94
  } | null;
95
95
  banner: BlumeBanner | null;
96
+ /** `markdown.codeBlocks.theme`: light/dark Shiki themes for code surfaces. */
97
+ codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
96
98
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
97
99
  codeWrap: boolean;
98
100
  description: string | undefined;
@@ -20,35 +20,40 @@ export const createDiagnostic = (diagnostic: Diagnostic): Diagnostic =>
20
20
  /** Docs site base; diagnostic help links resolve against it. */
21
21
  const DOCS_BASE = "https://useblume.dev";
22
22
 
23
+ const DOCS_DEPLOYMENT = "/docs/deployment";
24
+ const DOCS_REFERENCE_CLI = "/docs/reference/cli";
25
+ const DOCS_CONTENT_SOURCES = "/docs/content/sources";
26
+ const DOCS_CONTENT_NAVIGATION = "/docs/content/navigation";
27
+
23
28
  /** Diagnostic code → the docs page that explains it. */
24
29
  const DOCS_PATHS: Record<string, string> = {
25
- BLUME_ADAPTER_REQUIRED: "/docs/deployment",
26
- BLUME_ASSETS_UNCHECKED: "/docs/reference/cli",
27
- BLUME_ASSET_FETCH_FAILED: "/docs/content/sources",
28
- BLUME_BROKEN_ANCHOR: "/docs/reference/cli",
29
- BLUME_BROKEN_ASSET: "/docs/reference/cli",
30
- BLUME_BROKEN_LINK: "/docs/reference/cli",
30
+ BLUME_ADAPTER_REQUIRED: DOCS_DEPLOYMENT,
31
+ BLUME_ASSETS_UNCHECKED: DOCS_REFERENCE_CLI,
32
+ BLUME_ASSET_FETCH_FAILED: DOCS_CONTENT_SOURCES,
33
+ BLUME_BROKEN_ANCHOR: DOCS_REFERENCE_CLI,
34
+ BLUME_BROKEN_ASSET: DOCS_REFERENCE_CLI,
35
+ BLUME_BROKEN_LINK: DOCS_REFERENCE_CLI,
31
36
  BLUME_CONFIG_INVALID: "/docs/configuration",
32
37
  BLUME_CONFIG_LOAD_FAILED: "/docs/configuration",
33
- BLUME_CONTENT_ROOT_MISSING: "/docs/content/sources",
34
- BLUME_DEAD_LINK: "/docs/reference/cli",
35
- BLUME_DUPLICATE_ROUTE: "/docs/content/navigation",
38
+ BLUME_CONTENT_ROOT_MISSING: DOCS_CONTENT_SOURCES,
39
+ BLUME_DEAD_LINK: DOCS_REFERENCE_CLI,
40
+ BLUME_DUPLICATE_ROUTE: DOCS_CONTENT_NAVIGATION,
36
41
  BLUME_FRONTMATTER_INVALID: "/docs/reference/frontmatter",
37
42
  BLUME_META_INVALID: "/docs/content/meta",
38
43
  BLUME_META_LOAD_FAILED: "/docs/content/meta",
39
- BLUME_MISSING_SECRET: "/docs/deployment",
40
- BLUME_NAV_DUPLICATE_LABEL: "/docs/content/navigation",
41
- BLUME_NAV_HIDDEN_IN_SIDEBAR: "/docs/content/navigation",
42
- BLUME_NAV_MISSING_PAGE: "/docs/content/navigation",
44
+ BLUME_MISSING_SECRET: DOCS_DEPLOYMENT,
45
+ BLUME_NAV_DUPLICATE_LABEL: DOCS_CONTENT_NAVIGATION,
46
+ BLUME_NAV_HIDDEN_IN_SIDEBAR: DOCS_CONTENT_NAVIGATION,
47
+ BLUME_NAV_MISSING_PAGE: DOCS_CONTENT_NAVIGATION,
43
48
  BLUME_NODE_VERSION: "/docs/quickstart",
44
- BLUME_SERVER_FEATURE_REQUIRED: "/docs/deployment",
45
- BLUME_SOURCE_FETCH_FAILED: "/docs/content/sources",
46
- BLUME_SOURCE_MISCONFIGURED: "/docs/content/sources",
47
- BLUME_SOURCE_OFFLINE: "/docs/content/sources",
48
- BLUME_SOURCE_SDK_MISSING: "/docs/content/sources",
49
- BLUME_SOURCE_UNAVAILABLE: "/docs/content/sources",
49
+ BLUME_SERVER_FEATURE_REQUIRED: DOCS_DEPLOYMENT,
50
+ BLUME_SOURCE_FETCH_FAILED: DOCS_CONTENT_SOURCES,
51
+ BLUME_SOURCE_MISCONFIGURED: DOCS_CONTENT_SOURCES,
52
+ BLUME_SOURCE_OFFLINE: DOCS_CONTENT_SOURCES,
53
+ BLUME_SOURCE_SDK_MISSING: DOCS_CONTENT_SOURCES,
54
+ BLUME_SOURCE_UNAVAILABLE: DOCS_CONTENT_SOURCES,
50
55
  BLUME_UNKNOWN_COMPONENT: "/docs/configuration/customization",
51
- BLUME_UNKNOWN_ICON: "/docs/content/navigation",
56
+ BLUME_UNKNOWN_ICON: DOCS_CONTENT_NAVIGATION,
52
57
  };
53
58
 
54
59
  /** The docs URL that explains a diagnostic code, if one is mapped. */
@@ -74,6 +79,29 @@ const escapeRegExp = (value: string): string =>
74
79
  * lands under its parent. Array indices are skipped. Returns 1-based line/column,
75
80
  * or undefined when nothing matches.
76
81
  */
82
+ const stepSegment = (
83
+ source: string,
84
+ segment: string | number,
85
+ cursor: number
86
+ ): { index: number; next: number; stop: boolean } => {
87
+ // A non-string path segment (array index) is skipped without moving on.
88
+ if (typeof segment !== "string") {
89
+ return { index: -1, next: cursor, stop: false };
90
+ }
91
+ // The negative lookbehind keeps a segment like `title` from matching the
92
+ // tail of an unrelated key such as `subtitle:`.
93
+ const matcher = new RegExp(
94
+ `(?<![\\w$])${escapeRegExp(segment)}\\s*[:=]`,
95
+ "gu"
96
+ );
97
+ matcher.lastIndex = cursor;
98
+ const match = matcher.exec(source);
99
+ if (!match) {
100
+ return { index: -1, next: cursor, stop: true };
101
+ }
102
+ return { index: match.index, next: matcher.lastIndex, stop: false };
103
+ };
104
+
77
105
  const locatePath = (
78
106
  source: string,
79
107
  path: readonly (string | number)[]
@@ -81,22 +109,14 @@ const locatePath = (
81
109
  let cursor = 0;
82
110
  let found = -1;
83
111
  for (const segment of path) {
84
- if (typeof segment !== "string") {
85
- continue;
86
- }
87
- // The negative lookbehind keeps a segment like `title` from matching the
88
- // tail of an unrelated key such as `subtitle:`.
89
- const matcher = new RegExp(
90
- `(?<![\\w$])${escapeRegExp(segment)}\\s*[:=]`,
91
- "gu"
92
- );
93
- matcher.lastIndex = cursor;
94
- const match = matcher.exec(source);
95
- if (!match) {
112
+ const step = stepSegment(source, segment, cursor);
113
+ if (step.stop) {
96
114
  break;
97
115
  }
98
- found = match.index;
99
- cursor = matcher.lastIndex;
116
+ cursor = step.next;
117
+ if (step.index >= 0) {
118
+ found = step.index;
119
+ }
100
120
  }
101
121
  if (found < 0) {
102
122
  return;
@@ -18,7 +18,10 @@ export const ensureGitignore = async (
18
18
  const path = join(root, ".gitignore");
19
19
  const existing = existsSync(path) ? await readFile(path, "utf-8") : "";
20
20
  const present = new Set(
21
- existing.split("\n").map(gitignoreKey).filter(Boolean)
21
+ existing.split("\n").flatMap((line) => {
22
+ const key = gitignoreKey(line);
23
+ return key ? [key] : [];
24
+ })
22
25
  );
23
26
  const added = entries.filter((entry) => !present.has(gitignoreKey(entry)));
24
27
  if (added.length === 0) {
package/src/core/graph.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "./base-path.ts";
1
2
  import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
2
3
  import { validateNavIcons, validateNavStructure } from "./nav-diagnostics.ts";
3
4
  import { buildNavigation } from "./navigation.ts";
@@ -13,19 +14,23 @@ import type {
13
14
  PageRecord,
14
15
  } from "./types.ts";
15
16
 
16
- /** Assemble the content graph: routes map, nav, and duplicate diagnostics. */
17
- export const buildContentGraph = (
18
- pages: PageRecord[],
19
- options: {
20
- folderMeta: Map<string, FolderMeta>;
21
- sharedFolderMeta?: Map<string, FolderMeta>;
22
- navigation: ResolvedConfig["navigation"];
23
- i18n?: ResolvedI18nConfig;
24
- }
25
- ): ContentGraph => {
17
+ interface BuildContentGraphOptions {
18
+ /** Site-wide route mount point (`""` or `/seg`); invisible to the nav tree. */
19
+ basePath?: string;
20
+ folderMeta: Map<string, FolderMeta>;
21
+ sharedFolderMeta?: Map<string, FolderMeta>;
22
+ navigation: ResolvedConfig["navigation"];
23
+ i18n?: ResolvedI18nConfig;
24
+ }
25
+
26
+ type FallbackLocale = ReturnType<typeof resolveFallbackLocale>;
27
+
28
+ /** Build the route → page-id map, flagging any duplicate-route collisions. */
29
+ const collectRoutes = (
30
+ pages: PageRecord[]
31
+ ): { diagnostics: Diagnostic[]; routes: Map<string, string> } => {
26
32
  const routes = new Map<string, string>();
27
33
  const diagnostics: Diagnostic[] = [];
28
-
29
34
  for (const page of pages) {
30
35
  const existing = routes.get(page.route);
31
36
  if (existing) {
@@ -40,94 +45,157 @@ export const buildContentGraph = (
40
45
  }
41
46
  routes.set(page.route, page.id);
42
47
  }
48
+ return { diagnostics, routes };
49
+ };
43
50
 
44
- const { i18n } = options;
45
- const navigationByLocale: Record<string, Navigation> = {};
46
- let navigation: Navigation;
47
-
48
- if (i18n) {
49
- // Pages of the fallback locale, by translation key — used to fill in a
50
- // locale's sidebar for pages it hasn't translated yet, so navigation mirrors
51
- // the default structure instead of showing an empty (or partial) tree.
52
- const fallback = resolveFallbackLocale(i18n);
53
- const fallbackByKey = new Map<string, PageRecord>();
54
- if (fallback) {
55
- for (const page of pages) {
56
- if (page.locale === fallback) {
57
- fallbackByKey.set(page.translationKey, page);
58
- }
59
- }
51
+ /**
52
+ * A locale's pages, padded with fallback-locale entries for any translation it
53
+ * hasn't authored yet, so navigation mirrors the default structure instead of
54
+ * showing an empty (or partial) tree.
55
+ */
56
+ const localePagesFor = (
57
+ code: string,
58
+ real: PageRecord[],
59
+ fallback: FallbackLocale,
60
+ fallbackByKey: Map<string, PageRecord>,
61
+ i18n: ResolvedI18nConfig,
62
+ basePath: string
63
+ ): PageRecord[] => {
64
+ if (!(fallback && code !== fallback)) {
65
+ return real;
66
+ }
67
+ const present = new Set(real.map((page) => page.translationKey));
68
+ const filled: PageRecord[] = [];
69
+ for (const [key, source] of fallbackByKey) {
70
+ if (!present.has(key)) {
71
+ filled.push({
72
+ ...source,
73
+ locale: code,
74
+ route: withBasePath(basePath, localizeRoute(key, code, i18n)),
75
+ });
60
76
  }
77
+ }
78
+ return [...real, ...filled];
79
+ };
61
80
 
62
- // Each locale gets an independent tree from its own pages and folder meta,
63
- // so navigation may diverge per language.
64
- for (const { code } of i18n.locales) {
65
- // Localize internal tab paths so a header tab points to its in-locale
66
- // route (e.g. `/docs` -> `/fr/docs`); external paths pass through.
67
- const tabs = options.navigation.tabs?.map((tab) => ({
68
- ...tab,
69
- path: tab.path.startsWith("/")
70
- ? localizeRoute(tab.path, code, i18n)
71
- : tab.path,
72
- }));
81
+ /** Build one locale's navigation tree from its own pages and folder meta. */
82
+ const buildLocaleNavigation = (
83
+ code: string,
84
+ pages: PageRecord[],
85
+ fallback: FallbackLocale,
86
+ fallbackByKey: Map<string, PageRecord>,
87
+ options: BuildContentGraphOptions,
88
+ i18n: ResolvedI18nConfig
89
+ ): Navigation => {
90
+ // Localize internal tab paths so a header tab points to its in-locale route
91
+ // (e.g. `/docs` -> `/fr/docs`); external paths pass through.
92
+ const tabs = options.navigation.tabs?.map((tab) => ({
93
+ ...tab,
94
+ path: tab.path.startsWith("/")
95
+ ? localizeRoute(tab.path, code, i18n)
96
+ : tab.path,
97
+ }));
98
+ const real = pages.filter((page) => page.locale === code);
99
+ const localePages = localePagesFor(
100
+ code,
101
+ real,
102
+ fallback,
103
+ fallbackByKey,
104
+ i18n,
105
+ options.basePath ?? ""
106
+ );
107
+ return buildNavigation(localePages, {
108
+ basePath: options.basePath ?? "",
109
+ display: options.navigation.sidebar.display,
110
+ featured: options.navigation.featured,
111
+ folderMeta: options.folderMeta,
112
+ // Meta files live in locale directories only under the `dir` parser
113
+ // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
114
+ // next to the originals and `guides/meta.ts` applies to every locale —
115
+ // prefixing would look up keys that can never exist.
116
+ metaPrefix:
117
+ i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
118
+ refByLogical: true,
119
+ selectors: options.navigation.selectors,
120
+ sharedFolderMeta: options.sharedFolderMeta,
121
+ sidebar: options.navigation.sidebar.items,
122
+ tabs,
123
+ });
124
+ };
73
125
 
74
- const real = pages.filter((page) => page.locale === code);
75
- let localePages = real;
76
- if (fallback && code !== fallback) {
77
- const present = new Set(real.map((page) => page.translationKey));
78
- const filled: PageRecord[] = [];
79
- for (const [key, source] of fallbackByKey) {
80
- if (!present.has(key)) {
81
- filled.push({
82
- ...source,
83
- locale: code,
84
- route: localizeRoute(key, code, i18n),
85
- });
86
- }
87
- }
88
- localePages = [...real, ...filled];
126
+ /** Per-locale navigation trees plus the default-locale tree for i18n sites. */
127
+ const buildI18nNavigation = (
128
+ pages: PageRecord[],
129
+ options: BuildContentGraphOptions,
130
+ i18n: ResolvedI18nConfig
131
+ ): {
132
+ navigation: Navigation;
133
+ navigationByLocale: Record<string, Navigation>;
134
+ } => {
135
+ // Pages of the fallback locale, by translation key — used to fill in a
136
+ // locale's sidebar for pages it hasn't translated yet.
137
+ const fallback = resolveFallbackLocale(i18n);
138
+ const fallbackByKey = new Map<string, PageRecord>();
139
+ if (fallback) {
140
+ for (const page of pages) {
141
+ if (page.locale === fallback) {
142
+ fallbackByKey.set(page.translationKey, page);
89
143
  }
90
-
91
- navigationByLocale[code] = buildNavigation(localePages, {
92
- display: options.navigation.sidebar.display,
93
- featured: options.navigation.featured,
94
- folderMeta: options.folderMeta,
95
- // Meta files live in locale directories only under the `dir` parser
96
- // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations
97
- // sit next to the originals and `guides/meta.ts` applies to every
98
- // locale — prefixing would look up keys that can never exist.
99
- metaPrefix:
100
- i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
101
- refByLogical: true,
102
- selectors: options.navigation.selectors,
103
- sharedFolderMeta: options.sharedFolderMeta,
104
- sidebar: options.navigation.sidebar.items,
105
- tabs,
106
- });
107
144
  }
108
- navigation = navigationByLocale[i18n.defaultLocale] ?? {
109
- featured: [],
110
- selectors: [],
111
- sidebar: [],
112
- tabs: [],
113
- };
114
- } else {
115
- navigation = buildNavigation(pages, {
116
- display: options.navigation.sidebar.display,
117
- featured: options.navigation.featured,
118
- folderMeta: options.folderMeta,
119
- selectors: options.navigation.selectors,
120
- sharedFolderMeta: options.sharedFolderMeta,
121
- sidebar: options.navigation.sidebar.items,
122
- tabs: options.navigation.tabs,
123
- });
124
145
  }
125
146
 
147
+ // Each locale gets an independent tree, so navigation may diverge per language.
148
+ const navigationByLocale: Record<string, Navigation> = {};
149
+ for (const { code } of i18n.locales) {
150
+ navigationByLocale[code] = buildLocaleNavigation(
151
+ code,
152
+ pages,
153
+ fallback,
154
+ fallbackByKey,
155
+ options,
156
+ i18n
157
+ );
158
+ }
159
+ const navigation = navigationByLocale[i18n.defaultLocale] ?? {
160
+ featured: [],
161
+ selectors: [],
162
+ sidebar: [],
163
+ tabs: [],
164
+ };
165
+ return { navigation, navigationByLocale };
166
+ };
167
+
168
+ /** Assemble the content graph: routes map, nav, and duplicate diagnostics. */
169
+ export const buildContentGraph = (
170
+ pages: PageRecord[],
171
+ options: BuildContentGraphOptions
172
+ ): ContentGraph => {
173
+ const { diagnostics, routes } = collectRoutes(pages);
174
+ const { i18n } = options;
175
+
176
+ const { navigation, navigationByLocale } = i18n
177
+ ? buildI18nNavigation(pages, options, i18n)
178
+ : {
179
+ navigation: buildNavigation(pages, {
180
+ basePath: options.basePath ?? "",
181
+ display: options.navigation.sidebar.display,
182
+ featured: options.navigation.featured,
183
+ folderMeta: options.folderMeta,
184
+ selectors: options.navigation.selectors,
185
+ sharedFolderMeta: options.sharedFolderMeta,
186
+ sidebar: options.navigation.sidebar.items,
187
+ tabs: options.navigation.tabs,
188
+ }),
189
+ navigationByLocale: {} as Record<string, Navigation>,
190
+ };
191
+
126
192
  // Icon typos, duplicate labels, and hidden-page-in-sidebar are validated on
127
193
  // the built navigation. Missing-target detection needs the full route set
128
194
  // (incl. custom + generated pages), so it runs later in generateRuntime.
129
- diagnostics.push(...validateNavIcons(navigation));
130
- diagnostics.push(...validateNavStructure(navigation, pages));
195
+ diagnostics.push(
196
+ ...validateNavIcons(navigation),
197
+ ...validateNavStructure(navigation, pages)
198
+ );
131
199
 
132
200
  return {
133
201
  diagnostics,