blume 0.3.0 → 0.4.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 (81) hide show
  1. package/dist/cli/index.js +747 -471
  2. package/dist/cli/index.js.map +45 -38
  3. package/dist/types/core/schema.d.ts +289 -278
  4. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  5. package/docs/01-quickstart.mdx +5 -16
  6. package/docs/02-deployment.mdx +21 -54
  7. package/docs/advanced/api-reference.mdx +10 -37
  8. package/docs/advanced/blog.mdx +9 -25
  9. package/docs/advanced/changelog.mdx +10 -33
  10. package/docs/advanced/custom-pages.mdx +21 -78
  11. package/docs/configuration/ai.mdx +42 -103
  12. package/docs/configuration/analytics.mdx +20 -38
  13. package/docs/configuration/customization.mdx +40 -73
  14. package/docs/configuration/export.mdx +9 -34
  15. package/docs/configuration/index.mdx +67 -87
  16. package/docs/configuration/search.mdx +17 -54
  17. package/docs/configuration/seo.mdx +17 -48
  18. package/docs/configuration/theming.mdx +20 -42
  19. package/docs/content/components.mdx +42 -101
  20. package/docs/content/i18n.mdx +21 -72
  21. package/docs/content/index.mdx +18 -48
  22. package/docs/content/islands.mdx +25 -52
  23. package/docs/content/meta.mdx +23 -50
  24. package/docs/content/navigation.mdx +23 -62
  25. package/docs/content/sources.mdx +20 -83
  26. package/docs/content/syntax.mdx +37 -105
  27. package/docs/index.mdx +11 -40
  28. package/docs/reference/cli.mdx +18 -29
  29. package/docs/reference/frontmatter.mdx +2 -5
  30. package/package.json +1 -1
  31. package/src/astro/integration.ts +26 -3
  32. package/src/astro/islands.ts +6 -2
  33. package/src/astro/markdown-negotiation.ts +17 -3
  34. package/src/astro/pages.ts +6 -1
  35. package/src/astro/static-assets.ts +117 -0
  36. package/src/astro/templates.ts +48 -26
  37. package/src/cli/args.ts +23 -0
  38. package/src/cli/commands/build.ts +23 -0
  39. package/src/cli/commands/dev.ts +11 -2
  40. package/src/cli/commands/doctor.ts +10 -1
  41. package/src/cli/commands/eject.ts +3 -1
  42. package/src/cli/commands/init.ts +21 -1
  43. package/src/cli/commands/preview.ts +2 -1
  44. package/src/cli/commands/validate.ts +12 -1
  45. package/src/cli/dev-lock.ts +84 -0
  46. package/src/cli/log.ts +11 -0
  47. package/src/components/BlumePage.astro +2 -0
  48. package/src/components/content/YouTube.astro +35 -0
  49. package/src/components/content/youtube.ts +46 -0
  50. package/src/components/islands/ask-ai.tsx +14 -14
  51. package/src/components/props.ts +3 -0
  52. package/src/core/assets.ts +31 -0
  53. package/src/core/bridge.ts +10 -0
  54. package/src/core/builtin-tags.ts +1 -0
  55. package/src/core/diagnostics.ts +6 -1
  56. package/src/core/gitignore.ts +30 -0
  57. package/src/core/links.ts +60 -19
  58. package/src/core/schema.ts +7 -0
  59. package/src/core/sources/mdx-remote.ts +54 -8
  60. package/src/core/sources/normalize.ts +6 -1
  61. package/src/core/sources/notion.ts +49 -5
  62. package/src/core/sources/sanity.ts +5 -1
  63. package/src/deploy/rss.ts +1 -8
  64. package/src/deploy/sitemap.ts +20 -1
  65. package/src/deploy/xml.ts +8 -0
  66. package/src/markdown/directives.ts +15 -7
  67. package/src/markdown/package-commands.ts +26 -4
  68. package/src/migrate/fumadocs/content.ts +14 -1
  69. package/src/migrate/fumadocs/groups.ts +7 -0
  70. package/src/migrate/fumadocs/index.ts +5 -2
  71. package/src/migrate/mintlify/assets.ts +46 -0
  72. package/src/migrate/mintlify/index.ts +53 -45
  73. package/src/migrate/shared.ts +12 -27
  74. package/src/og/card.ts +14 -2
  75. package/src/registry/eject.ts +13 -3
  76. package/src/registry/registry.ts +6 -0
  77. package/src/registry/rewrite-imports.ts +31 -19
  78. package/src/search/documents.ts +23 -5
  79. package/src/search/sync/algolia.ts +5 -1
  80. package/src/search/sync/typesense.ts +24 -16
  81. package/src/theme/palette.ts +26 -7
