blume 1.4.3 → 1.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/CHANGELOG.md +17 -0
- package/dist/cli/index.js +1621 -576
- package/dist/cli/index.js.map +109 -104
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +79 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +54 -29
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +152 -33
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/RootLayout.astro +18 -0
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +88 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +15 -3
- package/src/core/schema.ts +213 -67
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +30 -11
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
package/src/openapi/scalar.ts
CHANGED
|
@@ -32,9 +32,7 @@ const referencePagePath = (route: string): string => {
|
|
|
32
32
|
return `${segments === "" ? "index" : segments}.astro`;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
const darkModeConfig = (
|
|
36
|
-
mode: ResolvedConfig["theme"]["mode"]
|
|
37
|
-
): Record<string, boolean> => {
|
|
35
|
+
const darkModeConfig = (mode: ResolvedConfig["theme"]["mode"]) => {
|
|
38
36
|
if (mode === "dark") {
|
|
39
37
|
return { darkMode: true };
|
|
40
38
|
}
|
|
@@ -51,10 +49,7 @@ const darkModeConfig = (
|
|
|
51
49
|
* `customCss`. Scalar re-injects `customCss` after its bundled theme, so these
|
|
52
50
|
* variables reliably override the defaults. Best-effort, not pixel-exact.
|
|
53
51
|
*/
|
|
54
|
-
const themeConfiguration = (
|
|
55
|
-
config: ResolvedConfig,
|
|
56
|
-
override?: string
|
|
57
|
-
): Record<string, unknown> => {
|
|
52
|
+
const themeConfiguration = (config: ResolvedConfig, override?: string) => {
|
|
58
53
|
if (override) {
|
|
59
54
|
return { theme: override };
|
|
60
55
|
}
|
|
@@ -70,7 +65,10 @@ const themeConfiguration = (
|
|
|
70
65
|
const specConfiguration = async (
|
|
71
66
|
spec: string,
|
|
72
67
|
root: string
|
|
73
|
-
): Promise<{
|
|
68
|
+
): Promise<{
|
|
69
|
+
config: { content: string } | { url: string };
|
|
70
|
+
warning?: string;
|
|
71
|
+
}> => {
|
|
74
72
|
if (URL_SPEC.test(spec)) {
|
|
75
73
|
return { config: { url: spec } };
|
|
76
74
|
}
|
package/src/openapi/source.ts
CHANGED
|
@@ -9,19 +9,30 @@ import type {
|
|
|
9
9
|
SourceLoadResult,
|
|
10
10
|
} from "../core/sources/types.ts";
|
|
11
11
|
import type { Diagnostic } from "../core/types.ts";
|
|
12
|
+
import { extractAsyncApiOperations } from "./asyncapi.ts";
|
|
13
|
+
import type { AsyncApiDocument } from "./asyncapi.ts";
|
|
12
14
|
import { extractOperations } from "./model.ts";
|
|
13
|
-
import type {
|
|
14
|
-
|
|
15
|
+
import type {
|
|
16
|
+
ApiDocument,
|
|
17
|
+
ApiOperationRef,
|
|
18
|
+
ApiSpecData,
|
|
19
|
+
ApiTagRef,
|
|
20
|
+
OpenApiData,
|
|
21
|
+
} from "./model.ts";
|
|
22
|
+
import { InvalidSpecError, parseAsyncApiSpec, parseSpec } from "./parse.ts";
|
|
15
23
|
import type { ReferenceSource } from "./references.ts";
|
|
16
24
|
import { operationMdx, overviewMdx } from "./render-mdx.ts";
|
|
17
25
|
import type { RenderedPage } from "./render-mdx.ts";
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
|
-
* The staged content source behind Blume's own
|
|
21
|
-
* spec is parsed once here, then lowered
|
|
22
|
-
* overview page — so operations become
|
|
23
|
-
* sidebar, search, i18n, OG) and the
|
|
24
|
-
* generated `blume:openapi` module for the
|
|
28
|
+
* The staged content source behind Blume's own API reference renderer (OpenAPI
|
|
29
|
+
* and AsyncAPI alike). Each configured spec is parsed once here, then lowered
|
|
30
|
+
* into one MDX page per operation plus an overview page — so operations become
|
|
31
|
+
* first-class Blume pages (real routes, sidebar, search, i18n, OG) and the
|
|
32
|
+
* parsed documents are handed to the generated `blume:openapi` module for the
|
|
33
|
+
* UI components to render. The source keeps its historical `openapi` name for
|
|
34
|
+
* both kinds — downstream consumers (`ai.llmsTxt.openapi`, the llms noindex
|
|
35
|
+
* exemption) key on it as "the generated API reference source".
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
/** A content source that also exposes the specs it parsed during `load()`. */
|
|
@@ -35,7 +46,7 @@ export interface OpenApiContentSource extends ContentSource {
|
|
|
35
46
|
export const isOpenApiSource = (
|
|
36
47
|
source: ContentSource
|
|
37
48
|
): source is OpenApiContentSource =>
|
|
38
|
-
|
|
49
|
+
"kind" in source && source.kind === "openapi-source";
|
|
39
50
|
|
|
40
51
|
/** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
|
|
41
52
|
const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
|
|
@@ -44,7 +55,9 @@ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
|
|
|
44
55
|
const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
|
|
45
56
|
return {
|
|
46
57
|
body: { format: "mdx", text: rendered.body },
|
|
47
|
-
|
|
58
|
+
// Spread so the named frontmatter shape satisfies the open metadata
|
|
59
|
+
// dictionary every source entry carries.
|
|
60
|
+
data: { ...rendered.data },
|
|
48
61
|
hash: hashText(raw),
|
|
49
62
|
raw,
|
|
50
63
|
ref,
|
|
@@ -107,6 +120,50 @@ interface LoadedSpec {
|
|
|
107
120
|
diagnostics: Diagnostic[];
|
|
108
121
|
}
|
|
109
122
|
|
|
123
|
+
/** One parsed spec, whichever front-end read it — the kind dispatch seam. */
|
|
124
|
+
interface ParsedReference {
|
|
125
|
+
document: ApiDocument | AsyncApiDocument;
|
|
126
|
+
warnings: string[];
|
|
127
|
+
operations: ApiOperationRef[];
|
|
128
|
+
tags: ApiTagRef[];
|
|
129
|
+
extractWarnings: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parseReference = async (
|
|
133
|
+
reference: ReferenceSource,
|
|
134
|
+
ctx: SourceContext
|
|
135
|
+
): Promise<ParsedReference> => {
|
|
136
|
+
const options = { cacheDir: ctx.cacheDir, refresh: ctx.refresh };
|
|
137
|
+
if (reference.kind === "asyncapi") {
|
|
138
|
+
const { document, warnings } = await parseAsyncApiSpec(
|
|
139
|
+
reference.spec,
|
|
140
|
+
ctx.projectRoot,
|
|
141
|
+
options
|
|
142
|
+
);
|
|
143
|
+
const extracted = extractAsyncApiOperations(document, reference.route);
|
|
144
|
+
return {
|
|
145
|
+
document,
|
|
146
|
+
extractWarnings: extracted.warnings,
|
|
147
|
+
operations: extracted.operations,
|
|
148
|
+
tags: extracted.tags,
|
|
149
|
+
warnings,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const { document, warnings } = await parseSpec(
|
|
153
|
+
reference.spec,
|
|
154
|
+
ctx.projectRoot,
|
|
155
|
+
options
|
|
156
|
+
);
|
|
157
|
+
const extracted = extractOperations(document, reference.route);
|
|
158
|
+
return {
|
|
159
|
+
document,
|
|
160
|
+
extractWarnings: extracted.warnings,
|
|
161
|
+
operations: extracted.operations,
|
|
162
|
+
tags: extracted.tags,
|
|
163
|
+
warnings,
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
110
167
|
export const openApiSource = (
|
|
111
168
|
references: ReferenceSource[],
|
|
112
169
|
ctx: SourceContext
|
|
@@ -116,23 +173,21 @@ export const openApiSource = (
|
|
|
116
173
|
const loadReference = async (
|
|
117
174
|
reference: ReferenceSource
|
|
118
175
|
): Promise<LoadedSpec | Diagnostic> => {
|
|
176
|
+
// Human label and diagnostic-code prefix for the spec's kind, so an
|
|
177
|
+
// AsyncAPI failure never reads as an OpenAPI one.
|
|
178
|
+
const kindLabel = reference.kind === "asyncapi" ? "AsyncAPI" : "OpenAPI";
|
|
179
|
+
const codePrefix =
|
|
180
|
+
reference.kind === "asyncapi" ? "BLUME_ASYNCAPI" : "BLUME_OPENAPI";
|
|
119
181
|
try {
|
|
120
|
-
const { document, warnings } =
|
|
121
|
-
reference
|
|
122
|
-
ctx.projectRoot,
|
|
123
|
-
{ cacheDir: ctx.cacheDir, refresh: ctx.refresh }
|
|
124
|
-
);
|
|
125
|
-
const {
|
|
126
|
-
operations,
|
|
127
|
-
tags,
|
|
128
|
-
warnings: extractWarnings,
|
|
129
|
-
} = extractOperations(document, reference.route);
|
|
182
|
+
const { document, warnings, operations, tags, extractWarnings } =
|
|
183
|
+
await parseReference(reference, ctx);
|
|
130
184
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
131
185
|
const spec: ApiSpecData = {
|
|
132
186
|
codeSamples: reference.display.codeSamples,
|
|
133
187
|
description: info.description ?? "",
|
|
134
188
|
document,
|
|
135
189
|
expandSchemas: reference.display.expandSchemas,
|
|
190
|
+
kind: reference.kind,
|
|
136
191
|
label: reference.label,
|
|
137
192
|
// Operation pages flow through the content pipeline, which mounts them
|
|
138
193
|
// under the site-wide `basePath` (staged entry refs below stay
|
|
@@ -156,13 +211,24 @@ export const openApiSource = (
|
|
|
156
211
|
return {
|
|
157
212
|
diagnostics: [
|
|
158
213
|
...warnings.map((message) => ({
|
|
159
|
-
|
|
214
|
+
// Parse-level notes: an offline cache fallback for either kind,
|
|
215
|
+
// plus lossy 2.x→3.0 conversion notes for AsyncAPI — hence the
|
|
216
|
+
// broader code on that side (OpenAPI keeps its historical one).
|
|
217
|
+
code:
|
|
218
|
+
reference.kind === "asyncapi"
|
|
219
|
+
? "BLUME_ASYNCAPI_SPEC_WARNING"
|
|
220
|
+
: "BLUME_OPENAPI_STALE",
|
|
160
221
|
message,
|
|
161
222
|
severity: "warning" as const,
|
|
162
223
|
})),
|
|
163
224
|
...extractWarnings.map((message) => ({
|
|
164
|
-
code
|
|
165
|
-
|
|
225
|
+
// OpenAPI keeps its historical code (the only extract warning it
|
|
226
|
+
// emits is the unresolved $ref path item).
|
|
227
|
+
code:
|
|
228
|
+
reference.kind === "asyncapi"
|
|
229
|
+
? "BLUME_ASYNCAPI_SKIPPED_OPERATION"
|
|
230
|
+
: "BLUME_OPENAPI_REF_PATH_ITEM",
|
|
231
|
+
message: `In ${kindLabel} spec "${reference.spec}": ${message}`,
|
|
166
232
|
severity: "warning" as const,
|
|
167
233
|
})),
|
|
168
234
|
// A document with no operations (say, a config file that happens to
|
|
@@ -171,11 +237,13 @@ export const openApiSource = (
|
|
|
171
237
|
...(operations.length === 0
|
|
172
238
|
? [
|
|
173
239
|
{
|
|
174
|
-
code:
|
|
175
|
-
message:
|
|
240
|
+
code: `${codePrefix}_EMPTY`,
|
|
241
|
+
message: `${kindLabel} spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
|
|
176
242
|
severity: "warning" as const,
|
|
177
243
|
suggestion:
|
|
178
|
-
|
|
244
|
+
reference.kind === "asyncapi"
|
|
245
|
+
? "Check the spec points at an AsyncAPI document with `channels` and `operations`."
|
|
246
|
+
: "Check the spec points at an OpenAPI document with operations under `paths`.",
|
|
179
247
|
},
|
|
180
248
|
]
|
|
181
249
|
: []),
|
|
@@ -187,8 +255,10 @@ export const openApiSource = (
|
|
|
187
255
|
};
|
|
188
256
|
} catch (error) {
|
|
189
257
|
return {
|
|
190
|
-
code:
|
|
191
|
-
|
|
258
|
+
code: `${codePrefix}_UNAVAILABLE`,
|
|
259
|
+
// SAFETY: spec loading fails with Error instances (fetch, read, and
|
|
260
|
+
// parse errors alike); only the message is read for the diagnostic.
|
|
261
|
+
message: `Could not load ${kindLabel} spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
|
|
192
262
|
// A configured-but-unloadable spec ships a dead nav tab (a 404 route),
|
|
193
263
|
// so fail loudly in build (blocks under --strict) while staying a warning
|
|
194
264
|
// in dev so offline work still runs.
|
|
@@ -197,7 +267,7 @@ export const openApiSource = (
|
|
|
197
267
|
// only point at reachability for actual fetch/read failures.
|
|
198
268
|
suggestion:
|
|
199
269
|
error instanceof InvalidSpecError
|
|
200
|
-
?
|
|
270
|
+
? `Point the spec at ${reference.kind === "asyncapi" ? "an AsyncAPI" : "an OpenAPI"} document (a YAML or JSON file with an object at the top level).`
|
|
201
271
|
: "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
|
|
202
272
|
};
|
|
203
273
|
}
|
package/src/registry/eject.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { scanProject } from "../core/project-graph.ts";
|
|
|
49
49
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
50
50
|
import type { ProjectContext } from "../core/types.ts";
|
|
51
51
|
import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
52
|
+
import type { OpenApiData } from "../openapi/model.ts";
|
|
52
53
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
53
54
|
import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
54
55
|
import { isOpenApiSource } from "../openapi/source.ts";
|
|
@@ -97,7 +98,7 @@ export const blumeSourceGlob = (
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
/** The `blume:openapi` payload for the ejected app (`{}` when none). */
|
|
100
|
-
const ejectOpenApiData = (project: BlumeProject):
|
|
101
|
+
const ejectOpenApiData = (project: BlumeProject): OpenApiData => {
|
|
101
102
|
const source = project.sources.find(isOpenApiSource);
|
|
102
103
|
return source ? source.openApiData() : {};
|
|
103
104
|
};
|
|
@@ -126,7 +127,11 @@ const askFiles = async (
|
|
|
126
127
|
const grounded = ask.provider !== "inkeep";
|
|
127
128
|
const files = [
|
|
128
129
|
{
|
|
129
|
-
content: askEndpointTemplate(
|
|
130
|
+
content: askEndpointTemplate(
|
|
131
|
+
resolveAskBackend(ask),
|
|
132
|
+
grounded,
|
|
133
|
+
ask.instructions
|
|
134
|
+
),
|
|
130
135
|
path: join(srcDir, "pages", "api", "ask.ts"),
|
|
131
136
|
},
|
|
132
137
|
];
|
package/src/search/documents.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface SearchDocument {
|
|
|
26
26
|
locale: string;
|
|
27
27
|
/** Resolved page `type` (`doc`, `blog`, a custom `rfc`…), for type filters. */
|
|
28
28
|
contentType: string;
|
|
29
|
+
/** Docs version (`""` for the current docs), so results scope to the viewed version. */
|
|
30
|
+
version: string;
|
|
29
31
|
/** Frontmatter `search.tags`, surfaced for hosted-provider faceting. */
|
|
30
32
|
tags?: string[];
|
|
31
33
|
/**
|
|
@@ -48,6 +50,12 @@ export interface SearchRecord {
|
|
|
48
50
|
content: string;
|
|
49
51
|
/** Locale code, carried as a facet for per-language filtering. */
|
|
50
52
|
locale: string;
|
|
53
|
+
/**
|
|
54
|
+
* Docs version, carried as a facet for per-version filtering. The current
|
|
55
|
+
* docs upload as `"current"` — hosted backends treat an empty facet value
|
|
56
|
+
* unreliably, so the sentinel stands in for the empty version id.
|
|
57
|
+
*/
|
|
58
|
+
version: string;
|
|
51
59
|
/** Single faceting tag (the first frontmatter tag, when present). */
|
|
52
60
|
tag?: string;
|
|
53
61
|
}
|
|
@@ -213,10 +221,17 @@ export const buildSearchDocuments = async (
|
|
|
213
221
|
// locale-prefixed routes), so localized pages get the right section/breadcrumb.
|
|
214
222
|
// Falls back to the single default-locale nav when i18n is off.
|
|
215
223
|
const byLocale = Object.values(project.graph.navigationByLocale ?? {});
|
|
216
|
-
|
|
217
|
-
|
|
224
|
+
// Archived versions' trees contribute too, so snapshot pages get their own
|
|
225
|
+
// section/breadcrumb instead of falling through to the "Docs" default.
|
|
226
|
+
const byVersion = Object.values(project.graph.navigationByVersion ?? {})
|
|
227
|
+
.flatMap((locales) => Object.values(locales))
|
|
228
|
+
.map((nav) => nav.sidebar);
|
|
229
|
+
const sidebars = [
|
|
230
|
+
...(byLocale.length > 0
|
|
218
231
|
? byLocale.map((nav) => nav.sidebar)
|
|
219
|
-
: [project.graph.navigation?.sidebar ?? []]
|
|
232
|
+
: [project.graph.navigation?.sidebar ?? []]),
|
|
233
|
+
...byVersion,
|
|
234
|
+
];
|
|
220
235
|
const crumbs = new Map<string, Crumbs>();
|
|
221
236
|
for (const sidebar of sidebars) {
|
|
222
237
|
for (const [route, crumb] of buildCrumbIndex(sidebar)) {
|
|
@@ -246,18 +261,22 @@ export const buildSearchDocuments = async (
|
|
|
246
261
|
const tags = page?.meta?.search?.tags;
|
|
247
262
|
const crumb = crumbs.get(route.path);
|
|
248
263
|
const facets = page ? pageFacets(page, project.config) : undefined;
|
|
249
|
-
|
|
264
|
+
const document: SearchDocument = {
|
|
250
265
|
breadcrumb: crumb?.breadcrumb ?? [],
|
|
251
266
|
content: body,
|
|
252
267
|
contentType: route.contentType,
|
|
253
268
|
description: page?.description ?? "",
|
|
254
|
-
...(facets ? { facets } : {}),
|
|
255
269
|
locale: route.locale,
|
|
256
270
|
route: route.path,
|
|
257
271
|
section: crumb?.section || "Docs",
|
|
258
272
|
tags: tags && tags.length > 0 ? tags : undefined,
|
|
259
273
|
title: route.title,
|
|
274
|
+
version: route.version,
|
|
260
275
|
};
|
|
276
|
+
if (facets) {
|
|
277
|
+
document.facets = facets;
|
|
278
|
+
}
|
|
279
|
+
return document;
|
|
261
280
|
})
|
|
262
281
|
);
|
|
263
282
|
};
|
|
@@ -276,4 +295,5 @@ export const toSearchRecords = (documents: SearchDocument[]): SearchRecord[] =>
|
|
|
276
295
|
tag: doc.tags?.[0],
|
|
277
296
|
title: doc.title,
|
|
278
297
|
url: doc.route,
|
|
298
|
+
version: doc.version || "current",
|
|
279
299
|
}));
|
package/src/search/facets.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type { PageRecord } from "../core/types.ts";
|
|
|
10
10
|
* when nothing facets, so the field stays absent from serialized documents
|
|
11
11
|
* rather than shipping as `{}` on every page.
|
|
12
12
|
*/
|
|
13
|
+
/** Whether a custom frontmatter value stringifies into a usable facet value. */
|
|
14
|
+
const isFacetValue = <T>(value: T): value is T & (string | number | boolean) =>
|
|
15
|
+
typeof value === "string" ||
|
|
16
|
+
typeof value === "number" ||
|
|
17
|
+
typeof value === "boolean";
|
|
18
|
+
|
|
13
19
|
export const pageFacets = (
|
|
14
20
|
page: Pick<PageRecord, "contentType" | "custom">,
|
|
15
21
|
config: ResolvedConfig
|
|
@@ -21,11 +27,7 @@ export const pageFacets = (
|
|
|
21
27
|
const facets: Record<string, string> = {};
|
|
22
28
|
for (const key of declared) {
|
|
23
29
|
const value = page.custom[key];
|
|
24
|
-
if (
|
|
25
|
-
typeof value === "string" ||
|
|
26
|
-
typeof value === "number" ||
|
|
27
|
-
typeof value === "boolean"
|
|
28
|
-
) {
|
|
30
|
+
if (isFacetValue(value)) {
|
|
29
31
|
facets[key] = String(value);
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { create, insertMultiple, search } from "@orama/orama";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AnyOrama,
|
|
4
|
+
EnumArrComparisonOperator,
|
|
5
|
+
EnumComparisonOperator,
|
|
6
|
+
Tokenizer,
|
|
7
|
+
} from "@orama/orama";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* The minimal document shape both the client-side search dialog and the
|
|
@@ -13,6 +18,11 @@ export interface OramaDoc {
|
|
|
13
18
|
title: string;
|
|
14
19
|
/** Locale code; indexed as an enum so queries can filter to one language. */
|
|
15
20
|
locale?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Docs version; indexed as an enum so queries can filter to one version.
|
|
23
|
+
* The current docs carry `""`, which the enum stores and matches exactly.
|
|
24
|
+
*/
|
|
25
|
+
version?: string;
|
|
16
26
|
/** Resolved page `type`; indexed as an enum so queries can filter by type. */
|
|
17
27
|
contentType?: string;
|
|
18
28
|
/** Declared facet values (`content.types.<type>.facets`), key → value. */
|
|
@@ -35,6 +45,7 @@ const SCHEMA = {
|
|
|
35
45
|
locale: "enum",
|
|
36
46
|
route: "string",
|
|
37
47
|
title: "string",
|
|
48
|
+
version: "enum",
|
|
38
49
|
} as const;
|
|
39
50
|
|
|
40
51
|
/** Flatten a facet map to the `key:value` terms the `facetTerms` enum holds. */
|
|
@@ -141,12 +152,20 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
|
|
|
141
152
|
* either way — whether they stand between segments, as 「クーリング・オフ」
|
|
142
153
|
* does, or inside one.
|
|
143
154
|
*/
|
|
155
|
+
/**
|
|
156
|
+
* `Intl.Segmenter` is missing on some runtimes even though the lib type
|
|
157
|
+
* declares it, so the constructor's presence is probed before use.
|
|
158
|
+
*/
|
|
159
|
+
const hasSegmenter = (
|
|
160
|
+
segmenter: typeof Intl.Segmenter | undefined
|
|
161
|
+
): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
|
|
162
|
+
|
|
144
163
|
const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
|
|
145
164
|
const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
|
|
146
165
|
if (!SEGMENTED_LANGUAGES.has(language)) {
|
|
147
166
|
return;
|
|
148
167
|
}
|
|
149
|
-
if (
|
|
168
|
+
if (!hasSegmenter(Intl.Segmenter)) {
|
|
150
169
|
return;
|
|
151
170
|
}
|
|
152
171
|
const segmenter = new Intl.Segmenter(language, { granularity: "word" });
|
|
@@ -217,10 +236,9 @@ export const buildOramaIndex = async (
|
|
|
217
236
|
locale?: string
|
|
218
237
|
): Promise<AnyOrama> => {
|
|
219
238
|
const tokenizer = segmentingTokenizer(locale);
|
|
220
|
-
const db =
|
|
221
|
-
schema: SCHEMA
|
|
222
|
-
|
|
223
|
-
});
|
|
239
|
+
const db = tokenizer
|
|
240
|
+
? create({ components: { tokenizer }, schema: SCHEMA })
|
|
241
|
+
: create({ schema: SCHEMA });
|
|
224
242
|
await insertMultiple(
|
|
225
243
|
db,
|
|
226
244
|
documents.map((doc) =>
|
|
@@ -244,6 +262,23 @@ export interface OramaQueryFilters {
|
|
|
244
262
|
facets?: Record<string, string>;
|
|
245
263
|
/** Keep only documents in this locale. */
|
|
246
264
|
locale?: string;
|
|
265
|
+
/**
|
|
266
|
+
* Keep only documents of this docs version (`""` is the current docs — a
|
|
267
|
+
* meaningful filter value, so absence alone disables version filtering).
|
|
268
|
+
*/
|
|
269
|
+
version?: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The exact-match `where` clause the filters compile to. Orama types `where`
|
|
274
|
+
* openly (any schema property to an operator), mirrored here by the index
|
|
275
|
+
* signature; this module only ever emits the two enum operators.
|
|
276
|
+
*/
|
|
277
|
+
interface OramaWhereClause {
|
|
278
|
+
[property: string]:
|
|
279
|
+
| EnumArrComparisonOperator
|
|
280
|
+
| EnumComparisonOperator
|
|
281
|
+
| undefined;
|
|
247
282
|
}
|
|
248
283
|
|
|
249
284
|
/**
|
|
@@ -265,27 +300,38 @@ export const queryOramaIndex = async (
|
|
|
265
300
|
filters?: OramaQueryFilters
|
|
266
301
|
): Promise<OramaDoc[]> => {
|
|
267
302
|
const facetTerms = filters?.facets ? toFacetTerms(filters.facets) : [];
|
|
268
|
-
const where = {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
const where: OramaWhereClause = {};
|
|
304
|
+
if (filters?.locale) {
|
|
305
|
+
where.locale = { eq: filters.locale };
|
|
306
|
+
}
|
|
307
|
+
// `""` (the current docs) is a real filter value, so test for presence.
|
|
308
|
+
if (filters?.version !== undefined) {
|
|
309
|
+
where.version = { eq: filters.version };
|
|
310
|
+
}
|
|
311
|
+
if (filters?.contentTypes && filters.contentTypes.length > 0) {
|
|
312
|
+
where.contentType = { in: filters.contentTypes };
|
|
313
|
+
}
|
|
314
|
+
if (facetTerms.length > 0) {
|
|
315
|
+
where.facetTerms = { containsAll: facetTerms };
|
|
316
|
+
}
|
|
317
|
+
const unfiltered = {
|
|
278
318
|
boost: BOOST,
|
|
279
319
|
limit,
|
|
280
320
|
properties: ["title", "description", "content"],
|
|
281
321
|
term,
|
|
282
|
-
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
283
322
|
};
|
|
323
|
+
const params =
|
|
324
|
+
Object.keys(where).length > 0 ? { ...unfiltered, where } : unfiltered;
|
|
284
325
|
const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
|
|
326
|
+
// The result-document generic is OramaDoc because `buildOramaIndex` is the
|
|
327
|
+
// only writer to this database and inserts OramaDoc records (plus the
|
|
328
|
+
// derived `facetTerms`).
|
|
285
329
|
const strict = bigrammed
|
|
286
|
-
? await search(db, { ...params, threshold: ALL_TOKENS })
|
|
330
|
+
? await search<AnyOrama, OramaDoc>(db, { ...params, threshold: ALL_TOKENS })
|
|
287
331
|
: undefined;
|
|
288
332
|
const found =
|
|
289
|
-
strict && strict.hits.length > 0
|
|
290
|
-
|
|
333
|
+
strict && strict.hits.length > 0
|
|
334
|
+
? strict
|
|
335
|
+
: await search<AnyOrama, OramaDoc>(db, params);
|
|
336
|
+
return found.hits.map((hit) => hit.document);
|
|
291
337
|
};
|
package/src/search/popular.ts
CHANGED
|
@@ -26,8 +26,13 @@ export const resolveSearchPopular = (
|
|
|
26
26
|
popular: { href: string; icon?: string; label: string }[],
|
|
27
27
|
basePath: string
|
|
28
28
|
): SearchPopularPage[] =>
|
|
29
|
-
popular.map(({ href, icon, label }) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
popular.map(({ href, icon, label }) => {
|
|
30
|
+
const page: SearchPopularPage = {
|
|
31
|
+
label,
|
|
32
|
+
route: withBasePath(basePath, href),
|
|
33
|
+
};
|
|
34
|
+
if (icon) {
|
|
35
|
+
page.icon = icon;
|
|
36
|
+
}
|
|
37
|
+
return page;
|
|
38
|
+
});
|
package/src/search/providers.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface SearchProviderMeta {
|
|
|
29
29
|
syncs: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export const SEARCH_PROVIDERS
|
|
32
|
+
export const SEARCH_PROVIDERS = {
|
|
33
33
|
algolia: {
|
|
34
34
|
kind: "hosted",
|
|
35
35
|
requiresServer: false,
|
|
@@ -80,7 +80,7 @@ export const SEARCH_PROVIDERS: Record<SearchProvider, SearchProviderMeta> = {
|
|
|
80
80
|
runtimeDeps: ["typesense"],
|
|
81
81
|
syncs: true,
|
|
82
82
|
},
|
|
83
|
-
}
|
|
83
|
+
} satisfies Record<SearchProvider, SearchProviderMeta>;
|
|
84
84
|
|
|
85
85
|
export const searchProviderMeta = (
|
|
86
86
|
provider: SearchProvider
|
package/src/search/sync/index.ts
CHANGED
|
@@ -45,6 +45,8 @@ export const syncSearchProvider = async (
|
|
|
45
45
|
`Synced ${records.length} record(s) to ${search.provider}`
|
|
46
46
|
);
|
|
47
47
|
} catch (error) {
|
|
48
|
+
// SAFETY: the three sync clients surface network/auth failures as Error
|
|
49
|
+
// instances; the message is read only to annotate the skip warning.
|
|
48
50
|
reporter.warn(`Search sync skipped: ${(error as Error).message}`);
|
|
49
51
|
}
|
|
50
52
|
};
|
|
@@ -56,9 +56,10 @@ export const syncTypesense = async (
|
|
|
56
56
|
{ name: "content", type: "string" },
|
|
57
57
|
{ name: "url", type: "string" },
|
|
58
58
|
{ facet: true, name: "tag", optional: true, type: "string" },
|
|
59
|
-
// Carried as
|
|
60
|
-
//
|
|
59
|
+
// Carried as facets so hosted results can filter per language and per
|
|
60
|
+
// docs version (the SearchRecord contract; current docs = "current").
|
|
61
61
|
{ facet: true, name: "locale", optional: true, type: "string" },
|
|
62
|
+
{ facet: true, name: "version", optional: true, type: "string" },
|
|
62
63
|
],
|
|
63
64
|
name: config.collection,
|
|
64
65
|
});
|
|
@@ -71,6 +72,7 @@ export const syncTypesense = async (
|
|
|
71
72
|
tag: record.tag,
|
|
72
73
|
title: record.title,
|
|
73
74
|
url: record.url,
|
|
75
|
+
version: record.version,
|
|
74
76
|
}));
|
|
75
77
|
await client
|
|
76
78
|
.collections(config.collection)
|
package/src/seo/jsonld.ts
CHANGED
|
@@ -27,10 +27,25 @@ export interface StructuredDataInput {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** schema.org `@type` for each content type; defaults to TechArticle. */
|
|
30
|
-
const ARTICLE_TYPES
|
|
30
|
+
const ARTICLE_TYPES = {
|
|
31
31
|
blog: "BlogPosting",
|
|
32
32
|
changelog: "TechArticle",
|
|
33
|
-
};
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `hasOwn` (not a bare index) so a content type named like an
|
|
37
|
+
* `Object.prototype` member can't resolve a function up the prototype chain.
|
|
38
|
+
*/
|
|
39
|
+
const isArticleType = (value: string): value is keyof typeof ARTICLE_TYPES =>
|
|
40
|
+
Object.hasOwn(ARTICLE_TYPES, value);
|
|
41
|
+
|
|
42
|
+
/** A value a schema.org node property can hold. */
|
|
43
|
+
type JsonLdValue = string | number | JsonLdValue[] | JsonLdNode;
|
|
44
|
+
|
|
45
|
+
/** A schema.org node: JSON-LD keys to concrete JSON values. */
|
|
46
|
+
export interface JsonLdNode {
|
|
47
|
+
[key: string]: JsonLdValue;
|
|
48
|
+
}
|
|
34
49
|
|
|
35
50
|
const trimSlash = (value: string): string => value.replace(/\/$/u, "");
|
|
36
51
|
|
|
@@ -59,14 +74,14 @@ export const toIso = (value: DateInput | undefined): string | undefined => {
|
|
|
59
74
|
*/
|
|
60
75
|
export const buildStructuredData = (
|
|
61
76
|
input: StructuredDataInput
|
|
62
|
-
):
|
|
77
|
+
): JsonLdNode | null => {
|
|
63
78
|
const base = input.siteUrl ? trimSlash(input.siteUrl) : null;
|
|
64
79
|
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
|
|
65
80
|
// so JSON-LD URLs match the served location.
|
|
66
81
|
const deployBase = normalizeBasePath(input.base);
|
|
67
82
|
const pageUrl = absolute(base, withBasePath(deployBase, input.route));
|
|
68
83
|
const rootUrl = absolute(base, deployBase);
|
|
69
|
-
const graph:
|
|
84
|
+
const graph: JsonLdNode[] = [];
|
|
70
85
|
|
|
71
86
|
if (base) {
|
|
72
87
|
graph.push({
|
|
@@ -80,9 +95,12 @@ export const buildStructuredData = (
|
|
|
80
95
|
// The homepage is fully described by the WebSite node; deeper pages get an
|
|
81
96
|
// article node plus a breadcrumb trail.
|
|
82
97
|
if (input.route !== "/") {
|
|
83
|
-
const
|
|
98
|
+
const pageType = input.pageType ?? "";
|
|
99
|
+
const node: JsonLdNode = {
|
|
84
100
|
"@id": `${pageUrl}#page`,
|
|
85
|
-
"@type":
|
|
101
|
+
"@type": isArticleType(pageType)
|
|
102
|
+
? ARTICLE_TYPES[pageType]
|
|
103
|
+
: "TechArticle",
|
|
86
104
|
headline: input.title,
|
|
87
105
|
inLanguage: input.locale || "en",
|
|
88
106
|
name: input.title,
|
package/src/seo/x-handle.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Frontmatter defense in depth: only a real string can carry a handle. */
|
|
2
|
+
const isString = <Value>(value: Value): value is Value & string =>
|
|
3
|
+
typeof value === "string";
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Normalize an X account to the leading `@` that `twitter:site`/`twitter:creator`
|
|
3
7
|
* require, so `acme`, `@acme`, and ` @acme ` all land on `@acme`. Empty or
|
|
@@ -7,10 +11,11 @@
|
|
|
7
11
|
* Astro's collections carry no schema here, so a page's `seo.x.creator` reaches
|
|
8
12
|
* them as raw frontmatter, and the schema's own transform never runs on it.
|
|
9
13
|
* (Blume's page pipeline does reject a non-string `creator` before the page is
|
|
10
|
-
* built, so
|
|
14
|
+
* built, so the string guard is defense in depth rather than the expected
|
|
15
|
+
* path.)
|
|
11
16
|
*/
|
|
12
|
-
export const normalizeXHandle = (value:
|
|
13
|
-
if (
|
|
17
|
+
export const normalizeXHandle = <Value>(value: Value): string | undefined => {
|
|
18
|
+
if (!isString(value)) {
|
|
14
19
|
return;
|
|
15
20
|
}
|
|
16
21
|
const handle = value.trim().replace(/^@+/u, "");
|