blume 1.6.2 → 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 (79) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli/index.js +307 -68
  3. package/dist/cli/index.js.map +28 -23
  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/components.mdx +1 -1
  18. package/docs/content/i18n.mdx +7 -1
  19. package/docs/content/index.mdx +1 -1
  20. package/docs/content/navigation.mdx +2 -2
  21. package/docs/content/syntax.mdx +1 -1
  22. package/docs/discoverability/agent-discovery.mdx +196 -0
  23. package/docs/discoverability/index.mdx +48 -0
  24. package/docs/discoverability/json-api.mdx +58 -0
  25. package/docs/discoverability/llms-txt.mdx +68 -0
  26. package/docs/discoverability/markdown.mdx +76 -0
  27. package/docs/discoverability/mcp.mdx +64 -0
  28. package/docs/discoverability/meta.ts +18 -0
  29. package/docs/discoverability/metadata.mdx +82 -0
  30. package/docs/discoverability/open-graph.mdx +113 -0
  31. package/docs/discoverability/rss.mdx +24 -0
  32. package/docs/discoverability/sitemap-and-robots.mdx +95 -0
  33. package/docs/discoverability/structured-data.mdx +51 -0
  34. package/docs/index.mdx +5 -5
  35. package/docs/reference/eval.mdx +1 -1
  36. package/docs/reference/meta.ts +1 -1
  37. package/docs/reference/translate.mdx +1 -0
  38. package/package.json +18 -18
  39. package/src/ai/component-markdown.ts +17 -2
  40. package/src/ai/llms.ts +3 -10
  41. package/src/ai/markdown.ts +3 -10
  42. package/src/ai/openapi-components.ts +123 -0
  43. package/src/ai/serializers.ts +24 -0
  44. package/src/astro/templates.ts +42 -12
  45. package/src/audit/checks/links.ts +1 -8
  46. package/src/audit/checks/llms.ts +5 -4
  47. package/src/audit/redirects.ts +4 -3
  48. package/src/audit/run.ts +6 -8
  49. package/src/audit/url.ts +33 -0
  50. package/src/cli/commands/validate.ts +1 -0
  51. package/src/components/content/Component.astro +65 -68
  52. package/src/components/content/Tabs.astro +24 -9
  53. package/src/components/content/example-pane.ts +6 -0
  54. package/src/components/layout/LocaleLinks.astro +42 -0
  55. package/src/components/layout/PageLayout.astro +5 -3
  56. package/src/components/layout/ReferenceLayout.astro +5 -0
  57. package/src/components/layout/RootLayout.astro +110 -39
  58. package/src/components/layout/search-locale.ts +13 -0
  59. package/src/components/openapi/ApiOverview.astro +7 -39
  60. package/src/components/openapi/ApiTagOperations.astro +2 -1
  61. package/src/components/openapi/AsyncApiOperation.astro +3 -2
  62. package/src/components/openapi/GraphqlOperation.astro +3 -2
  63. package/src/components/openapi/Operation.astro +3 -2
  64. package/src/core/i18n.ts +13 -2
  65. package/src/core/links.ts +33 -1
  66. package/src/core/locale-links.ts +163 -0
  67. package/src/core/sources/normalize.ts +57 -7
  68. package/src/markdown/package-commands.ts +27 -3
  69. package/src/openapi/graphql.ts +29 -0
  70. package/src/openapi/model.ts +69 -0
  71. package/src/openapi/render-mdx.ts +3 -2
  72. package/src/openapi/signature.ts +18 -0
  73. package/src/search/documents.ts +4 -9
  74. package/src/theme/code-block-padding.ts +0 -8
  75. package/src/theme/entry.ts +33 -27
  76. package/src/translate/anchors.ts +91 -0
  77. package/src/translate/validate.ts +8 -3
  78. package/docs/configuration/ai.mdx +0 -613
  79. package/docs/configuration/seo.mdx +0 -364