@@ -3,7 +3,8 @@ import type { MdastNode, MdastVisitorContext } from "./mdast.ts";
3
3
 
4
4
  interface DirectiveNode extends MdastNode {
5
5
  attributes?: Record<string, string | null | undefined> | null;
6
- children: MdastNode[];
6
+ // Satteri gives an empty container directive (`:::note\n:::`) `children: null`.
7
+ children?: MdastNode[] | null;
7
8
  name: string;
8
9
  }
9
10
 
@@ -38,11 +39,18 @@ interface TextNode extends MdastNode {
38
39
  value?: string;
39
40
  }
40
41
 
41
- /** Concatenate the plain text of a node's immediate phrasing children. */
42
- const textOf = (node: MdastNode): string =>
43
- ((node.children as TextNode[] | undefined) ?? [])
44
- .map((child) => child.value ?? "")
45
- .join("");
42
+ /**
43
+ * Concatenate the plain text of a node, recursing through phrasing children so
44
+ * formatted labels keep every word — `:::note[Read **this**]` yields
45
+ * `Read this`, not `Read ` (the bolded run dropped).
46
+ */
47
+ const textOf = (node: MdastNode): string => {
48
+ const { children } = node as { children?: MdastNode[] };
49
+ if (children && children.length > 0) {
50
+ return children.map(textOf).join("");
51
+ }
52
+ return (node as TextNode).value ?? "";
53
+ };
46
54
 
47
55
  /**
48
56
  * Satteri MDAST plugin mapping container directives (`:::note`, `:::warning`,
@@ -57,7 +65,7 @@ export const directiveToCalloutPlugin = () => ({
57
65
  return;
58
66
  }
59
67
 
60
- const children = [...node.children];
68
+ const children = [...(node.children ?? [])];
61
69
  let title = node.attributes?.title ?? undefined;
62
70
 
63
71
  // A leading `:::name[Label]` parses to a paragraph flagged `directiveLabel`.
@@ -10,7 +10,14 @@ const WHITESPACE = /\s+/u;
10
10
  const WHITESPACE_RUN = /\s+/gu;
11
11
  const GLOBAL_FLAGS = new Set(["-g", "--global"]);
12
12
 
13
- type Operation = "add" | "create" | "exec" | "install" | "remove" | "run";
13
+ type Operation =
14
+ | "add"
15
+ | "ci"
16
+ | "create"
17
+ | "exec"
18
+ | "install"
19
+ | "remove"
20
+ | "run";
14
21
 
15
22
  interface Intent {
16
23
  args: string[];
@@ -26,6 +33,9 @@ const normalizeVerb = (verb: string): Operation | null => {
26
33
  case "install": {
27
34
  return "add";
28
35
  }
36
+ case "ci": {
37
+ return "ci";
38
+ }
29
39
  case "create":
30
40
  case "init": {
31
41
  return "create";
@@ -125,10 +135,22 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
125
135
  }
126
136
  return `${manager} dlx ${args}`;
127
137
  }
128
- case "remove": {
138
+ case "ci": {
139
+ // `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
129
140
  return manager === "npm"
130
- ? `npm uninstall ${args}`
131
- : `${manager} remove ${args}`;
141
+ ? "npm ci"
142
+ : `${manager} install --frozen-lockfile`;
143
+ }
144
+ case "remove": {
145
+ if (manager === "npm") {
146
+ return `npm uninstall ${args}`;
147
+ }
148
+ // Yarn Classic has no `remove -g`; the global form is `yarn global remove`.
149
+ if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
150
+ const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
151
+ return `yarn global remove ${pkgs}`;
152
+ }
153
+ return `${manager} remove ${args}`;
132
154
  }
133
155
  case "run": {
134
156
  return `${manager} run ${args}`;
@@ -4,7 +4,12 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
4
4
  import { dirname, resolve } from "pathe";
5
5
 
6
6
  import matter from "../../core/frontmatter.ts";
7
- import { findOpenTagEnd, renameTag, rewriteCallouts } from "../shared.ts";
7
+ import {
8
+ findOpenTagEnd,
9
+ isInsideRoot,
10
+ renameTag,
11
+ rewriteCallouts,
12
+ } from "../shared.ts";
8
13
 
9
14
  /**
10
15
  * Source-to-source rewrites that turn Fumadocs-only MDX into idiomatic Blume
@@ -291,6 +296,8 @@ const INCLUDE = /<include\b[^>]*>(?<path>[\s\S]*?)<\/include>/gu;
291
296
  interface IncludeOptions {
292
297
  filePath: string;
293
298
  readFile?: (file: string) => Promise<string>;
299
+ /** Docs root the include must stay within; targets escaping it are skipped. */
300
+ root: string;
294
301
  seen?: Set<string>;
