blume 1.1.0 → 1.1.2

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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli/index.js +319 -91
  3. package/dist/cli/index.js.map +18 -18
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +18 -8
  6. package/dist/types/core/schema.d.ts +38 -24
  7. package/dist/types/core/types.d.ts +14 -0
  8. package/dist/types/markdown/themes.d.ts +21 -0
  9. package/docs/advanced/changelog.mdx +1 -1
  10. package/docs/configuration/ai.mdx +2 -2
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/configuration/seo.mdx +16 -0
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/navigation.mdx +4 -0
  15. package/docs/content/syntax.mdx +14 -0
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +2 -0
  18. package/package.json +2 -1
  19. package/src/ai/component-markdown.ts +39 -11
  20. package/src/ai/llms.ts +4 -2
  21. package/src/ai/markdown.ts +5 -1
  22. package/src/astro/generate.ts +121 -32
  23. package/src/astro/pages.ts +21 -5
  24. package/src/astro/templates.ts +70 -7
  25. package/src/audit/checks/llms.ts +4 -1
  26. package/src/cli/commands/build.ts +13 -1
  27. package/src/cli/prepare.ts +10 -2
  28. package/src/components/content/Component.astro +99 -6
  29. package/src/components/content/diff.ts +53 -4
  30. package/src/components/layout/RootLayout.astro +9 -1
  31. package/src/components/layout/nav-utils.ts +18 -7
  32. package/src/core/config-input.ts +18 -8
  33. package/src/core/diagnostics.ts +2 -0
  34. package/src/core/graph.ts +23 -4
  35. package/src/core/navigation.ts +180 -21
  36. package/src/core/project-graph.ts +54 -28
  37. package/src/core/schema.ts +39 -2
  38. package/src/core/sources/github-releases.ts +65 -2
  39. package/src/core/types.ts +14 -0
  40. package/src/markdown/index.ts +3 -0
  41. package/src/markdown/inline-code.ts +1 -1
  42. package/src/markdown/themes.ts +7 -2
  43. package/src/markdown/twoslash.ts +60 -0
  44. package/src/registry/eject.ts +3 -1
  45. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
@@ -15,7 +15,7 @@ import { resolveProjectContext } from "./project.ts";
15
15
  import type { ResolvedConfig } from "./schema.ts";
16
16
  import { normalizeEntry } from "./sources/normalize.ts";
17
17
  import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
