blume 0.7.0 → 1.0.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 +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- 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 +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -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/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- 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/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- 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 +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -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/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -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 +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- 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/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- 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 +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- 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 +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- 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 +71 -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 +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -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/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- 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 +20 -13
- package/src/seo/x-handle.ts +18 -0
|
@@ -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,18 +124,19 @@ 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"
|
|
127
138
|
>
|
|
128
|
-
<Icon name="arrow-left" size={16} />
|
|
139
|
+
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
129
140
|
</button>
|
|
130
141
|
{panel.route ? (
|
|
131
142
|
<a
|
|
@@ -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>
|
|
@@ -207,7 +219,7 @@ const initialId =
|
|
|
207
219
|
<span class="flex-1 truncate">{item.label}</span>
|
|
208
220
|
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
209
221
|
<Icon
|
|
210
|
-
class="shrink-0 text-muted-foreground"
|
|
222
|
+
class="shrink-0 text-muted-foreground rtl:-scale-x-100"
|
|
211
223
|
name="chevron-right"
|
|
212
224
|
size={14}
|
|
213
225
|
/>
|
|
@@ -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>
|
|
@@ -68,6 +68,7 @@ const menuRowClass =
|
|
|
68
68
|
class="mt-8 space-y-0.5 border-border border-t pt-4"
|
|
69
69
|
data-blume-page-actions
|
|
70
70
|
data-i18n-copied={a.copied}
|
|
71
|
+
data-i18n-generating={a.generating}
|
|
71
72
|
data-mcp-name={mcpName ?? undefined}
|
|
72
73
|
data-mcp-url={mcpUrl ?? undefined}
|
|
73
74
|
data-md={mdPath}
|
|
@@ -98,7 +99,7 @@ const menuRowClass =
|
|
|
98
99
|
class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
|
|
99
100
|
>
|
|
100
101
|
<Icon name="download" size={16} />
|
|
101
|
-
|
|
102
|
+
{a.export}
|
|
102
103
|
<Icon
|
|
103
104
|
class="ms-auto transition-transform group-open:rotate-180"
|
|
104
105
|
name="chevron-down"
|
|
@@ -112,14 +113,14 @@ const menuRowClass =
|
|
|
112
113
|
{exportPdf && (
|
|
113
114
|
<button class={menuRowClass} data-blume-export-pdf type="button">
|
|
114
115
|
<Icon name="file" size={16} />
|
|
115
|
-
<span class="flex-1">
|
|
116
|
+
<span class="flex-1">{a.exportPdf}</span>
|
|
116
117
|
</button>
|
|
117
118
|
)}
|
|
118
119
|
{exportEpub && (
|
|
119
120
|
<button class={menuRowClass} data-blume-export-epub type="button">
|
|
120
121
|
<Icon name="book-open" size={16} />
|
|
121
122
|
<span class="flex-1" data-blume-epub-label>
|
|
122
|
-
|
|
123
|
+
{a.exportEpub}
|
|
123
124
|
</span>
|
|
124
125
|
</button>
|
|
125
126
|
)}
|
|
@@ -218,6 +219,8 @@ const menuRowClass =
|
|
|
218
219
|
</div>
|
|
219
220
|
|
|
220
221
|
<script>
|
|
222
|
+
import { prefixBase } from "../islands/base-path.ts";
|
|
223
|
+
|
|
221
224
|
const CHAT_URLS: Record<string, (q: string) => string> = {
|
|
222
225
|
chatgpt: (q) => `https://chatgpt.com/?hints=search&prompt=${q}`,
|
|
223
226
|
claude: (q) => `https://claude.ai/new?q=${q}`,
|
|
@@ -317,8 +320,14 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
317
320
|
}
|
|
318
321
|
});
|
|
319
322
|
|
|
320
|
-
|
|
323
|
+
// `data-md` is the base-less logical route; the raw-markdown endpoint is a
|
|
324
|
+
// generated page route, so it's served under the deployment base like any
|
|
325
|
+
// other page. Prefix at emit time (the Search-island pattern).
|
|
326
|
+
const mdRoute = root.getAttribute("data-md") ?? "";
|
|
327
|
+
const md = prefixBase(import.meta.env.BASE_URL, mdRoute);
|
|
321
328
|
const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
|
|
329
|
+
const generatingLabel =
|
|
330
|
+
root.getAttribute("data-i18n-generating") || "Generating…";
|
|
322
331
|
const absolute = new URL(md, location.origin).href;
|
|
323
332
|
const query = encodeURIComponent(
|
|
324
333
|
`Read ${absolute} so I can ask you questions about this page.`
|
|
@@ -414,6 +423,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
414
423
|
?.addEventListener("click", async () => {
|
|
415
424
|
try {
|
|
416
425
|
const response = await fetch(md);
|
|
426
|
+
if (!response.ok) {
|
|
427
|
+
throw new Error(`Fetching ${md} failed (${response.status})`);
|
|
428
|
+
}
|
|
417
429
|
await navigator.clipboard.writeText(await response.text());
|
|
418
430
|
if (label) {
|
|
419
431
|
label.textContent = copiedLabel;
|
|
@@ -421,8 +433,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
421
433
|
label.textContent = original;
|
|
422
434
|
}, 1500);
|
|
423
435
|
}
|
|
424
|
-
} catch {
|
|
425
|
-
// Clipboard or fetch unavailable;
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.
|
|
438
|
+
console.error("[blume] Copy as Markdown failed", error);
|
|
426
439
|
}
|
|
427
440
|
});
|
|
428
441
|
|
|
@@ -432,6 +445,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
432
445
|
|
|
433
446
|
const epubButton = root.querySelector("[data-blume-export-epub]");
|
|
434
447
|
const epubLabel = root.querySelector("[data-blume-epub-label]");
|
|
448
|
+
// Captured once — inside the handler a click during a generation would
|
|
449
|
+
// capture and permanently restore "Generating…".
|
|
450
|
+
const epubReset = epubLabel?.textContent ?? "";
|
|
435
451
|
epubButton?.addEventListener("click", async () => {
|
|
436
452
|
const article = document.querySelector("#blume-content article");
|
|
437
453
|
if (!article) {
|
|
@@ -439,9 +455,8 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
439
455
|
}
|
|
440
456
|
const heading = document.querySelector("#blume-content h1");
|
|
441
457
|
const title = heading?.textContent?.trim() || document.title;
|
|
442
|
-
const reset = epubLabel?.textContent ?? "Export to EPUB";
|
|
443
458
|
if (epubLabel) {
|
|
444
|
-
epubLabel.textContent =
|
|
459
|
+
epubLabel.textContent = generatingLabel;
|
|
445
460
|
}
|
|
446
461
|
try {
|
|
447
462
|
// Browser bundle: avoids Node built-ins and returns a Blob. Lazy-loaded
|
|
@@ -461,7 +476,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
461
476
|
? result
|
|
462
477
|
: new Blob([result], { type: "application/epub+zip" });
|
|
463
478
|
const slug =
|
|
464
|
-
|
|
479
|
+
mdRoute
|
|
465
480
|
.replace(/\.md$/u, "")
|
|
466
481
|
.replace(/^\//u, "")
|
|
467
482
|
.replace(/\//gu, "-") || "index";
|
|
@@ -479,7 +494,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
479
494
|
console.error("[blume] EPUB export failed", error);
|
|
480
495
|
} finally {
|
|
481
496
|
if (epubLabel) {
|
|
482
|
-
epubLabel.textContent =
|
|
497
|
+
epubLabel.textContent = epubReset;
|
|
483
498
|
}
|
|
484
499
|
}
|
|
485
500
|
});
|
|
@@ -25,6 +25,12 @@ import type {
|
|
|
25
25
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
26
26
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
27
27
|
import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
|
|
28
|
+
import {
|
|
29
|
+
OG_IMAGE_HEIGHT,
|
|
30
|
+
OG_IMAGE_TYPE,
|
|
31
|
+
OG_IMAGE_WIDTH,
|
|
32
|
+
} from "../../og/dimensions.ts";
|
|
33
|
+
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
28
34
|
import { withBase } from "../islands/base-path.ts";
|
|
29
35
|
import "blume:theme";
|
|
30
36
|
import Analytics from "./Analytics.astro";
|
|
@@ -64,6 +70,11 @@ interface Props {
|
|
|
64
70
|
/** SEO overrides; a marketing page often sets its own canonical/og image. */
|
|
65
71
|
ogImage?: string | null;
|
|
66
72
|
canonical?: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* X (Twitter) attribution (`data.config.x`): the site's account and an author
|
|
75
|
+
* handle, emitted as `twitter:site`/`twitter:creator`.
|
|
76
|
+
*/
|
|
77
|
+
x?: { creator?: string; handle?: string };
|
|
67
78
|
noindex?: boolean;
|
|
68
79
|
/** Active locale + direction for `<html lang>`/`<html dir>`. */
|
|
69
80
|
locale?: string;
|
|
@@ -97,6 +108,7 @@ const {
|
|
|
97
108
|
ogEnabled,
|
|
98
109
|
ogImage,
|
|
99
110
|
canonical,
|
|
111
|
+
x,
|
|
100
112
|
noindex,
|
|
101
113
|
locale = "en",
|
|
102
114
|
dir = "ltr",
|
|
@@ -110,6 +122,10 @@ const clientDataJson = clientData
|
|
|
110
122
|
: null;
|
|
111
123
|
|
|
112
124
|
const strings = ui ?? EN_UI;
|
|
125
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
126
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
127
|
+
// blank — the PageActions pattern.
|
|
128
|
+
const navStrings = { ...EN_UI.nav, ...strings.nav };
|
|
113
129
|
// Filter search to the active language only when the site is multi-locale.
|
|
114
130
|
const searchLocale =
|
|
115
131
|
localeSwitch && localeSwitch.length > 1 ? locale : undefined;
|
|
@@ -125,20 +141,33 @@ const ogSlug = route === "/" ? "index" : route.slice(1);
|
|
|
125
141
|
// Absolute URLs carry the deployment base too (`site + base + route`), since the
|
|
126
142
|
// page is served under it. `withBase` handles a root base as a no-op.
|
|
127
143
|
const basedRoute = withBase(route);
|
|
144
|
+
// The schema accepts a trailing-slash site URL (`https://docs.example.com/`);
|
|
145
|
+
// strip it before joining with the root-relative route so canonical/og URLs
|
|
146
|
+
// don't come out double-slashed — the catch-all strips it the same way.
|
|
147
|
+
const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
|
|
128
148
|
const resolvedCanonical =
|
|
129
149
|
canonical ??
|
|
130
|
-
(
|
|
150
|
+
(siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
|
|
131
151
|
// An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
|
|
132
152
|
// `public/`) is resolved against the site URL so crawlers get an absolute
|
|
133
153
|
// `og:image`; an already-absolute URL passes through untouched. Otherwise fall
|
|
134
154
|
// back to the generated OG card for this route.
|
|
135
155
|
const absolutizeOgImage = (value: string): string =>
|
|
136
|
-
value.startsWith("/") &&
|
|
156
|
+
value.startsWith("/") && siteBase ? `${siteBase}${withBase(value)}` : value;
|
|
137
157
|
const resolvedOgImage = ogImage
|
|
138
158
|
? absolutizeOgImage(ogImage)
|
|
139
|
-
: ogEnabled &&
|
|
140
|
-
? `${
|
|
159
|
+
: ogEnabled && siteBase
|
|
160
|
+
? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
|
|
141
161
|
: null;
|
|
162
|
+
// Only the generated card has a known size and format, so its dimensions are
|
|
163
|
+
// declared for crawlers; a user-supplied `ogImage` could be any image.
|
|
164
|
+
const ogGenerated = !ogImage && Boolean(resolvedOgImage);
|
|
165
|
+
// A page with no image still gets a card on X — the compact `summary` variant
|
|
166
|
+
// — rather than rendering as a bare link.
|
|
167
|
+
const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
|
|
168
|
+
// A custom page passes these in by hand, so normalize rather than trust the `@`.
|
|
169
|
+
const xSite = normalizeXHandle(x?.handle);
|
|
170
|
+
const xCreator = normalizeXHandle(x?.creator);
|
|
142
171
|
|
|
143
172
|
const initialThemeScript = themeInitScript(themeMode);
|
|
144
173
|
const bannerScript = banner?.dismissible
|
|
@@ -158,17 +187,35 @@ const bannerScript = banner?.dismissible
|
|
|
158
187
|
{noindex && <meta content="noindex" name="robots" />}
|
|
159
188
|
{description && <meta content={description} name="description" />}
|
|
160
189
|
<meta content="website" property="og:type" />
|
|
190
|
+
<meta content={site.title} property="og:site_name" />
|
|
161
191
|
<meta content={pageTitle} property="og:title" />
|
|
162
192
|
{description && <meta content={description} property="og:description" />}
|
|
193
|
+
{resolvedCanonical && <meta content={resolvedCanonical} property="og:url" />}
|
|
163
194
|
{
|
|
164
195
|
resolvedOgImage && (
|
|
165
196
|
<>
|
|
166
197
|
<meta content={resolvedOgImage} property="og:image" />
|
|
198
|
+
{ogGenerated && (
|
|
199
|
+
<>
|
|
200
|
+
<meta content={OG_IMAGE_TYPE} property="og:image:type" />
|
|
201
|
+
<meta content={String(OG_IMAGE_WIDTH)} property="og:image:width" />
|
|
202
|
+
<meta
|
|
203
|
+
content={String(OG_IMAGE_HEIGHT)}
|
|
204
|
+
property="og:image:height"
|
|
205
|
+
/>
|
|
206
|
+
<meta content={pageTitle} property="og:image:alt" />
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
167
209
|
<meta content={resolvedOgImage} name="twitter:image" />
|
|
168
|
-
<meta content=
|
|
210
|
+
{ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
|
|
169
211
|
</>
|
|
170
212
|
)
|
|
171
213
|
}
|
|
214
|
+
<meta content={twitterCard} name="twitter:card" />
|
|
215
|
+
<meta content={pageTitle} name="twitter:title" />
|
|
216
|
+
{description && <meta content={description} name="twitter:description" />}
|
|
217
|
+
{xSite && <meta content={xSite} name="twitter:site" />}
|
|
218
|
+
{xCreator && <meta content={xCreator} name="twitter:creator" />}
|
|
172
219
|
{bannerScript && <script is:inline set:html={bannerScript} />}
|
|
173
220
|
<script is:inline set:html={initialThemeScript} />
|
|
174
221
|
<Analytics analytics={analytics} />
|
|
@@ -178,12 +225,13 @@ const bannerScript = banner?.dismissible
|
|
|
178
225
|
class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
|
|
179
226
|
href="#blume-content">{strings.page.skipToContent}</a
|
|
180
227
|
>
|
|
181
|
-
<Banner banner={banner} />
|
|
228
|
+
<Banner banner={banner} strings={strings.banner} />
|
|
182
229
|
<Header
|
|
183
230
|
askEnabled={askEnabled}
|
|
184
231
|
hasSidebar={false}
|
|
185
232
|
localeSwitch={localeSwitch}
|
|
186
233
|
logo={logo}
|
|
234
|
+
navStrings={navStrings}
|
|
187
235
|
navigation={navigation}
|
|
188
236
|
route={route}
|
|
189
237
|
searchEnabled={searchEnabled}
|
|
@@ -205,10 +253,11 @@ const bannerScript = banner?.dismissible
|
|
|
205
253
|
navigation.tabs.length > 0 && (
|
|
206
254
|
<>
|
|
207
255
|
<aside
|
|
208
|
-
aria-label=
|
|
256
|
+
aria-label={navStrings.navigation}
|
|
209
257
|
class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:hidden"
|
|
258
|
+
data-blume-nav-drawer
|
|
210
259
|
>
|
|
211
|
-
<nav aria-label=
|
|
260
|
+
<nav aria-label={navStrings.sections}>
|
|
212
261
|
<ul class="m-0 list-none p-0">
|
|
213
262
|
{navigation.tabs.map((tab) => (
|
|
214
263
|
<li>
|
|
@@ -230,7 +279,7 @@ const bannerScript = banner?.dismissible
|
|
|
230
279
|
</nav>
|
|
231
280
|
</aside>
|
|
232
281
|
<button
|
|
233
|
-
aria-label=
|
|
282
|
+
aria-label={navStrings.closeNavigation}
|
|
234
283
|
class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
|
|
235
284
|
data-blume-nav-toggle
|
|
236
285
|
type="button"
|
|
@@ -252,6 +301,33 @@ const bannerScript = banner?.dismissible
|
|
|
252
301
|
// Dev-only: friendly hint after a React island hydration mismatch;
|
|
253
302
|
// tree-shaken out of production builds.
|
|
254
303
|
import "./hydration-hint.ts";
|
|
304
|
+
|
|
305
|
+
// The closed tabs drawer is only translated off-canvas, so its links
|
|
306
|
+
// would stay in the tab order. Mirror the header's `data-blume-nav-open`
|
|
307
|
+
// toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
|
|
308
|
+
// breakpoint where the drawer is display-hidden anyway.
|
|
309
|
+
const drawer = document.querySelector<HTMLElement>(
|
|
310
|
+
"[data-blume-nav-drawer]"
|
|
311
|
+
);
|
|
312
|
+
if (drawer) {
|
|
313
|
+
const desktop = window.matchMedia("(min-width: 64rem)");
|
|
314
|
+
const syncDrawer = () => {
|
|
315
|
+
const hidden =
|
|
316
|
+
!desktop.matches &&
|
|
317
|
+
!document.documentElement.hasAttribute("data-blume-nav-open");
|
|
318
|
+
drawer.inert = hidden;
|
|
319
|
+
if (hidden) {
|
|
320
|
+
drawer.setAttribute("aria-hidden", "true");
|
|
321
|
+
} else {
|
|
322
|
+
drawer.removeAttribute("aria-hidden");
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
syncDrawer();
|
|
326
|
+
desktop.addEventListener("change", syncDrawer);
|
|
327
|
+
new MutationObserver(syncDrawer).observe(document.documentElement, {
|
|
328
|
+
attributeFilter: ["data-blume-nav-open"],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
255
331
|
</script>
|
|
256
332
|
</body>
|
|
257
333
|
</html>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { withBase } from "../islands/base-path.ts";
|
|
3
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
4
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
5
|
import type { FlatPage } from "./nav-utils.ts";
|
|
5
6
|
import Icon from "../Icon.astro";
|
|
@@ -9,17 +10,22 @@ interface Props {
|
|
|
9
10
|
prev: FlatPage | null;
|
|
10
11
|
/** Next page in reading order, or `null` at the end. */
|
|
11
12
|
next: FlatPage | null;
|
|
12
|
-
/** Localized page-level strings (`previous`, `next
|
|
13
|
+
/** Localized page-level strings (`previous`, `next`, the landmark label). */
|
|
13
14
|
strings: UIStrings["page"];
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const { prev, next, strings } = Astro.props;
|
|
18
|
+
|
|
19
|
+
// Merge over the English defaults so a label missing from a translation (or
|
|
20
|
+
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
21
|
+
// blank — the PageActions pattern.
|
|
22
|
+
const s = { ...EN_UI.page, ...strings };
|
|
17
23
|
---
|
|
18
24
|
|
|
19
25
|
{
|
|
20
26
|
(prev || next) && (
|
|
21
27
|
<nav
|
|
22
|
-
aria-label=
|
|
28
|
+
aria-label={s.pagination}
|
|
23
29
|
class="mx-auto mt-12 flex max-w-[42rem] justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
|
|
24
30
|
>
|
|
25
31
|
{prev ? (
|
|
@@ -30,7 +36,7 @@ const { prev, next, strings } = Astro.props;
|
|
|
30
36
|
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
31
37
|
<span>
|
|
32
38
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
33
|
-
{
|
|
39
|
+
{s.previous}
|
|
34
40
|
</span>
|
|
35
41
|
<span class="block font-medium">{prev.label}</span>
|
|
36
42
|
</span>
|
|
@@ -45,7 +51,7 @@ const { prev, next, strings } = Astro.props;
|
|
|
45
51
|
>
|
|
46
52
|
<span>
|
|
47
53
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
48
|
-
{
|
|
54
|
+
{s.next}
|
|
49
55
|
</span>
|
|
50
56
|
<span class="block font-medium">{next.label}</span>
|
|
51
57
|
</span>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "blume:theme";
|
|
3
|
+
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
4
|
+
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
3
5
|
import type { Navigation } from "../../core/types.ts";
|
|
4
6
|
import Analytics from "./Analytics.astro";
|
|
5
7
|
import Banner from "./Banner.astro";
|
|
@@ -50,6 +52,12 @@ interface Props {
|
|
|
50
52
|
fontCssVars?: string[];
|
|
51
53
|
searchEnabled: boolean;
|
|
52
54
|
pageTitle: string;
|
|
55
|
+
/** Active locale code for `<html lang>` (defaults to `en`). */
|
|
56
|
+
locale?: string;
|
|
57
|
+
/** Text direction for `<html dir>` (defaults to `ltr`). */
|
|
58
|
+
dir?: "ltr" | "rtl";
|
|
59
|
+
/** Resolved UI dictionary; English baseline when omitted. */
|
|
60
|
+
ui?: UIStrings;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
const {
|
|
@@ -65,8 +73,13 @@ const {
|
|
|
65
73
|
fontCssVars,
|
|
66
74
|
searchEnabled,
|
|
67
75
|
pageTitle,
|
|
76
|
+
locale = "en",
|
|
77
|
+
dir = "ltr",
|
|
78
|
+
ui,
|
|
68
79
|
} = Astro.props;
|
|
69
80
|
|
|
81
|
+
const strings = ui ?? EN_UI;
|
|
82
|
+
|
|
70
83
|
// Set the theme before paint so the navbar never flashes the wrong colors
|
|
71
84
|
// (mirrors RootLayout's pre-paint script).
|
|
72
85
|
const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
|
|
@@ -78,7 +91,7 @@ const bannerScript = banner?.dismissible
|
|
|
78
91
|
---
|
|
79
92
|
|
|
80
93
|
<!doctype html>
|
|
81
|
-
<html lang=
|
|
94
|
+
<html dir={dir} lang={locale}>
|
|
82
95
|
<head>
|
|
83
96
|
<meta charset="utf-8" />
|
|
84
97
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -90,7 +103,11 @@ const bannerScript = banner?.dismissible
|
|
|
90
103
|
<Analytics analytics={analytics} />
|
|
91
104
|
</head>
|
|
92
105
|
<body class="bg-background font-sans text-foreground antialiased">
|
|
93
|
-
<
|
|
106
|
+
<a
|
|
107
|
+
class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
|
|
108
|
+
href="#blume-content">{strings.page.skipToContent}</a
|
|
109
|
+
>
|
|
110
|
+
<Banner banner={banner} strings={strings.banner} />
|
|
94
111
|
<Header
|
|
95
112
|
hasDrawer={false}
|
|
96
113
|
hasSidebar={false}
|
|
@@ -102,6 +119,7 @@ const bannerScript = banner?.dismissible
|
|
|
102
119
|
/>
|
|
103
120
|
<div
|
|
104
121
|
class="h-[calc(100dvh-4rem)] overflow-auto border-border border-t [&>[data-scalar-client]]:h-full"
|
|
122
|
+
id="blume-content"
|
|
105
123
|
>
|
|
106
124
|
<slot />
|
|
107
125
|
</div>
|