blume 0.7.0 → 0.8.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 +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1487 -360
- package/dist/cli/index.js.map +91 -85
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +36 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +476 -132
- package/dist/types/core/schema.d.ts +216 -145
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +4 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +14 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +76 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +99 -12
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +99 -19
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +56 -6
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +17 -3
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +45 -8
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +67 -9
- package/src/components/layout/Search.astro +94 -22
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +37 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +45 -0
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +36 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +13 -12
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "../Icon.astro";
|
|
3
|
+
import { contentHref } from "./base-href.ts";
|
|
3
4
|
|
|
4
5
|
const { description, href, title } = Astro.props;
|
|
5
6
|
const Tag = href ? "a" : "div";
|
|
@@ -11,7 +12,7 @@ const previewPattern =
|
|
|
11
12
|
<Tag
|
|
12
13
|
class="not-prose block overflow-hidden rounded-blume border border-border bg-background text-inherit no-underline! transition-colors hover:no-underline! hover:[&[href]]:border-accent"
|
|
13
14
|
data-blume-tile
|
|
14
|
-
href={href}
|
|
15
|
+
href={href === undefined ? undefined : contentHref(href)}
|
|
15
16
|
rel={external ? "noreferrer" : undefined}
|
|
16
17
|
target={external ? "_blank" : undefined}
|
|
17
18
|
>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
|
|
4
|
+
import { contentHref } from "./base-href.ts";
|
|
5
|
+
|
|
4
6
|
interface Props {
|
|
5
7
|
cta?: string;
|
|
6
8
|
headline?: string;
|
|
@@ -43,7 +45,7 @@ const labelHtml = Astro.slots.has("default")
|
|
|
43
45
|
cta && href && (
|
|
44
46
|
<a
|
|
45
47
|
class="mt-2 inline-flex font-medium text-accent text-xs hover:underline"
|
|
46
|
-
href={href}
|
|
48
|
+
href={contentHref(href)}
|
|
47
49
|
rel={external ? "noreferrer" : undefined}
|
|
48
50
|
target={external ? "_blank" : undefined}
|
|
49
51
|
>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { contentHref } from "./base-href.ts";
|
|
3
|
+
|
|
2
4
|
interface RssMetadata {
|
|
3
5
|
description?: string;
|
|
4
6
|
title?: string;
|
|
@@ -6,7 +8,12 @@ interface RssMetadata {
|
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
description?: string;
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Link the heading to a dedicated page; falls back to the in-page anchor.
|
|
13
|
+
* A root-relative route gains the composed `deployment.base` + `basePath`
|
|
14
|
+
* prefix at emit time (like a markdown link); anchors and external URLs
|
|
15
|
+
* pass through.
|
|
16
|
+
*/
|
|
10
17
|
href?: string;
|
|
11
18
|
id?: string;
|
|
12
19
|
label?: string;
|
|
@@ -47,7 +54,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
|
|
47
54
|
<header class="md:border-border md:border-e md:pe-4">
|
|
48
55
|
<a
|
|
49
56
|
class="font-semibold text-foreground text-sm no-underline hover:text-accent"
|
|
50
|
-
href={href ?? `#${id}`}
|
|
57
|
+
href={contentHref(href ?? `#${id}`)}
|
|
51
58
|
>
|
|
52
59
|
{updateLabel}
|
|
53
60
|
</a>
|
|
@@ -144,7 +144,13 @@ export const extractTypeTable = async (
|
|
|
144
144
|
default: defaultValue || undefined,
|
|
145
145
|
description: description || undefined,
|
|
146
146
|
name: symbol.getName(),
|
|
147
|
-
|
|
147
|
+
// Optionality lives on the checker's symbol, not the backing
|
|
148
|
+
// declaration: mapped/utility types synthesize members whose
|
|
149
|
+
// declaration has no question token (`Partial<Base>` would read as
|
|
150
|
+
// all-required), and `Required<Base>` strips optionality while the
|
|
151
|
+
// declaration keeps its `?` — the token is wrong in both directions.
|
|
152
|
+
// oxlint-disable-next-line no-bitwise -- SymbolFlags is a bitfield
|
|
153
|
+
required: (symbol.flags & ts.SymbolFlags.Optional) === 0,
|
|
148
154
|
type: typeText,
|
|
149
155
|
};
|
|
150
156
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import data from "blume:data";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isInternalPath,
|
|
5
|
+
normalizeBasePath,
|
|
6
|
+
withComposedBasePath,
|
|
7
|
+
} from "../../core/base-path.ts";
|
|
8
|
+
|
|
9
|
+
// Mirrors `markdown/base-links.ts`: a path whose final segment carries a file
|
|
10
|
+
// extension is a `public/` asset, served at the site root and never moved
|
|
11
|
+
// under `basePath`.
|
|
12
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
13
|
+
|
|
14
|
+
/** Strip any `#fragment`/`?query` so only the path is extension-tested. */
|
|
15
|
+
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rebase a component-emitted `href` the way `markdown/base-links.ts` rebases
|
|
19
|
+
* `[x](/guide)`: a root-relative internal page link gains the composed
|
|
20
|
+
* `deployment.base` + `basePath` prefix, so authors write component links
|
|
21
|
+
* (`<Card href="/guide">`) under the same "as if mounted at root" contract as
|
|
22
|
+
* markdown links. Idempotent per layer (a hand-written `/docs/x` isn't
|
|
23
|
+
* double-prefixed) and inert for external URLs, fragments, relative paths, and
|
|
24
|
+
* asset links.
|
|
25
|
+
*/
|
|
26
|
+
export const contentHref = (href: string): string =>
|
|
27
|
+
isInternalPath(href) && !ASSET_PATH.test(pathOf(href))
|
|
28
|
+
? withComposedBasePath(
|
|
29
|
+
normalizeBasePath(import.meta.env.BASE_URL),
|
|
30
|
+
data.config.basePath,
|
|
31
|
+
href
|
|
32
|
+
)
|
|
33
|
+
: href;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client
|
|
2
|
+
* Client behavior for the `<blume-changelog>` custom element wrapping the
|
|
3
3
|
* generated changelog timeline when its releases are semver-versioned. The
|
|
4
4
|
* newest major line stays visible; every older major is collapsed into a group
|
|
5
5
|
* revealed one major at a time by the "Show N.x releases" button at the bottom.
|
|
@@ -24,6 +24,10 @@ class BlumeChangelog extends HTMLElement {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Localized button template from the generated page markup (the
|
|
28
|
+
// data-attribute channel); `{version}` is replaced with the major line.
|
|
29
|
+
const template = this.dataset.i18nMore || "Show {version} releases";
|
|
30
|
+
|
|
27
31
|
for (const group of groups) {
|
|
28
32
|
group.hidden = true;
|
|
29
33
|
// Focusable only programmatically, so revealing a group can move focus to
|
|
@@ -35,7 +39,10 @@ class BlumeChangelog extends HTMLElement {
|
|
|
35
39
|
const sync = () => {
|
|
36
40
|
const next = groups[revealed];
|
|
37
41
|
if (next) {
|
|
38
|
-
button.textContent =
|
|
42
|
+
button.textContent = template.replace(
|
|
43
|
+
"{version}",
|
|
44
|
+
next.dataset.changelogLabel ?? ""
|
|
45
|
+
);
|
|
39
46
|
button.hidden = false;
|
|
40
47
|
} else {
|
|
41
48
|
button.hidden = true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client
|
|
2
|
+
* Client behavior for the `<blume-mermaid>` custom element emitted by the
|
|
3
3
|
* Mermaid markdown plugin. Mermaid is lazy-loaded — it needs a DOM and is large,
|
|
4
4
|
* so the dependency only downloads on pages that actually contain a diagram —
|
|
5
5
|
* and each diagram re-renders when the color theme flips so it tracks light/dark.
|
|
@@ -61,7 +61,12 @@ class BlumeMermaid extends HTMLElement {
|
|
|
61
61
|
output.innerHTML = svg;
|
|
62
62
|
}
|
|
63
63
|
} catch {
|
|
64
|
-
|
|
64
|
+
// Localized message stamped on <body> by RootLayout's markup (the
|
|
65
|
+
// data-attribute channel); English fallback when the attribute is
|
|
66
|
+
// missing (a stale snapshot or a custom layout).
|
|
67
|
+
output.textContent =
|
|
68
|
+
document.body.dataset.i18nDiagramError ||
|
|
69
|
+
"Could not render this diagram.";
|
|
65
70
|
}
|
|
66
71
|
output.removeAttribute("aria-busy");
|
|
67
72
|
};
|
|
@@ -63,13 +63,16 @@ const icons = {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/* The table of contents only shows at xl; reclaim its column for the article
|
|
66
|
-
while the panel is open so the shrunken content still has room to breathe.
|
|
66
|
+
while the panel is open so the shrunken content still has room to breathe.
|
|
67
|
+
Scoped to grids that actually have a TOC column: a "bare" layout (the
|
|
68
|
+
changelog index) is single-column, and forcing the sidebar tracks onto it
|
|
69
|
+
would squeeze its only item — <main> — into the 17.5rem track. */
|
|
67
70
|
@media (min-width: 1280px) {
|
|
68
71
|
body[data-blume-ask="open"] [data-blume-toc] {
|
|
69
72
|
display: none;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
body[data-blume-ask="open"] [data-blume-doc-grid] {
|
|
75
|
+
body[data-blume-ask="open"] [data-blume-doc-grid]:has([data-blume-toc]) {
|
|
73
76
|
grid-template-columns: 17.5rem minmax(0, 1fr);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -5,7 +5,7 @@ import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import { joinBase, stripBase } from "./base-path.ts";
|
|
8
|
+
import { joinBase, prefixBase, stripBase } from "./base-path.ts";
|
|
9
9
|
|
|
10
10
|
interface ChatMessage {
|
|
11
11
|
content: string;
|
|
@@ -43,6 +43,7 @@ const EMPTY_ICONS: AskIcons = {
|
|
|
43
43
|
|
|
44
44
|
// English fallback so the island renders even if no dictionary is passed.
|
|
45
45
|
const DEFAULT_ASK: UIStrings["ask"] = {
|
|
46
|
+
ai: "AI",
|
|
46
47
|
clear: "Clear conversation",
|
|
47
48
|
close: "Close",
|
|
48
49
|
copy: "Copy conversation",
|
|
@@ -53,6 +54,7 @@ const DEFAULT_ASK: UIStrings["ask"] = {
|
|
|
53
54
|
send: "Send",
|
|
54
55
|
tip: "Tip: You can open and close chat with",
|
|
55
56
|
title: "Ask AI",
|
|
57
|
+
you: "You",
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
let idCounter = 0;
|
|
@@ -72,6 +74,17 @@ const currentPath = (): string =>
|
|
|
72
74
|
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
73
75
|
marked.setOptions({ breaks: true, gfm: true });
|
|
74
76
|
|
|
77
|
+
// The model cites pages as base-less logical routes (`[Title](/route)`); rewrite
|
|
78
|
+
// link targets to served URLs so citations resolve under `deployment.base`.
|
|
79
|
+
// `prefixBase` leaves external URLs and fragments untouched and is idempotent.
|
|
80
|
+
marked.use({
|
|
81
|
+
walkTokens: (token) => {
|
|
82
|
+
if (token.type === "link") {
|
|
83
|
+
token.href = prefixBase(import.meta.env.BASE_URL, token.href);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
75
88
|
const renderMarkdown = (content: string): string =>
|
|
76
89
|
DOMPurify.sanitize(marked.parse(content, { async: false }));
|
|
77
90
|
|
|
@@ -97,6 +110,14 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
|
97
110
|
// Stable empty default so an unset `suggestions` prop doesn't re-render.
|
|
98
111
|
const EMPTY_SUGGESTIONS: Suggestion[] = [];
|
|
99
112
|
|
|
113
|
+
// The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
|
|
114
|
+
// platform (same detection Search.astro uses for its ⌘K hint). Guarded so the
|
|
115
|
+
// island still server-renders, where `navigator` doesn't exist; the hint itself
|
|
116
|
+
// only renders client-side, inside the portaled panel.
|
|
117
|
+
const IS_APPLE =
|
|
118
|
+
typeof navigator !== "undefined" &&
|
|
119
|
+
/mac|iphone|ipad|ipod/iu.test(navigator.platform);
|
|
120
|
+
|
|
100
121
|
// Ghost icon button, matching the header's theme toggle and repo link.
|
|
101
122
|
const TRIGGER_CLASS =
|
|
102
123
|
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
@@ -120,7 +141,9 @@ const AskAI = ({
|
|
|
120
141
|
strings?: UIStrings["ask"];
|
|
121
142
|
suggestions?: Suggestion[];
|
|
122
143
|
}) => {
|
|
123
|
-
|
|
144
|
+
// Merge per key (not `strings ?? …`) so a dictionary from a stale snapshot
|
|
145
|
+
// that predates newer keys still resolves every label to its English default.
|
|
146
|
+
const t = { ...DEFAULT_ASK, ...strings };
|
|
124
147
|
const [mounted, setMounted] = useState(false);
|
|
125
148
|
const [open, setOpen] = useState(false);
|
|
126
149
|
const [input, setInput] = useState("");
|
|
@@ -128,6 +151,9 @@ const AskAI = ({
|
|
|
128
151
|
const [busy, setBusy] = useState(false);
|
|
129
152
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
130
153
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
154
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
155
|
+
// Where focus came from when the panel opened, restored on close.
|
|
156
|
+
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
131
157
|
|
|
132
158
|
// Portal target (document.body) only exists after mount; guards SSR. The
|
|
133
159
|
// one-time false→true flip is deliberate, so the initial `false` is required.
|
|
@@ -164,10 +190,24 @@ const AskAI = ({
|
|
|
164
190
|
// Drive the desktop content push from a body attribute (see AskAI.astro CSS).
|
|
165
191
|
useEffect(() => {
|
|
166
192
|
if (open) {
|
|
193
|
+
returnFocusRef.current =
|
|
194
|
+
document.activeElement instanceof HTMLElement
|
|
195
|
+
? document.activeElement
|
|
196
|
+
: null;
|
|
167
197
|
document.body.dataset.blumeAsk = "open";
|
|
168
198
|
inputRef.current?.focus();
|
|
169
199
|
} else {
|
|
170
200
|
delete document.body.dataset.blumeAsk;
|
|
201
|
+
// Return focus to the element that opened the panel (or the trigger when
|
|
202
|
+
// it's gone), so closing doesn't strand keyboard focus in an inert tree.
|
|
203
|
+
// `returnFocusRef` is only set on open, so initial mount is a no-op.
|
|
204
|
+
if (returnFocusRef.current) {
|
|
205
|
+
const target = returnFocusRef.current.isConnected
|
|
206
|
+
? returnFocusRef.current
|
|
207
|
+
: triggerRef.current;
|
|
208
|
+
returnFocusRef.current = null;
|
|
209
|
+
target?.focus();
|
|
210
|
+
}
|
|
171
211
|
}
|
|
172
212
|
return () => {
|
|
173
213
|
delete document.body.dataset.blumeAsk;
|
|
@@ -244,7 +284,13 @@ const AskAI = ({
|
|
|
244
284
|
};
|
|
245
285
|
|
|
246
286
|
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
247
|
-
|
|
287
|
+
// `isComposing` guards IME input: Enter confirming a CJK conversion must
|
|
288
|
+
// commit the text, not submit the question.
|
|
289
|
+
if (
|
|
290
|
+
event.key === "Enter" &&
|
|
291
|
+
!event.shiftKey &&
|
|
292
|
+
!event.nativeEvent.isComposing
|
|
293
|
+
) {
|
|
248
294
|
event.preventDefault();
|
|
249
295
|
void runQuestion(input);
|
|
250
296
|
}
|
|
@@ -252,7 +298,7 @@ const AskAI = ({
|
|
|
252
298
|
|
|
253
299
|
const copyConversation = () => {
|
|
254
300
|
const text = messages
|
|
255
|
-
.map((m) => `${m.role === "user" ?
|
|
301
|
+
.map((m) => `${m.role === "user" ? t.you : t.ai}: ${m.content}`)
|
|
256
302
|
.join("\n\n");
|
|
257
303
|
void navigator.clipboard?.writeText(text);
|
|
258
304
|
};
|
|
@@ -263,6 +309,9 @@ const AskAI = ({
|
|
|
263
309
|
<aside
|
|
264
310
|
aria-hidden={open ? undefined : "true"}
|
|
265
311
|
aria-label={t.title}
|
|
312
|
+
// The closed panel is only translated off-screen; `inert` drops its
|
|
313
|
+
// buttons/textarea from the tab order and the accessibility tree.
|
|
314
|
+
inert={!open}
|
|
266
315
|
className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
|
|
267
316
|
open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
|
268
317
|
}`}
|
|
@@ -355,7 +404,7 @@ const AskAI = ({
|
|
|
355
404
|
<p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
|
|
356
405
|
{t.tip}
|
|
357
406
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
358
|
-
⌘
|
|
407
|
+
{IS_APPLE ? "⌘" : "Ctrl"}
|
|
359
408
|
</kbd>
|
|
360
409
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
|
|
361
410
|
I
|
|
@@ -371,7 +420,7 @@ const AskAI = ({
|
|
|
371
420
|
>
|
|
372
421
|
<textarea
|
|
373
422
|
aria-label={t.label}
|
|
374
|
-
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm outline-none placeholder:text-muted-foreground"
|
|
423
|
+
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm pointer-coarse:text-base outline-none placeholder:text-muted-foreground"
|
|
375
424
|
onChange={(event) => setInput(event.target.value)}
|
|
376
425
|
onKeyDown={onInputKeyDown}
|
|
377
426
|
placeholder={t.placeholder}
|
|
@@ -398,6 +447,7 @@ const AskAI = ({
|
|
|
398
447
|
aria-label={t.title}
|
|
399
448
|
className={TRIGGER_CLASS}
|
|
400
449
|
onClick={() => setOpen((value) => !value)}
|
|
450
|
+
ref={triggerRef}
|
|
401
451
|
type="button"
|
|
402
452
|
>
|
|
403
453
|
<Glyph path={icons.chat} size={18} />
|
|
@@ -89,23 +89,36 @@ export const useSearch = (): UseSearch => {
|
|
|
89
89
|
const [results, setResults] = useState<SearchResult | null>(null);
|
|
90
90
|
const [loading, setLoading] = useState(false);
|
|
91
91
|
const searchFn = useRef<SearchFn | null>(null);
|
|
92
|
+
const generation = useRef(0);
|
|
92
93
|
|
|
93
94
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
94
95
|
// this useCallback keeps a stable `search` identity for consumers that use it
|
|
95
96
|
// as an effect/memo dependency. With the compiler on it's redundant but inert.
|
|
96
97
|
// oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
|
|
97
98
|
const search = useCallback<UseSearch["search"]>(async (query, options) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// Stale-response guard, mirroring the built-in dialog's renderGeneration:
|
|
100
|
+
// provider responses can land out of order, so only the latest call may
|
|
101
|
+
// commit results or clear `loading` — otherwise the last response to land
|
|
102
|
+
// wins over the last query typed ("a" clobbering "ab").
|
|
103
|
+
generation.current += 1;
|
|
104
|
+
const { current } = generation;
|
|
105
|
+
// Before the lazy client import: the first search's heaviest phase is
|
|
106
|
+
// creating the provider client (index download), and it must show loading.
|
|
102
107
|
setLoading(true);
|
|
103
108
|
try {
|
|
109
|
+
if (!searchFn.current) {
|
|
110
|
+
const { createSearch } = await import("blume:search-client");
|
|
111
|
+
searchFn.current = await createSearch();
|
|
112
|
+
}
|
|
104
113
|
const result = await searchFn.current(query, options);
|
|
105
|
-
|
|
114
|
+
if (current === generation.current) {
|
|
115
|
+
setResults(result);
|
|
116
|
+
}
|
|
106
117
|
return result;
|
|
107
118
|
} finally {
|
|
108
|
-
|
|
119
|
+
if (current === generation.current) {
|
|
120
|
+
setLoading(false);
|
|
121
|
+
}
|
|
109
122
|
}
|
|
110
123
|
}, []);
|
|
111
124
|
|
|
@@ -128,6 +141,9 @@ export interface UseAskAI {
|
|
|
128
141
|
|
|
129
142
|
const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
130
143
|
|
|
144
|
+
/** Shown as the assistant's answer when the request fails or throws. */
|
|
145
|
+
const ASK_ERROR = "Something went wrong answering that. Please try again.";
|
|
146
|
+
|
|
131
147
|
/** The current route with the deployment base stripped, for page grounding. */
|
|
132
148
|
const currentPath = (): string =>
|
|
133
149
|
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
@@ -169,8 +185,7 @@ export const useAskAI = (): UseAskAI => {
|
|
|
169
185
|
if (!response.ok) {
|
|
170
186
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
171
187
|
// assistant's answer.
|
|
172
|
-
assistant.content =
|
|
173
|
-
"Something went wrong answering that. Please try again.";
|
|
188
|
+
assistant.content = ASK_ERROR;
|
|
174
189
|
setMessages([...history, { ...assistant }]);
|
|
175
190
|
return;
|
|
176
191
|
}
|
|
@@ -195,6 +210,11 @@ export const useAskAI = (): UseAskAI => {
|
|
|
195
210
|
}
|
|
196
211
|
}
|
|
197
212
|
}
|
|
213
|
+
} catch {
|
|
214
|
+
// A thrown fetch (offline, DNS failure, CORS) must not strand the
|
|
215
|
+
// pre-appended empty assistant message as a stuck placeholder.
|
|
216
|
+
assistant.content = ASK_ERROR;
|
|
217
|
+
setMessages([...history, { ...assistant }]);
|
|
198
218
|
} finally {
|
|
199
219
|
setLoading(false);
|
|
200
220
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
2
4
|
import { withBase } from "../islands/base-path.ts";
|
|
3
5
|
import Icon from "../Icon.astro";
|
|
4
6
|
|
|
@@ -14,9 +16,15 @@ interface Props {
|
|
|
14
16
|
key: string;
|
|
15
17
|
}
|
|
16
18
|
| null;
|
|
19
|
+
/** Localized banner labels. */
|
|
20
|
+
strings?: UIStrings["banner"];
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
const { banner } = Astro.props;
|
|
23
|
+
const { banner, strings } = Astro.props;
|
|
24
|
+
// Merge over the English baseline per key (rather than `strings ?? …`) so a
|
|
25
|
+
// partial — or missing — strings object still resolves every label to a
|
|
26
|
+
// default, matching the pattern PageFeedback and Search use.
|
|
27
|
+
const b = { ...EN_UI.banner, ...strings };
|
|
20
28
|
---
|
|
21
29
|
|
|
22
30
|
{
|
|
@@ -38,7 +46,7 @@ const { banner } = Astro.props;
|
|
|
38
46
|
)}
|
|
39
47
|
{banner.dismissible && (
|
|
40
48
|
<button
|
|
41
|
-
aria-label=
|
|
49
|
+
aria-label={b.dismiss}
|
|
42
50
|
class="absolute end-1.5 inline-flex size-7 items-center justify-center rounded-full text-background/80 transition-colors hover:bg-white/10 hover:text-background dark:text-foreground/70 dark:hover:bg-foreground/10 dark:hover:text-foreground"
|
|
43
51
|
data-blume-banner-dismiss
|
|
44
52
|
type="button"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { withBase } from "../islands/base-path.ts";
|
|
3
3
|
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
4
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
4
5
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
5
6
|
import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
|
|
6
7
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
@@ -37,6 +38,8 @@ interface Props {
|
|
|
37
38
|
hasDrawer?: boolean;
|
|
38
39
|
searchStrings?: UIStrings["search"];
|
|
39
40
|
switcherStrings?: UIStrings["languageSwitcher"];
|
|
41
|
+
/** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
|
|
42
|
+
navStrings?: UIStrings["nav"];
|
|
40
43
|
localeSwitch?: LocaleSwitchOption[];
|
|
41
44
|
/** Active locale for per-language search filtering. */
|
|
42
45
|
searchLocale?: string;
|
|
@@ -59,11 +62,17 @@ const {
|
|
|
59
62
|
hasDrawer = true,
|
|
60
63
|
searchStrings,
|
|
61
64
|
switcherStrings,
|
|
65
|
+
navStrings,
|
|
62
66
|
localeSwitch,
|
|
63
67
|
searchLocale,
|
|
64
68
|
layout = {},
|
|
65
69
|
} = Astro.props;
|
|
66
70
|
|
|
71
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
72
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
73
|
+
// blank — the PageActions pattern.
|
|
74
|
+
const n = { ...EN_UI.nav, ...navStrings };
|
|
75
|
+
|
|
67
76
|
const LogoSlot = resolveSlot(layout.Logo, Logo);
|
|
68
77
|
const SearchSlot = resolveSlot(layout.Search, Search);
|
|
69
78
|
|
|
@@ -109,7 +118,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
109
118
|
{
|
|
110
119
|
showNavToggle && (
|
|
111
120
|
<button
|
|
112
|
-
aria-label=
|
|
121
|
+
aria-label={n.toggleNavigation}
|
|
113
122
|
class={`${iconButton} lg:hidden`}
|
|
114
123
|
data-blume-nav-toggle
|
|
115
124
|
type="button"
|
|
@@ -130,7 +139,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
130
139
|
}
|
|
131
140
|
{
|
|
132
141
|
navigation.tabs.length > 0 && (
|
|
133
|
-
<nav aria-label=
|
|
142
|
+
<nav aria-label={n.sections} class={tabsNavClass}>
|
|
134
143
|
{navigation.tabs.map((tab) => (
|
|
135
144
|
<a
|
|
136
145
|
aria-current={
|
|
@@ -172,7 +181,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
172
181
|
{
|
|
173
182
|
navigation.repoUrl && (
|
|
174
183
|
<a
|
|
175
|
-
aria-label=
|
|
184
|
+
aria-label={n.githubRepository}
|
|
176
185
|
class={iconButton}
|
|
177
186
|
href={navigation.repoUrl}
|
|
178
187
|
rel="noreferrer"
|
|
@@ -191,7 +200,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
191
200
|
)
|
|
192
201
|
}
|
|
193
202
|
<button
|
|
194
|
-
aria-label=
|
|
203
|
+
aria-label={n.toggleTheme}
|
|
195
204
|
class={iconButton}
|
|
196
205
|
data-blume-theme-toggle
|
|
197
206
|
type="button"
|
|
@@ -43,11 +43,19 @@ const brandText = logo?.text ?? site.title;
|
|
|
43
43
|
!logoSvg &&
|
|
44
44
|
logoLight &&
|
|
45
45
|
(logoLight === logoDark ? (
|
|
46
|
-
<img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
|
|
46
|
+
<img alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
|
|
47
47
|
) : (
|
|
48
48
|
<>
|
|
49
|
-
<img
|
|
50
|
-
|
|
49
|
+
<img
|
|
50
|
+
alt={logoAlt}
|
|
51
|
+
class="h-5 w-auto dark:hidden"
|
|
52
|
+
src={withBase(logoLight)}
|
|
53
|
+
/>
|
|
54
|
+
<img
|
|
55
|
+
alt={logoAlt}
|
|
56
|
+
class="hidden h-5 w-auto dark:block"
|
|
57
|
+
src={withBase(logoDark)}
|
|
58
|
+
/>
|
|
51
59
|
</>
|
|
52
60
|
))
|
|
53
61
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
2
4
|
import type { NavNode } from "../../core/types.ts";
|
|
3
5
|
import Icon from "../Icon.astro";
|
|
4
6
|
import { withBase } from "../islands/base-path.ts";
|
|
@@ -22,6 +24,8 @@ interface Props {
|
|
|
22
24
|
idPrefix?: string;
|
|
23
25
|
/** True only for the top-level call; renders the `<blume-nav>` panel stack. */
|
|
24
26
|
root?: boolean;
|
|
27
|
+
/** Localized labels ("Back", the deprecated badge); English when omitted. */
|
|
28
|
+
strings?: UIStrings["nav"];
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const {
|
|
@@ -30,12 +34,18 @@ const {
|
|
|
30
34
|
depth = 0,
|
|
31
35
|
idPrefix = "n",
|
|
32
36
|
root = depth === 0,
|
|
37
|
+
strings,
|
|
33
38
|
} = Astro.props;
|
|
34
39
|
|
|
40
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
41
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
42
|
+
// blank — the PageActions pattern.
|
|
43
|
+
const n = { ...EN_UI.nav, ...strings };
|
|
44
|
+
|
|
35
45
|
const badgeBase =
|
|
36
46
|
"shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
|
|
37
47
|
const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
|
|
38
|
-
// HTTP-method badges (from an OpenAPI reference's sidebar) are
|
|
48
|
+
// HTTP-method badges (from an OpenAPI reference's sidebar) are color-coded;
|
|
39
49
|
// every other badge keeps the neutral style.
|
|
40
50
|
const METHOD_BADGE: Record<string, string> = {
|
|
41
51
|
DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
|
|
@@ -114,13 +124,14 @@ const initialId =
|
|
|
114
124
|
idPrefix={idPrefix}
|
|
115
125
|
items={items}
|
|
116
126
|
root={false}
|
|
127
|
+
strings={n}
|
|
117
128
|
/>
|
|
118
129
|
</div>
|
|
119
130
|
{panels.map((panel) => (
|
|
120
131
|
<div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
|
|
121
132
|
<div class="mb-3 flex items-center gap-1.5">
|
|
122
133
|
<button
|
|
123
|
-
aria-label=
|
|
134
|
+
aria-label={n.back}
|
|
124
135
|
class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
125
136
|
data-nav-back={panel.parentId}
|
|
126
137
|
type="button"
|
|
@@ -147,6 +158,7 @@ const initialId =
|
|
|
147
158
|
idPrefix={panel.id}
|
|
148
159
|
items={panel.children}
|
|
149
160
|
root={false}
|
|
161
|
+
strings={n}
|
|
150
162
|
/>
|
|
151
163
|
</div>
|
|
152
164
|
))}
|
|
@@ -173,7 +185,7 @@ const initialId =
|
|
|
173
185
|
<span class="flex-1 truncate">{item.label}</span>
|
|
174
186
|
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
175
187
|
{item.deprecated && (
|
|
176
|
-
<span class={deprecatedClass}>deprecated</span>
|
|
188
|
+
<span class={deprecatedClass}>{n.deprecated}</span>
|
|
177
189
|
)}
|
|
178
190
|
</span>
|
|
179
191
|
</a>
|
|
@@ -272,6 +284,7 @@ const initialId =
|
|
|
272
284
|
idPrefix={id}
|
|
273
285
|
items={item.children}
|
|
274
286
|
root={false}
|
|
287
|
+
strings={n}
|
|
275
288
|
/>
|
|
276
289
|
</div>
|
|
277
290
|
</details>
|
|
@@ -324,6 +337,7 @@ const initialId =
|
|
|
324
337
|
idPrefix={id}
|
|
325
338
|
items={item.children}
|
|
326
339
|
root={false}
|
|
340
|
+
strings={n}
|
|
327
341
|
/>
|
|
328
342
|
</div>
|
|
329
343
|
</li>
|