18
- import type { ContentSource } from "./sources/types.ts";
18
+ import type { ContentSource, SourceLoadResult } from "./sources/types.ts";
19
19
  import type {
20
20
  BlumeManifest,
21
21
  ContentGraph,
@@ -70,6 +70,8 @@ export interface BlumeProject {
70
70
  graph: ContentGraph;
71
71
  manifest: BlumeManifest;
72
72
  diagnostics: Diagnostic[];
73
+ /** Entries excluded from the graph because their frontmatter failed validation. */
74
+ droppedPages: number;
73
75
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
74
76
  sources: ContentSource[];
75
77
  }
@@ -116,6 +118,51 @@ const entryIdDiagnostics = (
116
118
  return diagnostics;
117
119
  };
118
120
 
121
+ /**
122
+ * Funnel every loaded source's entries through the shared `normalizeEntry`,
123
+ * collecting pages, diagnostics, and the count of entries dropped outright —
124
+ * an entry that yields no pages but did yield diagnostics was rejected for
125
+ * invalid frontmatter, and callers surface that count so a build with missing
126
+ * pages can't read as clean.
127
+ */
128
+ const normalizeLoadedEntries = (
129
+ loaded: ({ source: ContentSource } & SourceLoadResult)[],
130
+ config: ResolvedConfig
131
+ ): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
132
+ // Only thread `frontmatter.extend` through when a project opts in, so the
133
+ // known-key split in `normalizeEntry` stays off the default path.
134
+ const frontmatterExtend =
135
+ Object.keys(config.frontmatter.extend).length > 0
136
+ ? config.frontmatter.extend
137
+ : undefined;
138
+
139
+ const pages: PageRecord[] = [];
140
+ const allDiagnostics: Diagnostic[] = [];
141
+ let droppedPages = 0;
142
+ for (const { source, entries, diagnostics } of loaded) {
143
+ allDiagnostics.push(...diagnostics);
144
+ for (const entry of entries) {
145
+ const normalized = normalizeEntry(entry, {
146
+ basePath: config.basePath,
147
+ defaultType: config.content.defaultType,
148
+ frontmatterExtend,
149
+ i18n: config.i18n,
150
+ source: {
151
+ name: source.name,
152
+ prefix: source.prefix,
153
+ staged: source.staged,
154
+ },
155
+ });
156
+ if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
157
+ droppedPages += 1;
158
+ }
159
+ pages.push(...normalized.pages);
160
+ allDiagnostics.push(...normalized.diagnostics);
161
+ }
162
+ }
163
+ return { diagnostics: allDiagnostics, droppedPages, pages };
164
+ };
165
+
119
166
  /**
120
167
  * Run the full core pipeline for a project root: load config, resolve paths,
121
168
  * discover content and folder meta, build the graph, and assemble the manifest.
@@ -190,33 +237,11 @@ export const scanProject = async (
190
237
  discoverFolderMeta(metaSources, { localeDirs }),
191
238
  ]);
192
239
 
193
- // Only thread `frontmatter.extend` through when a project opts in, so the
194
- // known-key split in `normalizeEntry` stays off the default path.
195
- const frontmatterExtend =
196
- Object.keys(config.frontmatter.extend).length > 0
197
- ? config.frontmatter.extend
198
- : undefined;
199
-
200
- const allPages: PageRecord[] = [];
201
- const contentDiagnostics: Diagnostic[] = [];
202
- for (const { source, entries, diagnostics } of loaded) {
203
- contentDiagnostics.push(...diagnostics);
204
- for (const entry of entries) {
205
- const normalized = normalizeEntry(entry, {
206
- basePath: config.basePath,
207
- defaultType: config.content.defaultType,
208
- frontmatterExtend,
209
- i18n: config.i18n,
210
- source: {
211
- name: source.name,
212
- prefix: source.prefix,
213
- staged: source.staged,
214
- },
215
- });
216
- allPages.push(...normalized.pages);
217
- contentDiagnostics.push(...normalized.diagnostics);
218
- }
219
- }
240
+ const {
241
+ diagnostics: contentDiagnostics,
242
+ droppedPages,
243
+ pages: allPages,
244
+ } = normalizeLoadedEntries(loaded, config);
220
245
 
221
246
  // Drafts render in dev and in preview, but are excluded from production builds.
222
247
  const pages =
@@ -267,6 +292,7 @@ export const scanProject = async (
267
292
  ...graph.diagnostics,
268
293
  ...i18nWarnings,
269
294
  ],
295
+ droppedPages,
270
296
  graph,
271
297
  manifest,
272
298
  mode,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
4
+ import type { CodeTheme } from "../markdown/themes.ts";
4
5
  import { normalizeRoute } from "../openapi/references.ts";
5
6
  import { normalizeXHandle } from "../seo/x-handle.ts";
6
7
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
@@ -870,6 +871,13 @@ const ogConfigSchema = z.strictObject({
870
871
  logo: z.string().optional(),
871
872
  /** Optional generated-card colors. */
872
873
  palette: ogPaletteSchema.optional(),
874
+ /**
875
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
876
+ * A custom page has no frontmatter to read, so its card is otherwise titled
877
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
878
+ * Content pages always take their card headline from the page title.
879
+ */
880
+ titles: z.record(z.string(), z.string()).optional(),
873
881
  });
874
882
 