@@ -0,0 +1,123 @@
1
+ import type { ApiOperationRef, OpenApiData } from "../openapi/model.ts";
2
+ import { operationOf, specAddresses, specOf } from "../openapi/model.ts";
3
+ import { operationSignature } from "../openapi/signature.ts";
4
+ import type {
5
+ ComponentMarkdown,
6
+ EvaluatedValue,
7
+ } from "./component-markdown.ts";
8
+ import { inlineCode, isString, linkDestination } from "./component-markdown.ts";
9
+
10
+ /**
11
+ * Spec-authored prose as one line of inline Markdown. Unlike the props the
12
+ * other serializers pass through, a summary is written by whoever wrote the
13
+ * spec, not by the docs author, so the characters CommonMark would read as
14
+ * markup — `<user>` as inline HTML, `*only*` as emphasis — are escaped.
15
+ */
16
+ const inlineText = (text: string): string =>
17
+ text
18
+ .trim()
19
+ .replaceAll(/\s+/gu, " ")
20
+ .replaceAll(/[\\`*_[\]<>~]/gu, String.raw`\$&`);
21
+
22
+ /** One operation as a link, for the tag listings. */
23
+ const listItem = (
24
+ signature: string,
25
+ operation: Pick<ApiOperationRef, "deprecated" | "route" | "summary">
26
+ ): string => {
27
+ const tail = [
28
+ inlineText(operation.summary),
29
+ operation.deprecated ? "Deprecated." : "",
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+ return `- [${inlineCode(signature)}](${linkDestination(operation.route)})${tail ? ` — ${tail}` : ""}`;
34
+ };
35
+
36
+ /**
37
+ * Agent-facing Markdown for the components a generated reference page is made
38
+ * of: `<Operation>`, `<ApiTagOperations>` and `<ApiOverview>`.
39
+ *
40
+ * `render-mdx.ts` builds each reference page as the operation's description in
41
+ * the body plus one of these components, deliberately: the structured UI is the
42
+ * component's job, and the prose stays Markdown so it indexes. That split is
43
+ * right for the rendered page and lossy everywhere else — `<route>.md`,
44
+ * llms-full.txt, MCP `get_page` and the Ask AI corpus all downlevel components
45
+ * to Markdown, and these three had no serializer, so an operation page reached
46
+ * an agent as its description followed by a bare tag. On a site whose reference
47
+ * is most of the corpus, that is most of the corpus: measured on one 449-page
48
+ * site, 266 pages and 266 raw `<Operation>` in llms-full.txt, so "which
49
+ * endpoint do I call?" had no answer anywhere in the agent surface.
50
+ *
51
+ * Each serializer emits what its component renders and no more: the endpoint
52
+ * in the spec kind's own notation ({@link operationSignature}), the version and
53
+ * addresses the overview shows ({@link specAddresses}), the linked list a tag
54
+ * section shows. Parameters, schemas and responses are deliberately left out:
55
+ * those are `operation-model.ts` plus each component's own preparation, and a
56
+ * second implementation here would be free to disagree with the page. The
57
+ * endpoint and what it does is the part that was missing altogether.
58
+ *
59
+ * `specs` is the parsed `blume:openapi` data — empty when the project has no
60
+ * API reference, in which case every serializer declines.
61
+ */
62
+ export const openapiComponentSerializers = (specs: OpenApiData) => {
63
+ const spec = (source: EvaluatedValue) =>
64
+ isString(source) ? specOf(specs, source) : undefined;
65
+
66
+ return {
67
+ /**
68
+ * The spec-level block at the top of the overview page: its version and
69
+ * where the API lives. The tag sections that follow are real Markdown
70
+ * headings over `<ApiTagOperations>`, so they downlevel on their own.
71
+ */
72
+ ApiOverview: ({ props }) => {
73
+ const data = spec(props.source);
74
+ if (!data) {
75
+ return null;
76
+ }
77
+ const { addresses, label } = specAddresses(data);
78
+ const lines = [
79
+ data.version ? `Version ${inlineText(data.version)}` : "",
80
+ addresses.length > 0
81
+ ? `${label}: ${addresses.map(inlineCode).join(", ")}`
82
+ : "",
83
+ ].filter(Boolean);
84
+ return lines.length > 0 ? lines.join("\n\n") : null;
85
+ },
86
+
87
+ /** One tag's operations, mirroring the list the rendered page shows. */
88
+ ApiTagOperations: ({ props }) => {
89
+ const { tag } = props;
90
+ const data = spec(props.source);
91
+ if (!(data && isString(tag))) {
92
+ return null;
93
+ }
94
+ const items = Object.values(data.operations)
95
+ .filter((operation) => operation.tagSlug === tag)
96
+ .map((operation) =>
97
+ listItem(operationSignature(data, operation), operation)
98
+ );
99
+ return items.length > 0 ? items.join("\n") : null;
100
+ },
101
+
102
+ /**
103
+ * One operation. Declines when the spec carries no such key — Blume's own
104
+ * fallback, which leaves the JSX visible rather than publishing a page that
105
+ * silently lost its endpoint.
106
+ */
107
+ Operation: ({ props }) => {
108
+ const { id } = props;
109
+ const data = spec(props.source);
110
+ if (!(data && isString(id))) {
111
+ return null;
112
+ }
113
+ const operation = operationOf(data, id);
114
+ if (!operation) {
115
+ return null;
116
+ }
117
+ const signature = inlineCode(operationSignature(data, operation));
118
+ return operation.deprecated
119
+ ? `${signature}\n\n**Deprecated.**`
120
+ : signature;
121
+ },
122
+ } satisfies Record<string, ComponentMarkdown>;
123
+ };
@@ -0,0 +1,24 @@
1
+ import type { BlumeProject } from "../core/project-graph.ts";
2
+ import { isOpenApiSource } from "../openapi/source.ts";
3
+ import type { ComponentMarkdown } from "./component-markdown.ts";
4
+ import { exampleComponentSerializers } from "./component-markdown.ts";
5
+ import { openapiComponentSerializers } from "./openapi-components.ts";
6
+
7
+ /**
8
+ * Every serializer a project brings to a downlevel pass, layered once for all
9
+ * the agent surfaces (`<route>.md`, llms-full.txt, the search and Ask AI
10
+ * corpora): the built-in families that read project data — examples, then the
11
+ * API reference — under the user's `ai.markdownComponents`, which is spread
12
+ * last so an entry of the same name still wins. One layering, so a new family
13
+ * or a new surface cannot leave one consumer printing raw JSX.
14
+ */
15
+ export const projectComponentSerializers = (
16
+ project: Pick<BlumeProject, "config" | "examples" | "sources">
17
+ ) =>
18
+ ({
19
+ ...exampleComponentSerializers(project.examples ?? {}),
20
+ ...openapiComponentSerializers(
21
+ project.sources.find(isOpenApiSource)?.openApiData() ?? {}
22
+ ),
23
+ ...project.config.ai.markdownComponents,
24
+ }) satisfies Record<string, ComponentMarkdown>;
@@ -1753,6 +1753,7 @@ import { getEntry, render } from "astro:content";
1753
1753
  import type { CollectionKey } from "astro:content";
1754
1754
  import RootLayout from "blume/components/layout/RootLayout.astro";
1755
1755
  import { withBase } from "blume/components/islands/base-path.ts";
1756
+ import { stripBasePath, withBasePath } from "blume/core/base-path.ts";
1756
1757
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1757
1758
  import Accordion from "blume/components/content/Accordion.astro";
1758
1759
  import AccordionItem from "blume/components/content/AccordionItem.astro";
@@ -1789,6 +1790,7 @@ import TypeTable from "blume/components/content/TypeTable.astro";
1789
1790
  import Visibility from "blume/components/content/Visibility.astro";
1790
1791
  import YouTube from "blume/components/content/YouTube.astro";
1791
1792
  import Icon from "blume/components/Icon.astro";
1793
+ import LocaleLinks from "blume/components/layout/LocaleLinks.astro";
1792
1794
  import ApiOverview from "blume/components/openapi/ApiOverview.astro";
1793
1795
  import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
1794
1796
  import Operation from "blume/components/openapi/Operation.astro";
@@ -1976,7 +1978,22 @@ const localeAlternates =
1976
1978
  const defaultAlt = i18n ? (alternates ?? []).find((alt) => alt.locale === i18n.defaultLocale) : null;
1977
1979
  const xDefault = defaultAlt && base ? absolute(defaultAlt.path) : null;
1978
1980
 
1979
- const logicalRoute = i18n ? stripLocale(route, locale) : route;
1981
+ // \`route\` arrives with \`basePath\` already applied, so the locale segment it
1982
+ // carries sits *after* the base (\`/docs/ja/guide\`), while \`localePrefix\` is
1983
+ // base-less (\`/ja\`). Stripping and re-adding a locale therefore happen in
1984
+ // base-less space, with the base re-applied at the end — the same
1985
+ // \`withBasePath(basePath, localizeRoute(...))\` composition the manifest uses to
1986
+ // build every real route. Done in based space, \`stripLocale\` matches nothing
1987
+ // and \`localizeRoute\` prepends a second prefix (\`/ja/docs/ja/guide\`). Only a
1988
+ // switcher entry for a locale with no real translation reaches this fallback:
1989
+ // a partially translated hand-written page for its missing locales, or a
1990
+ // generated reference (which has no \`alternates\` at all) for every locale
1991
+ // but its own.
1992
+ const mountLocalized = (logical: string, codeArg: string) =>
1993
+ withBasePath(data.config.basePath, localizeRoute(logical, codeArg));
1994
+ const logicalRoute = i18n
1995
+ ? stripLocale(stripBasePath(data.config.basePath, route), locale)
1996
+ : route;
1980
1997
  const localeSwitch = i18n
1981
1998
  ? i18n.locales.map((l) => {
1982
1999
  const alt = (alternates ?? []).find((x) => x.locale === l.code);
@@ -1984,7 +2001,7 @@ const localeSwitch = i18n
1984
2001
  code: l.code,
1985
2002
  current: l.code === locale,
1986
2003
  dir: l.dir,
1987
- href: alt ? alt.path : localizeRoute(logicalRoute, l.code),
2004
+ href: alt ? alt.path : mountLocalized(logicalRoute, l.code),
1988
2005
  label: l.label,
1989
2006
  untranslated: !alt,
1990
2007
  };
@@ -1998,12 +2015,9 @@ const localeSwitch = i18n
1998
2015
  // (manifest \`versionAlternates\` paths arrive with the base already applied).
1999
2016
  const versionRootFor = (id: string) => {
2000
2017
  const logical = id ? \`/\${id}\` : "/";
2001
- const localized = i18n ? localizeRoute(logical, locale) : logical;
2002
- const mount = data.config.basePath;
2003
- if (!mount) {
2004
- return localized;
2005
- }
2006
- return localized === "/" ? mount : \`\${mount}\${localized}\`;
2018
+ return i18n
2019
+ ? mountLocalized(logical, locale)
2020
+ : withBasePath(data.config.basePath, logical);
2007
2021
  };
2008
2022
  const samePageSwitch = versionsConfig
2009
2023
  ? versionsConfig.switcher.redirect === "same-page"
@@ -2112,7 +2126,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2112
2126
  >
2113
2127
  <h1>{title}</h1>
2114
2128
  {frontmatter.description && <p class="text-lg text-muted-foreground">{frontmatter.description}</p>}
2115
- <Content components={components} />
2129
+ <LocaleLinks locale={locale}>
2130
+ <Content components={components} />
2131
+ </LocaleLinks>
2116
2132
  </LayoutComponent>
2117
2133
  `;
2118
2134
  };
@@ -2912,7 +2928,9 @@ ${entries}
2912
2928
  * (`blume:example-height` via postMessage) so the docs page can size the
2913
2929
  * preview pane to the content instead of guessing from the source line count.
2914
2930
  * A ResizeObserver keeps the report live, so examples that grow or shrink
2915
- * after load (chat threads, accordions) stay in sync.
2931
+ * after load (chat threads, accordions) stay in sync, and the frame re-reports
2932
+ * on request (`blume:example-height-request`) so a report posted before the
2933
+ * docs page's listener registered isn't lost.
2916
2934
  */
2917
2935
  export const examplesPageTemplate = (): string =>
2918
2936
  `---
@@ -2991,7 +3009,7 @@ const Example = entry.Component;
2991
3009
  const bodyStyle = getComputedStyle(document.body);
2992
3010
  const paddingPx =
2993
3011
  parseFloat(bodyStyle.paddingTop) + parseFloat(bodyStyle.paddingBottom);
2994
- new ResizeObserver(() => {
3012
+ const report = () => {
2995
3013
  window.parent.postMessage(
2996
3014
  {
2997
3015
  height:
@@ -3000,7 +3018,19 @@ const Example = entry.Component;
3000
3018
  },
3001
3019
  window.location.origin
3002
3020
  );
3003
- }).observe(wrapper);
3021
+ };
3022
+ new ResizeObserver(report).observe(wrapper);
3023
+ // The parent asks for a fresh report when its listener comes up, in
3024
+ // case the first one above was posted before anyone was listening.
3025
+ window.addEventListener("message", (event) => {
3026
+ if (
3027
+ event.source === window.parent &&
3028
+ event.origin === window.location.origin &&
3029
+ event.data?.type === "blume:example-height-request"
3030
+ ) {
3031
+ report();
3032
+ }
3033
+ });
3004
3034
  })();
3005
3035
  </script>
3006
3036
  </body>
@@ -9,14 +9,7 @@ import type {
9
9
  PageSnapshot,
10
10
  SnapshotLink,
11
11
  } from "../types.ts";
12
- import { normalizePath, resolveHref, siteOrigin } from "../url.ts";
13
-
14
- /** Whether a path is served by the build — as a page, or as a static file. */
15
- const isServed = (context: AuditContext, path: string): boolean =>
16
- context.byUrl.has(path) ||
17
- context.files.has(path) ||
18
- // Astro's directory format serves `/docs/api` from `/docs/api/index.html`.
19
- context.files.has(`${path}/index.html`);
12
+ import { isServed, normalizePath, resolveHref, siteOrigin } from "../url.ts";
20
13
 
21
14
  /** Browser-magic fragments that scroll without needing a matching id. */
22
15
  const MAGIC_FRAGMENTS = new Set(["", "top"]);
@@ -4,7 +4,7 @@ import { finding } from "../catalog.ts";
4
4
  import { pageSite } from "../locate.ts";
5
5
  import { ERROR_ROUTES } from "../types.ts";
6
6
  import type { AuditContext, CheckModule } from "../types.ts";
7
- import { normalizePath, siteOrigin } from "../url.ts";
7
+ import { isServed, normalizePath, siteOrigin } from "../url.ts";
8
8
 
9
9
  /** The object form of `ai.llmsTxt`. The schema always emits it, but hand-built
10
10
  * audit contexts (tests, partial configs) may still carry the raw boolean. */
@@ -97,9 +97,10 @@ export const llmsChecks: CheckModule = {
97
97
  }
98
98
  listed.add(path);
99
99
  // A listed target may be a served asset rather than a page — Blume's own
100
- // llms.txt links the changelog RSS feed — so the file index vouches for
101
- // it too, the same way redirect targets may land on a served asset.
102
- if (!context.byUrl.has(path) && !context.files.has(path)) {
100
+ // llms.txt links the changelog RSS feed — or a route the server answers
101
+ // rather than a file the build writes, like the MCP endpoint. `isServed`
102
+ // vouches for all three, the same way it does for links and redirects.
103
+ if (!isServed(context, path)) {
103
104
  found.push(
104
105
  finding(
105
106
  "BLUME_AUDIT_LLMS_TXT_STALE_ENTRY",
@@ -12,7 +12,7 @@ interface ConfiguredRedirect {
12
12
  * it lands on.
13
13
  *
14
14
  * - `loop` — the chain revisits a hop it has already been to. Never resolves.
15
- * - `broken` — the chain ends somewhere that isn't a built page.
15
+ * - `broken` — the chain ends somewhere the build does not serve.
16
16
  * - `chain` — it resolves, but through at least one intermediate redirect.
17
17
  * - `ok` — one hop, straight to a real page.
18
18
  *
@@ -32,7 +32,8 @@ const pathOnly = (value: string): string => {
32
32
 
33
33
  export const resolveRedirects = (
34
34
  redirects: readonly ConfiguredRedirect[],
35
- pageUrls: ReadonlySet<string>
35
+ /** Whether the build serves a normalized path — see `isServed`. */
36
+ served: (path: string) => boolean
36
37
  ): RedirectResolution[] => {
37
38
  const byFrom = new Map<string, ConfiguredRedirect>();
38
39
  for (const redirect of redirects) {
@@ -71,7 +72,7 @@ export const resolveRedirects = (
71
72
 
72
73
  const destination = chain.at(-1) ?? from;
73
74
  const external = /^https?:\/\//iu.test(destination);
74
- if (!(external || pageUrls.has(destination))) {
75
+ if (!(external || served(destination))) {
75
76
  return { ...redirect, chain, outcome: "broken" as const };
76
77
  }
77
78
  // `chain` is [from, …hops, destination]; more than two entries means at
package/src/audit/run.ts CHANGED
@@ -36,7 +36,7 @@ import type {
36
36
  CheckModule,
37
37
  PageSnapshot,
38
38
  } from "./types.ts";
39
- import { normalizePath, siteOrigin } from "./url.ts";
39
+ import { isServed, siteOrigin } from "./url.ts";
40
40
 
41
41
  const MODULES: CheckModule[] = [
42
42
  contentChecks,
@@ -168,13 +168,11 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
168
168
  from: withBasePath(basePath, redirect.from),
169
169
  to: withBasePath(basePath, redirect.to),
170
170
  })),
171
- // Pages and static files both: a redirect may legitimately land on a
172
- // served asset (`/old-whitepaper` -> `/files/whitepaper.pdf`).
173
- new Set(
174
- [...byUrl.keys(), ...crawl.files.keys()].map((path) =>
175
- normalizePath(path)
176
- )
177
- )
171
+ // Pages, static files, and server routes alike: a redirect may
172
+ // legitimately land on a served asset (`/old-whitepaper` ->
173
+ // `/files/whitepaper.pdf`), and the same predicate the link and llms.txt
174
+ // checks use decides what counts.
175
+ (path) => isServed({ byUrl, files: crawl.files, project }, path)
178
176
  ),
179
177
  robots: crawl.robots,
180
178
  sitemap: crawl.sitemap,
package/src/audit/url.ts CHANGED
@@ -1,9 +1,42 @@
1
1
  import { normalizePath, stripBasePath } from "../core/base-path.ts";
2
+ import type { AuditContext } from "./types.ts";
2
3
 
3
4
  // Re-exported from its home next to the other path helpers; the audit checks
4
5
  // (and their tests) import it from here.
5
6
  export { normalizePath } from "../core/base-path.ts";
6
7
 
8
+ /** The slice of an audit context that says what the build serves. */
9
+ export type ServedContext = Pick<AuditContext, "byUrl" | "files" | "project">;
10
+
11
+ /**
12
+ * Routes the server answers that the build writes no file for.
13
+ *
14
+ * The MCP endpoint is streamable HTTP: llms.txt advertises `ai.mcp.route`
15
+ * whenever the server is enabled, but it appears in neither the page snapshots
16
+ * nor the static file index, so a check that only consults those reads the
17
+ * site's own index as broken. The exemption is the configured route while the
18
+ * server is on, and nothing wider — with `ai.mcp` off, a listed `/mcp` is as
19
+ * dead as any other missing target. Any future server route (a search API, a
20
+ * playground proxy) belongs here too, so every check recognizes it at once.
21
+ */
22
+ const serverRoutes = (context: ServedContext): string[] => {
23
+ const mcp = context.project.config.ai?.mcp;
24
+ return mcp?.enabled ? [normalizePath(mcp.route)] : [];
25
+ };
26
+
27
+ /**
28
+ * Whether a normalized path is served by the build — as a page, as a static
29
+ * file, or as a route the server answers. The one definition of "served" for
30
+ * the link, llms.txt, and redirect checks, so they can never disagree about
31
+ * the same target.
32
+ */
33
+ export const isServed = (context: ServedContext, path: string): boolean =>
34
+ context.byUrl.has(path) ||
35
+ context.files.has(path) ||
36
+ // Astro's directory format serves `/docs/api` from `/docs/api/index.html`.
37
+ context.files.has(`${path}/index.html`) ||
38
+ serverRoutes(context).includes(path);
39
+
7
40
  /** What an `href` in built HTML turned out to point at. */
8
41
  export type ResolvedHref =
9
42
  /** A path on this site. */
@@ -84,6 +84,7 @@ export const validateCommand = defineCommand({
84
84
  basePath: project.config.basePath,
85
85
  checkExternal: Boolean(args.external),
86
86
  extraRoutes,
87
+ i18n: project.config.i18n,
87
88
  publicDir: existsSync(publicDir) ? publicDir : null,
88
89
  redirects: project.config.redirects,
89
90
  }))
@@ -10,13 +10,11 @@
10
10
  // examples are all supported. The source is highlighted with the same Shiki
11
11
  // setup as ordinary code fences.
12
12
  import data from "blume:data";
13
- import {
14
- CODE_PADDING_BLOCK_REM,
15
- FLUSH_CODE_PADDING_TOP_REM,
16
- } from "../../theme/code-block-padding.ts";
13
+ import { CODE_PADDING_BLOCK_REM } from "../../theme/code-block-padding.ts";
17
14
 
18
15
  import { highlightCode } from "../../markdown/index.ts";
19
16
  import { withBase } from "../islands/base-path.ts";
17
+ import { EXAMPLE_PANE_MIN_PX } from "./example-pane.ts";
20
18
  import Tab from "./Tab.astro";
21
19
  import Tabs from "./Tabs.astro";
22
20
  // Generated per project; resolves to an empty map when there are no examples.
@@ -56,31 +54,45 @@ const codeHtml = entry
56
54
  // only the initial SSR/no-JS height: once the frame loads it reports its
57
55
  // rendered height (see the script below) and both panes follow it, so
58
56
  // previews fit the example — including ones that grow or shrink after load.
59
- // The measured height is never ceilinged — the code tab scrolls inside
60
- // whatever height it's given (`pre.blume-source`) — but the estimate is:
61
- // a long source would otherwise render a thousands-of-pixels placeholder
57
+ // The measured height is never ceilinged by the script — the code tab scrolls
58
+ // inside whatever height it's given (`pre.blume-source`) — but the estimate
59
+ // is: a long source would otherwise render a thousands-of-pixels placeholder
62
60
  // whose collapse to the measured height no transition could hide. No-JS
63
61
  // readers aren't hurt by the cap, since the source scrolls at any height.
64
62
  const LINE_PX = 21;
65
63
  const REM_PX = 16;
66
- // The pre's vertical padding, from the same constants the theme emits: the
67
- // copy-button strip on top (the pane is a flush block inside tabs) and the
68
- // plain inset below. The tab panel and the pre carry no border of their own.
69
- const PADDING_PX =
70
- (FLUSH_CODE_PADDING_TOP_REM + CODE_PADDING_BLOCK_REM) * REM_PX;
64
+ // The pre's vertical padding, from the same constant the theme emits: the
65
+ // plain inset above and below (the pane is a flush block inside tabs, with no
66
+ // language bar). The tab panel and the pre carry no border of their own.
67
+ const PADDING_PX = 2 * CODE_PADDING_BLOCK_REM * REM_PX;
71
68
  const ESTIMATE_MAX_PX = 400;
72
- // The floor also clamps the measured height client-side; it rides along on the
73
- // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
74
- const MIN_PANE_PX = 288;
75
69
  const lineCount = entry ? entry.code.replace(/\n+$/u, "").split("\n").length : 0;
76
70
  const paneHeight = Math.min(
77
71
  ESTIMATE_MAX_PX,
78
- Math.max(MIN_PANE_PX, lineCount * LINE_PX + PADDING_PX)
72
+ Math.max(EXAMPLE_PANE_MIN_PX, lineCount * LINE_PX + PADDING_PX)
79
73
  );
80
74
  const paneStyle = `height:${paneHeight}px`;
81
75
  // Animate the settle from the estimate to the measured height so the lazy
82
76
  // frame's load doesn't snap the layout.
83
- const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
77
+ //
78
+ // `max-h-lvh` refuses growth beyond the viewport. An example that sizes itself
79
+ // to the frame's viewport (h-screen/100svh) tracks whatever height the script
80
+ // sets, so each report would come back as the pane height plus the frame
81
+ // padding — unbounded growth. Capping at the viewport parks that cycle: once
82
+ // the pane reaches it, the frame's content stops changing size and the
83
+ // observer goes quiet. Genuinely tall examples scroll inside the frame past
84
+ // this point, which a taller-than-screen pane wouldn't have spared them
85
+ // anyway. It's a CSS cap rather than a clamp in the script so it follows the
86
+ // window on its own — a pane capped by a small viewport grows back when the
87
+ // window does, with no resize listener — and it's the *large* viewport unit
88
+ // on purpose: `lvh` holds still while a mobile browser's toolbar collapses
89
+ // and expands during scroll, where `innerHeight` (and `svh`/`dvh`) move by
90
+ // the toolbar's height on every direction change and would animate the pane
91
+ // under the reader's finger. `max-height` isn't transitioned, so the cap
92
+ // applies instantly during a live resize drag instead of retargeting the
93
+ // settle tween every frame.
94
+ const paneClass =
95
+ "max-h-lvh motion-safe:transition-[height] motion-safe:duration-200";
84
96
  ---
85
97
 
86
98
  {
@@ -96,7 +108,6 @@ const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
96
108
  <iframe
97
109
  class="h-full w-full"
98
110
  data-blume-example-frame
99
- data-blume-min-pane={MIN_PANE_PX}
100
111
  loading="lazy"
101
112
  src={previewSrc}
102
113
  title={`Preview of ${path}`}
@@ -115,75 +126,61 @@ const paneClass = "motion-safe:transition-[height] motion-safe:duration-200";
115
126
  }
116
127
 
117
128
  <script>
129
+ import { EXAMPLE_PANE_MIN_PX } from "./example-pane.ts";
130
+
118
131
  // Preview frames measure their rendered example and report the height (see
119
132
  // `examplesPageTemplate`). One listener serves every <Component> on the
120
133
  // page; the sender is matched to its iframe through `event.source`. The
121
134
  // measured height replaces the server's line-count estimate on both tab
122
135
  // panels together, preserving the shared-height invariant that keeps
123
- // Preview/Code toggles from shifting the layout. The floor comes from the
124
- // iframe's `data-blume-min-pane` (written next to the server estimate) so
125
- // there is one source of truth for it.
136
+ // Preview/Code toggles from shifting the layout. The viewport cap is CSS
137
+ // (`max-h-lvh` on the panels — see the frontmatter), so the script only
138
+ // applies the floor.
139
+ const HEIGHT_REPORT = "blume:example-height";
140
+ const HEIGHT_REQUEST = "blume:example-height-request";
126
141
 
127
- // Refuse growth beyond the viewport. An example that sizes itself to the
128
- // frame's viewport (h-screen/100svh) tracks whatever height this listener
129
- // sets, so each report would come back as the pane height plus the frame
130
- // padding — unbounded growth. Clamping to the viewport parks that cycle:
131
- // once the pane reaches it, the frame's content stops changing size and
132
- // the observer goes quiet. Genuinely tall examples scroll inside the
133
- // frame past this point, which a taller-than-screen pane wouldn't have
134
- // spared them anyway.
135
- const applyMeasuredHeight = (frame: HTMLIFrameElement) => {
136
- const tabs = frame.closest("blume-tabs");
137
- const reported = Number(frame.dataset.blumeReportedHeight);
138
- if (!tabs || !Number.isFinite(reported)) {
139
- return;
140
- }
141
- const height = `${Math.max(
142
- Number(frame.dataset.blumeMinPane) || 0,
143
- Math.min(reported, window.innerHeight)
144
- )}px`;
145
- for (const panel of tabs.querySelectorAll<HTMLElement>(
146
- "[data-blume-tab-panel]"
147
- )) {
148
- panel.style.height = height;
149
- }
150
- };
142
+ const frames = () =>
143
+ document.querySelectorAll<HTMLIFrameElement>(
144
+ "iframe[data-blume-example-frame]"
145
+ );
151
146
 
152
147
  window.addEventListener("message", (event) => {
153
148
  if (
154
149
  event.origin !== window.location.origin ||
155
- event.data?.type !== "blume:example-height" ||
150
+ event.data?.type !== HEIGHT_REPORT ||
156
151
  !Number.isFinite(event.data.height)
157
152
  ) {
158
153
  return;
159
154
  }
160
- const frames = document.querySelectorAll<HTMLIFrameElement>(
161
- "iframe[data-blume-example-frame]"
162
- );
163
- const frame = Array.from(frames).find(
155
+ const frame = Array.from(frames()).find(
164
156
  (candidate) => candidate.contentWindow === event.source
165
157
  );
166
- if (!frame) {
158
+ const tabs = frame?.closest("blume-tabs");
159
+ if (!frame || !tabs) {
167
160
  return;
168
161
  }
169
- // Keep the raw report around so the viewport clamp can be recomputed
170
- // when the window resizes, not only when the frame next reports.
162
+ // Recorded on the frame as a marker that its report arrived (the e2e
163
+ // suite asserts on it); the panels carry the applied height.
171
164
  frame.dataset.blumeReportedHeight = String(event.data.height);
172
- applyMeasuredHeight(frame);
165
+ const height = `${Math.max(EXAMPLE_PANE_MIN_PX, event.data.height)}px`;
166
+ for (const panel of tabs.querySelectorAll<HTMLElement>(
167
+ "[data-blume-tab-panel]"
168
+ )) {
169
+ panel.style.height = height;
170
+ }
173
171
  });
174
172
 
175
- // A pane capped by a small viewport would otherwise stay small after the
176
- // window grows: the frame's content stopped changing size, so its observer
177
- // has nothing new to report. rAF-coalesced so a live resize drag runs the
178
- // read-then-write pass once per frame, not once per event.
179
- window.addEventListener(
180
- "resize",
181
- rafThrottle(() => {
182
- for (const frame of document.querySelectorAll<HTMLIFrameElement>(
183
- "iframe[data-blume-example-frame][data-blume-reported-height]"
184
- )) {
185
- applyMeasuredHeight(frame);
186
- }
187
- })
188
- );
173
+ // A frame reports once its observer first fires, and nothing re-sends it.
174
+ // Whether this module runs before that report depends on how the bundle
175
+ // ships it (inlined in the document, or as a fetched chunk when it shares
176
+ // code with another script) and on the client router's swap timing, so
177
+ // don't rely on winning the race: ask every frame that's already loaded to
178
+ // report again. A frame still loading ignores the ping (it lands on the
179
+ // initial blank document) and reports on its own once it renders.
180
+ for (const frame of frames()) {
181
+ frame.contentWindow?.postMessage(
182
+ { type: HEIGHT_REQUEST },
183
+ window.location.origin
184
+ );
185
+ }
189
186
  </script>
@@ -62,19 +62,34 @@ const useDropdown = dropdown && !inline;
62
62
  data-sync={sync ? "true" : "false"}
63
63
  data-sync-key={syncKey}
64
64
  >
65
- <div
66
- class:list={[
67
- inline ? "not-prose" : "",
68
- useDropdown ? "" : "overflow-x-auto",
69
- ]}
70
- >
65
+ {/*
66
+ Header row: the scrolling tab strip (or the dropdown) grows, and an
67
+ actions slot sits fixed at the inline end, outside the scroller so it
68
+ never rides along with an overflowing strip. The docs layout fills it
69
+ with one copy button when every panel is a bare code block (CodeGroup,
70
+ ts2js pairs) — the strip is that group's chrome, so the button belongs
71
+ there rather than painted over the first line of each block. Empty
72
+ otherwise, and hidden so it adds no padding.
73
+ */}
74
+ <div class:list={["flex", inline ? "not-prose" : ""]}>
75
+ <div class:list={["min-w-0 grow", useDropdown ? "" : "overflow-x-auto"]}>
76
+ <div
77
+ class:list={[
78
+ useDropdown || borderBottom ? "border-border border-b" : "",
79
+ useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
80
+ ]}
81
+ data-blume-tablist
82
+ role={useDropdown ? undefined : "tablist"}
83
+ >
84
+ </div>
85
+ </div>
71
86
  <div
72
87
  class:list={[
88
+ "flex shrink-0 items-center gap-2 ps-2 empty:hidden",
89
+ inline ? "" : "pe-3",
73
90
  useDropdown || borderBottom ? "border-border border-b" : "",
74
- useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
75
91
  ]}
76
- data-blume-tablist
77
- role={useDropdown ? undefined : "tablist"}
92
+ data-blume-tab-actions
78
93
  >
79
94
  </div>
80
95
  </div>