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.
Files changed (76) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cli/index.js +294 -46
  3. package/dist/cli/index.js.map +27 -22
  4. package/dist/types/ai/component-markdown.d.ts +14 -0
  5. package/docs/01-quickstart.mdx +2 -2
  6. package/docs/02-deployment.mdx +5 -5
  7. package/docs/{07-faq.mdx → 08-faq.mdx} +7 -7
  8. package/docs/advanced/blog.mdx +3 -3
  9. package/docs/advanced/changelog.mdx +2 -2
  10. package/docs/advanced/custom-pages.mdx +4 -4
  11. package/docs/advanced/meta.ts +1 -1
  12. package/docs/configuration/ask-ai.mdx +179 -0
  13. package/docs/configuration/index.mdx +8 -7
  14. package/docs/configuration/meta.ts +1 -2
  15. package/docs/configuration/search.mdx +1 -1
  16. package/docs/configuration/theming.mdx +1 -1
  17. package/docs/content/i18n.mdx +7 -1
  18. package/docs/content/index.mdx +1 -1
  19. package/docs/content/navigation.mdx +2 -2
  20. package/docs/content/syntax.mdx +1 -1
  21. package/docs/discoverability/agent-discovery.mdx +196 -0
  22. package/docs/discoverability/index.mdx +48 -0
  23. package/docs/discoverability/json-api.mdx +58 -0
  24. package/docs/discoverability/llms-txt.mdx +68 -0
  25. package/docs/discoverability/markdown.mdx +76 -0
  26. package/docs/discoverability/mcp.mdx +64 -0
  27. package/docs/discoverability/meta.ts +18 -0
  28. package/docs/discoverability/metadata.mdx +82 -0
  29. package/docs/discoverability/open-graph.mdx +113 -0
  30. package/docs/discoverability/rss.mdx +24 -0
  31. package/docs/discoverability/sitemap-and-robots.mdx +95 -0
  32. package/docs/discoverability/structured-data.mdx +51 -0
  33. package/docs/index.mdx +5 -5
  34. package/docs/reference/eval.mdx +1 -1
  35. package/docs/reference/meta.ts +1 -1
  36. package/docs/reference/translate.mdx +1 -0
  37. package/package.json +18 -18
  38. package/src/ai/component-markdown.ts +17 -2
  39. package/src/ai/llms.ts +3 -10
  40. package/src/ai/markdown.ts +3 -10
  41. package/src/ai/openapi-components.ts +123 -0
  42. package/src/ai/serializers.ts +24 -0
  43. package/src/astro/templates.ts +42 -12
  44. package/src/audit/checks/links.ts +1 -8
  45. package/src/audit/checks/llms.ts +5 -4
  46. package/src/audit/redirects.ts +4 -3
  47. package/src/audit/run.ts +6 -8
  48. package/src/audit/url.ts +33 -0
  49. package/src/cli/commands/validate.ts +1 -0
  50. package/src/components/content/Component.astro +60 -59
  51. package/src/components/content/example-pane.ts +6 -0
  52. package/src/components/layout/LocaleLinks.astro +42 -0
  53. package/src/components/layout/PageLayout.astro +5 -3
  54. package/src/components/layout/ReferenceLayout.astro +5 -0
  55. package/src/components/layout/RootLayout.astro +5 -3
  56. package/src/components/layout/search-locale.ts +13 -0
  57. package/src/components/openapi/ApiOverview.astro +7 -39
  58. package/src/components/openapi/ApiTagOperations.astro +2 -1
  59. package/src/components/openapi/AsyncApiOperation.astro +3 -2
  60. package/src/components/openapi/GraphqlOperation.astro +3 -2
  61. package/src/components/openapi/Operation.astro +3 -2
  62. package/src/core/i18n.ts +13 -2
  63. package/src/core/links.ts +33 -1
  64. package/src/core/locale-links.ts +163 -0
  65. package/src/core/sources/normalize.ts +57 -7
  66. package/src/markdown/package-commands.ts +27 -3
  67. package/src/openapi/graphql.ts +29 -0
  68. package/src/openapi/model.ts +69 -0
  69. package/src/openapi/render-mdx.ts +3 -2
  70. package/src/openapi/signature.ts +18 -0
  71. package/src/search/documents.ts +4 -9
  72. package/src/theme/entry.ts +19 -2
  73. package/src/translate/anchors.ts +91 -0
  74. package/src/translate/validate.ts +8 -3
  75. package/docs/configuration/ai.mdx +0 -613
  76. 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
- // Filter search to the active language only when the site is multi-locale.
164
- const searchLocale =
165
- localeSwitch && localeSwitch.length > 1 ? locale : undefined;
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
- // Filter search to the active language only when the site is multi-locale.
293
- const searchLocale =
294
- localeSwitch && localeSwitch.length > 1 ? locale : undefined;
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 type { AsyncApiDocument } from "../../openapi/asyncapi.ts";
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[source];
16
-
17
- // OpenAPI declares `servers` as an array of URLs; AsyncAPI as a named map of
18
- // host/protocol/pathname; a GraphQL schema names no server, so its configured
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[source]?.operations ?? {}).filter(
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[source];
51
- const ref = spec?.operations[id];
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[source];
50
- const ref = spec?.operations[id];
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[source];
65
- const ref = spec?.operations[id];
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: ResolvedI18nConfig): string =>
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: ResolvedI18nConfig
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 route = toRoute(withBasePath(ctx.basePath, resolved));
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 (`&lt;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
- ): Heading => {
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 { depth, slug: slugger.slug(unescaped), text: unescaped };
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
- headings.push(toHeading(depth, text, slugger, isRefDefined));
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
- headings.push(toHeading(depth, text, slugger, isRefDefined));
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 { anchors: [...anchors], curlyMarkers: state.curlyMarkers, headings };
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[] =>