blume 0.3.0 → 0.5.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 (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -0,0 +1,140 @@
1
+ import matter from "../core/frontmatter.ts";
2
+ import { hashText } from "../core/sources/cache.ts";
3
+ import type {
4
+ ContentSource,
5
+ SourceContext,
6
+ SourceEntry,
7
+ SourceLoadResult,
8
+ } from "../core/sources/types.ts";
9
+ import type { Diagnostic } from "../core/types.ts";
10
+ import { extractOperations } from "./model.ts";
11
+ import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
12
+ import { parseSpec } from "./parse.ts";
13
+ import type { ReferenceSource } from "./references.ts";
14
+ import { operationMdx, overviewMdx } from "./render-mdx.ts";
15
+ import type { RenderedPage } from "./render-mdx.ts";
16
+
17
+ /**
18
+ * The staged content source behind Blume's own OpenAPI renderer. Each configured
19
+ * spec is parsed once here, then lowered into one MDX page per operation plus an
20
+ * overview page — so operations become first-class Blume pages (real routes,
21
+ * sidebar, search, i18n, OG) and the parsed documents are handed to the
22
+ * generated `blume:openapi` module for the UI components to render.
23
+ */
24
+
25
+ /** A content source that also exposes the specs it parsed during `load()`. */
26
+ export interface OpenApiContentSource extends ContentSource {
27
+ readonly kind: "openapi-source";
28
+ /** Parsed spec data, populated by `load()`; `{}` before the first load. */
29
+ openApiData: () => OpenApiData;
30
+ }
31
+
32
+ /** Narrow a content source to the OpenAPI source (to read its parsed specs). */
33
+ export const isOpenApiSource = (
34
+ source: ContentSource
35
+ ): source is OpenApiContentSource =>
36
+ (source as Partial<OpenApiContentSource>).kind === "openapi-source";
37
+
38
+ /** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
39
+ const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
40
+
41
+ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
42
+ const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
43
+ return {
44
+ body: { format: "mdx", text: rendered.body },
45
+ data: rendered.data,
46
+ hash: hashText(raw),
47
+ raw,
48
+ ref,
49
+ };
50
+ };
51
+
52
+ /** All staged entries for one spec: operations first, overview last. */
53
+ const specEntries = (
54
+ spec: ApiSpecData,
55
+ operations: ApiOperationRef[]
56
+ ): SourceEntry[] => {
57
+ const entries = operations.map((operation) =>
58
+ toEntry(operationMdx(spec, operation), `${routeToRef(operation.route)}.mdx`)
59
+ );
60
+ // Overview last so an operation sets the section's routePath before the index
61
+ // page is inserted (the group's routePath is derived from its first child).
62
+ entries.push(
63
+ toEntry(overviewMdx(spec), `${routeToRef(spec.route)}/index.mdx`)
64
+ );
65
+ return entries;
66
+ };
67
+
68
+ interface LoadedSpec {
69
+ slug: string;
70
+ spec: ApiSpecData;
71
+ entries: SourceEntry[];
72
+ }
73
+
74
+ export const openApiSource = (
75
+ references: ReferenceSource[],
76
+ ctx: SourceContext
77
+ ): OpenApiContentSource => {
78
+ let parsed: OpenApiData = {};
79
+
80
+ const loadReference = async (
81
+ reference: ReferenceSource
82
+ ): Promise<LoadedSpec | Diagnostic> => {
83
+ try {
84
+ const { document } = await parseSpec(reference.spec, ctx.projectRoot);
85
+ const { operations, tags } = extractOperations(document, reference.route);
86
+ const info = document.info ?? { title: reference.label, version: "" };
87
+ const spec: ApiSpecData = {
88
+ codeSamples: reference.display.codeSamples,
89
+ description: info.description ?? "",
90
+ document,
91
+ expandSchemas: reference.display.expandSchemas,
92
+ label: reference.label,
93
+ operations: Object.fromEntries(
94
+ operations.map((operation) => [operation.key, operation])
95
+ ),
96
+ route: reference.route,
97
+ slug: reference.slug,
98
+ tags,
99
+ title: info.title ?? reference.label,
100
+ version: info.version ?? "",
101
+ };
102
+ return {
103
+ entries: specEntries(spec, operations),
104
+ slug: reference.slug,
105
+ spec,
106
+ };
107
+ } catch (error) {
108
+ return {
109
+ code: "BLUME_OPENAPI_UNAVAILABLE",
110
+ message: `Could not load OpenAPI spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
111
+ severity: "warning",
112
+ };
113
+ }
114
+ };
115
+
116
+ const load = async (): Promise<SourceLoadResult> => {
117
+ const results = await Promise.all(references.map(loadReference));
118
+ const entries: SourceEntry[] = [];
119
+ const diagnostics: Diagnostic[] = [];
120
+ const data: OpenApiData = {};
121
+ for (const result of results) {
122
+ if ("severity" in result) {
123
+ diagnostics.push(result);
124
+ continue;
125
+ }
126
+ data[result.slug] = result.spec;
127
+ entries.push(...result.entries);
128
+ }
129
+ parsed = data;
130
+ return { diagnostics, entries };
131
+ };
132
+
133
+ return {
134
+ kind: "openapi-source",
135
+ load,
136
+ name: "openapi",
137
+ openApiData: () => parsed,
138
+ staged: true,
139
+ };
140
+ };
@@ -39,7 +39,9 @@ import { scanProject } from "../core/project-graph.ts";
39
39
  import type { BlumeProject } from "../core/project-graph.ts";
40
40
  import type { ProjectContext } from "../core/types.ts";
41
41
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
42
- import { buildReferenceFiles, hasReferences } from "../openapi/scalar.ts";
42
+ import { hasScalarReferences } from "../openapi/references.ts";
43
+ import { buildReferenceFiles } from "../openapi/scalar.ts";
44
+ import { isOpenApiSource } from "../openapi/source.ts";
43
45
  import { buildSearchDocuments } from "../search/documents.ts";
44
46
  import { servesStaticIndex } from "../search/providers.ts";
45
47
  import { tailwindEntryTemplate } from "../theme/entry.ts";
@@ -48,6 +50,12 @@ import { twoslashCss } from "../theme/twoslash.ts";
48
50
 
49
51
  const POSIX = (path: string): string => path.split("\\").join("/");
50
52
 
53
+ /** The `blume:openapi` payload for the ejected app (`{}` when none). */
54
+ const ejectOpenApiData = (project: BlumeProject): unknown => {
55
+ const source = project.sources.find(isOpenApiSource);
56
+ return source ? source.openApiData() : {};
57
+ };
58
+
51
59
  /**
52
60
  * The Ask AI endpoint plus, unless the backend runs its own retrieval (Inkeep),
53
61
  * its grounding snapshot. Empty when Ask AI is disabled.
@@ -140,7 +148,12 @@ export const eject = async (root: string): Promise<string[]> => {
140
148
  const hasStaged = staged.size > 0;
141
149
  const stagedDir = "blume-staged";
142
150
 
143
- const files: { path: string; content: string }[] = [
151
+ const files: {
152
+ path: string;
153
+ content: string;
154
+ /** Don't overwrite a file the user already owns (e.g. a tuned tsconfig). */
155
+ skipIfExists?: boolean;
156
+ }[] = [
144
157
  {
145
158
  content: astroConfigTemplate({
146
159
  config,
@@ -151,6 +164,7 @@ export const eject = async (root: string): Promise<string[]> => {
151
164
  needsReact,
152
165
  needsSvelte,
153
166
  needsVue,
167
+ openapiPath: "./src/generated/openapi.json",
154
168
  pages: relPages,
155
169
  searchClientPath: "./src/generated/search-client.ts",
156
170
  themePath: "./src/generated/app.css",
@@ -160,6 +174,8 @@ export const eject = async (root: string): Promise<string[]> => {
160
174
  {
161
175
  content: runtimeTsconfigTemplate(),
162
176
  path: join(root, "tsconfig.json"),
177
+ // Never clobber a hand-tuned tsconfig; only write ours if none exists.
178
+ skipIfExists: true,
163
179
  },
164
180
  { content: envTemplate(), path: join(srcDir, "env.d.ts") },
165
181
  {
@@ -212,6 +228,10 @@ export const eject = async (root: string): Promise<string[]> => {
212
228
  path: join(genDir, "app.css"),
213
229
  },
214
230
  { content: buildRuntimeData(project), path: join(genDir, "data.json") },
231
+ {
232
+ content: `${JSON.stringify(ejectOpenApiData(project))}\n`,
233
+ path: join(genDir, "openapi.json"),
234
+ },
215
235
  {
216
236
  content: `${JSON.stringify(rawMarkdown)}\n`,
217
237
  path: join(genDir, "raw-markdown.json"),
@@ -295,7 +315,7 @@ export const eject = async (root: string): Promise<string[]> => {
295
315
 
296
316
  // Scalar API/AsyncAPI reference pages, mirrored from the generated runtime so
297
317
  // the ejected app keeps its reference routes.
298
- if (hasReferences(config)) {
318
+ if (hasScalarReferences(config)) {
299
319
  const references = await buildReferenceFiles({
300
320
  config,
301
321
  contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
@@ -327,8 +347,11 @@ export const eject = async (root: string): Promise<string[]> => {
327
347
  files.push({ content, path: join(root, stagedDir, entryId) });
328
348
  }
329
349
 
350
+ const written = files.filter(
351
+ (file) => !(file.skipIfExists && existsSync(file.path))
352
+ );
330
353
  await Promise.all(
331
- files.map(async (file) => {
354
+ written.map(async (file) => {
332
355
  await mkdir(join(file.path, ".."), { recursive: true });
333
356
  await writeFile(file.path, file.content, "utf-8");
334
357
  })
@@ -347,5 +370,5 @@ export const eject = async (root: string): Promise<string[]> => {
347
370
  // The hidden runtime is no longer the source of truth.
348
371
  await rm(context.outDir, { force: true, recursive: true });
349
372
 
350
- return files.map((file) => file.path);
373
+ return written.map((file) => file.path);
351
374
  };
@@ -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
  });
@@ -8,9 +8,13 @@ export interface TypesenseSyncConfig {
8
8
  }
9
9
 
10
10
  /**
11
- * Import the search records into a Typesense collection, creating the
12
- * collection on first run. Uses the admin key from `TYPESENSE_ADMIN_API_KEY`.
13
- * Throws on a missing key/config so the caller can warn.
11
+ * Import the search records into a Typesense collection. Uses the admin key
12
+ * from `TYPESENSE_ADMIN_API_KEY`. Throws on a missing key/config so the caller
13
+ * can warn.
14
+ *
15
+ * The collection is dropped and recreated on each sync so that pages deleted or
16
+ * renamed since the last sync don't linger as stale search hits that 404 when
17
+ * clicked (an upsert alone never removes them).
14
18
  */
15
19
  export const syncTypesense = async (
16
20
  records: SearchRecord[],
@@ -35,20 +39,24 @@ export const syncTypesense = async (
35
39
  ],
36
40
  });
37
41
 
38
- try {
39
- await client.collections(config.collection).retrieve();
40
- } catch {
41
- await client.collections().create({
42
- fields: [
43
- { name: "title", type: "string" },
44
- { name: "description", optional: true, type: "string" },
45
- { name: "content", type: "string" },
46
- { name: "url", type: "string" },
47
- { facet: true, name: "tag", optional: true, type: "string" },
48
- ],
49
- name: config.collection,
50
- });
42
+ const collection = client.collections(config.collection);
43
+ const exists = await collection
44
+ .retrieve()
45
+ .then(() => true)
46
+ .catch(() => false);
47
+ if (exists) {
48
+ await collection.delete();
51
49
  }
50
+ await client.collections().create({
51
+ fields: [
52
+ { name: "title", type: "string" },
53
+ { name: "description", optional: true, type: "string" },
54
+ { name: "content", type: "string" },
55
+ { name: "url", type: "string" },
56
+ { facet: true, name: "tag", optional: true, type: "string" },
57
+ ],
58
+ name: config.collection,
59
+ });
52
60
 
53
61
  const documents = records.map((record) => ({
54
62
  content: record.content,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The handful of built-in glyphs Blume's own chrome renders from **client-side**
3
+ * scripts (copy/check buttons, search result rows). These stay hand-inlined and
4
+ * dependency-free so they can be bundled into client JS — the full icon
5
+ * resolver (`./icons.ts`) pulls in library data far too large to ship to the
6
+ * browser, so it must never be imported from a client script.
7
+ *
8
+ * Author-facing content icons (Cards, Steps, sidebar, `icon:` frontmatter) do
9
+ * NOT come from here — they resolve from the bundled icon libraries at build
10
+ * time via `resolveIcon` and inline as zero-JS SVG.
11
+ *
12
+ * Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
13
+ * `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
14
+ */
15
+ export const chromeIcons: Record<string, string> = {
16
+ check: '<path d="M20 6 9 17l-5-5"/>',
17
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
18
+ file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
19
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
20
+ sparkles:
21
+ '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
22
+ };