blume 1.5.3 → 1.6.1
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 +94 -0
- package/dist/cli/index.js +3949 -1403
- package/dist/cli/index.js.map +111 -96
- package/dist/types/ai/component-markdown.d.ts +79 -0
- package/dist/types/components/layout/nav-utils.d.ts +60 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +206 -4
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +33 -2
- package/dist/types/core/github.d.ts +35 -0
- package/dist/types/core/i18n-ui.d.ts +10 -0
- package/dist/types/core/navigation.d.ts +69 -0
- package/dist/types/core/schema.d.ts +122 -1
- package/dist/types/core/sources/types.d.ts +31 -6
- package/dist/types/core/types.d.ts +29 -2
- package/dist/types/markdown/features.d.ts +21 -0
- package/dist/types/openapi/references.d.ts +26 -1
- package/dist/types/seo/jsonld.d.ts +105 -0
- package/dist/types/theme/fonts.d.ts +34 -4
- package/docs/07-faq.mdx +9 -9
- package/docs/_snippets/include-demo.mdx +7 -0
- package/docs/advanced/api-reference.mdx +13 -4
- package/docs/advanced/custom-pages.mdx +4 -2
- package/docs/advanced/graphql.mdx +84 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/configuration/ai.mdx +25 -3
- package/docs/configuration/index.mdx +24 -0
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +30 -3
- package/docs/configuration/theming.mdx +23 -0
- package/docs/content/components.mdx +15 -1
- package/docs/content/includes.mdx +68 -0
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +42 -1
- package/docs/content/syntax.mdx +69 -1
- package/docs/content/versioning.mdx +15 -9
- package/docs/reference/cli.mdx +2 -1
- package/package.json +66 -57
- package/skills/blume-migrate/SKILL.md +16 -7
- package/skills/blume-migrate/references/docusaurus.md +5 -3
- package/skills/blume-migrate/references/fumadocs.md +10 -2
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/starlight.md +1 -1
- package/src/ai/agent-readability.ts +2 -1
- package/src/ai/ask-data.ts +2 -1
- package/src/ai/component-markdown.ts +199 -36
- package/src/ai/llms.ts +93 -6
- package/src/ai/markdown.ts +2 -2
- package/src/ai/mcp/discovery.ts +10 -2
- package/src/ai/mcp/server.ts +74 -2
- package/src/astro/examples.ts +29 -2
- package/src/astro/generate.ts +282 -177
- package/src/astro/include-hmr.ts +81 -0
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +10 -5
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/runtime-modules.ts +196 -0
- package/src/astro/templates.ts +365 -113
- package/src/cli/commands/build.ts +91 -16
- package/src/cli/commands/dev.ts +6 -3
- package/src/cli/host-args.ts +18 -0
- package/src/cli/index.ts +2 -1
- package/src/cli/init/questions.ts +1 -0
- package/src/cli/init/scaffold.ts +27 -4
- package/src/components/colors.ts +142 -0
- package/src/components/content/Badge.astro +5 -12
- package/src/components/content/Callout.astro +19 -36
- package/src/components/content/Card.astro +15 -21
- package/src/components/content/Component.astro +10 -1
- package/src/components/content/GithubInfo.astro +28 -9
- package/src/components/content/Tabs.astro +27 -5
- package/src/components/content/github-info.ts +20 -5
- package/src/components/copy-feedback.ts +93 -9
- package/src/components/dropdown-dismiss.ts +122 -0
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/hooks.ts +3 -1
- package/src/components/layout/Fonts.astro +15 -8
- package/src/components/layout/Header.astro +44 -0
- package/src/components/layout/LanguageSwitcher.astro +9 -1
- package/src/components/layout/NavSelector.astro +12 -3
- package/src/components/layout/NavTree.astro +6 -18
- package/src/components/layout/PageActions.astro +54 -22
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +42 -15
- package/src/components/layout/Search.astro +36 -4
- package/src/components/layout/TableOfContents.astro +8 -2
- package/src/components/layout/head-scripts.ts +30 -1
- package/src/components/openapi/ApiOverview.astro +13 -3
- package/src/components/openapi/AsyncApiOperation.astro +7 -14
- package/src/components/openapi/GraphqlChip.astro +33 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
- package/src/components/openapi/GraphqlOperation.astro +186 -0
- package/src/components/openapi/GraphqlType.astro +154 -0
- package/src/components/openapi/MethodBadge.astro +3 -14
- package/src/components/openapi/Operation.astro +12 -5
- package/src/components/openapi/OperationPanel.astro +43 -0
- package/src/components/openapi/RequestPanel.astro +5 -10
- package/src/components/openapi/Responses.astro +1 -16
- package/src/components/openapi/graphql-helpers.ts +466 -0
- package/src/components/openapi/playground-client.ts +15 -0
- package/src/components/openapi/sample-panels.ts +45 -0
- package/src/components/openapi/snippets.ts +13 -35
- package/src/core/base-path.ts +11 -0
- package/src/core/config-input.ts +209 -2
- package/src/core/config.ts +6 -4
- package/src/core/content-assets.ts +15 -4
- package/src/core/data.ts +28 -3
- package/src/core/define-components.ts +2 -0
- package/src/core/diagnostics.ts +8 -0
- package/src/core/frontmatter.ts +20 -8
- package/src/core/github.ts +71 -0
- package/src/core/graph.ts +22 -8
- package/src/core/heading-markers.ts +96 -0
- package/src/core/i18n-ui.ts +12 -0
- package/src/core/includes.ts +633 -0
- package/src/core/last-modified.ts +36 -11
- package/src/core/links.ts +79 -13
- package/src/core/manifest.ts +10 -0
- package/src/core/meta.ts +2 -1
- package/src/core/nav-diagnostics.ts +11 -2
- package/src/core/navigation.ts +27 -6
- package/src/core/project-graph.ts +61 -9
- package/src/core/schema.ts +235 -36
- package/src/core/server-features.ts +5 -9
- package/src/core/sources/github-releases.ts +2 -2
- package/src/core/sources/normalize.ts +502 -115
- package/src/core/sources/notion.ts +43 -8
- package/src/core/sources/obsidian.ts +1038 -0
- package/src/core/sources/read.ts +36 -1
- package/src/core/sources/resolve.ts +34 -1
- package/src/core/sources/types.ts +28 -6
- package/src/core/sources/watch.ts +12 -8
- package/src/core/tsconfig-aliases.ts +48 -35
- package/src/core/types.ts +31 -2
- package/src/core/ui-packs/ar.ts +2 -0
- package/src/core/ui-packs/bg.ts +3 -0
- package/src/core/ui-packs/bn.ts +2 -0
- package/src/core/ui-packs/ca.ts +3 -0
- package/src/core/ui-packs/cs.ts +2 -0
- package/src/core/ui-packs/da.ts +2 -0
- package/src/core/ui-packs/de.ts +3 -0
- package/src/core/ui-packs/el.ts +3 -0
- package/src/core/ui-packs/es.ts +3 -0
- package/src/core/ui-packs/fa.ts +2 -0
- package/src/core/ui-packs/fi.ts +2 -0
- package/src/core/ui-packs/fr.ts +3 -0
- package/src/core/ui-packs/he.ts +2 -0
- package/src/core/ui-packs/hi.ts +2 -0
- package/src/core/ui-packs/hr.ts +3 -0
- package/src/core/ui-packs/hu.ts +3 -0
- package/src/core/ui-packs/id.ts +3 -0
- package/src/core/ui-packs/it.ts +2 -0
- package/src/core/ui-packs/ja.ts +3 -0
- package/src/core/ui-packs/ko.ts +3 -0
- package/src/core/ui-packs/nl.ts +3 -0
- package/src/core/ui-packs/no.ts +3 -0
- package/src/core/ui-packs/pl.ts +3 -0
- package/src/core/ui-packs/pt-br.ts +3 -0
- package/src/core/ui-packs/pt.ts +3 -0
- package/src/core/ui-packs/ro.ts +3 -0
- package/src/core/ui-packs/ru.ts +3 -0
- package/src/core/ui-packs/sk.ts +2 -0
- package/src/core/ui-packs/sr.ts +2 -0
- package/src/core/ui-packs/sv.ts +3 -0
- package/src/core/ui-packs/th.ts +2 -0
- package/src/core/ui-packs/tr.ts +3 -0
- package/src/core/ui-packs/uk.ts +3 -0
- package/src/core/ui-packs/vi.ts +2 -0
- package/src/core/ui-packs/zh-tw.ts +2 -0
- package/src/core/ui-packs/zh.ts +2 -0
- package/src/core/version-cut.ts +26 -6
- package/src/core/yaml.ts +26 -0
- package/src/deploy/function-bundle.ts +251 -0
- package/src/deploy/vercel-negotiation.ts +49 -6
- package/src/eval/schema.ts +3 -1
- package/src/markdown/code-title.ts +22 -16
- package/src/markdown/features.ts +21 -0
- package/src/markdown/fence-meta.ts +50 -0
- package/src/markdown/heading-anchors.ts +198 -37
- package/src/markdown/include.ts +247 -0
- package/src/markdown/index.ts +43 -34
- package/src/markdown/language-icon.ts +2 -2
- package/src/markdown/mdast.ts +7 -3
- package/src/markdown/ts2js.ts +264 -0
- package/src/og/card.ts +1 -1
- package/src/openapi/asyncapi.ts +4 -1
- package/src/openapi/graphql-build.ts +293 -0
- package/src/openapi/graphql.ts +212 -0
- package/src/openapi/model.ts +38 -5
- package/src/openapi/parse.ts +34 -0
- package/src/openapi/proxy.ts +30 -5
- package/src/openapi/references.ts +97 -13
- package/src/openapi/render-mdx.ts +66 -12
- package/src/openapi/scalar.ts +5 -16
- package/src/openapi/source.ts +91 -23
- package/src/registry/eject.ts +47 -17
- package/src/search/documents.ts +229 -37
- package/src/search/orama-index.ts +9 -5
- package/src/seo/jsonld.ts +293 -51
- package/src/theme/code-block-padding.ts +16 -0
- package/src/theme/entry.ts +67 -13
- package/src/theme/fonts.ts +189 -16
- package/src/theme/sources.ts +49 -0
- package/src/translate/prompts.ts +2 -0
- package/src/translate/run.ts +7 -0
- package/src/translate/work-list.ts +0 -0
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// examples are all supported. The source is highlighted with the same Shiki
|
|
11
11
|
// setup as ordinary code fences.
|
|
12
12
|
import data from "blume:data";
|
|
13
|
+
import {
|
|
14
|
+
CODE_PADDING_BLOCK_REM,
|
|
15
|
+
FLUSH_CODE_PADDING_TOP_REM,
|
|
16
|
+
} from "../../theme/code-block-padding.ts";
|
|
13
17
|
|
|
14
18
|
import { highlightCode } from "../../markdown/index.ts";
|
|
15
19
|
import { withBase } from "../islands/base-path.ts";
|
|
@@ -58,7 +62,12 @@ const codeHtml = entry
|
|
|
58
62
|
// whose collapse to the measured height no transition could hide. No-JS
|
|
59
63
|
// readers aren't hurt by the cap, since the source scrolls at any height.
|
|
60
64
|
const LINE_PX = 21;
|
|
61
|
-
const
|
|
65
|
+
const REM_PX = 16;
|
|
66
|
+
// The pre's vertical padding, from the same constants the theme emits: the
|
|
67
|
+
// copy-button strip on top (the pane is a flush block inside tabs) and the
|
|
68
|
+
// plain inset below. The tab panel and the pre carry no border of their own.
|
|
69
|
+
const PADDING_PX =
|
|
70
|
+
(FLUSH_CODE_PADDING_TOP_REM + CODE_PADDING_BLOCK_REM) * REM_PX;
|
|
62
71
|
const ESTIMATE_MAX_PX = 400;
|
|
63
72
|
// The floor also clamps the measured height client-side; it rides along on the
|
|
64
73
|
// iframe as `data-blume-min-pane` so the script and this estimate can't drift.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// at build time (no client JS). Pass `owner`/`repo`, or omit them to use the
|
|
4
4
|
// repo from blume.config. A `GITHUB_TOKEN` env var lifts the API rate limit.
|
|
5
5
|
// If the API is unreachable the card still renders, just without counts.
|
|
6
|
+
import { apiUrl, PUBLIC_HOST_URL } from "../../core/github.ts";
|
|
6
7
|
import { resolveIcon } from "../../theme/icons.ts";
|
|
7
8
|
import data from "blume:data";
|
|
8
9
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
@@ -15,22 +16,40 @@ const starIcon = resolveIcon("star")?.body ?? "";
|
|
|
15
16
|
const forkIcon = resolveIcon("git-fork")?.body ?? "";
|
|
16
17
|
|
|
17
18
|
interface Props {
|
|
19
|
+
/**
|
|
20
|
+
* Origin of the GitHub instance the card's repo lives on. Defaults to the
|
|
21
|
+
* configured `github.host`, so a card on an Enterprise site reads that
|
|
22
|
+
* instance; set it to point one card elsewhere — `https://github.com` for a
|
|
23
|
+
* public project from an Enterprise-hosted docs site, say. The REST base is
|
|
24
|
+
* derived from it the same way `github.api` is derived from `github.host`.
|
|
25
|
+
*/
|
|
26
|
+
host?: string;
|
|
18
27
|
owner?: string;
|
|
19
28
|
repo?: string;
|
|
20
29
|
token?: string;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
32
|
+
// `data.config.github` carries the configured repo's coordinates, so the card
|
|
33
|
+
// defaults to it and addresses the right instance — an Enterprise host serves
|
|
34
|
+
// both the link and the API from somewhere other than github.com. Explicit
|
|
35
|
+
// `owner`/`repo` props are read against that same instance unless `host`
|
|
36
|
+
// points them somewhere else; with no `github` configured at all, they fall
|
|
37
|
+
// back to the public one.
|
|
38
|
+
const configured = data.config.github;
|
|
39
|
+
const owner = Astro.props.owner ?? configured?.owner;
|
|
40
|
+
const repo = Astro.props.repo ?? configured?.repo;
|
|
30
41
|
const token = Astro.props.token ?? process.env.GITHUB_TOKEN;
|
|
42
|
+
// A `host` prop is reduced to its origin, as the config field is, so a
|
|
43
|
+
// trailing slash or a path never lands mid-link.
|
|
44
|
+
const host = Astro.props.host
|
|
45
|
+
? new URL(Astro.props.host).origin
|
|
46
|
+
: (configured?.host ?? PUBLIC_HOST_URL);
|
|
47
|
+
const baseUrl = Astro.props.host ? apiUrl({ host }) : configured?.api;
|
|
31
48
|
|
|
32
49
|
const info =
|
|
33
|
-
owner && repo
|
|
50
|
+
owner && repo
|
|
51
|
+
? await fetchRepositoryInfo({ baseUrl, owner, repo, token })
|
|
52
|
+
: null;
|
|
34
53
|
|
|
35
54
|
// Compact notation matches GitHub's own counts (e.g. 1.2k, 34.5k).
|
|
36
55
|
const numbers = new Intl.NumberFormat("en", {
|
|
@@ -46,7 +65,7 @@ const statIcon =
|
|
|
46
65
|
owner && repo && (
|
|
47
66
|
<a
|
|
48
67
|
class="not-prose my-6 flex flex-col gap-2 rounded-blume border border-border p-4 no-underline! transition-colors hover:border-foreground/30 hover:bg-muted/40"
|
|
49
|
-
href={
|
|
68
|
+
href={`${host}/${owner}/${repo}`}
|
|
50
69
|
rel="noreferrer"
|
|
51
70
|
target="_blank"
|
|
52
71
|
>
|
|
@@ -19,6 +19,13 @@ interface Props {
|
|
|
19
19
|
*/
|
|
20
20
|
param?: string;
|
|
21
21
|
sync?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Sync tab selection only with other groups sharing the same key. Groups
|
|
24
|
+
* without a key form one page-wide pool (the default); keyed groups — like
|
|
25
|
+
* the generated ts2js dialect pairs — sync among themselves, so picking a
|
|
26
|
+
* tab there can't drag along an authored group with a same-titled tab.
|
|
27
|
+
*/
|
|
28
|
+
syncKey?: string;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const {
|
|
@@ -29,6 +36,7 @@ const {
|
|
|
29
36
|
inline = false,
|
|
30
37
|
param,
|
|
31
38
|
sync = true,
|
|
39
|
+
syncKey,
|
|
32
40
|
} = Astro.props;
|
|
33
41
|
|
|
34
42
|
// MDX string attributes (`hash="false"` from generated markup) must read as
|
|
@@ -52,6 +60,7 @@ const useDropdown = dropdown && !inline;
|
|
|
52
60
|
data-hash={hashEnabled ? "true" : "false"}
|
|
53
61
|
data-param={param}
|
|
54
62
|
data-sync={sync ? "true" : "false"}
|
|
63
|
+
data-sync-key={syncKey}
|
|
55
64
|
>
|
|
56
65
|
<div
|
|
57
66
|
class:list={[
|
|
@@ -116,9 +125,12 @@ const useDropdown = dropdown && !inline;
|
|
|
116
125
|
|
|
117
126
|
connectedCallback() {
|
|
118
127
|
const list = this.querySelector<HTMLElement>("[data-blume-tablist]");
|
|
128
|
+
// Nested groups (a generated ts2js pair inside a CodeGroup, say) own
|
|
129
|
+
// their panels; adopting a descendant group's panels here would leave
|
|
130
|
+
// both groups toggling the same elements.
|
|
119
131
|
let panels = Array.from(
|
|
120
132
|
this.querySelectorAll<HTMLElement>("[data-blume-tab-panel]")
|
|
121
|
-
);
|
|
133
|
+
).filter((panel) => panel.closest("blume-tabs") === this);
|
|
122
134
|
// CodeGroup passes raw code fences instead of <Tab> elements, so adopt
|
|
123
135
|
// the content wrapper's direct code blocks as panels. The tab label comes
|
|
124
136
|
// from each block's title (the text after the language in the fence).
|
|
@@ -251,7 +263,10 @@ const useDropdown = dropdown && !inline;
|
|
|
251
263
|
if (sync && this.#syncEnabled()) {
|
|
252
264
|
document.dispatchEvent(
|
|
253
265
|
new CustomEvent(SYNC_EVENT, {
|
|
254
|
-
detail: {
|
|
266
|
+
detail: {
|
|
267
|
+
key: this.#syncKey(),
|
|
268
|
+
title: panelTitle(activePanel, index),
|
|
269
|
+
},
|
|
255
270
|
})
|
|
256
271
|
);
|
|
257
272
|
}
|
|
@@ -339,6 +354,10 @@ const useDropdown = dropdown && !inline;
|
|
|
339
354
|
return this.dataset.sync !== "false";
|
|
340
355
|
}
|
|
341
356
|
|
|
357
|
+
#syncKey() {
|
|
358
|
+
return this.dataset.syncKey ?? "";
|
|
359
|
+
}
|
|
360
|
+
|
|
342
361
|
#hashEnabled() {
|
|
343
362
|
return this.dataset.hash !== "false";
|
|
344
363
|
}
|
|
@@ -358,9 +377,12 @@ const useDropdown = dropdown && !inline;
|
|
|
358
377
|
}
|
|
359
378
|
|
|
360
379
|
#sync = (event: Event) => {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
380
|
+
const detail = (event as CustomEvent<{ key?: string; title?: string }>)
|
|
381
|
+
.detail;
|
|
382
|
+
// Only same-key groups sync: keyless groups form one page-wide pool,
|
|
383
|
+
// keyed groups (generated dialect pairs) their own.
|
|
384
|
+
if (detail?.title && (detail.key ?? "") === this.#syncKey()) {
|
|
385
|
+
this.activateByTitle(detail.title, false);
|
|
364
386
|
}
|
|
365
387
|
};
|
|
366
388
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* than throwing, so a card never breaks the build when the API is unreachable.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { PUBLIC_API_URL } from "../../core/github.ts";
|
|
11
|
+
|
|
10
12
|
export interface RepositoryInfo {
|
|
11
13
|
description: string | null;
|
|
12
14
|
forks: number;
|
|
@@ -20,18 +22,31 @@ export interface FetchRepositoryOptions {
|
|
|
20
22
|
token?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
const DEFAULT_BASE_URL = "https://api.github.com";
|
|
24
|
-
|
|
25
25
|
/** In-process dedupe so the same repo is fetched once per build. */
|
|
26
26
|
const cache = new Map<string, Promise<RepositoryInfo | null>>();
|
|
27
27
|
|
|
28
|
+
/** Cleartext bases already warned about, so a site of cards logs once. */
|
|
29
|
+
const warnedCleartext = new Set<string>();
|
|
30
|
+
|
|
28
31
|
const load = async (
|
|
29
32
|
options: FetchRepositoryOptions
|
|
30
33
|
): Promise<RepositoryInfo | null> => {
|
|
31
|
-
const { baseUrl =
|
|
34
|
+
const { baseUrl = PUBLIC_API_URL, owner, repo, token } = options;
|
|
32
35
|
const headers = new Headers({ Accept: "application/vnd.github+json" });
|
|
36
|
+
// An Enterprise instance can be configured on plain HTTP; a bearer token is
|
|
37
|
+
// never worth putting on the wire in cleartext, so it is dropped rather than
|
|
38
|
+
// sent. The request still goes out — a public repo's counts render either way
|
|
39
|
+
// — but a private repo's card comes back bare, which looks exactly like a
|
|
40
|
+
// network failure, so say why once per base.
|
|
33
41
|
if (token) {
|
|
34
|
-
|
|
42
|
+
if (new URL(baseUrl).protocol === "https:") {
|
|
43
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
44
|
+
} else if (!warnedCleartext.has(baseUrl)) {
|
|
45
|
+
warnedCleartext.add(baseUrl);
|
|
46
|
+
console.warn(
|
|
47
|
+
`[blume] <GithubInfo> is not sending its token to ${baseUrl}: the API base is plain HTTP, so the bearer token would travel in cleartext. Counts for a private repository will be missing; serve the API over https to authenticate.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
const response = await fetch(`${baseUrl}/repos/${owner}/${repo}`, {
|
|
@@ -69,7 +84,7 @@ const loadSafe = async (
|
|
|
69
84
|
export const fetchRepositoryInfo = (
|
|
70
85
|
options: FetchRepositoryOptions
|
|
71
86
|
): Promise<RepositoryInfo | null> => {
|
|
72
|
-
const key = `${options.baseUrl ??
|
|
87
|
+
const key = `${options.baseUrl ?? PUBLIC_API_URL}/${options.owner}/${options.repo}`;
|
|
73
88
|
const existing = cache.get(key);
|
|
74
89
|
if (existing) {
|
|
75
90
|
return existing;
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* blocks, page actions, color swatches, prompts, API panels, Ask AI). One
|
|
4
4
|
* implementation owns the invariants each site used to hand-roll:
|
|
5
5
|
*
|
|
6
|
-
* - the clipboard write is guarded
|
|
7
|
-
*
|
|
6
|
+
* - the clipboard write is guarded and a confirmation must never lie: a
|
|
7
|
+
* site either shows nothing on failure or (the page actions) shows a
|
|
8
|
+
* failure label, but never a false "Copied";
|
|
8
9
|
* - repeat copies restart the hold instead of stacking timers, so the copied
|
|
9
10
|
* state never reverts early after a double-click;
|
|
10
|
-
* - every
|
|
11
|
-
* confirmation is audible, not just visual (previously
|
|
12
|
-
* button announced).
|
|
11
|
+
* - every flash is announced to a shared polite live region, so the
|
|
12
|
+
* confirmation — or the failure — is audible, not just visual (previously
|
|
13
|
+
* only the code-block button announced).
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
/** How long the copied confirmation holds before reverting. */
|
|
@@ -35,17 +36,100 @@ export const announceCopied = (message: string): void => {
|
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* The legacy copy path: select `text` in an off-screen textarea and run the
|
|
40
|
+
* `copy` editing command. It needs no clipboard permission, only the user
|
|
41
|
+
* activation the click already provides, so it covers the places the async
|
|
42
|
+
* Clipboard API doesn't reach — in-app browsers and WebViews that ship no
|
|
43
|
+
* `navigator.clipboard`, insecure origins, and a denied permission prompt.
|
|
44
|
+
*/
|
|
45
|
+
const copyViaCommand = (text: string): boolean => {
|
|
46
|
+
const previous = document.activeElement;
|
|
47
|
+
const textarea = document.createElement("textarea");
|
|
48
|
+
textarea.value = text;
|
|
49
|
+
textarea.setAttribute("readonly", "");
|
|
50
|
+
textarea.setAttribute("aria-hidden", "true");
|
|
51
|
+
// Off-screen rather than `display: none`: hidden controls can't be selected.
|
|
52
|
+
textarea.style.position = "fixed";
|
|
53
|
+
textarea.style.top = "0";
|
|
54
|
+
textarea.style.left = "-9999px";
|
|
55
|
+
textarea.style.opacity = "0";
|
|
56
|
+
// Beside the focused control, not on `<body>`: `select()` moves focus to the
|
|
57
|
+
// textarea, and a copy button inside a light-dismissed `<details>` menu (the
|
|
58
|
+
// MCP actions) would otherwise see focus leave the panel — closing the menu
|
|
59
|
+
// mid-click and hiding the label the outcome is about to flash on.
|
|
60
|
+
const host =
|
|
61
|
+
previous instanceof HTMLElement && previous.parentElement
|
|
62
|
+
? previous.parentElement
|
|
63
|
+
: document.body;
|
|
64
|
+
host.append(textarea);
|
|
65
|
+
textarea.select();
|
|
66
|
+
// iOS WebKit has been known to ignore `select()` on a readonly textarea;
|
|
67
|
+
// an explicit range covers it (the same belt-and-braces clipboard.js uses).
|
|
68
|
+
textarea.setSelectionRange(0, text.length);
|
|
69
|
+
let copied = false;
|
|
70
|
+
try {
|
|
71
|
+
copied = document.execCommand("copy");
|
|
72
|
+
} catch {
|
|
73
|
+
// Some engines throw instead of returning false; either way it failed.
|
|
74
|
+
}
|
|
75
|
+
textarea.remove();
|
|
76
|
+
// `select()` moved focus to the textarea; put it back on the button so a
|
|
77
|
+
// keyboard user isn't dropped at the top of the document.
|
|
78
|
+
if (previous instanceof HTMLElement) {
|
|
79
|
+
previous.focus();
|
|
80
|
+
}
|
|
81
|
+
return copied;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copy `text` to the clipboard. Returns whether the write succeeded. The async
|
|
86
|
+
* Clipboard API is tried first; when it is missing (in-app browsers, insecure
|
|
87
|
+
* contexts) or rejects (a denied permission), the legacy `copy` command is
|
|
88
|
+
* tried before giving up, so callers only see `false` when nothing worked and
|
|
89
|
+
* can show a failure instead of silently doing nothing.
|
|
41
90
|
*/
|
|
42
91
|
export const copyText = async (text: string): Promise<boolean> => {
|
|
43
92
|
try {
|
|
44
93
|
await navigator.clipboard.writeText(text);
|
|
45
94
|
return true;
|
|
46
95
|
} catch {
|
|
47
|
-
return
|
|
96
|
+
return copyViaCommand(text);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Copy text that still has to be loaded — the page's Markdown mirror, fetched
|
|
102
|
+
* on click. Safari and Firefox only honor a clipboard write issued inside the
|
|
103
|
+
* click's own task: awaiting the load first lands the write outside the user
|
|
104
|
+
* activation, so `writeText` rejects and the legacy command returns `false`
|
|
105
|
+
* even though the clipboard is perfectly available. `ClipboardItem` accepts a
|
|
106
|
+
* promise for its payload, so the write is issued synchronously with the
|
|
107
|
+
* load still in flight and the activation intact. Engines without it (or a
|
|
108
|
+
* write that rejects for any reason, a failed load included) fall back to the
|
|
109
|
+
* awaited {@link copyText}, which is what Chrome's longer activation window
|
|
110
|
+
* already tolerated. A load failure still throws, so the caller can report
|
|
111
|
+
* it rather than a clipboard problem.
|
|
112
|
+
*/
|
|
113
|
+
export const copyDeferredText = async (
|
|
114
|
+
load: () => Promise<string>
|
|
115
|
+
): Promise<boolean> => {
|
|
116
|
+
const text = load();
|
|
117
|
+
if ("ClipboardItem" in globalThis) {
|
|
118
|
+
try {
|
|
119
|
+
await navigator.clipboard.write([
|
|
120
|
+
new ClipboardItem({
|
|
121
|
+
"text/plain": text.then(
|
|
122
|
+
(value) => new Blob([value], { type: "text/plain" })
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
]);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
// Fall through to the awaited write; if the load itself failed, the
|
|
129
|
+
// `await` below rethrows that error for the caller.
|
|
130
|
+
}
|
|
48
131
|
}
|
|
132
|
+
return copyText(await text);
|
|
49
133
|
};
|
|
50
134
|
|
|
51
135
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light-dismiss for the site's `<details>`-based dropdowns: the page actions
|
|
3
|
+
* (Export / Open in chat / Connect to MCP), the header language switcher and
|
|
4
|
+
* the nav selectors all render the same floating `<details>` + absolute panel,
|
|
5
|
+
* and a native `<details>` only closes when its own `<summary>` is clicked
|
|
6
|
+
* again, leaving the panel hanging over the page. Any dropdown that opts in
|
|
7
|
+
* with `data-blume-dropdown` gets the four legs of a real menu:
|
|
8
|
+
*
|
|
9
|
+
* - a pointer press outside the open panel closes it;
|
|
10
|
+
* - Escape closes it, restoring focus to the trigger when the keypress
|
|
11
|
+
* originated inside the panel;
|
|
12
|
+
* - keyboard focus leaving the panel closes it;
|
|
13
|
+
* - the window losing focus closes it — the only signal the parent document
|
|
14
|
+
* gets when a press lands inside an `<iframe>` embed, since pointer events
|
|
15
|
+
* in a child browsing context never propagate up.
|
|
16
|
+
*
|
|
17
|
+
* Every listener sits on `document`/`window` once per real page load and
|
|
18
|
+
* queries the DOM live, so client-router swaps (which rebuild the dropdown
|
|
19
|
+
* markup) need no re-init and never stack duplicate listeners. Per-component
|
|
20
|
+
* behavior — one-open-at-a-time within a group, viewport flipping — stays in
|
|
21
|
+
* the component that owns it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const OPEN_DROPDOWN = "details[data-blume-dropdown][open]";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every dropdown currently open. Each component group keeps itself to one
|
|
28
|
+
* open panel, but a page holds several groups (the header selectors and the
|
|
29
|
+
* page actions), and a browser that doesn't focus a `<summary>` on click lets
|
|
30
|
+
* a keyboard-opened panel join a pointer-opened one — so the dismissal rules
|
|
31
|
+
* apply to all of them, never just the first in DOM order.
|
|
32
|
+
*/
|
|
33
|
+
const openDropdowns = (): HTMLDetailsElement[] => [
|
|
34
|
+
...document.querySelectorAll<HTMLDetailsElement>(OPEN_DROPDOWN),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Close `open`. `restoreFocus` moves focus back to its trigger, which a
|
|
39
|
+
* keyboard dismissal wants and a pointer or focus-driven one does not.
|
|
40
|
+
*/
|
|
41
|
+
const dismiss = (open: HTMLDetailsElement, restoreFocus: boolean): void => {
|
|
42
|
+
open.open = false;
|
|
43
|
+
if (restoreFocus) {
|
|
44
|
+
open.querySelector<HTMLElement>("summary")?.focus();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isInside = (
|
|
49
|
+
dropdown: HTMLDetailsElement,
|
|
50
|
+
target: EventTarget | null
|
|
51
|
+
): boolean => target instanceof Node && dropdown.contains(target);
|
|
52
|
+
|
|
53
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
54
|
+
for (const open of openDropdowns()) {
|
|
55
|
+
if (!isInside(open, event.target)) {
|
|
56
|
+
dismiss(open, false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
62
|
+
// `isComposing`: an IME cancel arrives as Escape and isn't a dismissal.
|
|
63
|
+
if (event.key !== "Escape" || event.isComposing) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// An Escape aimed at a modal surface stacked on top (the search dialog
|
|
67
|
+
// traps focus inside itself) dismisses that surface only — the same guard
|
|
68
|
+
// the Ask panel applies. Everything outside the modal is inert, so a focus
|
|
69
|
+
// restore here could not land anyway.
|
|
70
|
+
if (event.target instanceof Element && event.target.closest("dialog")) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Only a keypress that originated inside a panel gets its focus returned
|
|
74
|
+
// to that trigger; yanking focus from an unrelated control the user had
|
|
75
|
+
// moved on to would be a surprise.
|
|
76
|
+
for (const open of openDropdowns()) {
|
|
77
|
+
dismiss(open, isInside(open, event.target));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const onFocusOut = (event: FocusEvent): void => {
|
|
82
|
+
// Only focus *leaving a panel* counts: a move between two unrelated controls
|
|
83
|
+
// (a dialog handing focus back to its trigger, say) must not close a panel
|
|
84
|
+
// that never held focus. And a null `relatedTarget` means focus went
|
|
85
|
+
// nowhere focusable (a click on plain content, the panel being hidden, the
|
|
86
|
+
// window blurring) — the pointer and blur legs own those, and closing here
|
|
87
|
+
// would hide a panel item before its own click lands in browsers that
|
|
88
|
+
// don't focus buttons on press.
|
|
89
|
+
const { relatedTarget } = event;
|
|
90
|
+
if (!(relatedTarget instanceof Node)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const open of openDropdowns()) {
|
|
94
|
+
if (isInside(open, event.target) && !open.contains(relatedTarget)) {
|
|
95
|
+
dismiss(open, false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const onWindowBlur = (): void => {
|
|
101
|
+
for (const open of openDropdowns()) {
|
|
102
|
+
dismiss(open, false);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let installed = false;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Register the document/window listeners. Idempotent: every component that
|
|
110
|
+
* renders a dropdown calls this from its own script, and a page may render
|
|
111
|
+
* several of them.
|
|
112
|
+
*/
|
|
113
|
+
export const installDropdownDismiss = (): void => {
|
|
114
|
+
if (installed) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
installed = true;
|
|
118
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
119
|
+
document.addEventListener("keydown", onKeyDown);
|
|
120
|
+
document.addEventListener("focusout", onFocusOut);
|
|
121
|
+
window.addEventListener("blur", onWindowBlur);
|
|
122
|
+
};
|
|
@@ -176,7 +176,7 @@ const AskAI = ({
|
|
|
176
176
|
useEffect(() => {
|
|
177
177
|
// The initial null→body flip is deliberate (there is no body during SSR);
|
|
178
178
|
// it is the same one-time post-mount cascade the old `mounted` flag had.
|
|
179
|
-
// oxlint-disable-next-line react/react-compiler -- deliberate post-mount portal-target initialization
|
|
179
|
+
// oxlint-disable-next-line react/react-compiler, react/set-state-in-effect -- deliberate post-mount portal-target initialization
|
|
180
180
|
setPortalTarget(document.body);
|
|
181
181
|
const onSwap = () => setPortalTarget(document.body);
|
|
182
182
|
document.addEventListener("astro:after-swap", onSwap);
|
|
@@ -263,6 +263,7 @@ const AskAI = ({
|
|
|
263
263
|
if (open && portalTarget) {
|
|
264
264
|
document.body.dataset.blumeAsk = "open";
|
|
265
265
|
}
|
|
266
|
+
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
|
|
266
267
|
}, [open, portalTarget]);
|
|
267
268
|
|
|
268
269
|
// Below the desktop dock breakpoint the open panel is a full-width overlay,
|
|
@@ -328,12 +329,14 @@ const AskAI = ({
|
|
|
328
329
|
// portalTarget: each swap installs a new <body>, so the sweep and its
|
|
329
330
|
// observer must re-run against the new children (the old ones are
|
|
330
331
|
// detached).
|
|
332
|
+
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
|
|
331
333
|
}, [open, portalTarget]);
|
|
332
334
|
|
|
333
335
|
// Keep the newest message in view as it streams in — and after a swap, when
|
|
334
336
|
// the re-portaled panel's scroll container is reborn at the top.
|
|
335
337
|
useEffect(() => {
|
|
336
338
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
339
|
+
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- both deps are triggers, not values read here: re-run per streamed message and per re-portaled panel
|
|
337
340
|
}, [messages, portalTarget]);
|
|
338
341
|
|
|
339
342
|
const runQuestion = (raw: string) => {
|
|
@@ -57,7 +57,7 @@ const useClientData = (): BlumeClientData | null => {
|
|
|
57
57
|
// Intentional post-mount hydration guard: `null` on the server and first
|
|
58
58
|
// client render so hydration matches, then the snapshot once mounted. The
|
|
59
59
|
// extra render is required; do not seed the initial value from the DOM.
|
|
60
|
-
// oxlint-disable-next-line react/react-compiler, react-doctor/no-initialize-state -- deliberate SSR hydration guard
|
|
60
|
+
// oxlint-disable-next-line react/react-compiler, react/set-state-in-effect, react-doctor/no-initialize-state -- deliberate SSR hydration guard
|
|
61
61
|
useEffect(() => setData(readClientData()), []);
|
|
62
62
|
return data;
|
|
63
63
|
};
|
|
@@ -109,6 +109,7 @@ export const useSearch = (): UseSearch => {
|
|
|
109
109
|
// Before the lazy client import: the first search's heaviest phase is
|
|
110
110
|
// creating the provider client (index download), and it must show loading.
|
|
111
111
|
setLoading(true);
|
|
112
|
+
// oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
|
|
112
113
|
try {
|
|
113
114
|
if (!searchFn.current) {
|
|
114
115
|
const { createSearch } = await import("blume:search-client");
|
|
@@ -250,6 +251,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
250
251
|
assistant.content = errorMessage;
|
|
251
252
|
setMessages([...history, { ...assistant }]);
|
|
252
253
|
}
|
|
254
|
+
// oxlint-disable-next-line react/todo -- React Compiler cannot lower try/finally; the hook stays manually memoized above
|
|
253
255
|
} finally {
|
|
254
256
|
if (live()) {
|
|
255
257
|
setLoading(false);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Emits the optimized @font-face declarations + preload links for each
|
|
3
3
|
// configured font (Astro's Fonts API). Renders nothing when no fonts are set;
|
|
4
4
|
// the CSS variables match the astro.config `fonts:` entries. Preloads are
|
|
5
|
-
// narrowed to the weights above-the-fold text renders in
|
|
5
|
+
// narrowed to the weights above-the-fold text renders in and, for providers
|
|
6
|
+
// that split faces by subset, to the subsets the site's locales need (see
|
|
6
7
|
// `theme/fonts.ts`); a bare string entry — an older generated template — keeps
|
|
7
8
|
// the previous preload-everything behavior.
|
|
8
9
|
import { Font } from "astro:assets";
|
|
@@ -14,16 +15,22 @@ interface Props {
|
|
|
14
15
|
|
|
15
16
|
const { cssVars } = Astro.props;
|
|
16
17
|
|
|
18
|
+
/** The preload filters for one head entry: every weight × every subset. */
|
|
19
|
+
const preloadFilters = (head: FontHead) =>
|
|
20
|
+
head.preloadWeights.flatMap((weight) =>
|
|
21
|
+
head.preloadSubsets
|
|
22
|
+
? head.preloadSubsets.map((subset) => ({
|
|
23
|
+
style: "normal" as const,
|
|
24
|
+
subset,
|
|
25
|
+
weight,
|
|
26
|
+
}))
|
|
27
|
+
: [{ style: "normal" as const, weight }]
|
|
28
|
+
);
|
|
29
|
+
|
|
17
30
|
const entries = cssVars.map((value) =>
|
|
18
31
|
typeof value === "string"
|
|
19
32
|
? { cssVariable: value, preload: true as const }
|
|
20
|
-
: {
|
|
21
|
-
cssVariable: value.cssVariable,
|
|
22
|
-
preload: value.preloadWeights.map((weight) => ({
|
|
23
|
-
style: "normal" as const,
|
|
24
|
-
weight,
|
|
25
|
-
})),
|
|
26
|
-
}
|
|
33
|
+
: { cssVariable: value.cssVariable, preload: preloadFilters(value) }
|
|
27
34
|
);
|
|
28
35
|
---
|
|
29
36
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Ask from "blume:ask";
|
|
3
3
|
import data from "blume:data";
|
|
4
4
|
import { withBase } from "../islands/base-path.ts";
|
|
5
|
+
import { isExternalUrl } from "../../core/base-path.ts";
|
|
5
6
|
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
6
7
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
7
8
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
@@ -120,6 +121,19 @@ const tabsNavClass = hasSidebar
|
|
|
120
121
|
const iconButton =
|
|
121
122
|
"inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
|
|
122
123
|
|
|
124
|
+
// Header links: plain actions are secondary and hide below `sm`, where the
|
|
125
|
+
// header has room for the logo and the drawer toggle and nothing else. The one
|
|
126
|
+
// call to action hides there too — but only when a drawer toggle is actually
|
|
127
|
+
// competing for the space. A page with no drawer (a PageLayout landing page
|
|
128
|
+
// with no tabs) has nowhere else to surface it, so it stays. `withBase` passes
|
|
129
|
+
// external, protocol-relative and fragment hrefs through untouched.
|
|
130
|
+
const actionClass =
|
|
131
|
+
"hidden rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:text-foreground sm:inline-flex";
|
|
132
|
+
const ctaClass = [
|
|
133
|
+
showNavToggle ? "hidden sm:inline-flex" : "inline-flex",
|
|
134
|
+
"items-center gap-1 rounded-full bg-accent px-3.5 py-1.5 font-medium text-accent-foreground text-sm transition-opacity hover:opacity-90",
|
|
135
|
+
].join(" ");
|
|
136
|
+
|
|
123
137
|
// Delegated handlers for the theme toggle, mobile nav drawer, and banner
|
|
124
138
|
// dismiss. Living with the header means every page that renders it — docs pages
|
|
125
139
|
// and custom pages alike — gets identical behavior from one source.
|
|
@@ -212,6 +226,36 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
212
226
|
)
|
|
213
227
|
}
|
|
214
228
|
<div class="flex items-center gap-2">
|
|
229
|
+
{
|
|
230
|
+
/* Plain links first, then the one filled button: the reader's eye lands
|
|
231
|
+
on the button because nothing beside it competes. */
|
|
232
|
+
(navigation.actions ?? []).map((action) => {
|
|
233
|
+
const external = isExternalUrl(action.href);
|
|
234
|
+
return (
|
|
235
|
+
<a
|
|
236
|
+
class={actionClass}
|
|
237
|
+
href={withBase(action.href)}
|
|
238
|
+
rel={external ? "noreferrer" : undefined}
|
|
239
|
+
target={external ? "_blank" : undefined}
|
|
240
|
+
>
|
|
241
|
+
{action.label}
|
|
242
|
+
</a>
|
|
243
|
+
);
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
{
|
|
247
|
+
navigation.cta && (
|
|
248
|
+
<a
|
|
249
|
+
class={ctaClass}
|
|
250
|
+
href={withBase(navigation.cta.href)}
|
|
251
|
+
rel={isExternalUrl(navigation.cta.href) ? "noreferrer" : undefined}
|
|
252
|
+
target={isExternalUrl(navigation.cta.href) ? "_blank" : undefined}
|
|
253
|
+
>
|
|
254
|
+
{navigation.cta.label}
|
|
255
|
+
<Icon name="chevron-right" size={14} />
|
|
256
|
+
</a>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
215
259
|
{
|
|
216
260
|
navigation.repoUrl && (
|
|
217
261
|
<a
|
|
@@ -20,7 +20,7 @@ const menuRowClass =
|
|
|
20
20
|
|
|
21
21
|
{
|
|
22
22
|
options.length > 1 && (
|
|
23
|
-
<details class="group relative">
|
|
23
|
+
<details class="group relative" data-blume-dropdown>
|
|
24
24
|
<summary
|
|
25
25
|
aria-label={label}
|
|
26
26
|
class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
|
|
@@ -56,3 +56,11 @@ const menuRowClass =
|
|
|
56
56
|
</details>
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
import { installDropdownDismiss } from "../dropdown-dismiss.ts";
|
|
62
|
+
|
|
63
|
+
// Outside-click / Escape / focus-out dismissal, shared with the nav
|
|
64
|
+
// selectors and the page actions.
|
|
65
|
+
installDropdownDismiss();
|
|
66
|
+
</script>
|