blume 1.6.3 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/cli/index.js +294 -46
- package/dist/cli/index.js.map +27 -22
- package/dist/types/ai/component-markdown.d.ts +14 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/02-deployment.mdx +5 -5
- package/docs/{07-faq.mdx → 08-faq.mdx} +7 -7
- package/docs/advanced/blog.mdx +3 -3
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/custom-pages.mdx +4 -4
- package/docs/advanced/meta.ts +1 -1
- package/docs/configuration/ask-ai.mdx +179 -0
- package/docs/configuration/index.mdx +8 -7
- package/docs/configuration/meta.ts +1 -2
- package/docs/configuration/search.mdx +1 -1
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/i18n.mdx +7 -1
- package/docs/content/index.mdx +1 -1
- package/docs/content/navigation.mdx +2 -2
- package/docs/content/syntax.mdx +1 -1
- package/docs/discoverability/agent-discovery.mdx +196 -0
- package/docs/discoverability/index.mdx +48 -0
- package/docs/discoverability/json-api.mdx +58 -0
- package/docs/discoverability/llms-txt.mdx +68 -0
- package/docs/discoverability/markdown.mdx +76 -0
- package/docs/discoverability/mcp.mdx +64 -0
- package/docs/discoverability/meta.ts +18 -0
- package/docs/discoverability/metadata.mdx +82 -0
- package/docs/discoverability/open-graph.mdx +113 -0
- package/docs/discoverability/rss.mdx +24 -0
- package/docs/discoverability/sitemap-and-robots.mdx +95 -0
- package/docs/discoverability/structured-data.mdx +51 -0
- package/docs/index.mdx +5 -5
- package/docs/reference/eval.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +1 -0
- package/package.json +18 -18
- package/src/ai/component-markdown.ts +17 -2
- package/src/ai/llms.ts +3 -10
- package/src/ai/markdown.ts +3 -10
- package/src/ai/openapi-components.ts +123 -0
- package/src/ai/serializers.ts +24 -0
- package/src/astro/templates.ts +42 -12
- package/src/audit/checks/links.ts +1 -8
- package/src/audit/checks/llms.ts +5 -4
- package/src/audit/redirects.ts +4 -3
- package/src/audit/run.ts +6 -8
- package/src/audit/url.ts +33 -0
- package/src/cli/commands/validate.ts +1 -0
- package/src/components/content/Component.astro +60 -59
- package/src/components/content/example-pane.ts +6 -0
- package/src/components/layout/LocaleLinks.astro +42 -0
- package/src/components/layout/PageLayout.astro +5 -3
- package/src/components/layout/ReferenceLayout.astro +5 -0
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/search-locale.ts +13 -0
- package/src/components/openapi/ApiOverview.astro +7 -39
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/AsyncApiOperation.astro +3 -2
- package/src/components/openapi/GraphqlOperation.astro +3 -2
- package/src/components/openapi/Operation.astro +3 -2
- package/src/core/i18n.ts +13 -2
- package/src/core/links.ts +33 -1
- package/src/core/locale-links.ts +163 -0
- package/src/core/sources/normalize.ts +57 -7
- package/src/markdown/package-commands.ts +27 -3
- package/src/openapi/graphql.ts +29 -0
- package/src/openapi/model.ts +69 -0
- package/src/openapi/render-mdx.ts +3 -2
- package/src/openapi/signature.ts +18 -0
- package/src/search/documents.ts +4 -9
- package/src/theme/entry.ts +19 -2
- package/src/translate/anchors.ts +91 -0
- package/src/translate/validate.ts +8 -3
- package/docs/configuration/ai.mdx +0 -613
- package/docs/configuration/seo.mdx +0 -364
|
@@ -2,7 +2,14 @@ import type { Agent, Command } from "package-manager-detector";
|
|
|
2
2
|
import { resolveCommand } from "package-manager-detector/commands";
|
|
3
3
|
|
|
4
4
|
/** Supported package managers, in the order tabs are displayed. */
|
|
5
|
-
export const PACKAGE_MANAGERS = [
|
|
5
|
+
export const PACKAGE_MANAGERS = [
|
|
6
|
+
"npm",
|
|
7
|
+
"pnpm",
|
|
8
|
+
"yarn",
|
|
9
|
+
"bun",
|
|
10
|
+
"nub",
|
|
11
|
+
"aube",
|
|
12
|
+
] as const;
|
|
6
13
|
|
|
7
14
|
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
8
15
|
|
|
@@ -15,14 +22,29 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
|
15
22
|
* npm's form, matching `ni`'s table.
|
|
16
23
|
*/
|
|
17
24
|
const AGENT_FOR = {
|
|
25
|
+
aube: "aube",
|
|
18
26
|
bun: "bun",
|
|
19
27
|
npm: "npm",
|
|
28
|
+
nub: "nub",
|
|
20
29
|
pnpm: "pnpm",
|
|
21
30
|
yarn: "yarn@berry",
|
|
22
31
|
} satisfies Record<PackageManager, Agent>;
|
|
23
32
|
|
|
24
33
|
/** Words that mark the input as an explicit command rather than a bare list. */
|
|
25
|
-
const MANAGER_PREFIXES = new Set([
|
|
34
|
+
const MANAGER_PREFIXES = new Set([
|
|
35
|
+
"aube",
|
|
36
|
+
"bun",
|
|
37
|
+
"bunx",
|
|
38
|
+
"npm",
|
|
39
|
+
"npx",
|
|
40
|
+
"nub",
|
|
41
|
+
"nubx",
|
|
42
|
+
"pnpm",
|
|
43
|
+
"yarn",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Standalone runner binaries that spell `<manager> exec` as one word. */
|
|
47
|
+
const EXEC_BINARIES = new Set(["bunx", "npx", "nubx"]);
|
|
26
48
|
|
|
27
49
|
const WHITESPACE = /\s+/u;
|
|
28
50
|
const WHITESPACE_RUN = /\s+/gu;
|
|
@@ -104,7 +126,7 @@ const parseIntent = (input: string): Intent => {
|
|
|
104
126
|
if (!MANAGER_PREFIXES.has(first)) {
|
|
105
127
|
return { args: normalizeFlags(tokens), operation: "add" };
|
|
106
128
|
}
|
|
107
|
-
if (first
|
|
129
|
+
if (EXEC_BINARIES.has(first)) {
|
|
108
130
|
return { args: rest, operation: "exec" };
|
|
109
131
|
}
|
|
110
132
|
|
|
@@ -193,8 +215,10 @@ export const toPackageCommands = (input: string) => {
|
|
|
193
215
|
const normalize = (command: string): string =>
|
|
194
216
|
command.replaceAll(WHITESPACE_RUN, " ").trim();
|
|
195
217
|
return {
|
|
218
|
+
aube: normalize(buildCommand("aube", intent)),
|
|
196
219
|
bun: normalize(buildCommand("bun", intent)),
|
|
197
220
|
npm: normalize(buildCommand("npm", intent)),
|
|
221
|
+
nub: normalize(buildCommand("nub", intent)),
|
|
198
222
|
pnpm: normalize(buildCommand("pnpm", intent)),
|
|
199
223
|
yarn: normalize(buildCommand("yarn", intent)),
|
|
200
224
|
} satisfies Record<PackageManager, string>;
|
package/src/openapi/graphql.ts
CHANGED
|
@@ -124,6 +124,35 @@ export const isGraphqlOperationKind = (
|
|
|
124
124
|
): method is GraphqlOperationKind =>
|
|
125
125
|
GRAPHQL_OPERATION_KINDS.some((kind) => kind === method);
|
|
126
126
|
|
|
127
|
+
/** The SDL keyword that declares each named-type kind: `type Pet`, `enum Status`. */
|
|
128
|
+
const GRAPHQL_TYPE_KEYWORDS = {
|
|
129
|
+
enum: "enum",
|
|
130
|
+
input: "input",
|
|
131
|
+
interface: "interface",
|
|
132
|
+
object: "type",
|
|
133
|
+
scalar: "scalar",
|
|
134
|
+
union: "union",
|
|
135
|
+
} satisfies Record<GraphqlTypeKind, string>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A GraphQL member in the schema's own notation — `query pets` for a root
|
|
139
|
+
* field, `type Pet` / `input PetInput` / `enum Status` for a named type. The
|
|
140
|
+
* rendered page shows the same pair as a kind badge beside the name; the
|
|
141
|
+
* text form spells the kind the way SDL does, so a reader who knows GraphQL
|
|
142
|
+
* and not Blume's badges reads it right.
|
|
143
|
+
*/
|
|
144
|
+
export const graphqlSignature = (
|
|
145
|
+
operation: Pick<ApiOperationRef, "method" | "path">
|
|
146
|
+
): string => {
|
|
147
|
+
// SAFETY: the GraphQL extractor only ever assigns member kinds as the
|
|
148
|
+
// method (see `extractGraphqlOperations`).
|
|
149
|
+
const member = operation.method as GraphqlMember;
|
|
150
|
+
const keyword = isGraphqlOperationKind(member)
|
|
151
|
+
? member
|
|
152
|
+
: GRAPHQL_TYPE_KEYWORDS[member];
|
|
153
|
+
return `${keyword} ${operation.path}`;
|
|
154
|
+
};
|
|
155
|
+
|
|
127
156
|
// Deterministic name order for type pages, independent of schema declaration
|
|
128
157
|
// order and of the platform's collation (localeCompare varies across ICU
|
|
129
158
|
// builds; codepoint order does not).
|
package/src/openapi/model.ts
CHANGED
|
@@ -143,6 +143,75 @@ export interface ApiSpecData {
|
|
|
143
143
|
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
144
144
|
export type OpenApiData = Record<string, ApiSpecData>;
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The spec a `<Operation source>` / `<ApiOverview source>` names, or nothing.
|
|
148
|
+
* `blume:openapi` crosses a JSON boundary as a plain object, so a lookup must
|
|
149
|
+
* be an own-property one: `source="toString"` would otherwise resolve to the
|
|
150
|
+
* inherited function, which is truthy and carries no `operations`, and every
|
|
151
|
+
* consumer would throw where it means to decline. Shared by the components
|
|
152
|
+
* and the agent-surface serializers so they miss the same way.
|
|
153
|
+
*/
|
|
154
|
+
export const specOf = (
|
|
155
|
+
specs: OpenApiData,
|
|
156
|
+
source: string
|
|
157
|
+
): ApiSpecData | undefined =>
|
|
158
|
+
Object.hasOwn(specs, source) ? specs[source] : undefined;
|
|
159
|
+
|
|
160
|
+
/** The operation an `<Operation id>` names within its spec, or nothing. */
|
|
161
|
+
export const operationOf = (
|
|
162
|
+
spec: ApiSpecData,
|
|
163
|
+
id: string
|
|
164
|
+
): ApiOperationRef | undefined =>
|
|
165
|
+
Object.hasOwn(spec.operations, id) ? spec.operations[id] : undefined;
|
|
166
|
+
|
|
167
|
+
/** The addresses an API overview lists, and what to call them. */
|
|
168
|
+
export interface SpecAddresses {
|
|
169
|
+
/** `Base URL` (OpenAPI), `Servers` (AsyncAPI) or `Endpoint` (GraphQL). */
|
|
170
|
+
label: string;
|
|
171
|
+
addresses: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Where the API lives, flattened into one list for the overview page. OpenAPI
|
|
176
|
+
* declares `servers` as an array of URLs; AsyncAPI as a named map of
|
|
177
|
+
* host/protocol/pathname; a GraphQL schema names no server, so its configured
|
|
178
|
+
* live endpoint stands in.
|
|
179
|
+
*/
|
|
180
|
+
export const specAddresses = (spec: ApiSpecData): SpecAddresses => {
|
|
181
|
+
const addresses: string[] = [];
|
|
182
|
+
if (spec.kind === "graphql") {
|
|
183
|
+
if (spec.endpoint) {
|
|
184
|
+
addresses.push(spec.endpoint);
|
|
185
|
+
}
|
|
186
|
+
return { addresses, label: "Endpoint" };
|
|
187
|
+
}
|
|
188
|
+
if (spec.kind === "asyncapi") {
|
|
189
|
+
// SAFETY: an `asyncapi` spec's document is the AsyncAPI shape (`parse.ts`
|
|
190
|
+
// routes each kind to its own parser).
|
|
191
|
+
const servers = (spec.document as AsyncApiDocument).servers ?? {};
|
|
192
|
+
for (const server of Object.values(servers)) {
|
|
193
|
+
if (server?.host) {
|
|
194
|
+
addresses.push(
|
|
195
|
+
`${server.protocol ? `${server.protocol}://` : ""}${server.host}${server.pathname ?? ""}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { addresses, label: "Servers" };
|
|
200
|
+
}
|
|
201
|
+
// Hand-written specs sometimes declare `servers` as a bare object; degrade
|
|
202
|
+
// to no addresses instead of throwing mid-build.
|
|
203
|
+
// SAFETY: the remaining kind is OpenAPI, whose document declares `servers`
|
|
204
|
+
// as an array of server objects; the array check below guards a spec that
|
|
205
|
+
// wrote something else there.
|
|
206
|
+
const declared = (spec.document as { servers?: { url?: string }[] }).servers;
|
|
207
|
+
for (const server of Array.isArray(declared) ? declared : []) {
|
|
208
|
+
if (server.url) {
|
|
209
|
+
addresses.push(server.url);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { addresses, label: "Base URL" };
|
|
213
|
+
};
|
|
214
|
+
|
|
146
215
|
// The runtime object check stands guard because the document was parsed from
|
|
147
216
|
// arbitrary YAML/JSON: a spec can put a scalar where the type promises an
|
|
148
217
|
// operation object.
|
|
@@ -8,6 +8,7 @@ import type { GraphqlMember } from "./graphql.ts";
|
|
|
8
8
|
import { isGraphqlOperationKind } from "./graphql.ts";
|
|
9
9
|
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
10
10
|
import type { ReferenceSource } from "./references.ts";
|
|
11
|
+
import { operationSignature } from "./signature.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Lower a parsed spec into MDX for the staged content source. Each operation and
|
|
@@ -226,11 +227,11 @@ export const operationMdx = (
|
|
|
226
227
|
): RenderedPage => {
|
|
227
228
|
const method = operation.method.toUpperCase();
|
|
228
229
|
const graphql = spec.kind === "graphql";
|
|
229
|
-
// A GraphQL page IS its field/type — `
|
|
230
|
+
// A GraphQL page IS its field/type — `query pets` would double the badge the
|
|
230
231
|
// page already renders; the other kinds title an endpoint or channel action.
|
|
231
232
|
const fallbackTitle = graphql
|
|
232
233
|
? operation.path
|
|
233
|
-
:
|
|
234
|
+
: operationSignature(spec, operation);
|
|
234
235
|
const title = operation.summary || fallbackTitle;
|
|
235
236
|
// Skip the body description when it only repeats the summary (the `<h1>`) —
|
|
236
237
|
// common in specs that set summary and description to the same string.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { graphqlSignature } from "./graphql.ts";
|
|
2
|
+
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one-line name of what an operation page documents, in the notation of
|
|
6
|
+
* its spec kind: `GET /pets/{id}` for an HTTP endpoint, `SEND user/signup`
|
|
7
|
+
* for an AsyncAPI channel action, `query pets` or `type Pet` for a GraphQL
|
|
8
|
+
* member. The page titles an untitled operation with it and the agent
|
|
9
|
+
* surfaces (`<route>.md`, llms-full.txt, MCP `get_page`) downlevel
|
|
10
|
+
* `<Operation>` to it, so the two can never disagree about the endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export const operationSignature = (
|
|
13
|
+
spec: Pick<ApiSpecData, "kind">,
|
|
14
|
+
operation: Pick<ApiOperationRef, "method" | "path">
|
|
15
|
+
): string =>
|
|
16
|
+
spec.kind === "graphql"
|
|
17
|
+
? graphqlSignature(operation)
|
|
18
|
+
: `${operation.method.toUpperCase()} ${operation.path}`;
|
package/src/search/documents.ts
CHANGED
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
componentRegistry,
|
|
6
6
|
downlevelComponentNode,
|
|
7
7
|
downlevelComponents,
|
|
8
|
-
exampleComponentSerializers,
|
|
9
8
|
} from "../ai/component-markdown.ts";
|
|
10
9
|
import type {
|
|
11
10
|
ComponentMarkdown,
|
|
12
11
|
DownlevelWalk,
|
|
13
12
|
MdastNode as DownlevelNode,
|
|
14
13
|
} from "../ai/component-markdown.ts";
|
|
14
|
+
import { projectComponentSerializers } from "../ai/serializers.ts";
|
|
15
15
|
import { applyAudienceVisibility } from "../ai/visibility.ts";
|
|
16
16
|
import type { VisibilityAudience } from "../ai/visibility.ts";
|
|
17
17
|
import matter from "../core/frontmatter.ts";
|
|
@@ -437,14 +437,9 @@ export const buildSearchDocuments = async (
|
|
|
437
437
|
return page ? contentIndexable(page, project.config) : false;
|
|
438
438
|
});
|
|
439
439
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
// per call otherwise.
|
|
444
|
-
const components = {
|
|
445
|
-
...exampleComponentSerializers(project.examples ?? {}),
|
|
446
|
-
...project.config.ai.markdownComponents,
|
|
447
|
-
};
|
|
440
|
+
// Built once — `downlevelComponents` rebuilds its registry per call
|
|
441
|
+
// otherwise.
|
|
442
|
+
const components = projectComponentSerializers(project);
|
|
448
443
|
|
|
449
444
|
return await Promise.all(
|
|
450
445
|
indexable.map(async (route) => {
|
package/src/theme/entry.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { CODE_PADDING_BLOCK_REM } from "./code-block-padding.ts";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Bottom inset of the scrolling code element, in rem: keeps a horizontal
|
|
5
|
+
* scrollbar thumb off the last line's descenders. It is carved out of the
|
|
6
|
+
* pre's block padding rather than added to it, so a block's height is the
|
|
7
|
+
* same whether or not it scrolls.
|
|
8
|
+
*/
|
|
9
|
+
const CODE_SCROLL_INSET_REM = 0.375;
|
|
10
|
+
|
|
3
11
|
interface TailwindEntryOptions {
|
|
4
12
|
/**
|
|
5
13
|
* Globs to scan for utility classes. Typically the Blume package source and
|
|
@@ -495,8 +503,9 @@ blume-diff {
|
|
|
495
503
|
max-height: 24rem;
|
|
496
504
|
overflow: auto;
|
|
497
505
|
/* The small bottom inset keeps the horizontal thumb off the last line's
|
|
498
|
-
descenders now that scrollbars are visible.
|
|
499
|
-
|
|
506
|
+
descenders now that scrollbars are visible. The pre gives up the same
|
|
507
|
+
amount below (next rule), so the inset adds no height to the block. */
|
|
508
|
+
padding: 0 1.25rem ${CODE_SCROLL_INSET_REM}rem;
|
|
500
509
|
/* Thin theme-colored scrollbars, matching the sidebar treatment, so a
|
|
501
510
|
height-capped block reads as scrollable instead of simply ending.
|
|
502
511
|
Safari before 18.2 supports neither property and falls back to the
|
|
@@ -505,6 +514,14 @@ blume-diff {
|
|
|
505
514
|
scrollbar-width: thin;
|
|
506
515
|
}
|
|
507
516
|
|
|
517
|
+
/* The scroller's bottom inset comes out of the pre's own block padding: the
|
|
518
|
+
text still sits one full inset above the frame's bottom edge, the thumb sits
|
|
519
|
+
in the gap, and a one-line command is no taller than it was before the
|
|
520
|
+
scroller existed. */
|
|
521
|
+
.prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *):has(> code)) {
|
|
522
|
+
padding-bottom: calc(${CODE_PADDING_BLOCK_REM}rem - ${CODE_SCROLL_INSET_REM}rem);
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
/* The dark border token is too close to the page background to read as a
|
|
509
526
|
scrollbar thumb; derive a brighter one from the muted foreground instead. */
|
|
510
527
|
:root[data-theme="dark"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { scanBody } from "../core/sources/normalize.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep heading anchors identical across languages. A translated heading slugs
|
|
5
|
+
* to a different id (`## Ordering` → `#ordering`, `## Reihenfolge` →
|
|
6
|
+
* `#reihenfolge`), so a `/guide#ordering` link — which the runtime keeps
|
|
7
|
+
* inside the reader's locale — would land at the top of the translated page.
|
|
8
|
+
* After a translation validates, every heading whose rendered id would differ
|
|
9
|
+
* from its source counterpart gets the source id pinned as a trailing
|
|
10
|
+
* `[#id]` marker, so the anchor index (and every `#fragment` link) agrees in
|
|
11
|
+
* every locale. Headings the translation already pins are left alone: the
|
|
12
|
+
* agent is told to copy markers verbatim, and a hand-authored pin is the
|
|
13
|
+
* translator's choice.
|
|
14
|
+
*
|
|
15
|
+
* Headings pair positionally, so the translation must mirror the source's
|
|
16
|
+
* heading structure (the prompt demands exactly that); when it doesn't, no
|
|
17
|
+
* pins are added and the text is returned unchanged with a reason.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PinResult {
|
|
21
|
+
/** Number of headings that received a pin. */
|
|
22
|
+
pinned: number;
|
|
23
|
+
/** Why no pins were added, when the structures don't line up. */
|
|
24
|
+
skipped?: string;
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An ATX heading's optional closing hash run, which a marker must precede. */
|
|
29
|
+
const ATX_CLOSE = /\s+#+$/u;
|
|
30
|
+
|
|
31
|
+
const structureOf = (headings: readonly { depth: number }[]): string =>
|
|
32
|
+
headings.map((heading) => heading.depth).join(",");
|
|
33
|
+
|
|
34
|
+
/** Append `[#id]` to a heading line, ahead of any closing `##` and line end. */
|
|
35
|
+
const pinLine = (line: string, id: string): string => {
|
|
36
|
+
const eol = line.endsWith("\r") ? "\r" : "";
|
|
37
|
+
const body = line.slice(0, line.length - eol.length).trimEnd();
|
|
38
|
+
const close = body.match(ATX_CLOSE)?.[0] ?? "";
|
|
39
|
+
const head = body.slice(0, body.length - close.length);
|
|
40
|
+
return `${head} [#${id}]${close}${eol}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const pinHeadingAnchors = (
|
|
44
|
+
sourceText: string,
|
|
45
|
+
translatedText: string
|
|
46
|
+
): PinResult => {
|
|
47
|
+
const source = scanBody(sourceText);
|
|
48
|
+
const translated = scanBody(translatedText);
|
|
49
|
+
if (structureOf(source.headings) !== structureOf(translated.headings)) {
|
|
50
|
+
return {
|
|
51
|
+
pinned: 0,
|
|
52
|
+
skipped: `heading structure differs (source has ${source.headings.length} headings, translation has ${translated.headings.length})`,
|
|
53
|
+
text: translatedText,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lines = translatedText.split("\n");
|
|
58
|
+
let pinned = 0;
|
|
59
|
+
for (const [index, heading] of translated.headings.entries()) {
|
|
60
|
+
const site = translated.sites[index];
|
|
61
|
+
const sourceId = source.headings[index]?.slug;
|
|
62
|
+
if (
|
|
63
|
+
!site ||
|
|
64
|
+
site.pinned ||
|
|
65
|
+
sourceId === undefined ||
|
|
66
|
+
heading.slug === sourceId
|
|
67
|
+
) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
lines[site.line - 1] = pinLine(lines[site.line - 1] ?? "", sourceId);
|
|
71
|
+
pinned += 1;
|
|
72
|
+
}
|
|
73
|
+
if (pinned === 0) {
|
|
74
|
+
return { pinned, text: translatedText };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The pins must reproduce the source ids exactly once rendered; anything
|
|
78
|
+
// else (a heading the line-based rewrite couldn't reach) keeps the text as
|
|
79
|
+
// the agent wrote it rather than shipping a half-pinned page.
|
|
80
|
+
const text = lines.join("\n");
|
|
81
|
+
const rendered = scanBody(text).headings.map((heading) => heading.slug);
|
|
82
|
+
const expected = source.headings.map((heading) => heading.slug);
|
|
83
|
+
if (rendered.join("\n") !== expected.join("\n")) {
|
|
84
|
+
return {
|
|
85
|
+
pinned: 0,
|
|
86
|
+
skipped: "pinned anchors did not reproduce the source heading ids",
|
|
87
|
+
text: translatedText,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { pinned, text };
|
|
91
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import matter from "../core/frontmatter.ts";
|
|
2
|
+
import { pinHeadingAnchors } from "./anchors.ts";
|
|
2
3
|
import { TRANSLATABLE_KEY_PATHS } from "./prompts.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -123,13 +124,13 @@ export const validateTranslation = (
|
|
|
123
124
|
return { ok: false, reason: "frontmatter does not parse as YAML" };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
127
|
+
const rawBody = ensureTrailingNewline(parsed.content.replace(/^\r?\n/u, ""));
|
|
128
|
+
if (rawBody.trim() === "") {
|
|
128
129
|
return { ok: false, reason: "translation has an empty body" };
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
const sourceFences = countFenceLines(source.content);
|
|
132
|
-
const candidateFences = countFenceLines(
|
|
133
|
+
const candidateFences = countFenceLines(rawBody);
|
|
133
134
|
if (sourceFences !== candidateFences) {
|
|
134
135
|
return {
|
|
135
136
|
ok: false,
|
|
@@ -137,6 +138,10 @@ export const validateTranslation = (
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
// Anchors stay identical across languages: each translated heading is
|
|
142
|
+
// pinned to its source heading's id (see `anchors.ts`).
|
|
143
|
+
const body = pinHeadingAnchors(source.content, rawBody).text;
|
|
144
|
+
|
|
140
145
|
if (!sourceHasFrontmatter) {
|
|
141
146
|
// A frontmatter-less source writes the body alone; any frontmatter the
|
|
142
147
|
// agent invented is dropped with it.
|