blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "../core/base-path.ts";
1
2
  import type { ResolvedConfig } from "../core/schema.ts";
2
3
 
3
4
  /**
@@ -10,20 +11,42 @@ import type { ResolvedConfig } from "../core/schema.ts";
10
11
 
11
12
  type Redirect = ResolvedConfig["redirects"][number];
12
13
 
14
+ /**
15
+ * Prepend the site-wide `basePath` to each redirect's internal `from`/`to`
16
+ * (both are authored as if mounted at root); external `to` URLs pass through.
17
+ * Idempotent, so re-basing an already-based redirect is safe.
18
+ */
19
+ export const applyBaseToRedirects = (
20
+ redirects: Redirect[],
21
+ basePath: string
22
+ ): Redirect[] =>
23
+ basePath
24
+ ? redirects.map((redirect) => ({
25
+ ...redirect,
26
+ from: withBasePath(basePath, redirect.from),
27
+ to: withBasePath(basePath, redirect.to),
28
+ }))
29
+ : redirects;
30
+
13
31
  /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
14
32
  export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
15
33
  `${redirects
16
34
  .map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
17
35
  .join("\n")}\n`;
18
36
 
19
- /** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
37
+ /**
38
+ * `vercel.json` contents with a `redirects` array. Uses `statusCode` (Vercel's
39
+ * alternative to the boolean `permanent`) so the configured code ships exactly:
40
+ * `permanent` would silently coerce a 301 to 308 and a 302 to 307, diverging
41
+ * from the `_redirects` file, which preserves exact codes.
42
+ */
20
43
  export const buildVercelConfig = (redirects: Redirect[]): string =>
