blume 1.6.3 → 1.6.4
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 +19 -0
- package/dist/cli/index.js +294 -46
- package/dist/cli/index.js.map +27 -22
- package/dist/types/ai/component-markdown.d.ts +14 -0
- 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/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 +1 -1
- 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 +18 -18
- 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/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/layout/LocaleLinks.astro +42 -0
- package/src/components/layout/PageLayout.astro +5 -3
- package/src/components/layout/ReferenceLayout.astro +5 -0
- package/src/components/layout/RootLayout.astro +5 -3
- 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 +3 -2
- package/src/components/openapi/GraphqlOperation.astro +3 -2
- package/src/components/openapi/Operation.astro +3 -2
- 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/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 +19 -2
- 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
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { buildStructuredData } from "../../seo/jsonld.ts";
|
|
36
36
|
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
37
37
|
import { withBase } from "../islands/base-path.ts";
|
|
38
|
+
import { searchLocaleFor } from "./search-locale.ts";
|
|
38
39
|
import "blume:theme";
|
|
39
40
|
import { ClientRouter } from "astro:transitions";
|
|
40
41
|
import Analytics from "./Analytics.astro";
|
|
@@ -160,9 +161,10 @@ const strings = ui ?? EN_UI;
|
|
|
160
161
|
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
161
162
|
// blank — the PageActions pattern.
|
|
162
163
|
const navStrings = { ...EN_UI.nav, ...strings.nav };
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
// Scope search to the active language on a multi-locale site. Read from the
|
|
165
|
+
// i18n snapshot, not the switcher list, so pages rendered without one still
|
|
166
|
+
// filter (see `searchLocaleFor`).
|
|
167
|
+
const searchLocale = searchLocaleFor(data.config.i18n, locale);
|
|
166
168
|
const pageTitle = page?.title ?? site.title;
|
|
167
169
|
const description = page?.description ?? site.description;
|
|
168
170
|
const route = page?.route ?? "/";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "blume:theme";
|
|
3
|
+
import data from "blume:data";
|
|
3
4
|
import type { BlumeFavicon } from "../../core/data.ts";
|
|
4
5
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
5
6
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
THEME_INIT_SCRIPT,
|
|
17
18
|
} from "./head-scripts.ts";
|
|
18
19
|
import Header from "./Header.astro";
|
|
20
|
+
import { searchLocaleFor } from "./search-locale.ts";
|
|
19
21
|
|
|
20
22
|
// A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
|
|
21
23
|
// on top, then a full-height region the reference mounts into. Unlike RootLayout
|
|
@@ -91,6 +93,8 @@ const {
|
|
|
91
93
|
} = Astro.props;
|
|
92
94
|
|
|
93
95
|
const strings = ui ?? EN_UI;
|
|
96
|
+
// Scope search to the reference page's language on a multi-locale site.
|
|
97
|
+
const searchLocale = searchLocaleFor(data.config.i18n, locale);
|
|
94
98
|
|
|
95
99
|
const bannerKey = banner?.dismissible ? banner.key : null;
|
|
96
100
|
---
|
|
@@ -126,6 +130,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
126
130
|
navigation={navigation}
|
|
127
131
|
route={route}
|
|
128
132
|
searchEnabled={searchEnabled}
|
|
133
|
+
searchLocale={searchLocale}
|
|
129
134
|
site={site}
|
|
130
135
|
/>
|
|
131
136
|
<div
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./nav-utils.ts";
|
|
59
59
|
import NavTree from "./NavTree.astro";
|
|
60
60
|
import { resolveSlot } from "./overrides.ts";
|
|
61
|
+
import { searchLocaleFor } from "./search-locale.ts";
|
|
61
62
|
import PageActions from "./PageActions.astro";
|
|
62
63
|
import PageFeedback from "./PageFeedback.astro";
|
|
63
64
|
import Pagination from "./Pagination.astro";
|
|
@@ -289,9 +290,10 @@ const strings = ui ?? EN_UI;
|
|
|
289
290
|
const navStrings = { ...EN_UI.nav, ...strings.nav };
|
|
290
291
|
const actionStrings = { ...EN_UI.actions, ...strings.actions };
|
|
291
292
|
const contentStrings = { ...EN_UI.content, ...strings.content };
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
293
|
+
// Scope search to the active language on a multi-locale site. Read from the
|
|
294
|
+
// i18n snapshot, not the switcher list, so pages rendered without one still
|
|
295
|
+
// filter (see `searchLocaleFor`).
|
|
296
|
+
const searchLocale = searchLocaleFor(data.config.i18n, locale);
|
|
295
297
|
// TOC entries: the configured heading range, or none when the TOC is disabled
|
|
296
298
|
// (an empty list makes TableOfContents render nothing).
|
|
297
299
|
// API operation pages own a two-column body (docs + request panel) and their own
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locale the search dialog should scope to, or `undefined` when scoping
|
|
3
|
+
* is pointless. Derived from the resolved i18n settings rather than from the
|
|
4
|
+
* header's language-switcher entries: the switcher list is only assembled by
|
|
5
|
+
* the content catch-all, so pages rendered without one (custom pages built on
|
|
6
|
+
* `PageLayout`, the changelog index, the 404 page, the API reference shell)
|
|
7
|
+
* would otherwise search every language while showing no "All languages"
|
|
8
|
+
* toggle. A single-locale site (or one without i18n) has nothing to scope.
|
|
9
|
+
*/
|
|
10
|
+
export const searchLocaleFor = (
|
|
11
|
+
i18n: { locales: { code: string }[] } | null,
|
|
12
|
+
locale: string
|
|
13
|
+
): string | undefined => (i18n && i18n.locales.length > 1 ? locale : undefined);
|
|
@@ -1,54 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
import specs from "blume:openapi";
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import { specAddresses, specOf } from "../../openapi/model.ts";
|
|
5
5
|
|
|
6
6
|
// The spec-level metadata block (version + base URLs) at the top of an API
|
|
7
7
|
// overview page. The tag sections that follow are emitted by `overviewMdx` as
|
|
8
8
|
// markdown headings plus `<ApiTagOperations>` lists, so they land in the
|
|
9
|
-
// table of contents.
|
|
9
|
+
// table of contents. The agent surfaces downlevel this block from the same
|
|
10
|
+
// `specAddresses`, so the `.md` page lists what this one shows.
|
|
10
11
|
interface Props {
|
|
11
12
|
source: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const { source } = Astro.props;
|
|
15
|
-
const spec = specs
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// live endpoint stands in. All flatten into one list of address chips.
|
|
20
|
-
const addresses: string[] = [];
|
|
21
|
-
if (spec?.kind === "graphql") {
|
|
22
|
-
if (spec.endpoint) {
|
|
23
|
-
addresses.push(spec.endpoint);
|
|
24
|
-
}
|
|
25
|
-
} else if (spec?.kind === "asyncapi") {
|
|
26
|
-
const servers = (spec.document as AsyncApiDocument).servers ?? {};
|
|
27
|
-
for (const server of Object.values(servers)) {
|
|
28
|
-
if (server?.host) {
|
|
29
|
-
addresses.push(
|
|
30
|
-
`${server.protocol ? `${server.protocol}://` : ""}${server.host}${server.pathname ?? ""}`
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
} else {
|
|
35
|
-
// Hand-written specs sometimes declare `servers` as a bare object; degrade
|
|
36
|
-
// to no address chips instead of throwing mid-build.
|
|
37
|
-
const declared = ((spec?.document ?? {}) as { servers?: { url?: string }[] })
|
|
38
|
-
.servers;
|
|
39
|
-
const servers = Array.isArray(declared) ? declared : [];
|
|
40
|
-
for (const server of servers) {
|
|
41
|
-
if (server.url) {
|
|
42
|
-
addresses.push(server.url);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
let addressLabel = "Base URL";
|
|
47
|
-
if (spec?.kind === "asyncapi") {
|
|
48
|
-
addressLabel = "Servers";
|
|
49
|
-
} else if (spec?.kind === "graphql") {
|
|
50
|
-
addressLabel = "Endpoint";
|
|
51
|
-
}
|
|
16
|
+
const spec = specOf(specs, source);
|
|
17
|
+
const { addresses, label: addressLabel } = spec
|
|
18
|
+
? specAddresses(spec)
|
|
19
|
+
: { addresses: [], label: "" };
|
|
52
20
|
---
|
|
53
21
|
|
|
54
22
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import specs from "blume:openapi";
|
|
3
|
+
import { specOf } from "../../openapi/model.ts";
|
|
3
4
|
import { withBase } from "../islands/base-path.ts";
|
|
4
5
|
import MethodBadge from "./MethodBadge.astro";
|
|
5
6
|
|
|
@@ -14,7 +15,7 @@ interface Props {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const { source, tag } = Astro.props;
|
|
17
|
-
const operations = Object.values(specs
|
|
18
|
+
const operations = Object.values(specOf(specs, source)?.operations ?? {}).filter(
|
|
18
19
|
(operation) => operation.tagSlug === tag
|
|
19
20
|
);
|
|
20
21
|
---
|
|
@@ -4,6 +4,7 @@ import specs from "blume:openapi";
|
|
|
4
4
|
|
|
5
5
|
import type { AsyncApiDocument } from "../../openapi/asyncapi.ts";
|
|
6
6
|
import { asyncApiOperationObject } from "../../openapi/asyncapi.ts";
|
|
7
|
+
import { operationOf, specOf } from "../../openapi/model.ts";
|
|
7
8
|
import { highlightCode } from "../../markdown/index.ts";
|
|
8
9
|
import {
|
|
9
10
|
asyncApiSecurityEntries,
|
|
@@ -47,8 +48,8 @@ interface Props {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const { source, id } = Astro.props;
|
|
50
|
-
const spec = specs
|
|
51
|
-
const ref = spec
|
|
51
|
+
const spec = specOf(specs, source);
|
|
52
|
+
const ref = spec ? operationOf(spec, id) : undefined;
|
|
52
53
|
const document = (spec?.document ?? {}) as AsyncApiDocument;
|
|
53
54
|
const operation = ref ? asyncApiOperationObject(document, ref) : undefined;
|
|
54
55
|
const channel = ref?.channelId
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
graphqlRootField,
|
|
11
11
|
isGraphqlOperationKind,
|
|
12
12
|
} from "../../openapi/graphql.ts";
|
|
13
|
+
import { operationOf, specOf } from "../../openapi/model.ts";
|
|
13
14
|
import { highlightCode } from "../../markdown/index.ts";
|
|
14
15
|
import { DEPRECATED_LABEL_CLASS } from "../colors.ts";
|
|
15
16
|
import {
|
|
@@ -46,8 +47,8 @@ interface Props {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
const { source, id } = Astro.props;
|
|
49
|
-
const spec = specs
|
|
50
|
-
const ref = spec
|
|
50
|
+
const spec = specOf(specs, source);
|
|
51
|
+
const ref = spec ? operationOf(spec, id) : undefined;
|
|
51
52
|
const document = (spec?.document ?? { roots: {}, types: {} }) as GraphqlDocument;
|
|
52
53
|
const isOperation = ref !== undefined && isGraphqlOperationKind(ref.method);
|
|
53
54
|
const field = ref && isOperation ? graphqlRootField(document, ref) : undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import specs from "blume:openapi";
|
|
3
|
+
import { operationOf, specOf } from "../../openapi/model.ts";
|
|
3
4
|
import {
|
|
4
5
|
mergeParameters,
|
|
5
6
|
type ParameterLike,
|
|
@@ -61,8 +62,8 @@ interface FullOperation {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const { source, id } = Astro.props;
|
|
64
|
-
const spec = specs
|
|
65
|
-
const ref = spec
|
|
65
|
+
const spec = specOf(specs, source);
|
|
66
|
+
const ref = spec ? operationOf(spec, id) : undefined;
|
|
66
67
|
// The AsyncAPI and GraphQL front-ends render their own bodies; the lookups
|
|
67
68
|
// below are OpenAPI-shaped (paths, request/response) and resolve to nothing
|
|
68
69
|
// for them.
|
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
|
+
};
|
|
@@ -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[] =>
|