blume 0.0.0 → 0.1.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 (263) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +11989 -0
  3. package/dist/cli/index.js.map +141 -0
  4. package/docs/01-quickstart.mdx +99 -0
  5. package/docs/02-deployment.mdx +129 -0
  6. package/docs/advanced/api-reference.mdx +114 -0
  7. package/docs/advanced/blog.mdx +121 -0
  8. package/docs/advanced/changelog.mdx +113 -0
  9. package/docs/advanced/custom-pages.mdx +187 -0
  10. package/docs/advanced/meta.ts +7 -0
  11. package/docs/changelog/v0-1-0.mdx +12 -0
  12. package/docs/changelog/v0-2-0.mdx +16 -0
  13. package/docs/configuration/ai.mdx +228 -0
  14. package/docs/configuration/analytics.mdx +98 -0
  15. package/docs/configuration/customization.mdx +91 -0
  16. package/docs/configuration/export.mdx +70 -0
  17. package/docs/configuration/index.mdx +271 -0
  18. package/docs/configuration/meta.ts +15 -0
  19. package/docs/configuration/search.mdx +172 -0
  20. package/docs/configuration/seo.mdx +196 -0
  21. package/docs/configuration/theming.mdx +178 -0
  22. package/docs/content/components.mdx +565 -0
  23. package/docs/content/i18n.mdx +205 -0
  24. package/docs/content/index.mdx +161 -0
  25. package/docs/content/islands.mdx +94 -0
  26. package/docs/content/meta.mdx +119 -0
  27. package/docs/content/meta.ts +15 -0
  28. package/docs/content/navigation.mdx +168 -0
  29. package/docs/content/sources.mdx +216 -0
  30. package/docs/content/syntax.mdx +445 -0
  31. package/docs/index.mdx +112 -0
  32. package/docs/reference/cli.mdx +43 -0
  33. package/docs/reference/frontmatter.mdx +74 -0
  34. package/docs/reference/meta.ts +7 -0
  35. package/package.json +140 -6
  36. package/src/ai/ask.ts +93 -0
  37. package/src/ai/llms.ts +65 -0
  38. package/src/ai/markdown.ts +31 -0
  39. package/src/ai/mcp/data.ts +74 -0
  40. package/src/ai/mcp/discovery.ts +49 -0
  41. package/src/ai/mcp/server.ts +225 -0
  42. package/src/ai/mcp/tools.ts +47 -0
  43. package/src/assets/icon.png +0 -0
  44. package/src/astro/generate.ts +878 -0
  45. package/src/astro/index.ts +4 -0
  46. package/src/astro/integration.ts +74 -0
  47. package/src/astro/islands.ts +131 -0
  48. package/src/astro/markdown-negotiation.ts +68 -0
  49. package/src/astro/pages.ts +28 -0
  50. package/src/astro/templates.ts +1199 -0
  51. package/src/cli/commands/add.ts +81 -0
  52. package/src/cli/commands/build.ts +103 -0
  53. package/src/cli/commands/dev.ts +108 -0
  54. package/src/cli/commands/doctor.ts +74 -0
  55. package/src/cli/commands/eject.ts +57 -0
  56. package/src/cli/commands/init.ts +98 -0
  57. package/src/cli/commands/migrate.ts +39 -0
  58. package/src/cli/commands/preview.ts +39 -0
  59. package/src/cli/commands/sync.ts +52 -0
  60. package/src/cli/commands/validate.ts +60 -0
  61. package/src/cli/index.ts +35 -0
  62. package/src/cli/log.ts +37 -0
  63. package/src/cli/prepare.ts +80 -0
  64. package/src/components/Icon.astro +99 -0
  65. package/src/components/content/Accordion.astro +8 -0
  66. package/src/components/content/AccordionItem.astro +121 -0
  67. package/src/components/content/AutoTypeTable.astro +51 -0
  68. package/src/components/content/Badge.astro +124 -0
  69. package/src/components/content/Callout.astro +73 -0
  70. package/src/components/content/Card.astro +104 -0
  71. package/src/components/content/CardGroup.astro +14 -0
  72. package/src/components/content/CodeGroup.astro +13 -0
  73. package/src/components/content/Color.astro +15 -0
  74. package/src/components/content/ColorItem.astro +87 -0
  75. package/src/components/content/ColorRow.astro +10 -0
  76. package/src/components/content/Column.astro +6 -0
  77. package/src/components/content/Columns.astro +9 -0
  78. package/src/components/content/Expandable.astro +11 -0
  79. package/src/components/content/FileTree.astro +8 -0
  80. package/src/components/content/Frame.astro +70 -0
  81. package/src/components/content/GithubInfo.astro +110 -0
  82. package/src/components/content/Math.astro +24 -0
  83. package/src/components/content/Panel.astro +20 -0
  84. package/src/components/content/Prompt.astro +129 -0
  85. package/src/components/content/Step.astro +34 -0
  86. package/src/components/content/Steps.astro +20 -0
  87. package/src/components/content/Tab.astro +40 -0
  88. package/src/components/content/Tabs.astro +273 -0
  89. package/src/components/content/Tile.astro +42 -0
  90. package/src/components/content/Tooltip.astro +68 -0
  91. package/src/components/content/Tree.astro +300 -0
  92. package/src/components/content/TreeFile.astro +15 -0
  93. package/src/components/content/TreeFolder.astro +62 -0
  94. package/src/components/content/TypeTable.astro +106 -0
  95. package/src/components/content/Update.astro +66 -0
  96. package/src/components/content/Visibility.astro +12 -0
  97. package/src/components/content/Warning.astro +9 -0
  98. package/src/components/content/auto-type-table.ts +141 -0
  99. package/src/components/content/github-info.ts +79 -0
  100. package/src/components/content/mermaid-element.ts +68 -0
  101. package/src/components/github-mark.ts +9 -0
  102. package/src/components/index.ts +14 -0
  103. package/src/components/islands/AskAI.astro +12 -0
  104. package/src/components/islands/ask-ai.tsx +156 -0
  105. package/src/components/layout/Analytics.astro +63 -0
  106. package/src/components/layout/Banner.astro +50 -0
  107. package/src/components/layout/Breadcrumbs.astro +31 -0
  108. package/src/components/layout/Favicon.astro +15 -0
  109. package/src/components/layout/Fonts.astro +14 -0
  110. package/src/components/layout/Header.astro +188 -0
  111. package/src/components/layout/LanguageSwitcher.astro +56 -0
  112. package/src/components/layout/NavTree.astro +462 -0
  113. package/src/components/layout/PageActions.astro +438 -0
  114. package/src/components/layout/PageFeedback.astro +58 -0
  115. package/src/components/layout/Pagination.astro +56 -0
  116. package/src/components/layout/ReferenceLayout.astro +102 -0
  117. package/src/components/layout/RootLayout.astro +533 -0
  118. package/src/components/layout/Search.astro +608 -0
  119. package/src/components/layout/TableOfContents.astro +68 -0
  120. package/src/components/layout/analytics-client.ts +38 -0
  121. package/src/components/layout/nav-utils.ts +87 -0
  122. package/src/components/layout/overrides.ts +32 -0
  123. package/src/components/layout/search/algolia.ts +43 -0
  124. package/src/components/layout/search/endpoint.ts +22 -0
  125. package/src/components/layout/search/flexsearch.ts +52 -0
  126. package/src/components/layout/search/orama-cloud.ts +41 -0
  127. package/src/components/layout/search/orama.ts +26 -0
  128. package/src/components/layout/search/pagefind.ts +43 -0
  129. package/src/components/layout/search/types.ts +163 -0
  130. package/src/components/layout/search/typesense.ts +60 -0
  131. package/src/components/layout/toc-element.ts +108 -0
  132. package/src/core/bridge.ts +92 -0
  133. package/src/core/config.ts +112 -0
  134. package/src/core/content.ts +50 -0
  135. package/src/core/define-components.ts +34 -0
  136. package/src/core/define-meta.ts +20 -0
  137. package/src/core/deployment-env.ts +73 -0
  138. package/src/core/diagnostics.ts +104 -0
  139. package/src/core/graph.ts +128 -0
  140. package/src/core/i18n-ui.ts +171 -0
  141. package/src/core/i18n.ts +169 -0
  142. package/src/core/last-modified.ts +88 -0
  143. package/src/core/links.ts +336 -0
  144. package/src/core/load-module.ts +15 -0
  145. package/src/core/manifest.ts +126 -0
  146. package/src/core/meta.ts +97 -0
  147. package/src/core/navigation.ts +392 -0
  148. package/src/core/package-root.ts +37 -0
  149. package/src/core/project-graph.ts +153 -0
  150. package/src/core/project.ts +56 -0
  151. package/src/core/schema.ts +1057 -0
  152. package/src/core/server-features.ts +23 -0
  153. package/src/core/sources/assets.ts +77 -0
  154. package/src/core/sources/cache.ts +122 -0
  155. package/src/core/sources/filesystem.ts +99 -0
  156. package/src/core/sources/mdx-remote.ts +216 -0
  157. package/src/core/sources/mintlify.ts +161 -0
  158. package/src/core/sources/normalize.ts +227 -0
  159. package/src/core/sources/notion.ts +440 -0
  160. package/src/core/sources/portable-text.ts +143 -0
  161. package/src/core/sources/read.ts +36 -0
  162. package/src/core/sources/resolve.ts +158 -0
  163. package/src/core/sources/sanity.ts +218 -0
  164. package/src/core/sources/types.ts +105 -0
  165. package/src/core/types.ts +261 -0
  166. package/src/core/ui-packs/ar.ts +47 -0
  167. package/src/core/ui-packs/bg.ts +47 -0
  168. package/src/core/ui-packs/bn.ts +47 -0
  169. package/src/core/ui-packs/ca.ts +47 -0
  170. package/src/core/ui-packs/cs.ts +47 -0
  171. package/src/core/ui-packs/da.ts +47 -0
  172. package/src/core/ui-packs/de.ts +47 -0
  173. package/src/core/ui-packs/el.ts +47 -0
  174. package/src/core/ui-packs/es.ts +47 -0
  175. package/src/core/ui-packs/fa.ts +47 -0
  176. package/src/core/ui-packs/fi.ts +47 -0
  177. package/src/core/ui-packs/fr.ts +47 -0
  178. package/src/core/ui-packs/he.ts +47 -0
  179. package/src/core/ui-packs/hi.ts +47 -0
  180. package/src/core/ui-packs/hr.ts +47 -0
  181. package/src/core/ui-packs/hu.ts +47 -0
  182. package/src/core/ui-packs/id.ts +47 -0
  183. package/src/core/ui-packs/index.ts +87 -0
  184. package/src/core/ui-packs/it.ts +47 -0
  185. package/src/core/ui-packs/ja.ts +47 -0
  186. package/src/core/ui-packs/ko.ts +47 -0
  187. package/src/core/ui-packs/nl.ts +47 -0
  188. package/src/core/ui-packs/no.ts +47 -0
  189. package/src/core/ui-packs/pl.ts +47 -0
  190. package/src/core/ui-packs/pt-br.ts +47 -0
  191. package/src/core/ui-packs/pt.ts +47 -0
  192. package/src/core/ui-packs/ro.ts +47 -0
  193. package/src/core/ui-packs/ru.ts +47 -0
  194. package/src/core/ui-packs/sk.ts +47 -0
  195. package/src/core/ui-packs/sr.ts +47 -0
  196. package/src/core/ui-packs/sv.ts +47 -0
  197. package/src/core/ui-packs/th.ts +47 -0
  198. package/src/core/ui-packs/tr.ts +47 -0
  199. package/src/core/ui-packs/uk.ts +47 -0
  200. package/src/core/ui-packs/vi.ts +47 -0
  201. package/src/core/ui-packs/zh-tw.ts +47 -0
  202. package/src/core/ui-packs/zh.ts +47 -0
  203. package/src/core/version.ts +23 -0
  204. package/src/deploy/robots.ts +20 -0
  205. package/src/deploy/rss.ts +128 -0
  206. package/src/deploy/sitemap.ts +28 -0
  207. package/src/index.ts +27 -0
  208. package/src/markdown/code-title.ts +71 -0
  209. package/src/markdown/directives.ts +83 -0
  210. package/src/markdown/heading-anchors.ts +137 -0
  211. package/src/markdown/index.ts +159 -0
  212. package/src/markdown/inline-code.ts +108 -0
  213. package/src/markdown/language-icon.ts +172 -0
  214. package/src/markdown/math.ts +32 -0
  215. package/src/markdown/mdast.ts +48 -0
  216. package/src/markdown/mermaid.ts +37 -0
  217. package/src/markdown/package-commands.ts +159 -0
  218. package/src/markdown/package-install.ts +40 -0
  219. package/src/migrate/fumadocs/config.ts +106 -0
  220. package/src/migrate/fumadocs/content.ts +365 -0
  221. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  222. package/src/migrate/fumadocs/index.ts +252 -0
  223. package/src/migrate/fumadocs/meta.ts +114 -0
  224. package/src/migrate/migrate.ts +53 -0
  225. package/src/migrate/mintlify/config.ts +1040 -0
  226. package/src/migrate/mintlify/content.ts +98 -0
  227. package/src/migrate/mintlify/frontmatter.ts +126 -0
  228. package/src/migrate/mintlify/i18n.ts +51 -0
  229. package/src/migrate/mintlify/icons.ts +128 -0
  230. package/src/migrate/mintlify/index.ts +266 -0
  231. package/src/migrate/mintlify/snippets.ts +305 -0
  232. package/src/migrate/mintlify/transform.ts +81 -0
  233. package/src/migrate/nextra/content.ts +46 -0
  234. package/src/migrate/nextra/frontmatter.ts +40 -0
  235. package/src/migrate/nextra/index.ts +374 -0
  236. package/src/migrate/nextra/meta.ts +266 -0
  237. package/src/migrate/shared.ts +623 -0
  238. package/src/migrate/starlight/config.ts +459 -0
  239. package/src/migrate/starlight/content.ts +78 -0
  240. package/src/migrate/starlight/frontmatter.ts +111 -0
  241. package/src/migrate/starlight/i18n.ts +54 -0
  242. package/src/migrate/starlight/index.ts +131 -0
  243. package/src/og/card.ts +92 -0
  244. package/src/og/index.ts +2 -0
  245. package/src/openapi/scalar.ts +246 -0
  246. package/src/registry/eject.ts +263 -0
  247. package/src/registry/registry.ts +100 -0
  248. package/src/registry/rewrite-imports.ts +39 -0
  249. package/src/runtime/index.ts +14 -0
  250. package/src/search/build.ts +23 -0
  251. package/src/search/documents.ts +165 -0
  252. package/src/search/orama-index.ts +66 -0
  253. package/src/search/providers.ts +91 -0
  254. package/src/search/sync/algolia.ts +30 -0
  255. package/src/search/sync/index.ts +50 -0
  256. package/src/search/sync/orama-cloud.ts +40 -0
  257. package/src/search/sync/typesense.ts +65 -0
  258. package/src/seo/jsonld.ts +113 -0
  259. package/src/theme/entry.ts +608 -0
  260. package/src/theme/fonts.ts +198 -0
  261. package/src/theme/icons.ts +184 -0
  262. package/src/theme/palette.ts +143 -0
  263. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,128 @@
