blume 1.6.3 → 1.6.5
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 +38 -0
- package/dist/cli/index.js +308 -50
- package/dist/cli/index.js.map +29 -24
- package/dist/types/ai/component-markdown.d.ts +14 -0
- package/dist/types/core/config-input.d.ts +10 -0
- package/dist/types/core/schema.d.ts +4 -1
- package/docs/01-quickstart.mdx +2 -2
- package/docs/02-deployment.mdx +5 -5
- package/docs/{07-faq.mdx → 08-faq.mdx} +7 -7
- package/docs/advanced/blog.mdx +3 -3
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/custom-pages.mdx +4 -4
- package/docs/advanced/meta.ts +1 -1
- package/docs/configuration/analytics.mdx +21 -2
- package/docs/configuration/ask-ai.mdx +179 -0
- package/docs/configuration/index.mdx +8 -7
- package/docs/configuration/meta.ts +1 -2
- package/docs/configuration/search.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/i18n.mdx +7 -1
- package/docs/content/index.mdx +1 -1
- package/docs/content/navigation.mdx +2 -2
- package/docs/content/syntax.mdx +2 -2
- package/docs/discoverability/agent-discovery.mdx +196 -0
- package/docs/discoverability/index.mdx +48 -0
- package/docs/discoverability/json-api.mdx +58 -0
- package/docs/discoverability/llms-txt.mdx +68 -0
- package/docs/discoverability/markdown.mdx +76 -0
- package/docs/discoverability/mcp.mdx +64 -0
- package/docs/discoverability/meta.ts +18 -0
- package/docs/discoverability/metadata.mdx +82 -0
- package/docs/discoverability/open-graph.mdx +113 -0
- package/docs/discoverability/rss.mdx +24 -0
- package/docs/discoverability/sitemap-and-robots.mdx +95 -0
- package/docs/discoverability/structured-data.mdx +51 -0
- package/docs/index.mdx +5 -5
- package/docs/reference/eval.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +1 -0
- package/package.json +27 -27
- package/src/ai/component-markdown.ts +17 -2
- package/src/ai/llms.ts +3 -10
- package/src/ai/markdown.ts +3 -10
- package/src/ai/openapi-components.ts +123 -0
- package/src/ai/serializers.ts +24 -0
- package/src/astro/generate.ts +5 -6
- package/src/astro/templates.ts +42 -12
- package/src/audit/checks/links.ts +1 -8
- package/src/audit/checks/llms.ts +5 -4
- package/src/audit/redirects.ts +4 -3
- package/src/audit/run.ts +6 -8
- package/src/audit/url.ts +33 -0
- package/src/cli/commands/validate.ts +1 -0
- package/src/components/content/Component.astro +60 -59
- package/src/components/content/example-pane.ts +6 -0
- package/src/components/content/mermaid-element.ts +8 -0
- package/src/components/layout/Analytics.astro +20 -1
- package/src/components/layout/LocaleLinks.astro +42 -0
- package/src/components/layout/PageLayout.astro +5 -3
- package/src/components/layout/ReferenceLayout.astro +6 -0
- package/src/components/layout/RootLayout.astro +6 -3
- package/src/components/layout/analytics-client.ts +2 -1
- package/src/components/layout/search-locale.ts +13 -0
- package/src/components/openapi/ApiOverview.astro +7 -39
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/AsyncApiOperation.astro +8 -5
- package/src/components/openapi/Authorization.astro +4 -6
- package/src/components/openapi/Bindings.astro +2 -2
- package/src/components/openapi/Description.astro +109 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +5 -7
- package/src/components/openapi/GraphqlOperation.astro +7 -5
- package/src/components/openapi/GraphqlType.astro +4 -6
- package/src/components/openapi/Operation.astro +3 -2
- package/src/components/openapi/ParametersTable.astro +5 -7
- package/src/components/openapi/RequestBody.astro +2 -4
- package/src/components/openapi/Responses.astro +4 -3
- package/src/components/openapi/SchemaProperty.astro +11 -6
- package/src/components/openapi/description.ts +91 -0
- package/src/core/config-input.ts +10 -0
- package/src/core/i18n.ts +13 -2
- package/src/core/links.ts +33 -1
- package/src/core/locale-links.ts +163 -0
- package/src/core/schema.ts +8 -0
- package/src/core/sources/normalize.ts +57 -7
- package/src/markdown/package-commands.ts +27 -3
- package/src/openapi/graphql.ts +29 -0
- package/src/openapi/model.ts +69 -0
- package/src/openapi/render-mdx.ts +3 -2
- package/src/openapi/signature.ts +18 -0
- package/src/search/documents.ts +4 -9
- package/src/theme/entry.ts +28 -4
- package/src/translate/anchors.ts +91 -0
- package/src/translate/validate.ts +8 -3
- package/docs/configuration/ai.mdx +0 -613
- package/docs/configuration/seo.mdx +0 -364
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import type { SchemaLike } from "./helpers.ts";
|
|
3
4
|
import SchemaTable from "./SchemaTable.astro";
|
|
4
5
|
|
|
@@ -46,10 +47,7 @@ const schema = chosen?.[1]?.schema ?? {};
|
|
|
46
47
|
</div>
|
|
47
48
|
{
|
|
48
49
|
requestBody.description && (
|
|
49
|
-
<
|
|
50
|
-
class="text-muted-foreground text-sm"
|
|
51
|
-
set:text={requestBody.description}
|
|
52
|
-
/>
|
|
50
|
+
<Description class="text-muted-foreground text-sm" text={requestBody.description} />
|
|
53
51
|
)
|
|
54
52
|
}
|
|
55
53
|
<div class="mt-3">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { statusColor } from "../colors.ts";
|
|
3
|
+
import Description from "./Description.astro";
|
|
3
4
|
import type { SchemaLike } from "./helpers.ts";
|
|
4
5
|
import SchemaTable from "./SchemaTable.astro";
|
|
5
6
|
|
|
@@ -52,9 +53,9 @@ const items = Object.entries(responses);
|
|
|
52
53
|
{status}
|
|
53
54
|
</span>
|
|
54
55
|
{response.description && (
|
|
55
|
-
<
|
|
56
|
-
class="text-muted-foreground text-sm"
|
|
57
|
-
|
|
56
|
+
<Description
|
|
57
|
+
class="min-w-0 text-muted-foreground text-sm"
|
|
58
|
+
text={response.description}
|
|
58
59
|
/>
|
|
59
60
|
)}
|
|
60
61
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import Description from "./Description.astro";
|
|
2
3
|
import {
|
|
3
4
|
constraints,
|
|
4
5
|
isNullable,
|
|
@@ -54,7 +55,11 @@ const expandable =
|
|
|
54
55
|
!circular && (hasObjectShape(resolved) || Boolean(items && hasObjectShape(items)));
|
|
55
56
|
---
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
{/* The row's vertical scale is 8 / 12 / 16px, and every other reference table copies it. A
|
|
59
|
+
description is a block of Markdown, not a line: at 4px it sat closer to the label above it
|
|
60
|
+
than its own paragraphs sat to each other, and the disclosure below it touched the next row's
|
|
61
|
+
divider. Each step separates a bigger unit than the one before. */}
|
|
62
|
+
<div class="border-border border-t py-4 first:border-t-0 last:pb-0">
|
|
58
63
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
59
64
|
<code class="font-mono text-foreground text-sm">{name}</code>
|
|
60
65
|
<span class="text-muted-foreground text-xs"
|
|
@@ -77,17 +82,17 @@ const expandable =
|
|
|
77
82
|
</div>
|
|
78
83
|
{
|
|
79
84
|
description && (
|
|
80
|
-
<
|
|
85
|
+
<Description class="mt-2 text-muted-foreground text-sm" text={description} />
|
|
81
86
|
)
|
|
82
87
|
}
|
|
83
88
|
{
|
|
84
89
|
limits.length > 0 && (
|
|
85
|
-
<div class="mt-
|
|
90
|
+
<div class="mt-2 text-muted-foreground text-xs">{limits.join(" · ")}</div>
|
|
86
91
|
)
|
|
87
92
|
}
|
|
88
93
|
{
|
|
89
94
|
enumValues && (
|
|
90
|
-
<div class="mt-
|
|
95
|
+
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
91
96
|
<span class="text-muted-foreground">Allowed:</span>
|
|
92
97
|
{enumValues.map((value) => (
|
|
93
98
|
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
@@ -99,12 +104,12 @@ const expandable =
|
|
|
99
104
|
}
|
|
100
105
|
{
|
|
101
106
|
expandable && (
|
|
102
|
-
<details class="mt-
|
|
107
|
+
<details class="mt-3" open={expandAll}>
|
|
103
108
|
<summary class="cursor-pointer select-none text-accent text-xs hover:underline">
|
|
104
109
|
<span class="[details[open]>summary_&]:hidden">Show properties</span>
|
|
105
110
|
<span class="hidden [details[open]>summary_&]:inline">Hide properties</span>
|
|
106
111
|
</summary>
|
|
107
|
-
<div class="mt-
|
|
112
|
+
<div class="mt-3 border-border border-l pl-4">
|
|
108
113
|
<SchemaTable
|
|
109
114
|
schema={schema}
|
|
110
115
|
schemas={schemas}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a spec description as Markdown.
|
|
5
|
+
*
|
|
6
|
+
* An OpenAPI `description` is Markdown by specification — "CommonMark syntax MAY be used for rich
|
|
7
|
+
* text representation" — but the reference components printed it with `set:text`, so a schema
|
|
8
|
+
* property, parameter or header showed its source. On a spec generated from code docstrings that
|
|
9
|
+
* is most of them: `**Inline**` printed its asterisks, `` `apiKey` `` printed its backticks, and
|
|
10
|
+
* because HTML collapses newlines every paragraph and list ran together into one wall of text.
|
|
11
|
+
* The operation description does not have this problem — it is emitted into the MDX body and goes
|
|
12
|
+
* through the full pipeline — which is what made the difference visible page by page.
|
|
13
|
+
*
|
|
14
|
+
* `marked` rather than the site's own Markdown pipeline: the pipeline is async, plugin-laden and
|
|
15
|
+
* built for whole documents, while these are thousands of short strings per build — a large
|
|
16
|
+
* reference renders tens of thousands of them. `marked` is synchronous, already a dependency,
|
|
17
|
+
* and already how the Ask AI island renders model Markdown.
|
|
18
|
+
*/
|
|
19
|
+
const TABLE = /<table>[\s\S]*?<\/table>/gu;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An href the author can have meant: an absolute URL, a site-root path, a fragment or a mailto.
|
|
23
|
+
* A bare relative href in a spec description has never yet been a link, and the lookahead keeps
|
|
24
|
+
* `//host` out: that is a scheme-relative URL to another origin, not a site-root path.
|
|
25
|
+
*/
|
|
26
|
+
const DELIBERATE_HREF = /^(?:https?:|mailto:|#|\/(?!\/))/iu;
|
|
27
|
+
|
|
28
|
+
/** The source text of a demoted construct, made safe for `set:html`. */
|
|
29
|
+
const escapeHtml = (text: string): string =>
|
|
30
|
+
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
31
|
+
|
|
32
|
+
const markdown = new Marked({
|
|
33
|
+
// `breaks` is deliberately NOT set, unlike the Ask AI island. Docstring prose is hard-wrapped at
|
|
34
|
+
// 72 or 79 columns, so honouring single newlines would break every sentence mid-flow at exactly
|
|
35
|
+
// the width the source file happened to use.
|
|
36
|
+
breaks: false,
|
|
37
|
+
gfm: true,
|
|
38
|
+
hooks: {
|
|
39
|
+
// GFM tables reach the page through `set:html`, so they miss the `blume:table-wrap` plugin
|
|
40
|
+
// that gives every table in the body its scroll frame — and a description sits in a column
|
|
41
|
+
// narrower than the body. Wrapping here reuses that frame rather than reinventing it; the
|
|
42
|
+
// regex is safe because a table cannot nest and this HTML is `marked`'s own output.
|
|
43
|
+
postprocess: (html: string) =>
|
|
44
|
+
html.replaceAll(
|
|
45
|
+
TABLE,
|
|
46
|
+
(table) => `<div class="blume-table-scroll" tabindex="0">${table}</div>`
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
renderer: {
|
|
50
|
+
// Raw HTML is escaped rather than passed through. A description is data lifted out of a spec
|
|
51
|
+
// file, frequently generated upstream from source comments, and it is interpolated with
|
|
52
|
+
// `set:html`; the island that renders model output runs DOMPurify over it for the same
|
|
53
|
+
// reason, which needs a DOM and so is unavailable in a component that renders on the server.
|
|
54
|
+
html: ({ text }: { text: string }) => escapeHtml(text),
|
|
55
|
+
// An image is held to the same href policy as a link, for the same reason: `` is the
|
|
56
|
+
// one Markdown construct that fetches a resource, and a `javascript:` or relative source in a
|
|
57
|
+
// description is notation rather than a picture.
|
|
58
|
+
image: ({ href, raw }: { href: string; raw: string }) =>
|
|
59
|
+
DELIBERATE_HREF.test(href) ? false : escapeHtml(raw),
|
|
60
|
+
// A link is emitted only when the author clearly meant one; anything else keeps its source
|
|
61
|
+
// text verbatim. Two failures drove this, and both are prose that was never Markdown.
|
|
62
|
+
//
|
|
63
|
+
// GFM autolinks a BARE url (`raw === href`), and a spec description is full of EXAMPLE hosts
|
|
64
|
+
// — `https://myorg.my.salesforce.com`, `https://yourstore.myshopify.com`. Each became an
|
|
65
|
+
// anchor pointing at a host that does not exist and was never meant to be visited.
|
|
66
|
+
//
|
|
67
|
+
// Worse, regex and format notation reads as link syntax. Debezium's own wording for a column
|
|
68
|
+
// list is `schemaName[.]tableName[.](columnName1|columnName2)`, in which `[.](columnName1|
|
|
69
|
+
// columnName2)` is EXACTLY `[text](href)` — 48 pages of one reference linked to a path made
|
|
70
|
+
// of that notation.
|
|
71
|
+
// Rendering the demoted case as `raw` rather than as `text` is what keeps that intact: the
|
|
72
|
+
// text alone is `.`, so emitting it would silently delete the rest of the notation.
|
|
73
|
+
//
|
|
74
|
+
// The test for "meant one" is the href (see `DELIBERATE_HREF`) plus the shape of the source:
|
|
75
|
+
// an author-written link starts with `[` (inline or reference style) or `<` (an angle
|
|
76
|
+
// autolink). A GFM bare autolink never does — `https://…`, `www.…` or an email — and
|
|
77
|
+
// comparing `raw` to `href` is not enough to catch it, because marked prefixes the missing
|
|
78
|
+
// `http://` or `mailto:` for the last two, so the two strings differ exactly as they would
|
|
79
|
+
// for a deliberate link.
|
|
80
|
+
link({ href, raw }: { href: string; raw: string }) {
|
|
81
|
+
const deliberate =
|
|
82
|
+
DELIBERATE_HREF.test(href) &&
|
|
83
|
+
(raw.startsWith("[") || raw.startsWith("<"));
|
|
84
|
+
return deliberate ? false : escapeHtml(raw);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** `description` rendered to HTML, or an empty string when there is nothing to render. */
|
|
90
|
+
export const descriptionHtml = (description: string | undefined): string =>
|
|
91
|
+
description?.trim() ? markdown.parse(description, { async: false }) : "";
|
package/src/core/config-input.ts
CHANGED
|
@@ -895,6 +895,16 @@ export interface AnalyticsScript {
|
|
|
895
895
|
|
|
896
896
|
/** Analytics providers. Configure one, several, or none. */
|
|
897
897
|
export interface AnalyticsConfig {
|
|
898
|
+
/**
|
|
899
|
+
* Cloudflare Web Analytics, for a site Cloudflare doesn't proxy (manual
|
|
900
|
+
* setup). Not needed on a proxied zone with automatic RUM enabled — that
|
|
901
|
+
* injects the beacon at the edge, and configuring it here too would count
|
|
902
|
+
* every pageview twice.
|
|
903
|
+
*/
|
|
904
|
+
cloudflare?: {
|
|
905
|
+
/** Site token from the Web Analytics JS snippet (`data-cf-beacon`). */
|
|
906
|
+
token: string;
|
|
907
|
+
};
|
|
898
908
|
/** PostHog product analytics. */
|
|
899
909
|
posthog?: {
|
|
900
910
|
/** API host (for self-hosted / EU). Defaults to PostHog cloud. */
|
package/src/core/i18n.ts
CHANGED
|
@@ -48,8 +48,19 @@ export const resolveFallbackLocale = (
|
|
|
48
48
|
return i18n.fallbackLocale ?? i18n.defaultLocale;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The slice of the i18n settings URL routing reads. `ResolvedI18nConfig` and
|
|
53
|
+
* the `blume:data` runtime shape both satisfy it, so route helpers serve the
|
|
54
|
+
* Node-side graph and the rendered page alike.
|
|
55
|
+
*/
|
|
56
|
+
export interface LocaleRouting {
|
|
57
|
+
defaultLocale: string;
|
|
58
|
+
hideDefaultLocalePrefix: boolean;
|
|
59
|
+
locales: { code: string }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
/** URL prefix for a locale: `""` for the hidden default, else `/<code>`. */
|
|
52
|
-
export const localePrefix = (code: string, i18n:
|
|
63
|
+
export const localePrefix = (code: string, i18n: LocaleRouting): string =>
|
|
53
64
|
code === i18n.defaultLocale && i18n.hideDefaultLocalePrefix ? "" : `/${code}`;
|
|
54
65
|
|
|
55
66
|
/**
|
|
@@ -59,7 +70,7 @@ export const localePrefix = (code: string, i18n: ResolvedI18nConfig): string =>
|
|
|
59
70
|
export const localizeRoute = (
|
|
60
71
|
logicalRoute: string,
|
|
61
72
|
code: string,
|
|
62
|
-
i18n:
|
|
73
|
+
i18n: LocaleRouting
|
|
63
74
|
): string => {
|
|
64
75
|
const prefix = localePrefix(code, i18n);
|
|
65
76
|
if (!prefix) {
|
package/src/core/links.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
isRelativeImageTarget,
|
|
8
8
|
resolveRelativeImage,
|
|
9
9
|
} from "./content-assets.ts";
|
|
10
|
+
import type { LocaleRouting } from "./i18n.ts";
|
|
11
|
+
import { localizeLinkPath } from "./locale-links.ts";
|
|
10
12
|
import { gradeExternal, probeAll } from "./probe.ts";
|
|
11
13
|
import type {
|
|
12
14
|
ContentGraph,
|
|
@@ -78,6 +80,8 @@ interface LinkContext {
|
|
|
78
80
|
/** Servable routes outside the graph (custom pages, generated routes); their
|
|
79
81
|
* headings are unknown, so anchors there are accepted unchecked. */
|
|
80
82
|
extraRoutes: Set<string>;
|
|
83
|
+
/** Locale routing, when the site is multi-locale; drives served-route resolution. */
|
|
84
|
+
i18n: LocaleRouting | null;
|
|
81
85
|
publicDir: string | null;
|
|
82
86
|
/** Normalized `redirect.from` paths — valid targets that resolve at runtime. */
|
|
83
87
|
redirects: Set<string>;
|
|
@@ -189,6 +193,30 @@ const checkAnchor = (
|
|
|
189
193
|
};
|
|
190
194
|
};
|
|
191
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The route a link on `page` actually lands on. Rendered under a prefixed
|
|
198
|
+
* locale, a root-relative link moves into that locale when the localized route
|
|
199
|
+
* is served — a real translation (whose own headings then answer the anchor
|
|
200
|
+
* check) or a fallback page (accepted unchecked via `extraRoutes`) — exactly as
|
|
201
|
+
* `LocaleLinks.astro` rewrites it at render time. Otherwise the authored route
|
|
202
|
+
* stands.
|
|
203
|
+
*/
|
|
204
|
+
const servedRoute = (
|
|
205
|
+
authored: string,
|
|
206
|
+
page: PageRecord,
|
|
207
|
+
ctx: LinkContext
|
|
208
|
+
): string =>
|
|
209
|
+
ctx.i18n
|
|
210
|
+
? localizeLinkPath(authored, {
|
|
211
|
+
basePath: ctx.basePath,
|
|
212
|
+
i18n: ctx.i18n,
|
|
213
|
+
locale: page.locale,
|
|
214
|
+
routes: {
|
|
215
|
+
has: (route) => ctx.routes.has(route) || ctx.extraRoutes.has(route),
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
: authored;
|
|
219
|
+
|
|
192
220
|
/** Validate a resolved internal path: asset, route, then optional anchor. */
|
|
193
221
|
const checkPathLink = (
|
|
194
222
|
resolved: string,
|
|
@@ -204,7 +232,8 @@ const checkPathLink = (
|
|
|
204
232
|
// relative link already resolved against the based `page.route`). A real
|
|
205
233
|
// route always wins over the asset-extension heuristic, so a dotted route
|
|
206
234
|
// (e.g. `/releases/v1.0`) isn't misread as a missing asset.
|
|
207
|
-
const
|
|
235
|
+
const authoredRoute = toRoute(withBasePath(ctx.basePath, resolved));
|
|
236
|
+
const route = servedRoute(authoredRoute, page, ctx);
|
|
208
237
|
if (ctx.routes.has(route)) {
|
|
209
238
|
return fragment ? checkAnchor(route, fragment, site, ctx, via) : null;
|
|
210
239
|
}
|
|
@@ -374,6 +403,8 @@ export const validateLinks = async (
|
|
|
374
403
|
* full-route-set resolution in `nav-diagnostics.ts`/`generateRuntime`.
|
|
375
404
|
*/
|
|
376
405
|
extraRoutes?: string[];
|
|
406
|
+
/** Locale routing when i18n is on; links then resolve into the page's locale. */
|
|
407
|
+
i18n?: LocaleRouting | null;
|
|
377
408
|
publicDir: string | null;
|
|
378
409
|
checkExternal?: boolean;
|
|
379
410
|
/** Configured redirects; their `from` paths count as valid link targets. */
|
|
@@ -385,6 +416,7 @@ export const validateLinks = async (
|
|
|
385
416
|
anchors: buildAnchorIndex(graph.pages),
|
|
386
417
|
basePath,
|
|
387
418
|
extraRoutes: new Set((options.extraRoutes ?? []).map(toRoute)),
|
|
419
|
+
i18n: options.i18n ?? null,
|
|
388
420
|
publicDir: options.publicDir,
|
|
389
421
|
redirects: new Set(
|
|
390
422
|
(options.redirects ?? []).map((redirect) =>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalPath,
|
|
3
|
+
normalizePath,
|
|
4
|
+
stripBasePath,
|
|
5
|
+
withBasePath,
|
|
6
|
+
} from "./base-path.ts";
|
|
7
|
+
import { localePrefix, localizeRoute } from "./i18n.ts";
|
|
8
|
+
import type { LocaleRouting } from "./i18n.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Locale-aware resolution of content links.
|
|
12
|
+
*
|
|
13
|
+
* Authors write internal links as if mounted at the default locale's root
|
|
14
|
+
* (`[x](/guide)`, `<Card href="/guide">`), and translated pages are usually
|
|
15
|
+
* copies of the source with the same links — so read on `/fr/…`, a link would
|
|
16
|
+
* otherwise drop the reader back into the default language. These helpers move
|
|
17
|
+
* such a link to the reader's locale (`/fr/guide`) when that route is served
|
|
18
|
+
* (a real translation or a materialized fallback page), and leave it alone
|
|
19
|
+
* otherwise: an explicit cross-locale link (`/de/guide`), a custom `.astro`
|
|
20
|
+
* page or generated route that has no per-locale variant, or a missing
|
|
21
|
+
* translation on a site with fallbacks disabled all keep their authored
|
|
22
|
+
* target rather than pointing at a 404.
|
|
23
|
+
*
|
|
24
|
+
* The rewrite runs at render time (`components/layout/LocaleLinks.astro`)
|
|
25
|
+
* because content is compiled once per file but served per route: a fallback
|
|
26
|
+
* route renders the fallback locale's file under the missing locale's URL, and
|
|
27
|
+
* a shared `page.$.mdx` renders under every locale. The link checker applies
|
|
28
|
+
* the same resolution so anchors are validated against the page a reader lands
|
|
29
|
+
* on.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A route set, as the runtime (`Set`) or the checker (a predicate) sees it. */
|
|
33
|
+
export interface RouteSet {
|
|
34
|
+
has: (route: string) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LocalizeLinkOptions {
|
|
38
|
+
/** Site-wide route mount point (`""` or `/seg`); routes carry it. */
|
|
39
|
+
basePath: string;
|
|
40
|
+
i18n: LocaleRouting;
|
|
41
|
+
/** Locale of the page the link is rendered on. */
|
|
42
|
+
locale: string;
|
|
43
|
+
/** Every served route, base-prefixed like `path` (no `deployment.base`). */
|
|
44
|
+
routes: RouteSet;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mirrors the asset heuristic in `markdown/base-links.ts`: a path whose final
|
|
49
|
+
* segment carries a file extension is a `public/` asset (or a raw `.md` twin),
|
|
50
|
+
* served at the site root and never localized.
|
|
51
|
+
*/
|
|
52
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
53
|
+
|
|
54
|
+
/** Decode a percent-encoded path for a route lookup; leave junk as-is. */
|
|
55
|
+
const decodePercent = (value: string): string => {
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(value);
|
|
58
|
+
} catch {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Whether `path` (base-stripped) already sits under some locale's prefix. */
|
|
64
|
+
const hasLocalePrefix = (path: string, i18n: LocaleRouting): boolean =>
|
|
65
|
+
i18n.locales.some((locale) => {
|
|
66
|
+
const prefix = localePrefix(locale.code, i18n);
|
|
67
|
+
return prefix !== "" && (path === prefix || path.startsWith(`${prefix}/`));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Move a base-prefixed, fragment-less internal path into `locale` when that
|
|
72
|
+
* route is served, else return it unchanged. Idempotent: a path already under
|
|
73
|
+
* any configured locale prefix is never re-prefixed.
|
|
74
|
+
*/
|
|
75
|
+
export const localizeLinkPath = (
|
|
76
|
+
path: string,
|
|
77
|
+
options: LocalizeLinkOptions
|
|
78
|
+
): string => {
|
|
79
|
+
const { basePath, i18n, locale, routes } = options;
|
|
80
|
+
if (localePrefix(locale, i18n) === "") {
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
const rest = normalizePath(stripBasePath(basePath, path));
|
|
84
|
+
if (hasLocalePrefix(rest, i18n)) {
|
|
85
|
+
return path;
|
|
86
|
+
}
|
|
87
|
+
const localized = withBasePath(basePath, localizeRoute(rest, locale, i18n));
|
|
88
|
+
// Routes are stored decoded; a browser-copied `/caf%C3%A9` must still find
|
|
89
|
+
// its translation, but the emitted href keeps the author's encoding.
|
|
90
|
+
return routes.has(localized) || routes.has(decodePercent(localized))
|
|
91
|
+
? localized
|
|
92
|
+
: path;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export interface LocalizeHrefOptions extends LocalizeLinkOptions {
|
|
96
|
+
/** `deployment.base` (Astro's `BASE_URL`), layered over `basePath` in hrefs. */
|
|
97
|
+
deployBase: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Localize a rendered `href`: external URLs, relative paths, bare fragments, and
|
|
102
|
+
* asset links pass through; a root-relative page link keeps its `?query` and
|
|
103
|
+
* `#fragment` and its `deployment.base` layer around the localized path.
|
|
104
|
+
*/
|
|
105
|
+
export const localizeHref = (
|
|
106
|
+
href: string,
|
|
107
|
+
options: LocalizeHrefOptions
|
|
108
|
+
): string => {
|
|
109
|
+
if (!isInternalPath(href)) {
|
|
110
|
+
return href;
|
|
111
|
+
}
|
|
112
|
+
const suffixAt = href.search(/[#?]/u);
|
|
113
|
+
const path = suffixAt === -1 ? href : href.slice(0, suffixAt);
|
|
114
|
+
const suffix = suffixAt === -1 ? "" : href.slice(suffixAt);
|
|
115
|
+
if (ASSET_PATH.test(path)) {
|
|
116
|
+
return href;
|
|
117
|
+
}
|
|
118
|
+
const based = stripBasePath(options.deployBase, path);
|
|
119
|
+
const localized = localizeLinkPath(based, options);
|
|
120
|
+
if (localized === based) {
|
|
121
|
+
return href;
|
|
122
|
+
}
|
|
123
|
+
return `${withBasePath(options.deployBase, localized)}${suffix}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Every `<a …>` opening tag; `\s` keeps `<abbr>`/`<astro-island>` out. */
|
|
127
|
+
const ANCHOR_TAG = /<a\s[^>]*>/giu;
|
|
128
|
+
/** The tag's `href` attribute, double- or single-quoted. */
|
|
129
|
+
const HREF_ATTR = /(?<attr>\shref=)(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/iu;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rewrite every `<a href>` in rendered content HTML through `rewrite`. Code
|
|
133
|
+
* blocks are HTML-escaped (`<a`), and island props live on
|
|
134
|
+
* `<astro-island>`, so neither is touched.
|
|
135
|
+
*/
|
|
136
|
+
export const localizeContentLinks = (
|
|
137
|
+
html: string,
|
|
138
|
+
rewrite: (href: string) => string
|
|
139
|
+
): string =>
|
|
140
|
+
html.replace(ANCHOR_TAG, (tag) =>
|
|
141
|
+
tag.replace(HREF_ATTR, (_match, attr: string, dq?: string, sq?: string) => {
|
|
142
|
+
const quote = dq === undefined ? "'" : '"';
|
|
143
|
+
return `${attr}${quote}${rewrite(dq ?? sq ?? "")}${quote}`;
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The served route set for `blume:data`'s routes, built once per routes array
|
|
149
|
+
* (the virtual module is evaluated once, so every page render shares it).
|
|
150
|
+
*/
|
|
151
|
+
const routeSets = new WeakMap<readonly { path: string }[], Set<string>>();
|
|
152
|
+
|
|
153
|
+
export const routeSetFor = (
|
|
154
|
+
routes: readonly { path: string }[]
|
|
155
|
+
): Set<string> => {
|
|
156
|
+
const cached = routeSets.get(routes);
|
|
157
|
+
if (cached) {
|
|
158
|
+
return cached;
|
|
159
|
+
}
|
|
160
|
+
const built = new Set(routes.map((route) => route.path));
|
|
161
|
+
routeSets.set(routes, built);
|
|
162
|
+
return built;
|
|
163
|
+
};
|
package/src/core/schema.ts
CHANGED
|
@@ -1220,6 +1220,14 @@ const analyticsScriptSchema = z
|
|
|
1220
1220
|
});
|
|
1221
1221
|
|
|
1222
1222
|
const analyticsConfigSchema = z.strictObject({
|
|
1223
|
+
// Cloudflare Web Analytics in manual (JS snippet) mode; the token comes from
|
|
1224
|
+
// the site's snippet in the dashboard. A zone Cloudflare proxies with
|
|
1225
|
+
// automatic RUM injection on needs no config at all.
|
|
1226
|
+
cloudflare: z
|
|
1227
|
+
.strictObject({
|
|
1228
|
+
token: z.string().min(1),
|
|
1229
|
+
})
|
|
1230
|
+
.optional(),
|
|
1223
1231
|
posthog: z
|
|
1224
1232
|
.strictObject({
|
|
1225
1233
|
host: z.string().optional(),
|
|
@@ -280,6 +280,8 @@ interface HeadingScanState {
|
|
|
280
280
|
fence: FenceState;
|
|
281
281
|
/** 1-based body line of the line being scanned. */
|
|
282
282
|
line: number;
|
|
283
|
+
/** Where each heading in `headings` sits, index-aligned. */
|
|
284
|
+
sites: HeadingSite[];
|
|
283
285
|
/** Consecutive paragraph lines — the candidate text for a setext underline. */
|
|
284
286
|
paragraph: string[];
|
|
285
287
|
/** Body line of the first line in `paragraph`. */
|
|
@@ -399,23 +401,43 @@ const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
|
|
|
399
401
|
* regardless of TOC visibility. A heading that is nothing but markers keeps
|
|
400
402
|
* them as literal text, mirroring the renderer.
|
|
401
403
|
*/
|
|
404
|
+
/** A scanned heading plus whether its id came from an author pin. */
|
|
405
|
+
interface ScannedHeading {
|
|
406
|
+
heading: Heading;
|
|
407
|
+
pinned: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
402
410
|
const toHeading = (
|
|
403
411
|
depth: number,
|
|
404
412
|
raw: string,
|
|
405
413
|
slugger: GithubSlugger,
|
|
406
414
|
isRefDefined: (label: string) => boolean
|
|
407
|
-
):
|
|
415
|
+
): ScannedHeading => {
|
|
408
416
|
const unescaped = raw.replaceAll(ESCAPED_PUNCTUATION, "$<char>");
|
|
409
417
|
const markers = parseHeadingMarkers(unescaped, isRefDefined);
|
|
410
418
|
const text = markers.text.trim();
|
|
411
419
|
if (text === "" && (markers.id !== undefined || markers.toc !== undefined)) {
|
|
412
|
-
return {
|
|
420
|
+
return {
|
|
421
|
+
heading: { depth, slug: slugger.slug(unescaped), text: unescaped },
|
|
422
|
+
pinned: false,
|
|
423
|
+
};
|
|
413
424
|
}
|
|
414
425
|
if (markers.id !== undefined) {
|
|
415
426
|
occupySlug(slugger, markers.id);
|
|
416
|
-
return { depth, slug: markers.id, text };
|
|
427
|
+
return { heading: { depth, slug: markers.id, text }, pinned: true };
|
|
417
428
|
}
|
|
418
|
-
return { depth, slug: slugger.slug(text), text };
|
|
429
|
+
return { heading: { depth, slug: slugger.slug(text), text }, pinned: false };
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** Record a heading and where a trailing marker would be written for it. */
|
|
433
|
+
const pushHeading = (
|
|
434
|
+
headings: Heading[],
|
|
435
|
+
state: HeadingScanState,
|
|
436
|
+
scanned: ScannedHeading,
|
|
437
|
+
line: number
|
|
438
|
+
): void => {
|
|
439
|
+
headings.push(scanned.heading);
|
|
440
|
+
state.sites.push({ line, pinned: scanned.pinned });
|
|
419
441
|
};
|
|
420
442
|
|
|
421
443
|
/** Record a heading's unescaped trailing `{#id}` so `.mdx` pages can be warned. */
|
|
@@ -444,7 +466,12 @@ const scanContentLine = (
|
|
|
444
466
|
if (atx?.groups) {
|
|
445
467
|
const depth = atx.groups.hashes?.length ?? 1;
|
|
446
468
|
const text = (atx.groups.text ?? "").trim();
|
|
447
|
-
|
|
469
|
+
pushHeading(
|
|
470
|
+
headings,
|
|
471
|
+
state,
|
|
472
|
+
toHeading(depth, text, slugger, isRefDefined),
|
|
473
|
+
state.line
|
|
474
|
+
);
|
|
448
475
|
noteCurlyMarker(text, state.line, state);
|
|
449
476
|
state.paragraph = [];
|
|
450
477
|
return;
|
|
@@ -455,7 +482,14 @@ const scanContentLine = (
|
|
|
455
482
|
// a multi-line paragraph renders as one heading, soft breaks as spaces.
|
|
456
483
|
const depth = setext.groups.marker?.startsWith("=") ? 1 : 2;
|
|
457
484
|
const text = state.paragraph.join(" ").trim();
|
|
458
|
-
|
|
485
|
+
// A setext heading's markers trail its last text line, just above the
|
|
486
|
+
// underline — that is where a pin is appended.
|
|
487
|
+
pushHeading(
|
|
488
|
+
headings,
|
|
489
|
+
state,
|
|
490
|
+
toHeading(depth, text, slugger, isRefDefined),
|
|
491
|
+
state.line - 1
|
|
492
|
+
);
|
|
459
493
|
noteCurlyMarker(text, state.paragraphStart, state);
|
|
460
494
|
state.paragraph = [];
|
|
461
495
|
return;
|
|
@@ -515,6 +549,14 @@ const scanHeadingLine = (
|
|
|
515
549
|
scanContentLine(line, state, slugger, headings, isRefDefined);
|
|
516
550
|
};
|
|
517
551
|
|
|
552
|
+
/** Where a heading's text ends in the scanned text, for appending a marker. */
|
|
553
|
+
export interface HeadingSite {
|
|
554
|
+
/** 1-based line (of the text passed to `scanBody`) that any trailing marker ends. */
|
|
555
|
+
line: number;
|
|
556
|
+
/** True when the heading already pins its id with `[#id]`/`{#id}`. */
|
|
557
|
+
pinned: boolean;
|
|
558
|
+
}
|
|
559
|
+
|
|
518
560
|
/** Everything one walk over a body yields for the anchor index. */
|
|
519
561
|
export interface BodyScan {
|
|
520
562
|
/**
|
|
@@ -527,6 +569,8 @@ export interface BodyScan {
|
|
|
527
569
|
/** Headings whose trailing `{#id}` marker is unescaped, for `.mdx` pages. */
|
|
528
570
|
curlyMarkers: CurlyMarker[];
|
|
529
571
|
headings: Heading[];
|
|
572
|
+
/** Index-aligned with `headings`: where each one's markers would go. */
|
|
573
|
+
sites: HeadingSite[];
|
|
530
574
|
}
|
|
531
575
|
|
|
532
576
|
/**
|
|
@@ -546,6 +590,7 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
546
590
|
paragraphStart: 0,
|
|
547
591
|
promptDepth: 0,
|
|
548
592
|
promptTag: false,
|
|
593
|
+
sites: [],
|
|
549
594
|
};
|
|
550
595
|
|
|
551
596
|
const { lines, offset } = linesWithoutFrontMatter(body);
|
|
@@ -568,7 +613,12 @@ export const scanBody = (body: string): BodyScan => {
|
|
|
568
613
|
anchors.add(id);
|
|
569
614
|
}
|
|
570
615
|
}
|
|
571
|
-
return {
|
|
616
|
+
return {
|
|
617
|
+
anchors: [...anchors],
|
|
618
|
+
curlyMarkers: state.curlyMarkers,
|
|
619
|
+
headings,
|
|
620
|
+
sites: state.sites,
|
|
621
|
+
};
|
|
572
622
|
};
|
|
573
623
|
|
|
574
624
|
export const extractHeadings = (body: string): Heading[] =>
|