875
883
  const rssConfigSchema = z.strictObject({
@@ -943,9 +951,38 @@ const githubConfigSchema = z.strictObject({
943
951
  repo: z.string(),
944
952
  });
945
953
 
954
+ const codeThemeSchema = z.custom<CodeTheme>((value) => {
955
+ if (typeof value === "string") {
956
+ return true;
957
+ }
958
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
959
+ return false;
960
+ }
961
+ // Token rules live in `settings` (Shiki's canonical field, also the TextMate
962
+ // form themes like createCssVariablesTheme() produce) or `tokenColors` (the
963
+ // VS Code spelling Shiki falls back to). A colors-only theme (editor fg/bg,
964
+ // no token rules) is also valid — Shiki renders it from `colors` alone. Each
965
+ // field present must have the right shape, and at least one must be present.
966
+ const theme = value as Record<string, unknown>;
967
+ const settingsValid =
968
+ theme.settings === undefined || Array.isArray(theme.settings);
969
+ const tokenColorsValid =
970
+ theme.tokenColors === undefined || Array.isArray(theme.tokenColors);
971
+ const colorsValid =
972
+ theme.colors === undefined ||
973
+ (typeof theme.colors === "object" &&
974
+ theme.colors !== null &&
975
+ !Array.isArray(theme.colors));
976
+ const hasContent =
977
+ theme.settings !== undefined ||
978
+ theme.tokenColors !== undefined ||
979
+ theme.colors !== undefined;
980
+ return settingsValid && tokenColorsValid && colorsValid && hasContent;
981
+ }, "Expected a Shiki theme name or custom theme object");
982
+
946
983
  const codeBlockThemeSchema = z.strictObject({
947
- dark: z.string().default("github-dark"),
948
- light: z.string().default("github-light"),
984
+ dark: codeThemeSchema.default("github-dark"),
985
+ light: codeThemeSchema.default("github-light"),
949
986
  });
950
987
 
951
988
  const codeBlocksConfigSchema = z.strictObject({
@@ -56,6 +56,63 @@ const LEADING_V = /^v/iu;
56
56
  const NON_SLUG = /[^a-z0-9]+/gu;
57
57
  const EDGE_DASHES = /^-+|-+$/gu;
58
58
 
59
+ // `blume audit` grades meta descriptions against the 110–160 character search
60
+ // snippet range (audit/types.ts thresholds), so the derived summary aims for
61
+ // the longest word-boundary cut under the cap.
62
+ const DESCRIPTION_MAX = 160;
63
+ const DESCRIPTION_MIN = 110;
64
+
65
+ const CODE_FENCE = /```[\s\S]*?```/gu;
66
+ const HEADING_LINE = /^#{1,6}\s.*$/gmu;
67
+ const LIST_MARK = /^\s*(?:[-*+]|\d+[.)])\s+/u;
68
+ // Changesets-generated release bullets open with the changeset's short commit
69
+ // hash (`- cf8fa22: Fix …`) — noise in a search snippet.
70
+ const CHANGESET_HASH = /^[0-9a-f]{7,40}:\s+/u;
71
+ const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
72
+ const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
73
+ const INLINE_CODE = /`(?<code>[^`]+)`/gu;
74
+ // Tag-shaped only: a bare `<` in prose must not swallow text up to a later `>`.
75
+ const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
76
+ const MARKDOWN_PUNCT = /[*_~>]+/gu;
77
+ const WHITESPACE = /\s+/gu;
78
+ const TRAILING_FRAGMENT = /[\s,;:.—–-]+$/u;
79
+
80
+ /**
81
+ * Derive a meta description from release notes: markdown reduced to plain
82
+ * text — section headings ("### Patch Changes") and changesets' commit-hash
83
+ * bullet prefixes dropped — then cut at a word boundary to fit the search
84
+ * snippet cap. Undefined when the notes have no prose at all.
85
+ */
86
+ const releaseDescription = (body: string): string | undefined => {
87
+ const text = body
88
+ .replaceAll(CODE_FENCE, " ")
89
+ .replaceAll(HEADING_LINE, "")
90
+ .split("\n")
91
+ .map((line) => line.replace(LIST_MARK, "").replace(CHANGESET_HASH, ""))
92
+ .join("\n")
93
+ .replaceAll(IMAGE, " ")
94
+ .replaceAll(LINK, "$<text>")
95
+ .replaceAll(INLINE_CODE, "$<code>")
96
+ .replaceAll(HTML_OR_JSX, " ")
97
+ .replaceAll(MARKDOWN_PUNCT, " ")
98
+ .replaceAll(WHITESPACE, " ")
99
+ .trim();
100
+ if (!text) {
101
+ return undefined;
102
+ }
103
+ if (text.length <= DESCRIPTION_MAX) {
104
+ return text;
105
+ }
106
+ // Cut before the cap at a word boundary (kept only when it doesn't drop the
107
+ // summary under the minimum), shed any dangling punctuation, and mark the cut.
108
+ const slice = text.slice(0, DESCRIPTION_MAX - 1);
109
+ const boundary = slice.lastIndexOf(" ");
110
+ const head = (
111
+ boundary >= DESCRIPTION_MIN ? slice.slice(0, boundary) : slice
112
+ ).replace(TRAILING_FRAGMENT, "");
113
+ return `${head}…`;
114
+ };
115
+
59
116
  /** Slugify a tag into a stable, URL-safe source ref (`v1.2.0` -> `v1-2-0`). */
60
117
  const slugifyTag = (tag: string): string =>
61
118
  tag.toLowerCase().replaceAll(NON_SLUG, "-").replaceAll(EDGE_DASHES, "");
@@ -71,9 +128,10 @@ const githubHeaders = (): Headers => {
71
128
  };
72
129
 
73
130
  /**
74
- * Lower one release to a staged Markdown entry: the notes become the body and
131
+ * Lower one release to a staged Markdown entry: the notes become the body,
75
132
  * `type: changelog` frontmatter (title/date/version/category) drives the
76
- * generated `/changelog` timeline and RSS feed.
133
+ * generated `/changelog` timeline and RSS feed, and a summary derived from the
134
+ * notes becomes the release page's meta description.
77
135
  */
78
136
  const releaseToEntry = (release: GithubRelease): SourceEntry => {
79
137
  const version = release.tag_name.replace(LEADING_V, "");
@@ -81,9 +139,14 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
81
139
  const date = release.published_at ?? release.created_at;
82
140
  const category = release.prerelease ? "Prerelease" : "Release";
83
141
  const body = (release.body ?? "").replaceAll("\r\n", "\n").trim();
142
+ // A summary in `seo.description` gives each release page a unique meta
143
+ // description (instead of the site-wide fallback) without also rendering the
144
+ // visible lede paragraph a top-level `description` would add.
145
+ const description = releaseDescription(body);
84
146
  const data = {
85
147
  changelog: { category, version },
86
148
  date,
149
+ ...(description ? { seo: { description } } : {}),
87
150
  title,
88
151
  type: "changelog",
89
152
  };
package/src/core/types.ts CHANGED
@@ -108,6 +108,13 @@ export interface PageRecord {
108
108
  * `/guides/x`). Pages with the same key are translations of each other.
109
109
  */
110
110
  translationKey: string;
111
+ /**
112
+ * True for entries filled in from the fallback locale to pad a locale's
113
+ * navigation for pages it hasn't translated yet. The record's content —
114
+ * title included — belongs to the fallback locale, so per-locale content
115
+ * checks skip these.
116
+ */
117
+ fallback?: boolean;
111
118
  /**
112
119
  * Content-relative path with the leading locale directory stripped, used for
113
120
  * sidebar grouping so the locale dir is not surfaced as a nav group. Equals
@@ -220,6 +227,13 @@ export interface Navigation {
220
227
  tabs: NavTab[];
221
228
  selectors: NavSelector[];
222
229
  sidebar: NavNode[];
230
+ /**
231
+ * The tree root in final path space — localized and based (`/`, `/en`,
232
+ * `/docs`). Tab paths arrive in the same space, so the tab sitting at this
233
+ * path spans the whole tree and must be scoped as the root tab, not as a
234
+ * section tab. Absent on older serialized graphs; treat as `/`.
235
+ */
236
+ root?: string;
223
237
  /** Pinned links shown above the sidebar sections, unscoped by tab. */
224
238
  featured: FeaturedLink[];
225
239
  /** Repo URL for the header link, or null when hidden (`navigation.repo`). */
@@ -21,6 +21,8 @@ import { tableWrapPlugin } from "./table-wrap.ts";
21
21
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
22
22
  import type { CodeThemes } from "./themes.ts";
23
23
 
24
+ export type { CodeTheme, CodeThemes } from "./themes.ts";
25
+
24
26
  /** A Shiki transformer, derived from the upstream factories' return type. */
25
27
  type ShikiTransformer = ReturnType<typeof transformerNotationDiff>;
26
28
 
@@ -37,6 +39,7 @@ export { calloutTypeFor } from "./directives.ts";
37
39
  export { headingAnchorPlugin } from "./heading-anchors.ts";
38
40
  export { mermaidPlugin } from "./mermaid.ts";
39
41
  export { packageInstallPlugin } from "./package-install.ts";
42
+ export { blumeTwoslashTransformer } from "./twoslash.ts";
40
43
 
41
44
  /** Element type of Satteri's `mdastPlugins`, sourced from the (alpha) core. */
42
45
  type MdastPlugin = NonNullable<
@@ -64,7 +64,7 @@ type InlineHighlighter = (
64
64
  defaultColor: false;
65
65
  lang: string;
66
66
  structure: "inline";
67
- themes: { dark: string; light: string };
67
+ themes: CodeThemes;
68
68
  }
69
69
  ) => Promise<{ children: HastNode[] }>;
70
70
 
@@ -6,6 +6,11 @@
6
6
  * single home for the github fallback used when nothing is configured.
7
7
  */
8
8
 
9
+ import type { ThemeRegistrationAny } from "shiki";
10
+
11
+ /** A bundled Shiki theme name or an inline custom Shiki theme definition. */
12
+ export type CodeTheme = string | ThemeRegistrationAny;
13
+
9
14
  /**
10
15
  * A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
11
16
  * `interface`) so it keeps the implicit index signature Shiki's `themes`
@@ -13,8 +18,8 @@
13
18
  */
14
19
  // oxlint-disable-next-line typescript/consistent-type-definitions -- interface loses the implicit index signature Shiki's `themes` param needs
15
20
  export type CodeThemes = {
16
- dark: string;
17
- light: string;
21
+ dark: CodeTheme;
22
+ light: CodeTheme;
18
23
  };
19
24
 
20
25
  /** The default pair, used when `markdown.codeBlocks.theme` is unset. */
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The Twoslash transformer for fenced code blocks, compiled with Blume's own
3
+ * pinned TypeScript instead of whatever copy the user's project hoists.
4
+ *
5
+ * The stock `transformerTwoslash` from `@shikijs/twoslash` resolves the
6
+ * ambient `typescript` package, which is whatever version the surrounding
7
+ * project installed. Under TypeScript 7 (the native tsgo compiler) the
8
+ * package's main export is a version stub with no classic compiler API — no
9
+ * `ts.sys`, no language service — and its `lib/` directory ships no
10
+ * `lib.*.d.ts` files, so Twoslash breaks the moment a fence uses it. Blume
11
+ * ships a classic `typescript` as its own runtime dependency, so this factory
12
+ * resolves that copy from inside the package and hands it to Twoslash
13
+ * explicitly (`tsModule` for the compiler, `tsLibDirectory` for the default
14
+ * lib files), leaving the user's project free to use any TypeScript version.
15
+ *
16
+ * Composed from the `core` entrypoints of both packages: the main `twoslash`
17
+ * entry eagerly imports the ambient `typescript` (harmless under TS7 — the
18
+ * stub loads fine — but pointless), and its `transformerTwoslash` wrapper
19
+ * forwards `tsModule` to `createTwoslasher` while dropping `tsLibDirectory`.
20
+ * The core entries import no `typescript` at all and take both options.
21
+ */
22
+
23
+ import { createRequire } from "node:module";
24
+ import path from "node:path";
25
+
26
+ import { createTransformerFactory, rendererRich } from "@shikijs/twoslash/core";
27
+ import type { ShikiTransformer } from "shiki";
28
+ import { createTwoslasher } from "twoslash/core";
29
+ import type TS from "typescript";
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ /**
34
+ * Twoslash transformer preconfigured for Blume: opt-in per fence via the
35
+ * `twoslash` meta keyword (explicitTrigger), compiling with Blume's own
36
+ * pinned classic TypeScript. The compiler is resolved lazily at call time —
37
+ * this runs once, at Astro config load — so merely importing this module
38
+ * never pays the TypeScript parse cost.
39
+ */
40
+ export const blumeTwoslashTransformer = (): ShikiTransformer => {
41
+ const tsModule = require("typescript") as typeof TS;
42
+ const twoslasher = createTwoslasher({
43
+ // Match the stock transformer's default: fence snippets are authored
44
+ // bundler-style (extensionless relative imports, package imports).
45
+ compilerOptions: {
46
+ moduleResolution: tsModule.ModuleResolutionKind.Bundler,
47
+ },
48
+ // `require.resolve("typescript")` is the package's main entry,
49
+ // `lib/typescript.js`; its directory holds the `lib.*.d.ts` default libs.
50
+ tsLibDirectory: path.dirname(require.resolve("typescript")),
51
+ tsModule,
52
+ vfsRoot: process.cwd(),
53
+ });
54
+ return createTransformerFactory(
55
+ twoslasher,
56
+ rendererRich()
57
+ )({
58
+ explicitTrigger: true,
59
+ });
60
+ };
@@ -472,7 +472,9 @@ export const eject = async (
472
472
 
473
473
  if (config.seo.og.enabled) {
474
474
  files.push({
475
- content: ogEndpointTemplate(customOgRoutes(pages, config.title)),
475
+ content: ogEndpointTemplate(
476
+ customOgRoutes(pages, config.title, config.seo.og.titles)
477
+ ),
476
478
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
477
479
  });
478
480
  }
File without changes