21
44
  `${JSON.stringify(
22
45
  {
23
46
  redirects: redirects.map((redirect) => ({
24
47
  destination: redirect.to,
25
- permanent: redirect.status === 301 || redirect.status === 308,
26
48
  source: redirect.from,
49
+ statusCode: redirect.status,
27
50
  })),
28
51
  },
29
52
  null,
@@ -1,3 +1,4 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
2
  import type { BlumeProject } from "../core/project-graph.ts";
2
3
  import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
3
4
 
@@ -47,7 +48,11 @@ export const buildRobots = (project: BlumeProject): string | null => {
47
48
 
48
49
  const { site } = config.deployment;
49
50
  if (site && config.seo.sitemap) {
50
- lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}/sitemap.xml`);
51
+ const sitemapPath = withBasePath(
52
+ normalizeBasePath(config.deployment.base),
53
+ "/sitemap.xml"
54
+ );
55
+ lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}${sitemapPath}`);
51
56
  }
52
57
  return `${lines.join("\n")}\n`;
53
58
  };
package/src/deploy/rss.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
2
  import type { BlumeProject } from "../core/project-graph.ts";
2
3
  import type { PageRecord } from "../core/types.ts";
3
4
  import { escapeXml } from "./xml.ts";
@@ -52,6 +53,11 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
52
53
  return [];
53
54
  }
54
55
  const base = site.replace(/\/$/u, "");
56
+ // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
57
+ // The feed's own `link`/self URL points at the docs root under that base, while
58
+ // `path` stays base-less (it's also the on-disk output location).
59
+ const deployBase = normalizeBasePath(config.deployment.base);
60
+ const rootLink = `${base}${deployBase}`;
55
61
 
56
62
  const feeds: RssFeed[] = [];
57
63
  for (const type of rss.types) {
@@ -70,7 +76,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
70
76
  description: page.description,
71
77
  // Encode like the sitemap does: a route with spaces or non-ASCII
72
78
  // must still yield a valid <link>/<guid> URL after XML decoding.
73
- link: encodeURI(`${base}${page.route}`),
79
+ link: encodeURI(`${base}${withBasePath(deployBase, page.route)}`),
74
80
  title: page.title,
75
81
  }))
76
82
  .toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
@@ -79,7 +85,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
79
85
  feeds.push({
80
86
  description: config.description,
81
87
  items,
82
- link: base,
88
+ link: rootLink,
83
89
  path: `/${type}/rss.xml`,
84
90
  title: `${config.title} — ${capitalize(type)}`,
85
91
  type,
@@ -105,11 +111,12 @@ const renderItem = (item: RssItem): string => {
105
111
 
106
112
  /** Serialize a resolved feed into an RSS 2.0 XML document. */
107
113
  export const renderRssFeed = (feed: RssFeed): string => {
114
+ const feedSelfHref = `${feed.link}${feed.path}`;
108
115
  const channel = [
109
116
  ` <title>${escapeXml(feed.title)}</title>`,
110
117
  ` <link>${escapeXml(feed.link)}</link>`,
111
118
  ` <description>${escapeXml(feed.description ?? feed.title)}</description>`,
112
- ` <atom:link href="${escapeXml(`${feed.link}${feed.path}`)}" rel="self" type="application/rss+xml" />`,
119
+ ` <atom:link href="${escapeXml(feedSelfHref)}" rel="self" type="application/rss+xml" />`,
113
120
  ];
114
121
  const items = feed.items.map(renderItem).join("\n");
115
122
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -1,6 +1,21 @@
1
+ import {
2
+ customStaticRoutes,
3
+ discoverPagesSync,
4
+ hasGeneratedChangelog,
5
+ } from "../astro/pages.ts";
6
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
7
  import type { BlumeProject } from "../core/project-graph.ts";
2
8
  import { escapeXml } from "./xml.ts";
3
9
 
10
+ /**
11
+ * Astro's reserved error routes. A user-authored override (`pages/404.astro`,
12
+ * `pages/500.astro`, or a `404.md` content page — see `writeNotFoundPage` in
13
+ * `astro/generate.ts`) is neither dynamic nor private, so it would otherwise
14
+ * be emitted — but error pages aren't crawlable destinations and must stay out
15
+ * of the sitemap.
16
+ */
17
+ const ERROR_ROUTES = new Set(["/404", "/500"]);
18
+
4
19
  /** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
5
20
  const lastmodTag = (value: string | undefined): string => {
6
21
  if (!value) {
@@ -13,9 +28,11 @@ const lastmodTag = (value: string | undefined): string => {
13
28
  };
14
29
 
15
30
  /**
16
- * Build a sitemap.xml from the route manifest. Returns null when the sitemap is
17
- * disabled or no `site` is configured (absolute URLs are required for a valid
18
- * sitemap). Drafts, hidden, and `noindex` pages are excluded.
31
+ * Build a sitemap.xml from the route manifest plus the routes the manifest
32
+ * can't see: custom `.astro` pages (most importantly a custom landing `/`) and
33
+ * the generated `/changelog` index. Returns null when the sitemap is disabled
34
+ * or no `site` is configured (absolute URLs are required for a valid sitemap).
35
+ * Drafts, hidden, and `noindex` pages are excluded.
19
36
  */
20
37
  export const buildSitemap = (project: BlumeProject): string | null => {
21
38
  const { site } = project.config.deployment;
@@ -24,20 +41,49 @@ export const buildSitemap = (project: BlumeProject): string | null => {
24
41
  }
25
42
 
26
43
  const base = site.replace(/\/$/u, "");
27
- const urls = project.graph.pages
28
- .filter(
29
- (page) =>
30
- !(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex)
31
- )
44
+ // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
45
+ const deployBase = normalizeBasePath(project.config.deployment.base);
46
+ const seen = new Set<string>();
47
+ const urls: string[] = [];
48
+ const pushUrl = (route: string, lastModified?: string): void => {
32
49
  // `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
33
50
  // then escape XML metacharacters (notably `&`) so a route like
34
51
  // `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
35
52
  // rejected.