295
302
  }
296
303
 
@@ -321,6 +328,12 @@ export const inlineFumadocsIncludes = async (
321
328
  continue;
322
329
  }
323
330
  const target = resolve(dirname(options.filePath), rawPath);
331
+ if (!isInsideRoot(options.root, target)) {
332
+ warnings.push(
333
+ `<include> target "${rawPath}" is outside the docs tree — left as-is.`
334
+ );
335
+ continue;
336
+ }
324
337
  if (seen.has(target)) {
325
338
  warnings.push(`Circular <include> "${rawPath}" — left as-is.`);
326
339
  continue;
@@ -3,6 +3,7 @@ import { mkdir, rename, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { basename, join } from "pathe";
5
5
 
6
+ import { isInsideRoot } from "../shared.ts";
6
7
  import { renderMetaModule } from "./meta.ts";
7
8
  import type {
8
9
  FumadocsPageItem,
@@ -47,6 +48,12 @@ const isDirectory = (path: string): boolean => {
47
48
 
48
49
  /** Resolve a `pages` name to its on-disk page file or folder under `docsDir`. */
49
50
  const resolveEntry = (docsDir: string, name: string): ResolvedEntry | null => {
51
+ // A `pages` entry is author-controlled; reject any that escapes `docsDir`
52
+ // (e.g. `"../../victim"`) so the later `rename` can't move a file out of the
53
+ // docs tree.
54
+ if (!isInsideRoot(docsDir, join(docsDir, name))) {
55
+ return null;
56
+ }
50
57
  for (const ext of PAGE_EXTS) {
51
58
  const file = join(docsDir, `${name}${ext}`);
52
59
  if (existsSync(file)) {
@@ -5,9 +5,9 @@ import { dirname, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import matter from "../../core/frontmatter.ts";
8
+ import { ensureGitignore } from "../../core/gitignore.ts";
8
9
  import type { FolderMeta } from "../../core/schema.ts";
9
10
  import {
10
- ensureGitignore,
11
11
  leftoverFiles,
12
12
  rewriteFrameworkScripts,
13
13
  writeBlumeConfig,
@@ -85,7 +85,10 @@ const movePage = async (
85
85
  }
86
86
 
87
87
  const raw = await readFile(abs, "utf-8");
88
- const included = await inlineFumadocsIncludes(raw, { filePath: abs });
88
+ const included = await inlineFumadocsIncludes(raw, {
89
+ filePath: abs,
90
+ root: base,
91
+ });
89
92
  let text = stripFumadocsImports(included.content);
90
93
  text = rewriteFumadocsCallouts(text);
91
94
  text = rewriteFumadocsContainers(text);
@@ -0,0 +1,46 @@
1
+ import type { BlumeConfig } from "../../core/schema.ts";
2
+
3
+ /** Asset paths referenced by the resolved config (logo, favicon, backgrounds). */
4
+ const assetRefs = (config: BlumeConfig): unknown[] => {
5
+ const refs: unknown[] = ["/images"];
6
+ const logo = config.logo as
7
+ | string
8
+ | { dark?: string; light?: string }
9
+ | undefined;
10
+ if (typeof logo === "string") {
11
+ refs.push(logo);
12
+ } else if (logo) {
13
+ refs.push(logo.light, logo.dark);
14
+ }
15
+ const favicon = config.favicon as
16
+ | string
17
+ | { dark?: string; light?: string }
18
+ | undefined;
19
+ if (typeof favicon === "string") {
20
+ refs.push(favicon);
21
+ } else if (favicon) {
22
+ refs.push(favicon.light, favicon.dark);
23
+ }
24
+ refs.push(config.theme?.backgroundImage, config.theme?.backgroundImageDark);
25
+ return refs;
26
+ };
27
+
28
+ /**
29
+ * Top-level path segments referenced as static assets by a Mintlify config
30
+ * (the conventional `/images`, plus logo/favicon/background paths). These are
31
+ * the root-served folders Mintlify exposes at the site root; Blume serves them
32
+ * via `content.assets` (bridge) or relocates them under `public/` (migrator).
33
+ */
34
+ export const assetSegments = (config: BlumeConfig): string[] => {
35
+ const segments = new Set<string>();
36
+ for (const ref of assetRefs(config)) {
37
+ if (typeof ref !== "string" || !ref.startsWith("/")) {
38
+ continue;
39
+ }
40
+ const [segment] = ref.replace(/^\/+/u, "").split("/");
41
+ if (segment) {
42
+ segments.add(segment);
43
+ }
44
+ }
45
+ return [...segments];
46
+ };
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { dirname, join } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import type { BlumeConfig } from "../../core/schema.ts";
8
+ import { assetSegments } from "./assets.ts";
8
9
  import { loadMintlifyConfig } from "./config.ts";
9
10
  import { mintlifyI18n } from "./i18n.ts";
10
11
  import { transformMintlifyContent } from "./transform.ts";
@@ -52,28 +53,37 @@ const writeBlumeConfig = async (
52
53
  await writeFile(join(root, "blume.config.ts"), body, "utf-8");
53
54
  };
54
55
 
55
- /** Move a referenced top-level asset path (file or dir) under `public/`. */
56
+ interface RelocatedAssets {
57
+ /** Top-level dirs served in place via `content.assets` (no files moved). */
58
+ served: string[];
59
+ /** Top-level files moved under `public/`. */
60
+ moved: string[];
61
+ }
62
+
63
+ /**
64
+ * Make referenced top-level assets resolvable in Blume. Directories (e.g.
65
+ * Mintlify's `images/`) are left in place and served via `content.assets`, so
66
+ * the migration doesn't churn every file under them; loose top-level files
67
+ * (a root `favicon.png`, `logo.png`) are moved under `public/` since a mount
68
+ * points at a directory.
69
+ */
56
70
  const relocateAssets = async (
57
71
  root: string,
58
- refs: unknown[]
59
- ): Promise<string[]> => {
60
- const segments = new Set<string>();
61
- for (const ref of refs) {
62
- if (typeof ref !== "string" || !ref.startsWith("/")) {
63
- continue;
64
- }
65
- const [segment] = ref.replace(/^\/+/u, "").split("/");
66
- if (segment) {
67
- segments.add(segment);
68
- }
69
- }
70
-
72
+ segments: string[]
73
+ ): Promise<RelocatedAssets> => {
74
+ const served: string[] = [];
71
75
  const moved: string[] = [];
72
76
  for (const segment of segments) {
73
77
  const source = join(root, segment);
74
78
  if (!existsSync(source) || segment === "public") {
75
79
  continue;
76
80
  }
81
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs stats
82
+ const stats = await stat(source);
83
+ if (stats.isDirectory()) {
84
+ served.push(segment);
85
+ continue;
86
+ }
77
87
  const dest = join(root, "public", segment);
78
88
  if (existsSync(dest)) {
79
89
  continue;
@@ -84,7 +94,32 @@ const relocateAssets = async (
84
94
  await rename(source, dest);
85
95
  moved.push(segment);
86
96
  }
87
- return moved;
97
+ return { moved, served };
98
+ };
99
+
100
+ /**
101
+ * Fold relocated assets into the config (served dirs become `content.assets`)
102
+ * and record what happened. Served dirs stay in place; only loose files moved.
103
+ */
104
+ const applyRelocatedAssets = (
105
+ config: BlumeConfig,
106
+ assets: RelocatedAssets,
107
+ warnings: string[]
108
+ ): void => {
109
+ if (assets.served.length > 0) {
110
+ config.content = {
111
+ ...config.content,
112
+ assets: [
113
+ ...new Set([...(config.content?.assets ?? []), ...assets.served]),
114
+ ],
115
+ };
116
+ warnings.push(
117
+ `Kept asset dir(s) in place, served via content.assets: ${assets.served.join(", ")}.`
118
+ );
119
+ }
120
+ if (assets.moved.length > 0) {
121
+ warnings.push(`Moved assets into public/: ${assets.moved.join(", ")}.`);
122
+ }
88
123
  };
89
124
 
90
125
  /**
@@ -121,31 +156,6 @@ const cleanupSnippets = async (
121
156
  }
122
157
  };
123
158
 
124
- /** Asset paths referenced by the resolved config (logo, favicon, backgrounds). */
125
- const assetRefs = (config: BlumeConfig): unknown[] => {
126
- const refs: unknown[] = ["/images"];
127
- const logo = config.logo as
128
- | string
129
- | { dark?: string; light?: string }
130
- | undefined;
131
- if (typeof logo === "string") {
132
- refs.push(logo);
133
- } else if (logo) {
134
- refs.push(logo.light, logo.dark);
135
- }
136
- const favicon = config.favicon as
137
- | string
138
- | { dark?: string; light?: string }
139
- | undefined;
140
- if (typeof favicon === "string") {
141
- refs.push(favicon);
142
- } else if (favicon) {
143
- refs.push(favicon.light, favicon.dark);
144
- }
145
- refs.push(config.theme?.backgroundImage, config.theme?.backgroundImageDark);
146
- return refs;
147
- };
148
-
149
159
  /**
150
160
  * Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
151
161
  * `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
@@ -232,12 +242,13 @@ export const migrateMintlifyProject = async (
232
242
  moved += 1;
233
243
  }
234
244
 
235
- const movedAssets = await relocateAssets(root, assetRefs(config));
245
+ const assets = await relocateAssets(root, assetSegments(config));
236
246
  await cleanupSnippets(root, keptComponents, warnings);
237
247
 
238
248
  if (config.content?.exclude) {
239
249
  config.content.exclude = [...new Set(config.content.exclude)];
240
250
  }
251
+ applyRelocatedAssets(config, assets, warnings);
241
252
  await writeBlumeConfig(root, config);
242
253
 
243
254
  if (Object.keys(variables).length > 0) {
@@ -245,9 +256,6 @@ export const migrateMintlifyProject = async (
245
256
  `Inlined ${Object.keys(variables).length} docs.json variable(s) into content; Blume has no runtime variable substitution.`
246
257
  );
247
258
  }
248
- if (movedAssets.length > 0) {
249
- warnings.push(`Moved assets into public/: ${movedAssets.join(", ")}.`);
250
- }
251
259
  if (removedKeys.size > 0) {
252
260
  warnings.push(
253
261
  `Dropped unsupported page frontmatter keys: ${[...removedKeys].join(", ")}.`
@@ -1,11 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
 
4
- import { join } from "pathe";
4
+ import { isAbsolute, join, relative } from "pathe";
5
5
 
6
6
  import type { BlumeConfig } from "../core/schema.ts";
7
7
  import { pageMetaSchema } from "../core/schema.ts";
8
8
 
9
+ /**
10
+ * Whether `candidate` resolves to a path inside `root` (or is `root` itself).
11
+ * Guards migrators against `../` traversal in author-controlled source paths
12
+ * (`pages` entries, `<include>` targets) that would otherwise read or move
13
+ * files outside the docs tree.
14
+ */
15
+ export const isInsideRoot = (root: string, candidate: string): boolean => {
16
+ const rel = relative(root, candidate);
17
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
18
+ };
19
+
9
20
  /**
10
21
  * Framework-agnostic helpers shared by more than one migrator. Each piece here
11
22
  * was generalized from a migrator-specific implementation so Mintlify, Nextra,
@@ -86,32 +97,6 @@ export const rewriteFrameworkScripts = async (
86
97
  return changed;
87
98
  };
88
99
 
89
- /** A `.gitignore` line, normalized for comparison (trailing slashes dropped). */
90
- const gitignoreKey = (line: string): string => line.trim().replace(/\/+$/u, "");
91
-
92
- /**
93
- * Ensure `.gitignore` ignores each of `entries`, appending any that are missing
94
- * (creating the file when absent). Trailing-slash differences (`dist` vs
95
- * `dist/`) count as already present. Returns the entries actually added.
96
- */
97
- export const ensureGitignore = async (
98
- root: string,
99
- entries: string[]
100
- ): Promise<string[]> => {
101
- const path = join(root, ".gitignore");
102
- const existing = existsSync(path) ? await readFile(path, "utf-8") : "";
103
- const present = new Set(
104
- existing.split("\n").map(gitignoreKey).filter(Boolean)
105
- );
106
- const added = entries.filter((entry) => !present.has(gitignoreKey(entry)));
107
- if (added.length === 0) {
108
- return [];
109
- }
110
- const gap = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
111
- await writeFile(path, `${existing}${gap}${added.join("\n")}\n`, "utf-8");
112
- return added;
113
- };
114
-
115
100
  /** Of the candidate project-relative paths, the ones that still exist — the old
116
101
  * framework files a migration leaves behind for the user to remove by hand. */
117
102
  export const leftoverFiles = (root: string, candidates: string[]): string[] =>
package/src/og/card.ts CHANGED
@@ -56,8 +56,20 @@ const MUTED = "#737373";
56
56
  const FAINT = "#a3a3a3";
57
57
  const BORDER = "#e5e5e5";
58
58
 
59
- const truncate = (value: string, max: number): string =>
60
- value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
59
+ /**
60
+ * Truncate to `max` code points with an ellipsis. Slices by code points, not
61
+ * UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
62
+ * glyph) before the ellipsis.
63
+ */
64
+ export const truncate = (value: string, max: number): string => {
65
+ const chars = [...value];
66
+ return chars.length > max
67
+ ? `${chars
68
+ .slice(0, max - 1)
69
+ .join("")
70
+ .trimEnd()}…`
71
+ : value;
72
+ };
61
73
 
62
74
  // Brand mark sizing: target this height, but scale down so a wide wordmark logo
63
75
  // stays within the lockup.
@@ -140,7 +140,12 @@ export const eject = async (root: string): Promise<string[]> => {
140
140
  const hasStaged = staged.size > 0;
141
141
  const stagedDir = "blume-staged";
142
142
 
143
- const files: { path: string; content: string }[] = [
143
+ const files: {
144
+ path: string;
145
+ content: string;
146
+ /** Don't overwrite a file the user already owns (e.g. a tuned tsconfig). */
147
+ skipIfExists?: boolean;
148
+ }[] = [
144
149
  {
145
150
  content: astroConfigTemplate({
146
151
  config,
@@ -160,6 +165,8 @@ export const eject = async (root: string): Promise<string[]> => {
160
165
  {
161
166
  content: runtimeTsconfigTemplate(),
162
167
  path: join(root, "tsconfig.json"),
168
+ // Never clobber a hand-tuned tsconfig; only write ours if none exists.
169
+ skipIfExists: true,
163
170
  },
164
171
  { content: envTemplate(), path: join(srcDir, "env.d.ts") },
165
172
  {
@@ -327,8 +334,11 @@ export const eject = async (root: string): Promise<string[]> => {
327
334
  files.push({ content, path: join(root, stagedDir, entryId) });
328
335
  }
329
336
 
337
+ const written = files.filter(
338
+ (file) => !(file.skipIfExists && existsSync(file.path))
339
+ );
330
340
  await Promise.all(
331
- files.map(async (file) => {
341
+ written.map(async (file) => {
332
342
  await mkdir(join(file.path, ".."), { recursive: true });
333
343
  await writeFile(file.path, file.content, "utf-8");
334
344
  })
@@ -347,5 +357,5 @@ export const eject = async (root: string): Promise<string[]> => {
347
357
  // The hidden runtime is no longer the source of truth.
348
358
  await rm(context.outDir, { force: true, recursive: true });
349
359
 
350
- return files.map((file) => file.path);
360
+ return written.map((file) => file.path);
351
361
  };
@@ -219,6 +219,12 @@ const CONTENT_COMPONENTS: {
219
219
  name: "prompt",
220
220
  tag: "Prompt",
221
221
  },
222
+ {
223
+ description: "A responsive, privacy-friendly YouTube embed.",
224
+ file: "YouTube.astro",
225
+ name: "youtube",
226
+ tag: "YouTube",
227
+ },
222
228
  ];
223
229
 
224
230
  /** The built-in, Blume-owned source registry. */
@@ -1,10 +1,16 @@
1
1
  import { dirname, relative, resolve } from "pathe";
2
2
 
3
- // The module specifier in `... from "<spec>"` and side-effect `import "<spec>"`,
4
- // limited to relative specifiers (those starting with `.`). Capturing the
5
- // keyword, gap, and quote lets us rebuild the statement verbatim.
6
- const RELATIVE_IMPORT =
7
- /(?<kw>\bfrom|\bimport)(?<gap>\s+)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gu;
3
+ // A relative specifier (starting with `.`) in an `import … from ""` or
4
+ // `export … from "…"` statement. Anchored to the start of a line (`m` flag) and
5
+ // bounded by `[^;]` so it only matches a real statement — not a `from "./…"`
6
+ // that happens to appear inside a string or JSX text — while still allowing a
7
+ // multiline import body between the keyword and `from`.
8
+ const FROM_IMPORT =
9
+ /(?<prefix>^[ \t]*(?:import|export)\b[^;]*?\bfrom[ \t]*)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gmu;
10
+
11
+ // A side-effect `import "./…"` at the start of a line.
12
+ const SIDE_EFFECT_IMPORT =
13
+ /(?<prefix>^[ \t]*import[ \t]+)(?<quote>["'])(?<spec>\.[^"']*)\k<quote>/gmu;
8
14
 
9
15
  /**
10
16
  * Rewrite a built-in component's relative imports to `blume/*` package
@@ -22,18 +28,24 @@ export const rewriteImports = (
22
28
  content: string,
23
29
  sourceFile: string,
24
30
  srcRoot: string
25
- ): string =>
26
- content.replaceAll(
27
- RELATIVE_IMPORT,
28
- (match, kw: string, gap: string, quote: string, spec: string) => {
29
- const resolved = resolve(dirname(sourceFile), spec);
30
- if (resolved === sourceFile) {
31
- return match;
32
- }
33
- const rel = relative(srcRoot, resolved);
34
- if (rel.startsWith("..")) {
35
- return match;
36
- }
37
- return `${kw}${gap}${quote}blume/${rel}${quote}`;
31
+ ): string => {
32
+ const rewrite = (
33
+ match: string,
34
+ prefix: string,
35
+ quote: string,
36
+ spec: string
37
+ ): string => {
38
+ const resolved = resolve(dirname(sourceFile), spec);
39
+ if (resolved === sourceFile) {
40
+ return match;
41
+ }
42
+ const rel = relative(srcRoot, resolved);
43
+ if (rel.startsWith("..")) {
44
+ return match;
38
45
  }
39
- );
46
+ return `${prefix}${quote}blume/${rel}${quote}`;
47
+ };
48
+ return content
49
+ .replaceAll(FROM_IMPORT, rewrite)
50
+ .replaceAll(SIDE_EFFECT_IMPORT, rewrite);
51
+ };
@@ -47,17 +47,35 @@ const MARKDOWN_PUNCT = /[*_~>]+/gu;
47
47
  const WHITESPACE = /\s+/gu;
48
48
 
49
49
  /** Reduce Markdown/MDX to plain, searchable text. */
50
- const toPlainText = (markdown: string): string =>
51
- markdown
50
+ const toPlainText = (markdown: string): string => {
51
+ const withoutBlocks = markdown
52
52
  .replaceAll(CODE_FENCE, " ")
53
53
  .replaceAll(IMAGE, " ")
54
- .replaceAll(LINK, "$<text>")
55
- .replaceAll(HTML_OR_JSX, " ")
56
- .replaceAll(INLINE_CODE, "$<code>")
54
+ .replaceAll(LINK, "$<text>");
55
+
56
+ // Strip HTML/JSX from the prose, but keep the contents of inline code — an
57
+ // angle-bracket span like `<T>` inside `Array<T>` is a type parameter, not a
58
+ // tag, and stripping it would drop those tokens from the search index. Split
59
+ // on inline-code spans and only run the HTML strip on the text between them.
60
+ const pieces: string[] = [];
61
+ let cursor = 0;
62
+ for (const match of withoutBlocks.matchAll(INLINE_CODE)) {
63
+ const start = match.index ?? 0;
64
+ pieces.push(
65
+ withoutBlocks.slice(cursor, start).replaceAll(HTML_OR_JSX, " ")
66
+ );
67
+ pieces.push(match.groups?.code ?? "");
68
+ cursor = start + match[0].length;
69
+ }
70
+ pieces.push(withoutBlocks.slice(cursor).replaceAll(HTML_OR_JSX, " "));
71
+
72
+ return pieces
73
+ .join("")
57
74
  .replaceAll(HEADING_MARK, "")
58
75
  .replaceAll(MARKDOWN_PUNCT, " ")
59
76
  .replaceAll(WHITESPACE, " ")
60
77
  .trim();
78
+ };
61
79
 
62
80
  interface Crumbs {
63
81
  breadcrumb: string[];
@@ -9,6 +9,10 @@ export interface AlgoliaSyncConfig {
9
9
  * Upload the search records to Algolia. Uses the admin key from
10
10
  * `ALGOLIA_ADMIN_API_KEY` (never the config, which holds only the public,
11
11
  * search-only key). Throws on a missing key/config so the caller can warn.
12
+ *
13
+ * Uses `replaceAllObjects`, which atomically replaces the index contents, so
14
+ * pages deleted or renamed since the last sync don't linger as stale search
15
+ * hits that 404 when clicked.
12
16
  */
13
17
  export const syncAlgolia = async (
14
18
  records: SearchRecord[],
@@ -23,7 +27,7 @@ export const syncAlgolia = async (
23
27
  }
24
28
  const { algoliasearch } = await import("algoliasearch");
25
29
  const client = algoliasearch(config.appId, adminKey);
26
- await client.saveObjects({
30
+ await client.replaceAllObjects({
27
31
  indexName: config.indexName,
28
32
  objects: records.map((record) => ({ ...record, objectID: record._id })),
29
33
  });