1
+ import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
2
+ import { buildNavigation } from "./navigation.ts";
3
+ import type {
4
+ FolderMeta,
5
+ ResolvedConfig,
6
+ ResolvedI18nConfig,
7
+ } from "./schema.ts";
8
+ import type {
9
+ ContentGraph,
10
+ Diagnostic,
11
+ Navigation,
12
+ PageRecord,
13
+ } from "./types.ts";
14
+
15
+ /** Assemble the content graph: routes map, nav, and duplicate diagnostics. */
16
+ export const buildContentGraph = (
17
+ pages: PageRecord[],
18
+ options: {
19
+ folderMeta: Map<string, FolderMeta>;
20
+ sharedFolderMeta?: Map<string, FolderMeta>;
21
+ navigation: ResolvedConfig["navigation"];
22
+ i18n?: ResolvedI18nConfig;
23
+ }
24
+ ): ContentGraph => {
25
+ const routes = new Map<string, string>();
26
+ const diagnostics: Diagnostic[] = [];
27
+
28
+ for (const page of pages) {
29
+ const existing = routes.get(page.route);
30
+ if (existing) {
31
+ diagnostics.push({
32
+ code: "BLUME_DUPLICATE_ROUTE",
33
+ file: page.sourcePath ?? page.id,
34
+ message: `Two files resolve to ${page.route}: ${existing} and ${page.id}`,
35
+ severity: "error",
36
+ suggestion: "Rename or move one of the files so each route is unique.",
37
+ });
38
+ continue;
39
+ }
40
+ routes.set(page.route, page.id);
41
+ }
42
+
43
+ const { i18n } = options;
44
+ const navigationByLocale: Record<string, Navigation> = {};
45
+ let navigation: Navigation;
46
+
47
+ if (i18n) {
48
+ // Pages of the fallback locale, by translation key — used to fill in a
49
+ // locale's sidebar for pages it hasn't translated yet, so navigation mirrors
50
+ // the default structure instead of showing an empty (or partial) tree.
51
+ const fallback = resolveFallbackLocale(i18n);
52
+ const fallbackByKey = new Map<string, PageRecord>();
53
+ if (fallback) {
54
+ for (const page of pages) {
55
+ if (page.locale === fallback) {
56
+ fallbackByKey.set(page.translationKey, page);
57
+ }
58
+ }
59
+ }
60
+
61
+ // Each locale gets an independent tree from its own pages and folder meta,
62
+ // so navigation may diverge per language (Mintlify-style).
63
+ for (const { code } of i18n.locales) {
64
+ // Localize internal tab paths so a header tab points to its in-locale
65
+ // route (e.g. `/docs` -> `/fr/docs`); external paths pass through.
66
+ const tabs = options.navigation.tabs?.map((tab) => ({
67
+ ...tab,
68
+ path: tab.path.startsWith("/")
69
+ ? localizeRoute(tab.path, code, i18n)
70
+ : tab.path,
71
+ }));
72
+
73
+ const real = pages.filter((page) => page.locale === code);
74
+ let localePages = real;
75
+ if (fallback && code !== fallback) {
76
+ const present = new Set(real.map((page) => page.translationKey));
77
+ const filled: PageRecord[] = [];
78
+ for (const [key, source] of fallbackByKey) {
79
+ if (!present.has(key)) {
80
+ filled.push({
81
+ ...source,
82
+ locale: code,
83
+ route: localizeRoute(key, code, i18n),
84
+ });
85
+ }
86
+ }
87
+ localePages = [...real, ...filled];
88
+ }
89
+
90
+ navigationByLocale[code] = buildNavigation(localePages, {
91
+ chromeVariants: options.navigation.chromeVariants,
92
+ folderMeta: options.folderMeta,
93
+ metaPrefix: code === i18n.defaultLocale ? "" : code,
94
+ refByLogical: true,
95
+ selectors: options.navigation.selectors,
96
+ sharedFolderMeta: options.sharedFolderMeta,
97
+ sidebar: options.navigation.sidebar,
98
+ sidebarVariants: options.navigation.sidebarVariants,
99
+ tabs,
100
+ });
101
+ }
102
+ navigation = navigationByLocale[i18n.defaultLocale] ?? {
103
+ chromeVariants: [],
104
+ selectors: [],
105
+ sidebar: [],
106
+ sidebarVariants: [],
107
+ tabs: [],
108
+ };
109
+ } else {
110
+ navigation = buildNavigation(pages, {
111
+ chromeVariants: options.navigation.chromeVariants,
112
+ folderMeta: options.folderMeta,
113
+ selectors: options.navigation.selectors,
114
+ sharedFolderMeta: options.sharedFolderMeta,
115
+ sidebar: options.navigation.sidebar,
116
+ sidebarVariants: options.navigation.sidebarVariants,
117
+ tabs: options.navigation.tabs,
118
+ });
119
+ }
120
+
121
+ return {
122
+ diagnostics,
123
+ navigation,
124
+ navigationByLocale,
125
+ pages,
126
+ routes,
127
+ };
128
+ };
@@ -0,0 +1,171 @@
1
+ import { z } from "zod";
2
+
3
+ import { UI_PACKS } from "./ui-packs/index.ts";
4
+
5
+ /**
6
+ * Translatable UI chrome strings.
7
+ *
8
+ * The schema is the single source of truth: each field's `.default()` is the
9
+ * English baseline, so `EN_UI = uiStringsSchema.parse({})`. Shipped packs and
10
+ * user overrides merge on top (see {@link resolveUIStrings}). Grouped by surface
11
+ * to keep the runtime payload and component props readable.
12
+ */
13
+ const uiStringsObject = z.object({
14
+ actions: z
15
+ .object({
16
+ addToCursor: z.string().default("Add to Cursor"),
17
+ addToVscode: z.string().default("Add to VS Code"),
18
+ askAI: z.string().default("Ask AI about this page"),
19
+ connectMcp: z.string().default("Connect to MCP"),
20
+ copied: z.string().default("Copied!"),
21
+ copyClaudeCode: z.string().default("Copy Claude Code command"),
22
+ copyMarkdown: z.string().default("Copy as Markdown"),
23
+ copyServerUrl: z.string().default("Copy server URL"),
24
+ edit: z.string().default("Edit on GitHub"),
25
+ openInChat: z.string().default("Open in chat"),
26
+ scrollToTop: z.string().default("Scroll to top"),
27
+ })
28
+ .default({}),
29
+ ask: z
30
+ .object({
31
+ empty: z.string().default("Ask a question about the docs."),
32
+ error: z.string().default("Sorry, something went wrong."),
33
+ label: z.string().default("Ask a question"),
34
+ placeholder: z.string().default("Ask a question…"),
35
+ send: z.string().default("Send"),
36
+ title: z.string().default("Ask AI"),
37
+ })
38
+ .default({}),
39
+ feedback: z
40
+ .object({
41
+ no: z.string().default("No"),
42
+ question: z.string().default("Was this page helpful?"),
43
+ thanks: z.string().default("Thanks for your feedback!"),
44
+ yes: z.string().default("Yes"),
45
+ })
46
+ .default({}),
47
+ languageSwitcher: z
48
+ .object({
49
+ label: z.string().default("Language"),
50
+ untranslated: z.string().default("Not translated"),
51
+ })
52
+ .default({}),
53
+ page: z
54
+ .object({
55
+ lastUpdated: z.string().default("Last updated on"),
56
+ next: z.string().default("Next"),
57
+ previous: z.string().default("Previous"),
58
+ skipToContent: z.string().default("Skip to content"),
59
+ })
60
+ .default({}),
61
+ search: z
62
+ .object({
63
+ allLanguages: z.string().default("All languages"),
64
+ button: z.string().default("Search"),
65
+ devOnly: z
66
+ .string()
67
+ .default("Search is available in the production build."),
68
+ label: z.string().default("Search docs"),
69
+ noResults: z.string().default("No results found."),
70
+ placeholder: z.string().default("Search documentation…"),
71
+ })
72
+ .default({}),
73
+ toc: z
74
+ .object({
75
+ title: z.string().default("On this page"),
76
+ })
77
+ .default({}),
78
+ });
79
+
80
+ export const uiStringsSchema = uiStringsObject.default({});
81
+
82
+ /** A fully-resolved dictionary; every key present. */
83
+ export type UIStrings = z.infer<typeof uiStringsObject>;
84
+
85
+ /** The English baseline, derived from the schema defaults. */
86
+ export const EN_UI: UIStrings = uiStringsObject.parse({});
87
+
88
+ /**
89
+ * A partial override: `{ group: { key: "translation" } }`. Validated loosely
90
+ * (object of objects of strings) so packs and user config can supply only the
91
+ * keys they translate. Unknown groups/keys merge harmlessly.
92
+ */
93
+ export const uiStringsOverrideSchema = z.record(
94
+ z.string(),
95
+ z.record(z.string(), z.string())
96
+ );
97
+
98
+ export type UIStringsOverride = z.infer<typeof uiStringsOverrideSchema>;
99
+
100
+ /** Per-locale UI overrides supplied in `i18n.ui`. */
101
+ export const uiLocaleOverridesSchema = z.record(
102
+ z.string(),
103
+ uiStringsOverrideSchema
104
+ );
105
+
106
+ /** Merge an override's string leaves onto a base dictionary (two levels deep). */
107
+ const mergeUI = (base: UIStrings, override?: UIStringsOverride): UIStrings => {
108
+ if (!override) {
109
+ return base;
110
+ }
111
+ const out: UIStrings = structuredClone(base);
112
+ for (const [group, values] of Object.entries(override)) {
113
+ const target = (out as Record<string, Record<string, string>>)[group];
114
+ if (target && values) {
115
+ Object.assign(target, values);
116
+ }
117
+ }
118
+ return out;
119
+ };
120
+
121
+ /**
122
+ * Built-in translation packs, one module per locale under {@link ./ui-packs}.
123
+ * English is the schema baseline (no pack); every other locale ships a starter
124
+ * pack so adopters get translated chrome out of the box. Re-exported here so the
125
+ * resolver and existing imports keep a single entry point.
126
+ */
127
+ export { UI_PACKS };
128
+
129
+ /** Case-insensitive index for region-variant lookup (`pt-br` -> `pt-BR`). */
130
+ const PACKS_BY_LOWER: Record<string, UIStringsOverride> = Object.fromEntries(
131
+ Object.entries(UI_PACKS).map(([code, pack]) => [code.toLowerCase(), pack])
132
+ );
133
+
134
+ /**
135
+ * Find the built-in pack for a locale code, tolerating case and region subtags:
136
+ * an exact match wins, then a case-insensitive match (`pt-br` -> `pt-BR`), then
137
+ * the base language (`fr-CA` -> `fr`). So any reasonable code gets sensible
138
+ * chrome without the project having to match our exact casing.
139
+ */
140
+ const packFor = (code: string): UIStringsOverride | undefined => {
141
+ const lower = code.toLowerCase();
142
+ return (
143
+ UI_PACKS[code] ??
144
+ PACKS_BY_LOWER[lower] ??
145
+ PACKS_BY_LOWER[lower.split(/[-_]/u)[0] ?? lower]
146
+ );
147
+ };
148
+
149
+ /**
150
+ * Resolve the active dictionary for a locale. Layers, in order:
151
+ * English baseline ← default-locale pack ← default-locale override ←
152
+ * locale pack ← locale override. So a missing key falls back to the default
153
+ * locale's translation, then to English.
154
+ */
155
+ export const resolveUIStrings = (
156
+ locale: string,
157
+ options: {
158
+ defaultLocale: string;
159
+ overrides?: Record<string, UIStringsOverride>;
160
+ }
161
+ ): UIStrings => {
162
+ const { defaultLocale, overrides } = options;
163
+ let dict = EN_UI;
164
+ dict = mergeUI(dict, packFor(defaultLocale));
165
+ dict = mergeUI(dict, overrides?.[defaultLocale]);
166
+ if (locale !== defaultLocale) {
167
+ dict = mergeUI(dict, packFor(locale));
168
+ dict = mergeUI(dict, overrides?.[locale]);
169
+ }
170
+ return dict;
171
+ };
@@ -0,0 +1,169 @@
1
+ import type { ResolvedConfig, ResolvedI18nConfig } from "./schema.ts";
2
+ import type { Diagnostic, PageRecord } from "./types.ts";
3
+ import { UI_PACKS } from "./ui-packs/index.ts";
4
+
5
+ /**
6
+ * Locale logic, centralized. Every seam that needs to reason about locales
7
+ * (content discovery, navigation, manifest, runtime generation, the catch-all)
8
+ * goes through these helpers so the routing rules live in exactly one place.
9
+ */
10
+
11
+ /** Locale codes Blume recognizes (those it ships a UI pack for, plus English). */
12
+ const KNOWN_LOCALES = new Set(
13
+ [...Object.keys(UI_PACKS), "en"].map((code) => code.toLowerCase())
14
+ );
15
+
16
+ /** True when the project opts into i18n. */
17
+ export const i18nEnabled = (
18
+ config: ResolvedConfig
19
+ ): config is ResolvedConfig & { i18n: ResolvedI18nConfig } =>
20
+ config.i18n !== undefined;
21
+
22
+ /** All configured locale codes, default first as authored. */
23
+ export const localeCodes = (i18n: ResolvedI18nConfig): string[] =>
24
+ i18n.locales.map((locale) => locale.code);
25
+
26
+ /** Text direction for a locale (`ltr` when unknown). */
27
+ export const localeDir = (
28
+ code: string,
29
+ i18n: ResolvedI18nConfig
30
+ ): "ltr" | "rtl" =>
31
+ i18n.locales.find((locale) => locale.code === code)?.dir ?? "ltr";
32
+
33
+ /**
34
+ * The locale a missing translation falls back to: `fallbackLocale` when set,
35
+ * the default locale when `fallbackLocale` is omitted, or `null` (disabled)
36
+ * when explicitly set to `null`.
37
+ */
38
+ export const resolveFallbackLocale = (
39
+ i18n: ResolvedI18nConfig
40
+ ): string | null => {
41
+ if (i18n.fallbackLocale === null) {
42
+ return null;
43
+ }
44
+ return i18n.fallbackLocale ?? i18n.defaultLocale;
45
+ };
46
+
47
+ /** URL prefix for a locale: `""` for the hidden default, else `/<code>`. */
48
+ export const localePrefix = (code: string, i18n: ResolvedI18nConfig): string =>
49
+ code === i18n.defaultLocale && i18n.hideDefaultLocalePrefix ? "" : `/${code}`;
50
+
51
+ /**
52
+ * Prefix a locale-agnostic route (e.g. `/guides/x` or `/`) with its locale.
53
+ * `/` becomes `/fr` (or stays `/` for the hidden default).
54
+ */
55
+ export const localizeRoute = (
56
+ logicalRoute: string,
57
+ code: string,
58
+ i18n: ResolvedI18nConfig
59
+ ): string => {
60
+ const prefix = localePrefix(code, i18n);
61
+ if (!prefix) {
62
+ return logicalRoute;
63
+ }
64
+ return logicalRoute === "/" ? prefix : `${prefix}${logicalRoute}`;
65
+ };
66
+
67
+ /**
68
+ * Detect a leading non-default locale directory in a path's segments. The
69
+ * default locale lives at the content root, so only non-default codes are
70
+ * matched as a leading segment. Returns the resolved locale and the remaining
71
+ * (locale-stripped) segments.
72
+ */
73
+ export const detectLocale = (
74
+ parts: string[],
75
+ i18n: ResolvedI18nConfig
76
+ ): { locale: string; rest: string[] } => {
77
+ const [first] = parts;
78
+ const isNonDefault = i18n.locales.some(
79
+ (locale) => locale.code !== i18n.defaultLocale && locale.code === first
80
+ );
81
+ if (first !== undefined && isNonDefault) {
82
+ return { locale: first, rest: parts.slice(1) };
83
+ }
84
+ return { locale: i18n.defaultLocale, rest: parts };
85
+ };
86
+
87
+ /**
88
+ * Resolve where a content file lives across locales, by parser:
89
+ * - `dir`: a leading locale directory (`fr/page.mdx`)
90
+ * - `dot`: a filename suffix (`page.fr.mdx`)
91
+ *
92
+ * A `.$.` infix (e.g. `changelog.$.mdx`) marks a shared, locale-agnostic file
93
+ * that is materialized into every configured locale. Returns the locale-stripped
94
+ * path (used for nav grouping) and the locale codes the file maps to (one for a
95
+ * normal file, all locales for a shared one).
96
+ */
97
+ export const localePlacement = (
98
+ rel: string,
99
+ ext: string,
100
+ i18n: ResolvedI18nConfig
101
+ ): { navPath: string; locales: string[] } => {
102
+ const base = rel.slice(0, rel.length - ext.length);
103
+
104
+ // Shared `$` file: the same content in every locale.
105
+ if (base.endsWith(".$")) {
106
+ return {
107
+ locales: i18n.locales.map((locale) => locale.code),
108
+ navPath: `${base.slice(0, -2)}${ext}`,
109
+ };
110
+ }
111
+
112
+ if (i18n.parser === "dot") {
113
+ const lastDot = base.lastIndexOf(".");
114
+ // Only a dot inside the filename (not a directory) is a locale suffix.
115
+ if (lastDot > base.lastIndexOf("/")) {
116
+ const suffix = base.slice(lastDot + 1);
117
+ const isNonDefault = i18n.locales.some(
118
+ (locale) => locale.code !== i18n.defaultLocale && locale.code === suffix
119
+ );
120
+ if (isNonDefault) {
121
+ return {
122
+ locales: [suffix],
123
+ navPath: `${base.slice(0, lastDot)}${ext}`,
124
+ };
125
+ }
126
+ }
127
+ return { locales: [i18n.defaultLocale], navPath: rel };
128
+ }
129
+
130
+ const { locale, rest } = detectLocale(rel.split("/"), i18n);
131
+ return { locales: [locale], navPath: rest.join("/") };
132
+ };
133
+
134
+ /**
135
+ * Warn about top-level content folders that look like a locale (a code Blume
136
+ * recognizes) but aren't declared in `i18n.locales`. Without this they're
137
+ * silently treated as default-locale content under a `/<code>/…` route, which
138
+ * is almost never intended — usually a translation that wasn't registered.
139
+ */
140
+ export const i18nDiagnostics = (
141
+ pages: PageRecord[],
142
+ i18n: ResolvedI18nConfig
143
+ ): Diagnostic[] => {
144
+ const configured = new Set(
145
+ i18n.locales.map((locale) => locale.code.toLowerCase())
146
+ );
147
+ const seen = new Set<string>();
148
+ const diagnostics: Diagnostic[] = [];
149
+ for (const page of pages) {
150
+ // The locale-looking folder is the first segment of the source-local ref
151
+ // (e.g. `fr/guide.md`), not the namespaced id (`filesystem:fr/guide.md`).
152
+ const first = page.source.ref.split("/")[0]?.toLowerCase();
153
+ if (
154
+ first &&
155
+ !seen.has(first) &&
156
+ KNOWN_LOCALES.has(first) &&
157
+ !configured.has(first)
158
+ ) {
159
+ seen.add(first);
160
+ diagnostics.push({
161
+ code: "BLUME_I18N_UNCONFIGURED_LOCALE",
162
+ message: `Folder "${first}/" looks like a locale, but "${first}" is not in i18n.locales — its pages are treated as "${i18n.defaultLocale}" content at /${first}/….`,
163
+ severity: "warning",
164
+ suggestion: `Add { code: "${first}", label: "…" } to i18n.locales, or rename the folder if it isn't a translation.`,
165
+ });
166
+ }
167
+ }
168
+ return diagnostics;
169
+ };
@@ -0,0 +1,88 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ import { relative } from "pathe";
4
+
5
+ /** Normalized form of the `lastModified` config. */
6
+ export interface ResolvedLastModified {
7
+ enabled: boolean;
8
+ source: "git" | "frontmatter";
9
+ }
10
+
11
+ /** Normalize the `lastModified` config union into `{ enabled, source }`. */
12
+ export const resolveLastModifiedConfig = (
13
+ value: boolean | { type: "git" | "frontmatter" }
14
+ ): ResolvedLastModified => {
15
+ if (value === false) {
16
+ return { enabled: false, source: "git" };
17
+ }
18
+ if (value === true) {
19
+ return { enabled: true, source: "git" };
20
+ }
21
+ return { enabled: true, source: value.type };
22
+ };
23
+
24
+ /**
25
+ * Parse `git log --format=%x00%cI --name-only` output into a map of
26
+ * repo-root-relative path → most recent committer ISO date. Each commit emits a
27
+ * NUL-prefixed date line followed by the paths it touched; since git logs
28
+ * newest-first, the first date seen for a path wins. Blank lines are ignored.
29
+ */
30
+ export const parseGitLog = (output: string): Map<string, string> => {
31
+ const times = new Map<string, string>();
32
+ let current: string | null = null;
33
+ for (const line of output.split("\n")) {
34
+ if (line.startsWith("\0")) {
35
+ current = line.slice(1);
36
+ } else if (line && current && !times.has(line)) {
37
+ times.set(line, current);
38
+ }
39
+ }
40
+ return times;
41
+ };
42
+
43
+ /**
44
+ * Resolve each source file's last-modified date from git history, keyed by
45
+ * absolute source path. Runs a single `git log` over the content tree and maps
46
+ * repo-root-relative paths back to the given absolute paths (monorepo-safe via
47
+ * `rev-parse --show-toplevel`). Returns an empty map if git is unavailable or
48
+ * the project isn't a repo — the feature then simply shows no dates.
49
+ */
50
+ export const gitLastModifiedTimes = (
51
+ root: string,
52
+ contentRoot: string,
53
+ sourcePaths: string[]
54
+ ): Map<string, string> => {
55
+ try {
56
+ const gitRoot = execFileSync(
57
+ "git",
58
+ ["-C", root, "rev-parse", "--show-toplevel"],
59
+ { encoding: "utf-8" }
60
+ ).trim();
61
+ const output = execFileSync(
62
+ "git",
63
+ [
64
+ "-C",
65
+ root,
66
+ "-c",
67
+ "core.quotePath=false",
68
+ "log",
69
+ "--format=%x00%cI",
70
+ "--name-only",
71
+ "--",
72
+ contentRoot,
73
+ ],
74
+ { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }
75
+ );
76
+ const byRepoPath = parseGitLog(output);
77
+ const result = new Map<string, string>();
78
+ for (const sourcePath of sourcePaths) {
79
+ const iso = byRepoPath.get(relative(gitRoot, sourcePath));
80
+ if (iso) {
81
+ result.set(sourcePath, iso);
82
+ }
83
+ }
84
+ return result;
85
+ } catch {
86
+ return new Map();
87
+ }
88
+ };