36
- .map(
37
- (page) =>
38
- ` <url><loc>${escapeXml(encodeURI(`${base}${page.route}`))}</loc>${lastmodTag(page.lastModified)}</url>`
39
- )
40
- .toSorted();
53
+ const loc = escapeXml(encodeURI(`${base}${route}`));
54
+ if (seen.has(loc)) {
55
+ return;
56
+ }
57
+ seen.add(loc);
58
+ urls.push(` <url><loc>${loc}</loc>${lastmodTag(lastModified)}</url>`);
59
+ };
60
+ for (const page of project.graph.pages) {
61
+ if (
62
+ page.meta.draft ||
63
+ page.meta.sidebar.hidden ||
64
+ page.meta.seo.noindex ||
65
+ ERROR_ROUTES.has(page.route)
66
+ ) {
67
+ continue;
68
+ }
69
+ pushUrl(withBasePath(deployBase, page.route), page.lastModified);
70
+ }
71
+ // Custom `.astro` pages and the generated changelog index mount outside
72
+ // `basePath` (they're injected at their pattern — see `blumeIntegration`), so
73
+ // only the deployment base layers onto their URLs.
74
+ const userPages = project.context.pagesRoot
75
+ ? discoverPagesSync(project.context.pagesRoot)
76
+ : [];
77
+ const extraRoutes = customStaticRoutes(userPages).filter(
78
+ (route) => !ERROR_ROUTES.has(route)
79
+ );
80
+ if (hasGeneratedChangelog(project, userPages)) {
81
+ extraRoutes.push("/changelog");
82
+ }
83
+ for (const route of extraRoutes) {
84
+ pushUrl(withBasePath(deployBase, route));
85
+ }
86
+ urls.sort();
41
87
 
42
88
  return `<?xml version="1.0" encoding="UTF-8"?>
43
89
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ export type {
2
+ ComponentMarkdown,
3
+ ComponentMarkdownChild,
4
+ ComponentMarkdownContext,
5
+ } from "./ai/component-markdown.ts";
1
6
  export { defineConfig } from "./core/config.ts";
2
7
  export type {
3
8
  BlumeBanner,
@@ -0,0 +1,60 @@
1
+ import { isInternalPath, withComposedBasePath } from "../core/base-path.ts";
2
+ import type { MdastNode } from "./mdast.ts";
3
+
4
+ interface UrlNode extends MdastNode {
5
+ url?: string | null;
6
+ }
7
+
8
+ /**
9
+ * The slice of Satteri's MDAST visitor context this plugin needs. Nodes are
10
+ * read-only (the tree compiles to an op-stream), so a URL edit is recorded via
11
+ * `setProperty`, not by mutating the node object.
12
+ */
13
+ interface MdastUrlContext {
14
+ setProperty: (node: unknown, key: "url", value: string) => void;
15
+ }
16
+
17
+ /**
18
+ * A path whose final segment carries a file extension (`/spec.pdf`, `/logo.svg`)
19
+ * — treated as a public asset, which Blume serves from `public/` at the site
20
+ * root and does *not* move under `basePath`. Bare page links (`/guide`) have no
21
+ * extension. The rare dotted route (`/releases/v1.0`) is left un-based here; the
22
+ * build-time link checker still resolves it against the route set.
23
+ */
24
+ const ASSET_PATH = /\.[a-z0-9]+$/iu;
25
+
26
+ /** Strip any `#fragment`/`?query` so only the path is extension-tested. */
27
+ const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
28
+
29
+ /**
30
+ * Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
31
+ * layered over the site-wide `basePath` — to root-relative internal page links
32
+ * (`[x](/guide)` -> `/base/docs/guide`), so authors write links as if mounted
33
+ * at root. Idempotent per layer (via `withComposedBasePath`, so a hand-written
34
+ * `/docs/x` isn't double-prefixed) and inert for external URLs, fragments,
35
+ * relative paths, images, and asset links. Only constructed when a base is set
36
+ * (see `markdown/index.ts`).
37
+ */
38
+ export const baseLinksPlugin = (deployBase: string, basePath: string) => {
39
+ const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
40
+ const { url } = node;
41
+ if (
42
+ typeof url === "string" &&
43
+ isInternalPath(url) &&
44
+ !ASSET_PATH.test(pathOf(url))
45
+ ) {
46
+ const next = withComposedBasePath(deployBase, basePath, url);
47
+ if (next !== url) {
48
+ ctx.setProperty(node, "url", next);
49
+ }
50
+ }
51
+ };
52
+ // `link` covers inline links; `definition` covers reference-style link
53
+ // definitions (`[x]: /guide`). `image` is intentionally excluded — images are
54
+ // public assets served at the site root, unaffected by `basePath`.
55
+ return {
56
+ definition: rebase,
57
+ link: rebase,
58
+ name: "blume-base-links",
59
+ };
60
+ };
@@ -37,6 +37,16 @@ const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
37
37
  const withoutQuotedAttrs = (raw: string): string =>
