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.
- package/CHANGELOG.md +25 -0
- package/dist/cli/index.js +307 -68
- package/dist/cli/index.js.map +28 -23
- 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/components.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 +65 -68
- package/src/components/content/Tabs.astro +24 -9
- 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 +110 -39
- 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/code-block-padding.ts +0 -8
- package/src/theme/entry.ts +33 -27
- 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
|
@@ -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>;
|
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
|
|
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 :
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
}
|
|
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"]);
|
package/src/audit/checks/llms.ts
CHANGED
|
@@ -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 —
|
|
101
|
-
//
|
|
102
|
-
|
|
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",
|
package/src/audit/redirects.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ||
|
|
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 {
|
|
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
|
|
172
|
-
// served asset (`/old-whitepaper` ->
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
60
|
-
// whatever height it's given (`pre.blume-source`) — but the estimate
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 !==
|
|
150
|
+
event.data?.type !== HEIGHT_REPORT ||
|
|
156
151
|
!Number.isFinite(event.data.height)
|
|
157
152
|
) {
|
|
158
153
|
return;
|
|
159
154
|
}
|
|
160
|
-
const
|
|
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
|
-
|
|
158
|
+
const tabs = frame?.closest("blume-tabs");
|
|
159
|
+
if (!frame || !tabs) {
|
|
167
160
|
return;
|
|
168
161
|
}
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
-
|
|
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
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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-
|
|
77
|
-
role={useDropdown ? undefined : "tablist"}
|
|
92
|
+
data-blume-tab-actions
|
|
78
93
|
>
|
|
79
94
|
</div>
|
|
80
95
|
</div>
|