blume 1.1.0 → 1.1.1

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.
@@ -870,6 +870,13 @@ const ogConfigSchema = z.strictObject({
870
870
  logo: z.string().optional(),
871
871
  /** Optional generated-card colors. */
872
872
  palette: ogPaletteSchema.optional(),
873
+ /**
874
+ * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
875
+ * A custom page has no frontmatter to read, so its card is otherwise titled
876
+ * by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
877
+ * Content pages always take their card headline from the page title.
878
+ */
879
+ titles: z.record(z.string(), z.string()).optional(),
873
880
  });
874
881
 
875
882
  const rssConfigSchema = 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
@@ -37,6 +37,7 @@ export { calloutTypeFor } from "./directives.ts";
37
37
  export { headingAnchorPlugin } from "./heading-anchors.ts";
38
38
  export { mermaidPlugin } from "./mermaid.ts";
39
39
  export { packageInstallPlugin } from "./package-install.ts";
40
+ export { blumeTwoslashTransformer } from "./twoslash.ts";
40
41
 
41
42
  /** Element type of Satteri's `mdastPlugins`, sourced from the (alpha) core. */
42
43
  type MdastPlugin = NonNullable<
@@ -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