38
38
  raw.replace(QUOTED_ATTR, " ");
39
39
 
40
+ // The first bare token is the title (```ts blume.config.ts): a non-empty token
41
+ // that isn't a Shiki line range (`{1,3-5}`), a `key=value` attr, or a reserved
42
+ // `lineNumbers`/`twoslash` keyword.
43
+ const isTitleToken = (token: string): boolean => {
44
+ if (token.length === 0 || token.startsWith("{") || token.includes("=")) {
45
+ return false;
46
+ }
47
+ return token !== "lineNumbers" && token !== "twoslash";
48
+ };
49
+
40
50
  const parseTitle = (raw: string | undefined): string | undefined => {
41
51
  if (!raw) {
42
52
  return undefined;
@@ -46,20 +56,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
46
56
  if (attrTitle) {
47
57
  return attrTitle;
48
58
  }
49
- // The first bare token is the title (```ts blume.config.ts), skipping Shiki
50
- // line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
51
- // and `twoslash` keywords.
52
- return withoutQuotedAttrs(raw)
53
- .trim()
54
- .split(/\s+/u)
55
- .find(
56
- (token) =>
57
- token.length > 0 &&
58
- token !== "lineNumbers" &&
59
- token !== "twoslash" &&
60
- !token.startsWith("{") &&
61
- !token.includes("=")
62
- );
59
+ return withoutQuotedAttrs(raw).trim().split(/\s+/u).find(isTitleToken);
63
60
  };
64
61
 
65
62
  const hasLineNumbers = (raw: string | undefined): boolean =>
@@ -8,6 +8,7 @@ import {
8
8
  } from "@shikijs/transformers";
9
9
  import { codeToHtml } from "shiki";
10
10
 
11
+ import { baseLinksPlugin } from "./base-links.ts";
11
12
  import { codeTitleTransformer } from "./code-title.ts";
12
13
  import { directiveToCalloutPlugin } from "./directives.ts";
13
14
  import { headingAnchorPlugin } from "./heading-anchors.ts";
@@ -16,6 +17,8 @@ import { languageIconTransformer } from "./language-icon.ts";
16
17
  import { mathPlugin } from "./math.ts";
17
18
  import { mermaidPlugin } from "./mermaid.ts";
18
19
  import { packageInstallPlugin } from "./package-install.ts";
20
+ import { DEFAULT_CODE_THEMES } from "./themes.ts";
21
+ import type { CodeThemes } from "./themes.ts";
19
22
 
20
23
  /** A Shiki transformer, derived from the upstream factories' return type. */
21
24
  type ShikiTransformer = ReturnType<typeof transformerNotationDiff>;
@@ -54,7 +57,7 @@ type HastPlugin = NonNullable<
54
57
  */
55
58
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
56
59
  const plugins: HastPlugin[] = [
57
- inlineCodeHighlightPlugin() as unknown as HastPlugin,
60
+ inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
58
61
  ];
59
62
  if (options.headingAnchors !== false) {
60
63
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
@@ -99,13 +102,6 @@ export const blumeShikiTransformers = (
99
102
  return transformers;
100
103
  };
101
104
 
102
- /**
103
- * The light/dark Shiki themes Blume highlights with. Kept in lockstep with the
104
- * generated Astro config's `shikiConfig.themes` so code highlighted outside the
105
- * Markdown pipeline (via {@link highlightCode}) matches fenced code exactly.
106
- */
107
- const CODE_THEMES = { dark: "github-dark", light: "github-light" } as const;
108
-
109
105
  const escapeHtml = (value: string): string =>
110
106
  value
111
107
  .replaceAll("&", "&amp;")
@@ -147,6 +143,11 @@ const languageAttrTransformer = (lang: string): ShikiTransformer =>
147
143
  export interface HighlightCodeOptions extends BlumeShikiOptions {
148
144
  /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
149
145
  className?: string;
146
+ /**
147
+ * Light/dark Shiki themes (`markdown.codeBlocks.theme`). Defaults to the same
148
+ * github pair fenced code uses, so out-of-pipeline code stays in lockstep.
149
+ */
150
+ themes?: CodeThemes;
150
151
  /** Header title (a filename), matching a fence's `title="..."` meta. */
151
152
  title?: string;
152
153
  }
@@ -176,7 +177,7 @@ export const highlightCode = async (
176
177
  meta: options.title
177
178
  ? { __raw: `title="${options.title.replaceAll('"', "")}"` }
178
179
  : undefined,
179
- themes: CODE_THEMES,
180
+ themes: options.themes ?? DEFAULT_CODE_THEMES,
180
181
  transformers: [
181
182
  ...blumeShikiTransformers({ icons: options.icons }),
182
183
  astroCodeClassTransformer(options.className),
@@ -200,18 +201,53 @@ const FEATURES = { subscript: true, superscript: true };
200
201
 
201
202
  /** Options shared by both processors. */
202
203
  export interface BlumeMarkdownOptions {
204
+ /**
205
+ * Light/dark Shiki themes for inline `` `code`{:lang} `` highlighting
206
+ * (`markdown.codeBlocks.theme`). Defaults to the github pair fenced code uses.
207
+ */
208
+ codeThemes?: CodeThemes;
203
209
  /**
204
210
  * Wrap `<h2>`–`<h6>` in self-linking anchors (`markdown.headingAnchors`).
205
211
  * On unless explicitly `false`.
206
212
  */
207
213
  headingAnchors?: boolean;
214
+ /**
215
+ * Site-wide route mount point (`""` or `/seg`). When set, root-relative
216
+ * internal page links in content are rewritten under it, so authors write
217
+ * links as if mounted at root.
218
+ */
219
+ basePath?: string;
220
+ /**
221
+ * Astro's `deployment.base` subdirectory (`""` or `/seg`), layered on top of
222
+ * `basePath` when links are rewritten. Kept separate so a hand-written
223
+ * `basePath` link isn't double-prefixed (see `withComposedBasePath`).
224
+ */
225
+ deployBase?: string;
208
226
  }
209
227
 
228
+ /**
229
+ * MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
230
+ * base-path link rewrite, added only when a `basePath` or `deployBase` is
231
+ * configured.
232
+ */
233
+ const blumeSharedMdastPlugins = (
234
+ options: BlumeMarkdownOptions
235
+ ): MdastPlugin[] =>
236
+ options.basePath || options.deployBase
237
+ ? [
238
+ baseLinksPlugin(
239
+ options.deployBase ?? "",
240
+ options.basePath ?? ""
241
+ ) as unknown as MdastPlugin,
242
+ ]
243
+ : [];
244
+
210
245
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
211
246
  export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
212
247
  satteri({
213
248
  features: { ...FEATURES },
214
249
  hastPlugins: blumeHastPlugins(options),
250
+ mdastPlugins: blumeSharedMdastPlugins(options),
215
251
  });
216
252
 
217
253
  export type BlumeMdxOptions = BlumeMarkdownOptions;
@@ -246,5 +282,6 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
246
282
  directiveToCalloutPlugin(),
247
283
  mermaidPlugin(),
248
284
  mathPlugin(),
285
+ ...blumeSharedMdastPlugins(options),
249
286
  ] as unknown as MdastPlugin[],
250
287
  });
@@ -12,6 +12,9 @@
12
12
  * added to the pipeline at all).
