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.
- package/dist/cli/index.js +1631 -940
- package/dist/cli/index.js.map +62 -50
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +442 -292
- package/dist/types/core/types.d.ts +7 -0
- package/dist/types/migrate/mintlify/assets.d.ts +8 -0
- package/docs/01-quickstart.mdx +5 -16
- package/docs/02-deployment.mdx +21 -54
- package/docs/advanced/api-reference.mdx +34 -51
- package/docs/advanced/blog.mdx +9 -25
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/changelog.mdx +10 -33
- package/docs/advanced/custom-pages.mdx +21 -78
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/ai.mdx +42 -103
- package/docs/configuration/analytics.mdx +20 -38
- package/docs/configuration/customization.mdx +40 -73
- package/docs/configuration/export.mdx +9 -34
- package/docs/configuration/index.mdx +67 -87
- package/docs/configuration/search.mdx +17 -54
- package/docs/configuration/seo.mdx +17 -48
- package/docs/configuration/theming.mdx +20 -42
- package/docs/content/components.mdx +95 -101
- package/docs/content/i18n.mdx +21 -72
- package/docs/content/index.mdx +18 -48
- package/docs/content/islands.mdx +25 -52
- package/docs/content/meta.mdx +23 -50
- package/docs/content/navigation.mdx +23 -62
- package/docs/content/sources.mdx +20 -83
- package/docs/content/syntax.mdx +37 -105
- package/docs/index.mdx +12 -41
- package/docs/reference/cli.mdx +47 -30
- package/docs/reference/frontmatter.mdx +7 -5
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/integration.ts +26 -3
- package/src/astro/islands.ts +6 -2
- package/src/astro/markdown-negotiation.ts +17 -3
- package/src/astro/pages.ts +6 -1
- package/src/astro/static-assets.ts +117 -0
- package/src/astro/templates.ts +76 -30
- package/src/cli/args.ts +23 -0
- package/src/cli/commands/build.ts +129 -62
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/commands/dev.ts +11 -2
- package/src/cli/commands/doctor.ts +10 -1
- package/src/cli/commands/eject.ts +3 -1
- package/src/cli/commands/init.ts +21 -1
- package/src/cli/commands/preview.ts +2 -1
- package/src/cli/commands/validate.ts +12 -1
- package/src/cli/dev-lock.ts +92 -0
- package/src/cli/log.ts +11 -0
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +8 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/content/YouTube.astro +35 -0
- package/src/components/content/youtube.ts +46 -0
- package/src/components/islands/ask-ai.tsx +14 -14
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/components/props.ts +3 -0
- package/src/core/assets.ts +31 -0
- package/src/core/bridge.ts +10 -0
- package/src/core/builtin-tags.ts +6 -0
- package/src/core/data.ts +2 -0
- package/src/core/diagnostics.ts +6 -1
- package/src/core/gitignore.ts +30 -0
- package/src/core/links.ts +60 -19
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +54 -6
- package/src/core/sources/mdx-remote.ts +54 -8
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/normalize.ts +6 -1
- package/src/core/sources/notion.ts +49 -5
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/sources/sanity.ts +5 -1
- package/src/core/types.ts +7 -0
- package/src/deploy/rss.ts +1 -8
- package/src/deploy/sitemap.ts +20 -1
- package/src/deploy/xml.ts +8 -0
- package/src/markdown/directives.ts +15 -7
- package/src/markdown/package-commands.ts +26 -4
- package/src/migrate/fumadocs/content.ts +14 -1
- package/src/migrate/fumadocs/groups.ts +7 -0
- package/src/migrate/fumadocs/index.ts +5 -2
- package/src/migrate/mintlify/assets.ts +46 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +111 -46
- package/src/migrate/shared.ts +12 -27
- package/src/og/card.ts +14 -2
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +28 -5
- package/src/registry/registry.ts +6 -0
- package/src/registry/rewrite-imports.ts +31 -19
- package/src/search/documents.ts +23 -5
- package/src/search/sync/algolia.ts +5 -1
- package/src/search/sync/typesense.ts +24 -16
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
- 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
|
+
};
|
package/src/registry/eject.ts
CHANGED
|
@@ -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 {
|
|
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: {
|
|
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 (
|
|
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
|
-
|
|
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
|
|
373
|
+
return written.map((file) => file.path);
|
|
351
374
|
};
|
package/src/registry/registry.ts
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
};
|
package/src/search/documents.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
};
|