blume 2.1.1 → 2.1.2
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 +16 -0
- package/dist/cli/{chunk-ncj7w596.js → chunk-0pmb4tr4.js} +22 -18
- package/dist/cli/{chunk-ncj7w596.js.map → chunk-0pmb4tr4.js.map} +3 -3
- package/dist/cli/{chunk-nq1d20w2.js → chunk-0w6hs479.js} +15 -15
- package/dist/cli/{chunk-ehweg4fr.js → chunk-124fvmzq.js} +1031 -903
- package/dist/cli/{chunk-ehweg4fr.js.map → chunk-124fvmzq.js.map} +20 -18
- package/dist/cli/{chunk-kma9f6sf.js → chunk-1f882900.js} +2 -2
- package/dist/cli/{chunk-e8xw9qkh.js → chunk-297ajdcv.js} +4 -4
- package/dist/cli/{chunk-e6atmn4h.js → chunk-32khwcb6.js} +7 -7
- package/dist/cli/{chunk-073rdvgb.js → chunk-36bxs9c7.js} +16 -16
- package/dist/cli/{chunk-mrcccgnh.js → chunk-53jqw8z0.js} +3 -3
- package/dist/cli/{chunk-ft9vfys6.js → chunk-58bt3hag.js} +4 -4
- package/dist/cli/{chunk-sds923fa.js → chunk-5cmts6dv.js} +3 -3
- package/dist/cli/{chunk-cfyg47zg.js → chunk-60hz265r.js} +4 -4
- package/dist/cli/{chunk-j134vrp2.js → chunk-74wfhv79.js} +1 -1
- package/dist/cli/{chunk-nrns10mj.js → chunk-9qzgg6mj.js} +3 -3
- package/dist/cli/{chunk-tphac74f.js → chunk-a57yh0h9.js} +5 -5
- package/dist/cli/{chunk-hebn0tpx.js → chunk-ave1wcbr.js} +1 -1
- package/dist/cli/{chunk-htrwgabr.js → chunk-bqxaqn5p.js} +6 -6
- package/dist/cli/{chunk-5vjtgs6e.js → chunk-bxwhtvw8.js} +5 -5
- package/dist/cli/{chunk-66w9j7p6.js → chunk-ckrcm08h.js} +5 -3
- package/dist/cli/{chunk-66w9j7p6.js.map → chunk-ckrcm08h.js.map} +3 -3
- package/dist/cli/{chunk-n0v7m8ry.js → chunk-desvm72z.js} +3 -3
- package/dist/cli/{chunk-3v3ffbrj.js → chunk-dhwtqnnd.js} +2 -2
- package/dist/cli/{chunk-gb420vvz.js → chunk-dpy8qdjf.js} +7 -4
- package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
- package/dist/cli/{chunk-c1y538kd.js → chunk-dqhg54mr.js} +1 -1
- package/dist/cli/{chunk-42t4jxdx.js → chunk-dv2z0rzf.js} +1 -1
- package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
- package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
- package/dist/cli/{chunk-hx3xqnk9.js → chunk-gfc5fqvf.js} +14 -14
- package/dist/cli/{chunk-7ppf1rva.js → chunk-gxtg6ed2.js} +16 -16
- package/dist/cli/{chunk-8y2kmjv8.js → chunk-hvcxng5f.js} +6 -6
- package/dist/cli/{chunk-54ztw1nj.js → chunk-j1x92mx9.js} +1 -1
- package/dist/cli/{chunk-313vnn40.js → chunk-k3gr1nm9.js} +11 -9
- package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
- package/dist/cli/{chunk-c887fj0s.js → chunk-kdpzw38a.js} +2 -2
- package/dist/cli/{chunk-t2m5j4pr.js → chunk-ms9z66nz.js} +2 -2
- package/dist/cli/{chunk-t2m5j4pr.js.map → chunk-ms9z66nz.js.map} +2 -2
- package/dist/cli/{chunk-ep6nd0rn.js → chunk-nbyvgnnm.js} +26 -14
- package/dist/cli/{chunk-ep6nd0rn.js.map → chunk-nbyvgnnm.js.map} +7 -7
- package/dist/cli/{chunk-w1r4vase.js → chunk-s0bdnc7v.js} +2 -2
- package/dist/cli/{chunk-fdrkhpjy.js → chunk-sc93apfs.js} +14 -14
- package/dist/cli/{chunk-dzwehpy7.js → chunk-tfycddbt.js} +2 -2
- package/dist/cli/{chunk-3x6x3pph.js → chunk-twy7psdz.js} +7 -7
- package/dist/cli/{chunk-e9q7a2ak.js → chunk-y4msrwm9.js} +15 -15
- package/dist/cli/{chunk-tdqhyxfz.js → chunk-ydp3065p.js} +5 -5
- package/dist/cli/index.js +18 -18
- package/dist/types/ai/ask.d.ts +24 -5
- package/dist/types/analytics/clics.d.ts +37 -0
- package/dist/types/analytics/index.d.ts +2 -0
- package/dist/types/analytics/schema.d.ts +18 -0
- package/dist/types/astro/integration.d.ts +6 -0
- package/dist/types/core/config-input.d.ts +7 -3
- package/dist/types/core/powered-by.d.ts +12 -0
- package/dist/types/core/schema.d.ts +109 -3
- package/dist/types/core/sources/lower.d.ts +10 -2
- package/dist/types/core/sources/notion.d.ts +8 -0
- package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
- package/dist/types/deploy/node-headers.d.ts +12 -4
- package/dist/types/deploy/platforms/vercel.d.ts +2 -2
- package/dist/types/deploy/vercel-negotiation.d.ts +4 -2
- package/dist/types/narration/build.d.ts +34 -12
- package/dist/types/narration/provider.d.ts +96 -8
- package/dist/types/openapi/sentence.d.ts +11 -1
- package/dist/types/seo/jsonld.d.ts +11 -6
- package/dist/types/sources/notion.d.ts +8 -0
- package/dist/types/sources/registry.d.ts +9 -0
- package/docs/02-deployment.mdx +29 -1
- package/docs/03-upgrading.mdx +2 -1
- package/docs/04-migrating.mdx +9 -9
- package/docs/08-faq.mdx +12 -12
- package/docs/advanced/changelog.mdx +19 -2
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -1
- package/docs/configuration/analytics.mdx +19 -1
- package/docs/configuration/index.mdx +1 -0
- package/docs/configuration/narration.mdx +43 -6
- package/docs/content/navigation.mdx +4 -2
- package/docs/content/sources/contentful.mdx +2 -0
- package/docs/content/sources/github-releases.mdx +2 -0
- package/docs/content/sources/notion.mdx +16 -1
- package/docs/content/sources/obsidian.mdx +2 -0
- package/docs/content/sources/payload.mdx +2 -0
- package/docs/content/sources/remote-mdx.mdx +2 -0
- package/docs/content/sources/sanity.mdx +2 -0
- package/docs/content/sources/strapi.mdx +2 -0
- package/docs/discoverability/mcp.mdx +2 -0
- package/docs/discoverability/structured-data.mdx +8 -6
- package/docs/references/asyncapi.mdx +2 -0
- package/docs/references/graphql.mdx +2 -0
- package/docs/references/openapi.mdx +14 -0
- package/package.json +1 -1
- package/skills/blume/SKILL.md +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
- package/skills/blume-migrate/references/monorepo.md +5 -5
- package/src/ai/ask.ts +24 -6
- package/src/analytics/clics.ts +58 -0
- package/src/analytics/head.ts +5 -0
- package/src/analytics/index.ts +2 -0
- package/src/analytics/schema.ts +2 -0
- package/src/astro/generate.ts +13 -8
- package/src/astro/integration.ts +56 -9
- package/src/astro/render-deps.ts +10 -5
- package/src/astro/runtime-deps.ts +23 -2
- package/src/astro/templates.ts +4 -1
- package/src/cli/init/scaffold.ts +6 -3
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/view-switcher.ts +7 -4
- package/src/components/layout/NavTree.astro +49 -5
- package/src/components/layout/NavTreeScript.astro +18 -0
- package/src/components/layout/PageLayout.astro +13 -3
- package/src/components/layout/RootLayout.astro +17 -12
- package/src/components/layout/analytics-client.ts +8 -8
- package/src/components/openapi/playground-client.ts +53 -53
- package/src/components/powered-by-middleware.ts +33 -0
- package/src/core/config-input.ts +11 -3
- package/src/core/powered-by.ts +12 -0
- package/src/core/schema.ts +5 -4
- package/src/core/sources/lower.ts +19 -3
- package/src/core/sources/notion.ts +27 -9
- package/src/deploy/cloudflare-negotiation.ts +36 -7
- package/src/deploy/headers.ts +15 -1
- package/src/deploy/node-headers.ts +32 -20
- package/src/deploy/platforms/cloudflare.ts +1 -0
- package/src/deploy/platforms/vercel.ts +4 -3
- package/src/deploy/vercel-negotiation.ts +38 -4
- package/src/markdown/base-links.ts +15 -14
- package/src/markdown/package-commands.ts +9 -8
- package/src/narration/build.ts +218 -40
- package/src/narration/provider.ts +89 -9
- package/src/openapi/render-mdx.ts +68 -8
- package/src/openapi/sentence.ts +29 -2
- package/src/registry/eject.ts +5 -1
- package/src/seo/jsonld.ts +38 -20
- package/src/sources/notion.ts +7 -0
- package/src/theme/color.ts +9 -10
- package/src/theme/entry.ts +14 -3
- package/dist/cli/chunk-313vnn40.js.map +0 -10
- package/dist/cli/chunk-gb420vvz.js.map +0 -17
- /package/dist/cli/{chunk-nq1d20w2.js.map → chunk-0w6hs479.js.map} +0 -0
- /package/dist/cli/{chunk-kma9f6sf.js.map → chunk-1f882900.js.map} +0 -0
- /package/dist/cli/{chunk-e8xw9qkh.js.map → chunk-297ajdcv.js.map} +0 -0
- /package/dist/cli/{chunk-e6atmn4h.js.map → chunk-32khwcb6.js.map} +0 -0
- /package/dist/cli/{chunk-073rdvgb.js.map → chunk-36bxs9c7.js.map} +0 -0
- /package/dist/cli/{chunk-mrcccgnh.js.map → chunk-53jqw8z0.js.map} +0 -0
- /package/dist/cli/{chunk-ft9vfys6.js.map → chunk-58bt3hag.js.map} +0 -0
- /package/dist/cli/{chunk-sds923fa.js.map → chunk-5cmts6dv.js.map} +0 -0
- /package/dist/cli/{chunk-cfyg47zg.js.map → chunk-60hz265r.js.map} +0 -0
- /package/dist/cli/{chunk-j134vrp2.js.map → chunk-74wfhv79.js.map} +0 -0
- /package/dist/cli/{chunk-nrns10mj.js.map → chunk-9qzgg6mj.js.map} +0 -0
- /package/dist/cli/{chunk-tphac74f.js.map → chunk-a57yh0h9.js.map} +0 -0
- /package/dist/cli/{chunk-hebn0tpx.js.map → chunk-ave1wcbr.js.map} +0 -0
- /package/dist/cli/{chunk-htrwgabr.js.map → chunk-bqxaqn5p.js.map} +0 -0
- /package/dist/cli/{chunk-5vjtgs6e.js.map → chunk-bxwhtvw8.js.map} +0 -0
- /package/dist/cli/{chunk-n0v7m8ry.js.map → chunk-desvm72z.js.map} +0 -0
- /package/dist/cli/{chunk-3v3ffbrj.js.map → chunk-dhwtqnnd.js.map} +0 -0
- /package/dist/cli/{chunk-c1y538kd.js.map → chunk-dqhg54mr.js.map} +0 -0
- /package/dist/cli/{chunk-42t4jxdx.js.map → chunk-dv2z0rzf.js.map} +0 -0
- /package/dist/cli/{chunk-hx3xqnk9.js.map → chunk-gfc5fqvf.js.map} +0 -0
- /package/dist/cli/{chunk-7ppf1rva.js.map → chunk-gxtg6ed2.js.map} +0 -0
- /package/dist/cli/{chunk-8y2kmjv8.js.map → chunk-hvcxng5f.js.map} +0 -0
- /package/dist/cli/{chunk-54ztw1nj.js.map → chunk-j1x92mx9.js.map} +0 -0
- /package/dist/cli/{chunk-c887fj0s.js.map → chunk-kdpzw38a.js.map} +0 -0
- /package/dist/cli/{chunk-w1r4vase.js.map → chunk-s0bdnc7v.js.map} +0 -0
- /package/dist/cli/{chunk-fdrkhpjy.js.map → chunk-sc93apfs.js.map} +0 -0
- /package/dist/cli/{chunk-dzwehpy7.js.map → chunk-tfycddbt.js.map} +0 -0
- /package/dist/cli/{chunk-3x6x3pph.js.map → chunk-twy7psdz.js.map} +0 -0
- /package/dist/cli/{chunk-e9q7a2ak.js.map → chunk-y4msrwm9.js.map} +0 -0
- /package/dist/cli/{chunk-tdqhyxfz.js.map → chunk-ydp3065p.js.map} +0 -0
|
@@ -103,6 +103,25 @@ export const assistantProviderDependencies = (
|
|
|
103
103
|
)
|
|
104
104
|
: [];
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The narration provider's SDK, when missing: `openai()` speaks through
|
|
108
|
+
* `@ai-sdk/openai`, which the build loads to generate the clips, while
|
|
109
|
+
* `gateway()` needs nothing beyond the core `ai` package Blume ships.
|
|
110
|
+
*/
|
|
111
|
+
export const narrationProviderDependencies = (
|
|
112
|
+
narration: ResolvedConfig["narration"],
|
|
113
|
+
root: string,
|
|
114
|
+
pkgDir: string = packageRoot()
|
|
115
|
+
): MissingDependency[] =>
|
|
116
|
+
narration.enabled && narration.provider
|
|
117
|
+
? unresolved(
|
|
118
|
+
`Narration provider "${narration.provider.kind}"`,
|
|
119
|
+
narration.provider.runtimeDeps,
|
|
120
|
+
root,
|
|
121
|
+
pkgDir
|
|
122
|
+
)
|
|
123
|
+
: [];
|
|
124
|
+
|
|
106
125
|
/**
|
|
107
126
|
* The deployment adapter's package, when missing. Node and Vercel ship with
|
|
108
127
|
* Blume, so theirs always resolve; Netlify and Cloudflare are optional peers.
|
|
@@ -152,8 +171,9 @@ export const islandFrameworkDependencies = (
|
|
|
152
171
|
/**
|
|
153
172
|
* Every package the resolved config's adapters (and the given island
|
|
154
173
|
* frameworks) import that isn't installed: the search SDK, content source
|
|
155
|
-
* SDKs, the assistant provider
|
|
156
|
-
* integrations. The adapters' `runtimeDeps` are the one place that
|
|
174
|
+
* SDKs, the assistant and narration provider SDKs, the deployment adapter,
|
|
175
|
+
* and island integrations. The adapters' `runtimeDeps` are the one place that
|
|
176
|
+
* knows.
|
|
157
177
|
*/
|
|
158
178
|
export const missingRuntimeDependencies = (
|
|
159
179
|
config: ResolvedConfig,
|
|
@@ -164,6 +184,7 @@ export const missingRuntimeDependencies = (
|
|
|
164
184
|
...searchProviderDependencies(config.search.provider, root, pkgDir),
|
|
165
185
|
...sourceAdapterDependencies(config.content.sources, root, pkgDir),
|
|
166
186
|
...assistantProviderDependencies(config.ai.assistant, root, pkgDir),
|
|
187
|
+
...narrationProviderDependencies(config.narration, root, pkgDir),
|
|
167
188
|
...deploymentAdapterDependencies(config.deployment, root, pkgDir),
|
|
168
189
|
...islandFrameworkDependencies(frameworks, root),
|
|
169
190
|
];
|
package/src/astro/templates.ts
CHANGED
|
@@ -567,7 +567,10 @@ const blumeIntegrationOptions = (options: {
|
|
|
567
567
|
pages: BlumePageRoute[];
|
|
568
568
|
redirects: CompiledRedirect[];
|
|
569
569
|
}): BlumeIntegrationOptions => {
|
|
570
|
-
const shared: BlumeIntegrationOptions = {
|
|
570
|
+
const shared: BlumeIntegrationOptions = {
|
|
571
|
+
pages: options.pages,
|
|
572
|
+
poweredBy: options.config.poweredBy,
|
|
573
|
+
};
|
|
571
574
|
if (options.redirects.length > 0) {
|
|
572
575
|
shared.redirects = options.redirects;
|
|
573
576
|
}
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -557,6 +557,8 @@ const pnpmWorkspaceSchema = z.looseObject({
|
|
|
557
557
|
// A glob's leading `./` and trailing `/`, which pnpm ignores.
|
|
558
558
|
const GLOB_EDGES = /^\.\/|\/+$/gu;
|
|
559
559
|
|
|
560
|
+
const globPattern = (glob: string): string => glob.replaceAll(GLOB_EDGES, "");
|
|
561
|
+
|
|
560
562
|
/** Whether any of `globs` matches the workspace-relative path `at`. */
|
|
561
563
|
const matchesAny = (globs: string[], at: string): boolean =>
|
|
562
564
|
globs.length > 0 && picomatch(globs)(at);
|
|
@@ -589,11 +591,12 @@ const listsPackage = (workspace: string, root: string): boolean => {
|
|
|
589
591
|
return true;
|
|
590
592
|
}
|
|
591
593
|
const globs = parsed.data.packages;
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
+
const included = globs
|
|
595
|
+
.filter((glob) => !glob.startsWith("!"))
|
|
596
|
+
.map(globPattern);
|
|
594
597
|
const excluded = globs
|
|
595
598
|
.filter((glob) => glob.startsWith("!"))
|
|
596
|
-
.map((glob) =>
|
|
599
|
+
.map((glob) => globPattern(glob.slice(1)));
|
|
597
600
|
return matchesAny(included, at) && !matchesAny(excluded, at);
|
|
598
601
|
};
|
|
599
602
|
|
|
@@ -92,7 +92,8 @@ export const checkRequiredSecrets = (config: ResolvedConfig): Diagnostic[] => {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Generated narration reads its key at build, not at runtime; without it the
|
|
95
|
-
// build skips the audio and pages read with browser voices.
|
|
95
|
+
// build skips the audio and pages read with browser voices. `openai()` with
|
|
96
|
+
// a `baseUrl` requires only a key it names (see `narrationProviderSchema`).
|
|
96
97
|
const { narration } = config;
|
|
97
98
|
if (narration.enabled && narration.provider) {
|
|
98
99
|
for (const env of narration.provider.requiredSecrets) {
|
|
@@ -42,6 +42,10 @@ const cssString = (value: string): string =>
|
|
|
42
42
|
.replaceAll("<", String.raw`\3c `)
|
|
43
43
|
.replaceAll(/[\n\r\f]/gu, " ")}"`;
|
|
44
44
|
|
|
45
|
+
/** Everything tagged with a view other than `title`. */
|
|
46
|
+
const otherViews = (title: string): string =>
|
|
47
|
+
`[data-blume-view]:not([data-blume-view=${cssString(title)}])`;
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* The page's view CSS: with a view chosen on `<body>`, everything tagged with
|
|
47
51
|
* another is hidden; before a choice, everything but the first view is.
|
|
@@ -51,12 +55,11 @@ export const viewStyle = (titles: readonly string[]): string => {
|
|
|
51
55
|
if (first === undefined) {
|
|
52
56
|
return "";
|
|
53
57
|
}
|
|
54
|
-
const other = (title: string): string =>
|
|
55
|
-
`[data-blume-view]:not([data-blume-view=${cssString(title)}])`;
|
|
56
58
|
const selectors = [
|
|
57
|
-
`body:not([data-blume-view]) ${
|
|
59
|
+
`body:not([data-blume-view]) ${otherViews(first)}`,
|
|
58
60
|
...titles.map(
|
|
59
|
-
(title) =>
|
|
61
|
+
(title) =>
|
|
62
|
+
`body[data-blume-view=${cssString(title)}] ${otherViews(title)}`
|
|
60
63
|
),
|
|
61
64
|
];
|
|
62
65
|
return `${selectors.join(",")}{display:none!important}`;
|
|
@@ -307,15 +307,59 @@ const initialId =
|
|
|
307
307
|
const spacing =
|
|
308
308
|
index === 0 ? "mt-0" : display === "flat" ? "mt-6" : "mt-0.5";
|
|
309
309
|
|
|
310
|
-
// `page`: a single row that drills into this group's panel.
|
|
311
|
-
// controls that panel; NavTreeScript keeps `aria-expanded` in
|
|
312
|
-
// with the panel shown (static markup, since cached subtrees and
|
|
313
|
-
// fragments can't carry per-page state).
|
|
310
|
+
// `page`: a single row that drills into this group's panel. Its drill
|
|
311
|
+
// button controls that panel; NavTreeScript keeps `aria-expanded` in
|
|
312
|
+
// step with the panel shown (static markup, since cached subtrees and
|
|
313
|
+
// fragments can't carry per-page state). A group with an index page
|
|
314
|
+
// splits the row, like the `group` summary: the label links that page
|
|
315
|
+
// (which renders with the panel open), so the row opens the section
|
|
316
|
+
// even when the index hides its own row, and the chevron stays a
|
|
317
|
+
// button that slides the panel in without leaving the page. Without
|
|
318
|
+
// one, the whole row is the drill button, so it never links a missing
|
|
319
|
+
// page. `aria-current` marks the row whose panel holds the current
|
|
320
|
+
// page (`page` on the section's own page), so the parent panel still
|
|
321
|
+
// shows where the reader is after the back arrow. Only rows on the
|
|
322
|
+
// active path carry it, and those always render live, never from a
|
|
323
|
+
// cache.
|
|
314
324
|
if (display === "page") {
|
|
315
|
-
|
|
325
|
+
const current = hasActivePage(item)
|
|
326
|
+
? isGroupRowCurrent(item, currentRoute)
|
|
327
|
+
? "page"
|
|
328
|
+
: "true"
|
|
329
|
+
: undefined;
|
|
330
|
+
return item.route ? (
|
|
331
|
+
<li class={`flex items-center gap-0.5 ${spacing}`}>
|
|
332
|
+
<a
|
|
333
|
+
aria-current={current}
|
|
334
|
+
class="blume-nav-drill-link"
|
|
335
|
+
href={withMountedBase(item.route)}
|
|
336
|
+
>
|
|
337
|
+
{item.icon && (
|
|
338
|
+
<Icon
|
|
339
|
+
class="shrink-0 text-muted-foreground"
|
|
340
|
+
name={item.icon}
|
|
341
|
+
size={14}
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
344
|
+
<span class="flex-1 truncate">{item.label}</span>
|
|
345
|
+
{item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
|
|
346
|
+
</a>
|
|
347
|
+
<button
|
|
348
|
+
aria-controls={navPanelId(id)}
|
|
349
|
+
aria-expanded="false"
|
|
350
|
+
aria-label={item.label}
|
|
351
|
+
class="blume-nav-drill-toggle"
|
|
352
|
+
data-nav-to={id}
|
|
353
|
+
type="button"
|
|
354
|
+
>
|
|
355
|
+
<Icon class="rtl:-scale-x-100" name="chevron-right" size={14} />
|
|
356
|
+
</button>
|
|
357
|
+
</li>
|
|
358
|
+
) : (
|
|
316
359
|
<li class={spacing}>
|
|
317
360
|
<button
|
|
318
361
|
aria-controls={navPanelId(id)}
|
|
362
|
+
aria-current={current}
|
|
319
363
|
aria-expanded="false"
|
|
320
364
|
class="blume-nav-drill"
|
|
321
365
|
data-nav-to={id}
|
|
@@ -146,6 +146,24 @@
|
|
|
146
146
|
}
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
|
+
// After the back arrow, the current section's name links the page
|
|
150
|
+
// already on screen, so a plain click slides its panel back in rather
|
|
151
|
+
// than reloading that page. Modified clicks stay the browser's.
|
|
152
|
+
const own = target.closest('.blume-nav-drill-link[aria-current="page"]');
|
|
153
|
+
const ownId = own?.parentElement
|
|
154
|
+
?.querySelector("[data-nav-to]")
|
|
155
|
+
?.getAttribute("data-nav-to");
|
|
156
|
+
if (
|
|
157
|
+
own &&
|
|
158
|
+
ownId &&
|
|
159
|
+
this.contains(own) &&
|
|
160
|
+
this.panel(ownId) &&
|
|
161
|
+
!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
162
|
+
) {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
void this.show(ownId, true);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
149
167
|
const back = target.closest("[data-nav-back]");
|
|
150
168
|
if (back && this.contains(back)) {
|
|
151
169
|
event.preventDefault();
|
|
@@ -126,10 +126,17 @@ interface Props {
|
|
|
126
126
|
canonical?: string | null;
|
|
127
127
|
/**
|
|
128
128
|
* Emit schema.org JSON-LD for this page (`data.config.structuredData`) — a
|
|
129
|
-
* WebSite node, plus
|
|
130
|
-
* matching RootLayout.
|
|
129
|
+
* WebSite node, plus a WebPage node on the homepage and an article node on
|
|
130
|
+
* other routes. Defaults to on, matching RootLayout.
|
|
131
131
|
*/
|
|
132
132
|
structuredDataEnabled?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* When the page last changed, emitted as the JSON-LD `dateModified`. A
|
|
135
|
+
* custom page has no content file to date, so pass it by hand; without a
|
|
136
|
+
* machine-readable date, search engines take whatever other date the page
|
|
137
|
+
* shows.
|
|
138
|
+
*/
|
|
139
|
+
lastModified?: string | Date | null;
|
|
133
140
|
/**
|
|
134
141
|
* Which agent-discovery resources exist, advertised as `describedby` head
|
|
135
142
|
* links plus the AI Catalog/ARD pair — the same `DiscoveryLinks` partial
|
|
@@ -184,6 +191,7 @@ const {
|
|
|
184
191
|
ogImageSize,
|
|
185
192
|
canonical,
|
|
186
193
|
structuredDataEnabled,
|
|
194
|
+
lastModified,
|
|
187
195
|
discovery = data.config.discovery,
|
|
188
196
|
x,
|
|
189
197
|
noindex,
|
|
@@ -279,7 +287,8 @@ const xSite = normalizeXHandle(x?.handle);
|
|
|
279
287
|
const xCreator = normalizeXHandle(x?.creator);
|
|
280
288
|
|
|
281
289
|
// JSON-LD, mirroring RootLayout: skipped when disabled or noindexed. A custom
|
|
282
|
-
// page has no breadcrumb trail; the home route
|
|
290
|
+
// page has no breadcrumb trail; the home route is a WebPage, dated by
|
|
291
|
+
// `lastModified` when the page passes one.
|
|
283
292
|
const structuredData =
|
|
284
293
|
structuredDataEnabled === false || noindex
|
|
285
294
|
? null
|
|
@@ -289,6 +298,7 @@ const structuredData =
|
|
|
289
298
|
description,
|
|
290
299
|
identity: data.config.identity,
|
|
291
300
|
locale,
|
|
301
|
+
modified: lastModified,
|
|
292
302
|
route,
|
|
293
303
|
siteName: site.title,
|
|
294
304
|
siteUrl: siteUrl ?? null,
|
|
@@ -432,10 +432,13 @@ const description = page.description ?? site.description;
|
|
|
432
432
|
// pages (and the changelog index, which has no `type`) stay `website`.
|
|
433
433
|
const ogType =
|
|
434
434
|
pageType === "blog" || pageType === "changelog" ? "article" : "website";
|
|
435
|
+
// The last-modified date as ISO 8601, parsed once for every surface that
|
|
436
|
+
// carries it; undefined when absent or unparseable.
|
|
437
|
+
const lastModifiedIso = toIso(lastModified);
|
|
435
438
|
// `article:*` timestamps only belong on an article; a docs page carrying them
|
|
436
439
|
// would be declaring properties its `og:type` doesn't define.
|
|
437
440
|
const articlePublished = ogType === "article" ? toIso(published) : undefined;
|
|
438
|
-
const articleModified = ogType === "article" ?
|
|
441
|
+
const articleModified = ogType === "article" ? lastModifiedIso : undefined;
|
|
439
442
|
// X sizes the card from `twitter:card`: a large image needs the wide variant,
|
|
440
443
|
// and a page with no image still gets a card (the compact `summary`) rather
|
|
441
444
|
// than rendering as a bare link.
|
|
@@ -448,14 +451,12 @@ const xCreator = normalizeXHandle(x?.creator);
|
|
|
448
451
|
|
|
449
452
|
// "Last updated on <date>" — the configured `dateFormat`, in UTC (unless the
|
|
450
453
|
// config names a zone) so it matches the changelog timeline.
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
).format(lastModifiedDate)
|
|
458
|
-
: null;
|
|
454
|
+
const formattedLastModified = lastModifiedIso
|
|
455
|
+
? new Intl.DateTimeFormat(
|
|
456
|
+
locale || "en",
|
|
457
|
+
resolveDateFormatOptions(dateFormat)
|
|
458
|
+
).format(new Date(lastModifiedIso))
|
|
459
|
+
: null;
|
|
459
460
|
|
|
460
461
|
// This page's raw-Markdown mirror, advertised as a `text/markdown` alternate
|
|
461
462
|
// in the head. Content routes always have one (see `markdownRoutePaths`, which
|
|
@@ -521,6 +522,7 @@ const structuredData =
|
|
|
521
522
|
base: import.meta.env.BASE_URL,
|
|
522
523
|
breadcrumbs: crumbs,
|
|
523
524
|
description,
|
|
525
|
+
homeRoute: navigation.root,
|
|
524
526
|
identity: data.config.identity,
|
|
525
527
|
locale,
|
|
526
528
|
modified: lastModified,
|
|
@@ -929,7 +931,7 @@ const localeRedirect = localeTargets
|
|
|
929
931
|
/>
|
|
930
932
|
{formattedLastModified && (
|
|
931
933
|
<p class="mx-auto mt-10 max-w-content text-muted-foreground text-sm">
|
|
932
|
-
{strings.page.lastUpdated} {formattedLastModified}
|
|
934
|
+
{strings.page.lastUpdated} <time datetime={lastModifiedIso}>{formattedLastModified}</time>
|
|
933
935
|
</p>
|
|
934
936
|
)}
|
|
935
937
|
{feedback && (
|
|
@@ -1378,10 +1380,13 @@ const localeRedirect = localeTargets
|
|
|
1378
1380
|
}
|
|
1379
1381
|
@media (prefers-reduced-motion: reduce) {
|
|
1380
1382
|
/* html prefix outranks the library's rules regardless of sheet
|
|
1381
|
-
order; its transition declarations carry !important.
|
|
1383
|
+
order; its transition declarations carry !important. Near-instant,
|
|
1384
|
+
never `none`: medium-zoom only finishes opening and closing on the
|
|
1385
|
+
image's `transitionend`, so with no transition a zoomed image
|
|
1386
|
+
ignores Escape, clicks, and scrolls forever. */
|
|
1382
1387
|
html .medium-zoom-image,
|
|
1383
1388
|
html .medium-zoom-overlay {
|
|
1384
|
-
transition:
|
|
1389
|
+
transition-duration: 1ms !important;
|
|
1385
1390
|
}
|
|
1386
1391
|
}
|
|
1387
1392
|
</style>
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Send a custom analytics event to every analytics platform configured in
|
|
3
3
|
* `blume.config.ts`. Mirrors the adapters `Analytics.astro` wires
|
|
4
4
|
* (`src/analytics/*`): each one with a custom-event API is called through the
|
|
5
|
-
* global its snippet defines (Cloudflare and
|
|
6
|
-
* to), and the `blume:track` CustomEvent fires unconditionally so a
|
|
7
|
-
* can bridge the event to anything else — a `script()` adapter, say.
|
|
8
|
-
* call no-ops cleanly when a provider isn't present — for example
|
|
9
|
-
* `blume dev`, where `Analytics.astro` injects nothing — and a provider
|
|
10
|
-
* throws (a consent shim that stubs `gtag` with a raise, a broken
|
|
11
|
-
* isolated so it neither starves the providers after it nor
|
|
12
|
-
* feature that reported the event.
|
|
5
|
+
* global its snippet defines (Cloudflare, Clearbit, and Clics have none to
|
|
6
|
+
* forward to), and the `blume:track` CustomEvent fires unconditionally so a
|
|
7
|
+
* project can bridge the event to anything else — a `script()` adapter, say.
|
|
8
|
+
* Every call no-ops cleanly when a provider isn't present — for example
|
|
9
|
+
* during `blume dev`, where `Analytics.astro` injects nothing — and a provider
|
|
10
|
+
* that throws (a consent shim that stubs `gtag` with a raise, a broken
|
|
11
|
+
* snippet) is isolated so it neither starves the providers after it nor
|
|
12
|
+
* surfaces in the feature that reported the event.
|
|
13
13
|
*/
|
|
14
14
|
import { track as vercelTrack } from "@vercel/analytics";
|
|
15
15
|
|
|
@@ -271,6 +271,59 @@ const line = (className: string, text: string): HTMLElement => {
|
|
|
271
271
|
return el;
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
+
/** Render one settled HTTP exchange: status + time, headers, pretty body. */
|
|
275
|
+
const renderResponse = (
|
|
276
|
+
region: HTMLElement,
|
|
277
|
+
res: Response,
|
|
278
|
+
ms: number,
|
|
279
|
+
text: string
|
|
280
|
+
): void => {
|
|
281
|
+
region.textContent = "";
|
|
282
|
+
region.append(
|
|
283
|
+
line(
|
|
284
|
+
"font-mono font-semibold text-foreground text-sm",
|
|
285
|
+
`${res.status} ${res.statusText} \u00B7 ${ms} ms`.trim()
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
const table = document.createElement("table");
|
|
289
|
+
table.className = "w-full text-start text-xs";
|
|
290
|
+
for (const [name, value] of res.headers) {
|
|
291
|
+
const row = document.createElement("tr");
|
|
292
|
+
const header = document.createElement("th");
|
|
293
|
+
header.setAttribute("scope", "row");
|
|
294
|
+
header.className = "pe-3 align-top font-medium text-muted-foreground";
|
|
295
|
+
header.textContent = name;
|
|
296
|
+
const cell = document.createElement("td");
|
|
297
|
+
cell.className = "break-all font-mono text-foreground";
|
|
298
|
+
cell.textContent = value;
|
|
299
|
+
row.append(header, cell);
|
|
300
|
+
table.append(row);
|
|
301
|
+
}
|
|
302
|
+
region.append(table);
|
|
303
|
+
const pre = document.createElement("pre");
|
|
304
|
+
pre.className =
|
|
305
|
+
"overflow-x-auto rounded-blume border border-border p-3 font-mono text-foreground text-xs";
|
|
306
|
+
const code = document.createElement("code");
|
|
307
|
+
code.textContent = prettyBody(text);
|
|
308
|
+
pre.append(code);
|
|
309
|
+
region.append(pre);
|
|
310
|
+
// At xl the panel is its own scroll region capped to the viewport, so a
|
|
311
|
+
// response appended under a tall form can land below the panel's fold
|
|
312
|
+
// where nothing brings it into view. Scroll the panel alone — "nearest",
|
|
313
|
+
// by hand — and never the document: below xl the panel does not scroll,
|
|
314
|
+
// and a document scroll would carry the form (Send, the body editor, its
|
|
315
|
+
// errors) off the top on a phone.
|
|
316
|
+
const panel = region.closest<HTMLElement>("[data-operation-panel]");
|
|
317
|
+
if (panel && panel.scrollHeight > panel.clientHeight) {
|
|
318
|
+
const box = panel.getBoundingClientRect();
|
|
319
|
+
const target = region.getBoundingClientRect();
|
|
320
|
+
const delta = Math.min(target.bottom - box.bottom, target.top - box.top);
|
|
321
|
+
if (delta > 0) {
|
|
322
|
+
panel.scrollBy({ top: delta });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
274
327
|
/**
|
|
275
328
|
* Wire the playground inside `root` (the `<blume-playground>` element). Reads
|
|
276
329
|
* the server-rendered model JSON, keeps the request samples in sync with the
|
|
@@ -470,59 +523,6 @@ export const initPlayground = (root: HTMLElement): void => {
|
|
|
470
523
|
}
|
|
471
524
|
};
|
|
472
525
|
|
|
473
|
-
/** Render one settled HTTP exchange: status + time, headers, pretty body. */
|
|
474
|
-
const renderResponse = (
|
|
475
|
-
region: HTMLElement,
|
|
476
|
-
res: Response,
|
|
477
|
-
ms: number,
|
|
478
|
-
text: string
|
|
479
|
-
): void => {
|
|
480
|
-
region.textContent = "";
|
|
481
|
-
region.append(
|
|
482
|
-
line(
|
|
483
|
-
"font-mono font-semibold text-foreground text-sm",
|
|
484
|
-
`${res.status} ${res.statusText} \u00B7 ${ms} ms`.trim()
|
|
485
|
-
)
|
|
486
|
-
);
|
|
487
|
-
const table = document.createElement("table");
|
|
488
|
-
table.className = "w-full text-start text-xs";
|
|
489
|
-
for (const [name, value] of res.headers) {
|
|
490
|
-
const row = document.createElement("tr");
|
|
491
|
-
const header = document.createElement("th");
|
|
492
|
-
header.setAttribute("scope", "row");
|
|
493
|
-
header.className = "pe-3 align-top font-medium text-muted-foreground";
|
|
494
|
-
header.textContent = name;
|
|
495
|
-
const cell = document.createElement("td");
|
|
496
|
-
cell.className = "break-all font-mono text-foreground";
|
|
497
|
-
cell.textContent = value;
|
|
498
|
-
row.append(header, cell);
|
|
499
|
-
table.append(row);
|
|
500
|
-
}
|
|
501
|
-
region.append(table);
|
|
502
|
-
const pre = document.createElement("pre");
|
|
503
|
-
pre.className =
|
|
504
|
-
"overflow-x-auto rounded-blume border border-border p-3 font-mono text-foreground text-xs";
|
|
505
|
-
const code = document.createElement("code");
|
|
506
|
-
code.textContent = prettyBody(text);
|
|
507
|
-
pre.append(code);
|
|
508
|
-
region.append(pre);
|
|
509
|
-
// At xl the panel is its own scroll region capped to the viewport, so a
|
|
510
|
-
// response appended under a tall form can land below the panel's fold
|
|
511
|
-
// where nothing brings it into view. Scroll the panel alone — "nearest",
|
|
512
|
-
// by hand — and never the document: below xl the panel does not scroll,
|
|
513
|
-
// and a document scroll would carry the form (Send, the body editor, its
|
|
514
|
-
// errors) off the top on a phone.
|
|
515
|
-
const panel = region.closest<HTMLElement>("[data-operation-panel]");
|
|
516
|
-
if (panel && panel.scrollHeight > panel.clientHeight) {
|
|
517
|
-
const box = panel.getBoundingClientRect();
|
|
518
|
-
const target = region.getBoundingClientRect();
|
|
519
|
-
const delta = Math.min(target.bottom - box.bottom, target.top - box.top);
|
|
520
|
-
if (delta > 0) {
|
|
521
|
-
panel.scrollBy({ top: delta });
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
};
|
|
525
|
-
|
|
526
526
|
/**
|
|
527
527
|
* Send the real request. HTTP error statuses render like any response; only
|
|
528
528
|
* a rejected fetch (the CORS wall) gets the explanatory message. One request
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "astro";
|
|
2
|
+
|
|
3
|
+
import { POWERED_BY_HEADERS } from "../core/powered-by.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Names Blume in the responses Astro renders (wired by the integration unless
|
|
7
|
+
* `poweredBy` is `false`), the server-rendered ones a host's own header rules
|
|
8
|
+
* don't reach. Innermost, so a page or the project's own middleware that sets
|
|
9
|
+
* a header of the same name keeps its value. Headers are set in place; a
|
|
10
|
+
* response whose headers are immutable (a proxied `fetch()` result) is copied
|
|
11
|
+
* first.
|
|
12
|
+
*/
|
|
13
|
+
export const onRequest: MiddlewareHandler = async (_context, render) => {
|
|
14
|
+
const response = await render();
|
|
15
|
+
const missing = Object.entries(POWERED_BY_HEADERS).filter(
|
|
16
|
+
([name]) => !response.headers.has(name)
|
|
17
|
+
);
|
|
18
|
+
if (missing.length === 0) {
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
for (const [name, value] of missing) {
|
|
23
|
+
response.headers.set(name, value);
|
|
24
|
+
}
|
|
25
|
+
return response;
|
|
26
|
+
} catch {
|
|
27
|
+
const copy = new Response(response.body, response);
|
|
28
|
+
for (const [name, value] of missing) {
|
|
29
|
+
copy.headers.set(name, value);
|
|
30
|
+
}
|
|
31
|
+
return copy;
|
|
32
|
+
}
|
|
33
|
+
};
|
package/src/core/config-input.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { AstroIntegration } from "astro";
|
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
|
|
4
4
|
import type { AskRetrievalOptions } from "../ai/ask-context.ts";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
AssistantAdapter,
|
|
7
|
+
AssistantGatewayAdapter,
|
|
8
|
+
AssistantOpenAIAdapter,
|
|
9
|
+
} from "../ai/ask.ts";
|
|
6
10
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
7
11
|
import type { AnalyticsAdapter } from "../analytics/schema.ts";
|
|
8
12
|
import type { CaptchaAdapter } from "../captcha/schema.ts";
|
|
@@ -1399,9 +1403,11 @@ export type NarrationConfig =
|
|
|
1399
1403
|
/**
|
|
1400
1404
|
* Generate audio at build with a speech model:
|
|
1401
1405
|
* `gateway({ model: "openai/tts-1-hd", voice: "alloy" })` from
|
|
1402
|
-
* `blume/ai
|
|
1406
|
+
* `blume/ai`, or `openai({ model, voice })` for OpenAI or, with a
|
|
1407
|
+
* `baseUrl`, any OpenAI-compatible speech endpoint. Without it, pages
|
|
1408
|
+
* are read with browser voices.
|
|
1403
1409
|
*/
|
|
1404
|
-
provider?: AssistantGatewayAdapter;
|
|
1410
|
+
provider?: AssistantGatewayAdapter | AssistantOpenAIAdapter;
|
|
1405
1411
|
};
|
|
1406
1412
|
|
|
1407
1413
|
/**
|
|
@@ -1585,6 +1591,8 @@ export interface BlumeConfig {
|
|
|
1585
1591
|
narration?: NarrationConfig;
|
|
1586
1592
|
/** Header, sidebar, tabs, and switchers. */
|
|
1587
1593
|
navigation?: NavigationConfig;
|
|
1594
|
+
/** Send `X-Powered-By: Blume` with responses. Defaults to `true`. */
|
|
1595
|
+
poweredBy?: boolean;
|
|
1588
1596
|
/**
|
|
1589
1597
|
* Limit how often one reader (by IP address) can call the server routes —
|
|
1590
1598
|
* the assistant, the API playground proxy, server-side search — with an
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header that names Blume as the framework behind a response, sent unless
|
|
3
|
+
* `poweredBy` is `false`. The dev server and Blume's runtime middleware
|
|
4
|
+
* (`components/powered-by-middleware.ts`) send it, and so does every host
|
|
5
|
+
* for the built site: static hosts through `deploy/headers.ts`'s rules,
|
|
6
|
+
* server builds through each platform's own mechanism (`deploy/platforms`).
|
|
7
|
+
* It lives apart from those modules so the runtime middleware can import it
|
|
8
|
+
* without pulling in the deploy code.
|
|
9
|
+
*/
|
|
10
|
+
export const POWERED_BY_HEADERS = {
|
|
11
|
+
"X-Powered-By": "Blume",
|
|
12
|
+
} satisfies Record<string, string>;
|
package/src/core/schema.ts
CHANGED
|
@@ -1206,10 +1206,10 @@ const feedbackConfigSchema = z
|
|
|
1206
1206
|
|
|
1207
1207
|
// "Listen to this page". Off by default. `true` reads pages aloud with the
|
|
1208
1208
|
// reader's browser voices, which needs no key and works on any host; an object
|
|
1209
|
-
// with a `provider` (`gateway()` from `blume/ai`) generates
|
|
1210
|
-
// build instead, one cached clip per sentence, and falls back
|
|
1211
|
-
// voices where no clips exist (`blume dev`, a build without
|
|
1212
|
-
// normalize to `{ enabled, provider }`.
|
|
1209
|
+
// with a `provider` (`gateway()` or `openai()` from `blume/ai`) generates
|
|
1210
|
+
// neural audio at build instead, one cached clip per sentence, and falls back
|
|
1211
|
+
// to the browser's voices where no clips exist (`blume dev`, a build without
|
|
1212
|
+
// the key). Both normalize to `{ enabled, provider }`.
|
|
1213
1213
|
const narrationConfigSchema = z
|
|
1214
1214
|
.union([
|
|
1215
1215
|
z.boolean(),
|
|
@@ -2159,6 +2159,7 @@ export const blumeConfigSchema = z
|
|
|
2159
2159
|
markdown: markdownConfigSchema.prefault({}),
|
|
2160
2160
|
narration: narrationConfigSchema.prefault(false),
|
|
2161
2161
|
navigation: navigationConfigSchema.prefault({}),
|
|
2162
|
+
poweredBy: z.boolean().default(true),
|
|
2162
2163
|
// An adapter from `blume/ratelimit`, `memory()` by default; `false` is off.
|
|
2163
2164
|
rateLimit: rateLimitConfigSchema,
|
|
2164
2165
|
react: reactConfigSchema.prefault({}),
|
|
@@ -299,6 +299,17 @@ const renderParts = (
|
|
|
299
299
|
export const renderInline = (parts: InlinePart[]): string =>
|
|
300
300
|
renderParts(parts, escapeProse);
|
|
301
301
|
|
|
302
|
+
const asTyped = (text: string): string => text;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A block's inline content as MDX source, for a source whose site author
|
|
306
|
+
* opted in to reading its text that way: marks still become delimiters and
|
|
307
|
+
* code stays a code span, but the text goes in as typed, so a component, an
|
|
308
|
+
* expression, or Markdown written in the CMS renders.
|
|
309
|
+
*/
|
|
310
|
+
export const renderInlineMdx = (parts: InlinePart[]): string =>
|
|
311
|
+
renderParts(parts, asTyped);
|
|
312
|
+
|
|
302
313
|
// A destination with whitespace, a control character, or parentheses ends
|
|
303
314
|
// early in `[label](…)`; CommonMark's pointy-bracket form carries it intact.
|
|
304
315
|
// oxlint-disable-next-line no-control-regex -- control characters are exactly what the bare form refuses.
|
|
@@ -331,13 +342,18 @@ export const destination = (url: string): string => {
|
|
|
331
342
|
* `data:`) never becomes a clickable link on the docs site (see
|
|
332
343
|
* `safe-links.ts`). The parts stay parts then, so their runs merge with their
|
|
333
344
|
* neighbors'. A label escapes a bare URL too: GFM autolinks nothing inside a
|
|
334
|
-
* link, so its specials would read as syntax
|
|
345
|
+
* link, so its specials would read as syntax — unless `mdx` reads the label
|
|
346
|
+
* as MDX source, like {@link renderInlineMdx}.
|
|
335
347
|
*/
|
|
336
|
-
export const linkParts = (
|
|
348
|
+
export const linkParts = (
|
|
349
|
+
parts: InlinePart[],
|
|
350
|
+
href?: string,
|
|
351
|
+
mdx = false
|
|
352
|
+
): InlinePart[] =>
|
|
337
353
|
href && isSafeHref(href)
|
|
338
354
|
? [
|
|
339
355
|
{
|
|
340
|
-
markdown: `[${renderParts(parts, escapeMarkdownText)}](${destination(href)})`,
|
|
356
|
+
markdown: `[${renderParts(parts, mdx ? asTyped : escapeMarkdownText)}](${destination(href)})`,
|
|
341
357
|
},
|
|
342
358
|
]
|
|
343
359
|
: parts;
|