13
13
  */
14
14
 
15
+ import { DEFAULT_CODE_THEMES } from "./themes.ts";
16
+ import type { CodeThemes } from "./themes.ts";
17
+
15
18
  /** A minimal hast node (avoids a hast type dependency). */
16
19
  interface HastNode {
17
20
  children?: HastNode[];
@@ -72,7 +75,9 @@ const loadHighlighter = async (): Promise<InlineHighlighter> => {
72
75
  };
73
76
 
74
77
  /** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
75
- export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
78
+ export const inlineCodeHighlightPlugin = (
79
+ themes: CodeThemes = DEFAULT_CODE_THEMES
80
+ ): InlineCodePlugin => ({
76
81
  element: {
77
82
  filter: ["code"],
78
83
  async visit(node, ctx) {
@@ -90,7 +95,7 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
90
95
  defaultColor: false,
91
96
  lang: parsed.lang,
92
97
  structure: "inline",
93
- themes: { dark: "github-dark", light: "github-light" },
98
+ themes,
94
99
  });
95
100
  return {
96
101
  children: root.children,
@@ -99,8 +104,13 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
99
104
  type: "element",
100
105
  };
101
106
  } catch {
102
- // Unknown language or load failure: leave the code unhighlighted
103
- // (falling through returns undefined, so the node is left as-is).
107
+ // Unknown language or load failure: still strip the marker — the
108
+ // literal `{:lang}` must not ship in the page and fall back to
109
+ // plain, unhighlighted inline code.
110
+ return {
111
+ ...node,
112
+ children: [{ type: "text", value: parsed.code }],
113
+ };
104
114
  }
105
115
  },
106
116
  },
@@ -149,10 +149,16 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
149
149
  return `${manager} dlx ${args}`;
150
150
  }
151
151
  case "ci": {
152
- // `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
153
- return manager === "npm"
154
- ? "npm ci"
155
- : `${manager} install --frozen-lockfile`;
152
+ // `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
153
+ // Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
154
+ // Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
155
+ if (manager === "npm") {
156
+ return "npm ci";
157
+ }
158
+ if (manager === "yarn") {
159
+ return "yarn install --immutable";
160
+ }
161
+ return `${manager} install --frozen-lockfile`;
156
162
  }
157
163
  case "remove": {
158
164
  if (manager === "npm") {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
3
+ * fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
4
+ * out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
5
+ * project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
6
+ * single home for the github fallback used when nothing is configured.
7
+ */
8
+
9
+ /**
10
+ * A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
11
+ * `interface`) so it keeps the implicit index signature Shiki's `themes`
12
+ * parameter (`Partial<Record<string, …>>`) expects.
13
+ */
14
+ // oxlint-disable-next-line typescript/consistent-type-definitions -- interface loses the implicit index signature Shiki's `themes` param needs
15
+ export type CodeThemes = {
16
+ dark: string;
17
+ light: string;
18
+ };
19
+
20
+ /** The default pair, used when `markdown.codeBlocks.theme` is unset. */
21
+ export const DEFAULT_CODE_THEMES: CodeThemes = {
22
+ dark: "github-dark",
23
+ light: "github-light",
24
+ };
@@ -104,22 +104,31 @@ const isOperation = (value: unknown): value is OperationObject =>
104
104
  /**
105
105
  * Flatten a 3.1 document into a route-mapped operation list and its ordered
106
106
  * tags. Operations inherit the first tag they declare; keys are de-duplicated so
107
- * a repeated `operationId` still yields distinct routes.
107
+ * a repeated `operationId` still yields distinct routes. `warnings` reports
108
+ * anything skipped (a `$ref` path item), so missing operations aren't silent.
108
109
  */
109
110
  export const extractOperations = (
110
111
  document: ApiDocument,
111
112
  baseRoute: string
112
- ): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
113
+ ): { operations: ApiOperationRef[]; tags: ApiTagRef[]; warnings: string[] } => {
113
114
  const operations: ApiOperationRef[] = [];
114
115
  const tagOrder: string[] = [];
116
+ const tagsSeen = new Set<string>();
115
117
  const tagMeta = new Map(
116
118
  (document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
117
119
  );
118
120
  const seen = new Set<string>();
121
+ const warnings: string[] = [];
119
122
 
120
123
  for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
121
124
  const item = rawItem as PathItemObject | undefined;
122
- if (!item || "$ref" in item) {
125
+ if (!item) {
126
+ continue;
127
+ }
128
+ if ("$ref" in item) {
129
+ warnings.push(
130
+ `Path "${path}" is a $ref to a shared path item; referenced path items are not resolved, so its operations are missing from the reference. Inline the path item under "paths" to render it.`
131
+ );
123
132
  continue;
124
133
  }
125
134
  for (const method of HTTP_METHODS) {
@@ -129,7 +138,8 @@ export const extractOperations = (
129
138
  }
130
139
  const tag = operation.tags?.[0] ?? UNTAGGED;
131
140
  const tagSlug = slugify(tag) || "operations";
132
- if (!tagOrder.includes(tag)) {
141
+ if (!tagsSeen.has(tag)) {
142
+ tagsSeen.add(tag);
133
143
  tagOrder.push(tag);
134
144
  }
135
145
  let key = operationKey(method, path, operation.operationId);
@@ -159,7 +169,7 @@ export const extractOperations = (
159
169
  slug: slugify(name) || "operations",
160
170
  }));
161
171
 
162
- return { operations, tags };
172
+ return { operations, tags, warnings };
163
173
  };
164
174
 
165
175
  /** Resolve the operation object for a ref out of its document. */
@@ -47,6 +47,19 @@ export interface ParsedSpec {
47
47
  warnings: string[];
48
48
  }
49
49
 
50
+ /**
51
+ * The spec was read successfully but its contents aren't an OpenAPI document
52
+ * (an empty file, or YAML that parses to a scalar or list — say, a README
53
+ * pointed at by mistake). Kept distinct from read/fetch failures so callers
54
+ * can suggest fixing the file instead of checking reachability.
55
+ */
56
+ export class InvalidSpecError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = "InvalidSpecError";
60
+ }
61
+ }
62
+
50
63
  /** Where and whether to cache a remote spec's text between runs. */
51
64
  export interface SpecFetchOptions {
52
65
  /** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
@@ -235,5 +248,13 @@ export const parseSpec = async (
235
248
  const { text, warnings } = await readSpecText(spec, root, options);
236
249
  const normalized = normalize(text);
237
250
  const { specification } = upgrade(normalized);
251
+ // `normalize` yields undefined for anything that isn't a YAML/JSON mapping
252
+ // (empty file, scalar, list) and `upgrade(undefined)` yields a null
253
+ // specification — reject it here so the renderer never sees a non-document.
254
+ if (specification === null || typeof specification !== "object") {
255
+ throw new InvalidSpecError(
256
+ `${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
257
+ );
258
+ }
238
259
  return { document: specification as ApiDocument, warnings };
239
260
  };