blume 1.6.6 → 1.7.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli/{chunk-etsqspj6.js → chunk-12dzsn9b.js} +140 -23
  3. package/dist/cli/{chunk-etsqspj6.js.map → chunk-12dzsn9b.js.map} +6 -5
  4. package/dist/cli/{chunk-aerwpe14.js → chunk-4ae4f395.js} +164 -51
  5. package/dist/cli/chunk-4ae4f395.js.map +15 -0
  6. package/dist/cli/{chunk-m3p3wahd.js → chunk-52cwcqvp.js} +4 -4
  7. package/dist/cli/{chunk-j00ezcg5.js → chunk-5gfw0q4j.js} +9 -9
  8. package/dist/cli/{chunk-bawgnt8x.js → chunk-6mq7qkve.js} +3 -3
  9. package/dist/cli/{chunk-nyqzjdhj.js → chunk-82atea4k.js} +5 -5
  10. package/dist/cli/{chunk-tc89yh2r.js → chunk-8p3xe5jv.js} +2 -2
  11. package/dist/cli/{chunk-s4k1pnvf.js → chunk-90pdhkpm.js} +11 -11
  12. package/dist/cli/{chunk-n0y172hf.js → chunk-aqjvpd03.js} +4 -4
  13. package/dist/cli/{chunk-x1vrdjyk.js → chunk-h9ekmtz7.js} +5 -5
  14. package/dist/cli/{chunk-f75cqye8.js → chunk-he2zfgah.js} +10 -10
  15. package/dist/cli/{chunk-s4jn7f1q.js → chunk-j5f2wrj5.js} +2 -2
  16. package/dist/cli/{chunk-wkq5tbtq.js → chunk-k0v1f8bb.js} +3 -3
  17. package/dist/cli/{chunk-n4qjabmt.js → chunk-ka5k7cz9.js} +6 -19
  18. package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ka5k7cz9.js.map} +3 -4
  19. package/dist/cli/{chunk-5yvt556e.js → chunk-kmx2mydj.js} +2 -2
  20. package/dist/cli/{chunk-ag1zyr5x.js → chunk-mfm4sjwx.js} +11 -11
  21. package/dist/cli/{chunk-0ewz4trd.js → chunk-np8dmfb0.js} +6 -6
  22. package/dist/cli/{chunk-cnvm6k3e.js → chunk-pdwg3q9g.js} +11 -11
  23. package/dist/cli/{chunk-vv237fp3.js → chunk-q56730e0.js} +26 -12
  24. package/dist/cli/{chunk-vv237fp3.js.map → chunk-q56730e0.js.map} +3 -3
  25. package/dist/cli/{chunk-3k0kzs6d.js → chunk-qvvpnwaz.js} +2 -2
  26. package/dist/cli/{chunk-vv3f8mb6.js → chunk-r99hynxh.js} +25 -24
  27. package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-r99hynxh.js.map} +4 -4
  28. package/dist/cli/{chunk-wb067mv3.js → chunk-vyqj481z.js} +19 -7
  29. package/dist/cli/chunk-vyqj481z.js.map +13 -0
  30. package/dist/cli/{chunk-62qsssnh.js → chunk-x1wvw7a8.js} +411 -145
  31. package/dist/cli/chunk-x1wvw7a8.js.map +40 -0
  32. package/dist/cli/{chunk-9sh49q0h.js → chunk-ywn7t0pb.js} +2 -2
  33. package/dist/cli/index.js +13 -13
  34. package/dist/types/components/layout/nav-utils.d.ts +46 -1
  35. package/dist/types/core/config-input.d.ts +7 -0
  36. package/dist/types/core/schema.d.ts +2 -0
  37. package/dist/types/theme/fonts.d.ts +22 -22
  38. package/docs/02-deployment.mdx +21 -0
  39. package/docs/08-faq.mdx +21 -0
  40. package/docs/configuration/ask-ai.mdx +16 -0
  41. package/docs/content/navigation.mdx +2 -0
  42. package/docs/content/sources.mdx +2 -2
  43. package/docs/content/syntax.mdx +1 -1
  44. package/docs/discoverability/open-graph.mdx +4 -0
  45. package/package.json +1 -1
  46. package/src/ai/ask.ts +31 -4
  47. package/src/astro/generate.ts +142 -5
  48. package/src/astro/integration.ts +12 -1
  49. package/src/astro/module-types.ts +9 -0
  50. package/src/astro/templates.ts +260 -56
  51. package/src/cli/commands/build.ts +28 -0
  52. package/src/components/Icon.astro +24 -0
  53. package/src/components/content/YouTube.astro +1 -1
  54. package/src/components/icon-sprite-middleware.ts +41 -0
  55. package/src/components/icon-sprite.ts +93 -0
  56. package/src/components/layout/Header.astro +1 -1
  57. package/src/components/layout/IconSprite.astro +11 -0
  58. package/src/components/layout/NavTree.astro +156 -188
  59. package/src/components/layout/NavTreeCache.astro +45 -0
  60. package/src/components/layout/NavTreeScript.astro +256 -0
  61. package/src/components/layout/PageActions.astro +11 -5
  62. package/src/components/layout/PageLayout.astro +7 -0
  63. package/src/components/layout/ReferenceLayout.astro +7 -0
  64. package/src/components/layout/RootLayout.astro +30 -2
  65. package/src/components/layout/Search.astro +11 -0
  66. package/src/components/layout/nav-cache.ts +49 -0
  67. package/src/components/layout/nav-utils.ts +87 -1
  68. package/src/core/config-input.ts +7 -0
  69. package/src/core/schema.ts +5 -0
  70. package/src/core/sources/assets.ts +162 -26
  71. package/src/core/sources/notion.ts +60 -1
  72. package/src/markdown/language-icon.ts +64 -20
  73. package/src/markdown/mermaid.ts +11 -0
  74. package/src/og/cache.ts +236 -0
  75. package/src/og/card.ts +12 -4
  76. package/src/og/index.ts +8 -1
  77. package/src/registry/eject.ts +24 -8
  78. package/src/theme/entry.ts +50 -7
  79. package/src/theme/fonts.ts +30 -23
  80. package/dist/cli/chunk-62qsssnh.js.map +0 -36
  81. package/dist/cli/chunk-aerwpe14.js.map +0 -15
  82. package/dist/cli/chunk-wb067mv3.js.map +0 -13
  83. /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-52cwcqvp.js.map} +0 -0
  84. /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-5gfw0q4j.js.map} +0 -0
  85. /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-6mq7qkve.js.map} +0 -0
  86. /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-82atea4k.js.map} +0 -0
  87. /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-8p3xe5jv.js.map} +0 -0
  88. /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-90pdhkpm.js.map} +0 -0
  89. /package/dist/cli/{chunk-n0y172hf.js.map → chunk-aqjvpd03.js.map} +0 -0
  90. /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-h9ekmtz7.js.map} +0 -0
  91. /package/dist/cli/{chunk-f75cqye8.js.map → chunk-he2zfgah.js.map} +0 -0
  92. /package/dist/cli/{chunk-s4jn7f1q.js.map → chunk-j5f2wrj5.js.map} +0 -0
  93. /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-k0v1f8bb.js.map} +0 -0
  94. /package/dist/cli/{chunk-5yvt556e.js.map → chunk-kmx2mydj.js.map} +0 -0
  95. /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-mfm4sjwx.js.map} +0 -0
  96. /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-np8dmfb0.js.map} +0 -0
  97. /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-pdwg3q9g.js.map} +0 -0
  98. /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-qvvpnwaz.js.map} +0 -0
  99. /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-ywn7t0pb.js.map} +0 -0
@@ -1,4 +1,12 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ access,
4
+ mkdir,
5
+ readdir,
6
+ rename,
7
+ rm,
8
+ writeFile,
9
+ } from "node:fs/promises";
2
10
 
3
11
  import { extname, join } from "pathe";
4
12
 
@@ -6,6 +14,12 @@ import type { Diagnostic } from "../types.ts";
6
14
  import { hashText } from "./cache.ts";
7
15
 
8
16
  const MD_IMAGE = /!\[(?<alt>[^\]]*)\]\((?<url>[^)\s]+)\)/gu;
17
+ // A `<video>` tag's `src`, split so the rewrite can swap the URL and keep the
18
+ // surrounding attributes untouched. Notion's uploaded videos arrive as signed,
19
+ // expiring URLs exactly like its images, so they rot the same way. `[^>]`
20
+ // bounds the attribute run to a single tag.
21
+ const HTML_VIDEO_SRC =
22
+ /(?<open><video\b[^>]*?\ssrc=")(?<url>[^"]+)(?<close>")/gu;
9
23
  const REMOTE = /^https?:\/\//u;
10
24
  const SAFE_EXT = /^\.[a-z0-9]+$/iu;
11
25
  const CODE_FENCE_BLOCK =
@@ -14,32 +28,107 @@ const CODE_FENCE_BLOCK =
14
28
  // oxlint-disable-next-line no-control-regex -- the NUL is the collision guard.
15
29
  const FENCE_TOKEN = /\u0000blume-fence-(?<index>\d+)\u0000/gu;
16
30
 
31
+ // The extension for a URL whose path carries none, keyed by the media type the
32
+ // server reports. A static host serves by extension, so a `.png` holding JPEG
33
+ // bytes is mislabeled and a `.png` holding a video is refused by strict
34
+ // players — the response is the source of truth, not the reference kind.
35
+ const EXT_BY_MIME = new Map([
36
+ ["image/avif", ".avif"],
37
+ ["image/gif", ".gif"],
38
+ ["image/jpeg", ".jpg"],
39
+ ["image/png", ".png"],
40
+ ["image/svg+xml", ".svg"],
41
+ ["image/webp", ".webp"],
42
+ ["video/mp4", ".mp4"],
43
+ ["video/ogg", ".ogv"],
44
+ ["video/quicktime", ".mov"],
45
+ ["video/webm", ".webm"],
46
+ ]);
47
+ const UNKNOWN_EXT = ".bin";
48
+ // Response types that are never a media file. A pasted Vimeo/Loom/Wistia link
49
+ // is a video block in Notion whose URL is a watch page (200 text/html), and an
50
+ // API endpoint answers JSON or XML; `image/svg+xml` is media and stays.
51
+ const NON_MEDIA_TYPE =
52
+ /^(?:text\/|application\/(?:[\w.-]+\+)?(?:json|xml|javascript))/u;
53
+ const PART_SUFFIX = ".part";
54
+ // Generous enough for a multi-hundred-megabyte recording on an ordinary
55
+ // connection; its job is to fail a stalled download rather than hang the build.
56
+ const DEFAULT_TIMEOUT_MS = 120_000;
57
+
17
58
  /** Where to write downloaded assets and how to reference them publicly. */
18
59
  export interface AssetContext {
19
60
  assetsDir: string;
20
61
  assetsBaseUrl: string;
21
62
  /** Injected for tests; defaults to the global `fetch`. */
22
63
  fetchImpl?: typeof fetch;
64
+ /**
65
+ * Gate for concurrent downloads. A source shares one gate across every page
66
+ * it materializes, so a database of video-heavy pages doesn't open every
67
+ * download at once. Defaults to no gate.
68
+ */
69
+ limit?: <T>(task: () => Promise<T>) => Promise<T>;
70
+ /** Abort a download that hasn't completed within this many milliseconds. */
71
+ timeoutMs?: number;
23
72
  }
24
73
 
25
- /** Pick a file extension from a URL, defaulting to `.png`. */
26
- const extFor = (url: string): string => {
74
+ /** The extension in a URL's path, or null when it carries none we'd trust. */
75
+ const extFromUrl = (url: string): string | null => {
27
76
  const clean = url.split("?")[0] ?? url;
28
77
  const ext = extname(clean);
29
- return SAFE_EXT.test(ext) ? ext.toLowerCase() : ".png";
78
+ return SAFE_EXT.test(ext) ? ext.toLowerCase() : null;
79
+ };
80
+
81
+ /** The response's media type, lowercased and stripped of parameters. */
82
+ const mediaType = (res: Response): string =>
83
+ res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
84
+
85
+ const exists = async (path: string): Promise<boolean> => {
86
+ try {
87
+ await access(path);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ };
93
+
94
+ /**
95
+ * The completed file for a stem whose extension came from an earlier response
96
+ * rather than the URL, so an extension-less asset is not fetched on every
97
+ * poll. Only a lone candidate counts: query-less URLs can collide, and picking
98
+ * between two files would be a guess.
99
+ */
100
+ const completedFor = async (
101
+ dir: string,
102
+ stem: string
103
+ ): Promise<string | null> => {
104
+ let names: string[];
105
+ try {
106
+ names = await readdir(dir);
107
+ } catch {
108
+ return null;
109
+ }
110
+ const candidates = names.filter(
111
+ (name) => name.startsWith(`${stem}.`) && !name.endsWith(PART_SUFFIX)
112
+ );
113
+ return candidates.length === 1 ? (candidates[0] ?? null) : null;
30
114
  };
31
115
 
32
116
  /**
33
- * Download remote images referenced in a Markdown body into the asset dir and
34
- * rewrite their `src` to the local public path. Remote CMS URLs (notably
35
- * Notion's signed, expiring links) would otherwise rot a static build. Assets
36
- * are content-addressed by URL hash, so repeated builds are stable and deduped.
117
+ * Download remote media referenced in a Markdown body into the asset dir and
118
+ * rewrite the reference to the local public path. Markdown images and the
119
+ * `src` of a `<video>` tag are both covered. Remote CMS URLs (notably Notion's
120
+ * signed, expiring links) would otherwise rot a static build. Assets are
121
+ * content-addressed by URL hash, so repeated builds are stable and deduped —
122
+ * and a file already on disk is not fetched again, which keeps a dev poll
123
+ * from re-downloading every video on every tick.
37
124
  */
38
125
  export const materializeAssets = async (
39
126
  markdown: string,
40
127
  ctx: AssetContext
41
128
  ): Promise<{ markdown: string; diagnostics: Diagnostic[] }> => {
42
129
  const doFetch = ctx.fetchImpl ?? globalThis.fetch;
130
+ const limit = ctx.limit ?? ((task) => task());
131
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
43
132
  const diagnostics: Diagnostic[] = [];
44
133
 
45
134
  // Mask fenced code blocks so an image URL inside a code sample is neither
@@ -52,34 +141,77 @@ export const materializeAssets = async (
52
141
  });
53
142
 
54
143
  const urls = new Set<string>();
55
- for (const match of masked.matchAll(MD_IMAGE)) {
56
- const url = match.groups?.url;
57
- if (url && REMOTE.test(url)) {
58
- urls.add(url);
144
+ for (const pattern of [MD_IMAGE, HTML_VIDEO_SRC]) {
145
+ for (const match of masked.matchAll(pattern)) {
146
+ const url = match.groups?.url;
147
+ if (url && REMOTE.test(url)) {
148
+ urls.add(url);
149
+ }
59
150
  }
60
151
  }
61
152
 
153
+ /** Fetch one asset into the asset dir and return its file name. */
154
+ const download = async (url: string): Promise<string> => {
155
+ // Hash the query-less URL: CMS asset URLs are pre-signed, so the query
156
+ // changes on every fetch of the same file — hashing it would mint a new
157
+ // file each refresh and re-dirty the content digest. Two real assets
158
+ // sharing scheme+host+path and differing only in query are rare enough to
159
+ // accept colliding.
160
+ const stem = hashText(url.split("?")[0] ?? url);
161
+ const urlExt = extFromUrl(url);
162
+ // The name is known up front whenever the path has an extension (every
163
+ // Notion upload does), so a file from an earlier run is reused as is;
164
+ // otherwise the extension came from the last response, so look for it.
165
+ if (urlExt && (await exists(join(ctx.assetsDir, `${stem}${urlExt}`)))) {
166
+ return `${stem}${urlExt}`;
167
+ }
168
+ const completed = urlExt ? null : await completedFor(ctx.assetsDir, stem);
169
+ if (completed) {
170
+ return completed;
171
+ }
172
+ const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) });
173
+ if (!res.ok) {
174
+ throw new Error(`${res.status}`);
175
+ }
176
+ // Writing a watch page or an API response out as `.mp4` gives a player
177
+ // that can't play and a green build, so refuse what is never media.
178
+ const type = mediaType(res);
179
+ if (NON_MEDIA_TYPE.test(type)) {
180
+ throw new Error(`responded with ${type}, not a media file`);
181
+ }
182
+ if (!res.body) {
183
+ throw new Error("empty response body");
184
+ }
185
+ const file = `${stem}${urlExt ?? EXT_BY_MIME.get(type) ?? UNKNOWN_EXT}`;
186
+ const target = join(ctx.assetsDir, file);
187
+ await mkdir(ctx.assetsDir, { recursive: true });
188
+ // Stream the body to disk rather than buffering it: a video is hundreds of
189
+ // megabytes where an image was a hundred kilobytes. Write to a temporary
190
+ // name unique to this attempt and rename on completion, so a download that
191
+ // dies midway never leaves a truncated file the next run would trust as
192
+ // complete, and two pages fetching the same asset at once (the gate bounds
193
+ // concurrency, it doesn't dedupe) never write into each other's file —
194
+ // both publish identical bytes and the last rename wins.
195
+ const part = `${target}.${randomUUID()}${PART_SUFFIX}`;
196
+ try {
197
+ await writeFile(part, res.body);
198
+ } catch (error) {
199
+ await rm(part, { force: true });
200
+ throw error;
201
+ }
202
+ await rename(part, target);
203
+ return file;
204
+ };
205
+
62
206
  const rewrites = new Map<string, string>();
63
207
  await Promise.all(
64
208
  [...urls].map(async (url) => {
65
209
  try {
66
- const res = await doFetch(url);
67
- if (!res.ok) {
68
- throw new Error(`${res.status}`);
69
- }
70
- const bytes = new Uint8Array(await res.arrayBuffer());
71
- // Hash the query-less URL (as `extFor` does): CMS asset URLs are
72
- // pre-signed, so the query changes on every fetch of the same image —
73
- // hashing it would mint a new file each refresh and re-dirty the
74
- // content digest. Two real assets sharing scheme+host+path and
75
- // differing only in query are rare enough to accept colliding.
76
- const file = `${hashText(url.split("?")[0] ?? url)}${extFor(url)}`;
77
- await mkdir(ctx.assetsDir, { recursive: true });
78
- await writeFile(join(ctx.assetsDir, file), bytes);
210
+ const file = await limit(() => download(url));
79
211
  rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
80
212
  } catch (error) {
81
213
  // SAFETY: everything thrown in this block is an Error — the manual
82
- // `!res.ok` throw above, and fetch/fs failures.
214
+ // throws in `download`, and fetch/fs failures.
83
215
  diagnostics.push({
84
216
  code: "BLUME_ASSET_FETCH_FAILED",
85
217
  message: `Failed to download asset ${url}: ${(error as Error).message}`,
@@ -94,6 +226,10 @@ export const materializeAssets = async (
94
226
  const local = rewrites.get(url);
95
227
  return local ? `![${alt}](${local})` : match;
96
228
  })
229
+ .replaceAll(HTML_VIDEO_SRC, (match, open, url, close) => {
230
+ const local = rewrites.get(url);
231
+ return local ? `${open}${local}${close}` : match;
232
+ })
97
233
  .replaceAll(FENCE_TOKEN, (token, index) => fences[Number(index)] ?? token);
98
234
 
99
235
  return { diagnostics, markdown: rewritten };
@@ -3,6 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises";
3
3
  import pLimit from "p-limit";
4
4
  import { join } from "pathe";
5
5
 
6
+ import { parseYouTubeId } from "../../components/content/youtube.ts";
6
7
  import { BlumeError } from "../diagnostics.ts";
7
8
  import matter from "../frontmatter.ts";
8
9
  import type { Diagnostic } from "../types.ts";
@@ -189,6 +190,7 @@ const MAX_RETRIES = 4;
189
190
  const BASE_DELAY_MS = 500;
190
191
  const SECOND_MS = 1000;
191
192
  const DEFAULT_CONCURRENCY = 3;
193
+ const ASSET_DOWNLOAD_CONCURRENCY = 4;
192
194
 
193
195
  /**
194
196
  * Retry a Notion API call on a `429 rate_limited`, honoring the `Retry-After`
@@ -250,6 +252,55 @@ const LIST_BLOCKS = new Set([
250
252
  const isListItem = (block: NotionBlock | undefined): boolean =>
251
253
  block !== undefined && LIST_BLOCKS.has(block.type);
252
254
 
255
+ const richToPlain = (rich: NotionRichText[] = []): string =>
256
+ rich.map((node) => node.plain_text).join("");
257
+
258
+ /**
259
+ * A string prop in JSX expression form. The quoted form (`title="…"`) keeps
260
+ * `\"` and `\n` as literal characters — MDX decodes no escapes there, so a
261
+ * caption holding a double quote fails to compile the whole page — where the
262
+ * expression form (`title={"…"}`) is a JSON string literal and decodes them.
263
+ */
264
+ const jsxString = (value: string): string => `{${JSON.stringify(value)}}`;
265
+
266
+ const YOUTUBE_HOST = /(?:^|\.)(?:youtube(?:-nocookie)?\.com|youtu\.be)$/u;
267
+
268
+ // `parseYouTubeId` is host-agnostic on purpose (the component accepts bare
269
+ // ids), so gate on the hostname first: `https://cdn.example.com/live/promo.mp4`
270
+ // matches its `/live/<11 chars>` shape but is a media file, not an embed.
271
+ const isYouTubeUrl = (url: string): boolean => {
272
+ const host = URL.parse(url)?.hostname ?? "";
273
+ return YOUTUBE_HOST.test(host) && parseYouTubeId(url) !== null;
274
+ };
275
+
276
+ /**
277
+ * Render a Notion `video` block. Kept out of `renderLeaf`'s switch so that
278
+ * function stays under the complexity limit.
279
+ */
280
+ const renderVideo = (data: NotionBlockPayload): string => {
281
+ const url = data.external?.url ?? data.file?.url;
282
+ if (!url) {
283
+ return "";
284
+ }
285
+ const caption = richToMarkdown(data.caption);
286
+ // A YouTube link pasted into Notion becomes a `video` block holding an
287
+ // external URL. That URL is a watch page, not a media file, so it has to
288
+ // become the embed component — a `<video src>` pointing at it plays nothing,
289
+ // and `materializeAssets` would download the HTML page. The iframe's title
290
+ // is its accessible name, so it gets the caption's plain text; the Markdown
291
+ // rendering goes to the Frame's caption, as it does for an upload.
292
+ const title = caption ? ` title=${jsxString(richToPlain(data.caption))}` : "";
293
+ // Everything else (a Notion upload, or a direct link to a media file) is a
294
+ // real video file: `materializeAssets` rewrites the `src`, which matters most
295
+ // for uploads, whose Notion URLs are signed and expire.
296
+ const media = isYouTubeUrl(url)
297
+ ? `<YouTube${title} url=${JSON.stringify(url)} />`
298
+ : `<video controls src=${JSON.stringify(url)} />`;
299
+ return caption
300
+ ? `<Frame caption=${jsxString(caption)}>\n${media}\n</Frame>`
301
+ : media;
302
+ };
303
+
253
304
  /** Render a leaf (non-container) block to Markdown, or null for containers. */
254
305
  const renderLeaf = (block: NotionBlock): string | null => {
255
306
  const data = payloadOf(block) ?? {};
@@ -289,6 +340,9 @@ const renderLeaf = (block: NotionBlock): string | null => {
289
340
  const url = data.external?.url ?? data.file?.url;
290
341
  return url ? `![${richToMarkdown(data.caption)}](${url})` : "";
291
342
  }
343
+ case "video": {
344
+ return renderVideo(data);
345
+ }
292
346
  default: {
293
347
  return null;
294
348
  }
@@ -311,6 +365,10 @@ export const notionSource = (
311
365
  // container — an unbounded burst guarantees 429s that even the retry loop
312
366
  // can't recover from, so every API call funnels through this limiter.
313
367
  const limit = pLimit(Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY));
368
+ // Asset downloads get their own gate: they go to Notion's S3, not its rate-
369
+ // limited API, and a slow video must not hold API slots. One gate per source
370
+ // bounds the fan-out across every page, not just within one.
371
+ const downloads = pLimit(ASSET_DOWNLOAD_CONCURRENCY);
314
372
  // Every Notion API call goes through the limiter, inside the retry — so a
315
373
  // call sleeping through a backoff doesn't hold a slot while it waits.
316
374
  const notionCall = <T>(call: () => Promise<T>): Promise<T> =>
@@ -380,7 +438,7 @@ export const notionSource = (
380
438
  return `<Callout>\n${body}\n</Callout>`;
381
439
  }
382
440
  if (block.type === "toggle") {
383
- const title = JSON.stringify(richToMarkdown(blockField(block)));
441
+ const title = jsxString(richToMarkdown(blockField(block)));
384
442
  return `<Accordion>\n<AccordionItem title=${title}>\n${await children(block)}\n</AccordionItem>\n</Accordion>`;
385
443
  }
386
444
  if (block.type === "column_list") {
@@ -510,6 +568,7 @@ export const notionSource = (
510
568
  assetsBaseUrl,
511
569
  assetsDir,
512
570
  fetchImpl: options.fetchImpl,
571
+ limit: downloads,
513
572
  });
514
573
  const raw = matter.stringify(assets.markdown, data);
515
574
  return {
@@ -49,9 +49,10 @@ import {
49
49
  siYaml,
50
50
  } from "simple-icons";
51
51
 
52
- /** The slice of a `simple-icons` icon Blume reads (the SVG path data). */
52
+ /** The slice of a `simple-icons` icon Blume reads: its slug and path data. */
53
53
  interface SimpleIcon {
54
54
  path: string;
55
+ slug: string;
55
56
  }
56
57
 
57
58
  /** Fence language (and common aliases) → icon. Unmapped languages get none. */
@@ -146,23 +147,6 @@ export interface LanguageIconTransformer {
146
147
  pre: (this: IconContext, node: IconPreNode) => void;
147
148
  }
148
149
 
149
- /** Build an inline SVG hast node from a simple-icons path. */
150
- const iconNode = (path: string): HastNode => ({
151
- children: [
152
- { children: [], properties: { d: path }, tagName: "path", type: "element" },
153
- ],
154
- properties: {
155
- ariaHidden: "true",
156
- className: ["blume-lang-icon"],
157
- fill: "currentColor",
158
- height: 14,
159
- viewBox: "0 0 24 24",
160
- width: 14,
161
- },
162
- tagName: "svg",
163
- type: "element",
164
- });
165
-
166
150
  /** Build the transformer. Runs after Shiki's built-in `data-language` hook. */
167
151
  export const languageIconTransformer = (): LanguageIconTransformer => ({
168
152
  name: "blume:language-icon",
@@ -171,7 +155,67 @@ export const languageIconTransformer = (): LanguageIconTransformer => ({
171
155
  if (!icon) {
172
156
  return;
173
157
  }
174
- node.children.unshift(iconNode(icon.path));
175
- node.properties.dataIcon = "";
158
+ // The icon itself is CSS: the theme paints `pre[data-icon="<slug>"]::after`
159
+ // with the brand path as a mask (see `languageIconCss`), so a block
160
+ // carries a short attribute instead of ~1 kB of SVG — on a reference page
161
+ // with twenty TypeScript blocks, the difference is most of the page.
162
+ node.properties.dataIcon = icon.slug;
176
163
  },
177
164
  });
165
+
166
+ /** The icon slug for a fence language, or null for an unmapped language. */
167
+ export const languageIconSlug = (language: string): string | null =>
168
+ LANGUAGE_ICONS[language.toLowerCase()]?.slug ?? null;
169
+
170
+ // Fence openers (```ts, ~~~tsx) and the `lang`/`language` props of code
171
+ // components (<CodeBlock lang="ts">), which highlight through the same
172
+ // transformer. Word characters plus the few punctuation marks languages use.
173
+ const FENCE_LANGUAGE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*(?<lang>[\w+#.-]+)/gmu;
174
+ const PROP_LANGUAGE = /\blang(?:uage)?=["'](?<lang>[\w+#.-]+)["']/gu;
175
+
176
+ /**
177
+ * The icon slugs a site's Markdown uses, sorted and deduped, so the theme
178
+ * carries a mask rule for each of them and none for the other thirty.
179
+ */
180
+ export const languageIconSlugsIn = (markdown: string): string[] => {
181
+ const slugs = new Set<string>();
182
+ for (const pattern of [FENCE_LANGUAGE, PROP_LANGUAGE]) {
183
+ for (const match of markdown.matchAll(pattern)) {
184
+ const slug = languageIconSlug(match.groups?.lang ?? "");
185
+ if (slug) {
186
+ slugs.add(slug);
187
+ }
188
+ }
189
+ }
190
+ return [...slugs].toSorted();
191
+ };
192
+
193
+ const iconBySlug = (slug: string): SimpleIcon | undefined =>
194
+ Object.values(LANGUAGE_ICONS).find((icon) => icon.slug === slug);
195
+
196
+ /** A simple-icons path as a `mask-image` data URI (24×24 viewBox). */
197
+ const maskUri = (path: string): string =>
198
+ `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${path}"/></svg>`)}")`;
199
+
200
+ /**
201
+ * The per-language rules that paint a code block's icon: each gives the
202
+ * block's `::after` (positioned by the theme) the brand path as a mask over
203
+ * the muted foreground. Only the listed slugs get a rule, so an unmapped or
204
+ * unused language paints nothing rather than a blank square.
205
+ */
206
+ export const languageIconCss = (slugs: string[]): string =>
207
+ slugs
208
+ .map((slug) => {
209
+ const icon = iconBySlug(slug);
210
+ if (!icon) {
211
+ return "";
212
+ }
213
+ const mask = maskUri(icon.path);
214
+ return `.prose > :where(pre[data-language][data-icon="${slug}"])::after {
215
+ background-color: var(--blume-muted-foreground);
216
+ -webkit-mask-image: ${mask};
217
+ mask-image: ${mask};
218
+ }`;
219
+ })
220
+ .filter((rule) => rule !== "")
221
+ .join("\n");
@@ -13,6 +13,17 @@ interface CodeNode extends MdastNode {
13
13
  * rendered on the client (Mermaid needs a DOM), so the source rides on a string
14
14
  * attribute rather than as child text (which MDX would try to parse).
15
15
  */
16
+ /**
17
+ * A ```mermaid (or ~~~mermaid) fence opener at the start of a line. Used to
18
+ * decide, at generation time, whether the site needs the Mermaid client
19
+ * library at all — see `featuresTemplate`.
20
+ */
21
+ const MERMAID_FENCE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*mermaid\b/mu;
22
+
23
+ /** Whether a page's Markdown/MDX source contains a mermaid fence. */
24
+ export const hasMermaidFence = (text: string): boolean =>
25
+ MERMAID_FENCE.test(text);
26
+
16
27
  export const mermaidPlugin = () => ({
17
28
  code(node: CodeNode, ctx: MdastVisitorContext) {
18
29
  if (node.lang !== "mermaid") {