blume 1.3.1 → 1.4.1
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 +72 -0
- package/dist/cli/index.js +3512 -814
- package/dist/cli/index.js.map +99 -87
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +22 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/generate.ts +4 -13
- package/src/astro/integration.ts +3 -1
- package/src/astro/islands.ts +4 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +69 -22
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/env.ts +6 -52
- package/src/cli/index.ts +2 -0
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +18 -25
- package/src/components/layout/search/orama.ts +3 -1
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/base-path.ts +9 -0
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config-input.ts +84 -6
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/notion.ts +17 -23
- package/src/core/sources/types.ts +5 -0
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/agents.ts +1 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +142 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +354 -0
- package/src/translate/run.ts +357 -0
- package/src/translate/validate.ts +171 -0
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
package/src/eval/report.ts
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
|
|
4
|
+
import { colors } from "consola/utils";
|
|
5
|
+
import type { ColorFunction } from "consola/utils";
|
|
4
6
|
import { join, relative } from "pathe";
|
|
5
7
|
|
|
6
8
|
import { AGENTS } from "../audit/agent.ts";
|
|
7
9
|
import { countBySeverity } from "../core/diagnostics.ts";
|
|
8
10
|
import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
|
|
9
11
|
|
|
10
|
-
const ESC = String.fromCodePoint(27);
|
|
11
|
-
const COLORS = {
|
|
12
|
-
bold: `${ESC}[1m`,
|
|
13
|
-
cyan: `${ESC}[36m`,
|
|
14
|
-
dim: `${ESC}[2m`,
|
|
15
|
-
green: `${ESC}[32m`,
|
|
16
|
-
red: `${ESC}[31m`,
|
|
17
|
-
reset: `${ESC}[0m`,
|
|
18
|
-
yellow: `${ESC}[33m`,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
12
|
const GLYPH: Record<QuestionStatus, string> = {
|
|
22
13
|
error: "!",
|
|
23
14
|
fail: "✖",
|
|
@@ -25,11 +16,11 @@ const GLYPH: Record<QuestionStatus, string> = {
|
|
|
25
16
|
skip: "⊘",
|
|
26
17
|
};
|
|
27
18
|
|
|
28
|
-
const STATUS_COLOR: Record<QuestionStatus,
|
|
29
|
-
error:
|
|
30
|
-
fail:
|
|
31
|
-
pass:
|
|
32
|
-
skip:
|
|
19
|
+
const STATUS_COLOR: Record<QuestionStatus, ColorFunction> = {
|
|
20
|
+
error: colors.yellow,
|
|
21
|
+
fail: colors.red,
|
|
22
|
+
pass: colors.green,
|
|
23
|
+
skip: colors.dim,
|
|
33
24
|
};
|
|
34
25
|
|
|
35
26
|
/** Longest id gets the room; everything shorter aligns to it. */
|
|
@@ -52,17 +43,18 @@ const duration = (ms: number): string => {
|
|
|
52
43
|
/** One question's progress/report line: glyph, id, status, score, time, cost. */
|
|
53
44
|
export const questionLine = (result: QuestionResult): string => {
|
|
54
45
|
const color = STATUS_COLOR[result.status];
|
|
55
|
-
const glyph =
|
|
46
|
+
const glyph = color(GLYPH[result.status]);
|
|
56
47
|
const id = result.id.padEnd(ID_PAD);
|
|
57
48
|
if (result.status === "skip") {
|
|
58
|
-
return ` ${glyph} ${id} ${
|
|
49
|
+
return ` ${glyph} ${id} ${colors.dim("skipped")}`;
|
|
59
50
|
}
|
|
60
51
|
const score = result.score === undefined ? "" : result.score.toFixed(2);
|
|
52
|
+
const cost = money(result.costUsd);
|
|
61
53
|
const cells = [
|
|
62
|
-
|
|
54
|
+
color(result.status),
|
|
63
55
|
score,
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
colors.dim(seconds(result.durationMs)),
|
|
57
|
+
cost === "" ? "" : colors.dim(cost),
|
|
66
58
|
]
|
|
67
59
|
.filter((cell) => cell !== "")
|
|
68
60
|
.join(" ");
|
|
@@ -77,17 +69,17 @@ export const questionDetails = (
|
|
|
77
69
|
const lines: string[] = [];
|
|
78
70
|
if (result.status === "fail") {
|
|
79
71
|
for (const fact of result.missing) {
|
|
80
|
-
lines.push(` ${
|
|
72
|
+
lines.push(` ${colors.dim(`missing: ${fact}`)}`);
|
|
81
73
|
}
|
|
82
74
|
}
|
|
83
75
|
if (result.status === "error" && result.detail) {
|
|
84
|
-
lines.push(` ${
|
|
76
|
+
lines.push(` ${colors.dim(result.detail)}`);
|
|
85
77
|
}
|
|
86
78
|
if (verbose && result.answer && result.status !== "pass") {
|
|
87
79
|
lines.push(
|
|
88
80
|
...result.answer
|
|
89
81
|
.split("\n")
|
|
90
|
-
.map((line) => ` ${
|
|
82
|
+
.map((line) => ` ${colors.dim(`> ${line}`)}`)
|
|
91
83
|
);
|
|
92
84
|
}
|
|
93
85
|
return lines;
|
|
@@ -109,11 +101,11 @@ export const summaryLine = (result: EvalResult): string => {
|
|
|
109
101
|
|
|
110
102
|
/** The header line the command prints before the first question runs. */
|
|
111
103
|
export const headerLine = (total: number, agent: EvalResult["agent"]): string =>
|
|
112
|
-
`${
|
|
104
|
+
`${colors.bold("blume eval")} ${total} question(s) · ${AGENTS[agent].name}`;
|
|
113
105
|
|
|
114
106
|
/** The dim announce line while a question's agents run. */
|
|
115
107
|
export const startLine = (id: string, index: number, total: number): string =>
|
|
116
|
-
` ${
|
|
108
|
+
` ${colors.dim(`▸ ${id} (${index + 1}/${total})`)}`;
|
|
117
109
|
|
|
118
110
|
/** `fix:` pointers for failed questions, naming the file that resolves each. */
|
|
119
111
|
export const fixLines = (result: EvalResult, root: string): string[] =>
|
|
@@ -123,7 +115,7 @@ export const fixLines = (result: EvalResult, root: string): string[] =>
|
|
|
123
115
|
const site = finding.file
|
|
124
116
|
? `${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
|
|
125
117
|
: "";
|
|
126
|
-
return ` ${
|
|
118
|
+
return ` ${colors.cyan("fix:")} ${site} ${colors.dim(finding.message)}`;
|
|
127
119
|
});
|
|
128
120
|
|
|
129
121
|
/** Dim warnings for route hints that no longer match a page. */
|
|
@@ -134,7 +126,7 @@ export const warningLines = (result: EvalResult, root: string): string[] =>
|
|
|
134
126
|
const site = finding.file
|
|
135
127
|
? ` ${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
|
|
136
128
|
: "";
|
|
137
|
-
return ` ${
|
|
129
|
+
return ` ${colors.yellow("⚠")}${site} ${colors.dim(finding.message)}`;
|
|
138
130
|
});
|
|
139
131
|
|
|
140
132
|
/** The human report, written to stderr by the command. */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toString as mdastToString } from "mdast-util-to-string";
|
|
2
|
+
|
|
1
3
|
import { jsxAttribute, jsxFlowElement } from "./mdast.ts";
|
|
2
4
|
import type { MdastNode, MdastVisitorContext } from "./mdast.ts";
|
|
3
5
|
|
|
@@ -35,23 +37,6 @@ export const calloutTypeFor = (name: string): string | null => {
|
|
|
35
37
|
return ALIASES[lower] ?? null;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
interface TextNode extends MdastNode {
|
|
39
|
-
value?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Concatenate the plain text of a node, recursing through phrasing children so
|
|
44
|
-
* formatted labels keep every word — `:::note[Read **this**]` yields
|
|
45
|
-
* `Read this`, not `Read ` (the bolded run dropped).
|
|
46
|
-
*/
|
|
47
|
-
const textOf = (node: MdastNode): string => {
|
|
48
|
-
const { children } = node as { children?: MdastNode[] };
|
|
49
|
-
if (children && children.length > 0) {
|
|
50
|
-
return children.map(textOf).join("");
|
|
51
|
-
}
|
|
52
|
-
return (node as TextNode).value ?? "";
|
|
53
|
-
};
|
|
54
|
-
|
|
55
40
|
/**
|
|
56
41
|
* Satteri MDAST plugin mapping container directives (`:::note`, `:::warning`,
|
|
57
42
|
* `:::tip`, …) onto Blume's `<Callout>` component. The title comes from a
|
|
@@ -77,7 +62,10 @@ export const directiveToCalloutPlugin = () => ({
|
|
|
77
62
|
if (labelIndex !== -1) {
|
|
78
63
|
const [label] = children.splice(labelIndex, 1);
|
|
79
64
|
if (label) {
|
|
80
|
-
|
|
65
|
+
// Flatten the label's phrasing children so `:::note[Read **this**]`
|
|
66
|
+
// yields `Read this`; image alt is excluded (an image is not label
|
|
67
|
+
// text), matching the historical child-values-only behavior.
|
|
68
|
+
title ??= mdastToString(label, { includeImageAlt: false }) || undefined;
|
|
81
69
|
}
|
|
82
70
|
}
|
|
83
71
|
|
package/src/markdown/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
transformerNotationHighlight,
|
|
7
7
|
transformerNotationWordHighlight,
|
|
8
8
|
} from "@shikijs/transformers";
|
|
9
|
+
import { escape as escapeHtml } from "html-escaper";
|
|
9
10
|
import { codeToHtml } from "shiki";
|
|
10
11
|
|
|
11
12
|
import { baseLinksPlugin } from "./base-links.ts";
|
|
@@ -107,12 +108,6 @@ export const blumeShikiTransformers = (
|
|
|
107
108
|
return transformers;
|
|
108
109
|
};
|
|
109
110
|
|
|
110
|
-
const escapeHtml = (value: string): string =>
|
|
111
|
-
value
|
|
112
|
-
.replaceAll("&", "&")
|
|
113
|
-
.replaceAll("<", "<")
|
|
114
|
-
.replaceAll(">", ">");
|
|
115
|
-
|
|
116
111
|
/**
|
|
117
112
|
* Tag the highlighted `<pre>` with `astro-code` (plus any extra classes) so the
|
|
118
113
|
* theme's code-block styles apply — `codeToHtml`'s bare output is `pre.shiki`,
|
|
@@ -79,10 +79,6 @@ const normalizeFlags = (args: string[]): string[] =>
|
|
|
79
79
|
*/
|
|
80
80
|
const parseIntent = (input: string): Intent => {
|
|
81
81
|
const tokens = input.trim().split(WHITESPACE).filter(Boolean);
|
|
82
|
-
if (tokens.length === 0) {
|
|
83
|
-
return { args: [], operation: "install" };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
82
|
const [first, ...rest] = tokens;
|
|
87
83
|
if (first === undefined) {
|
|
88
84
|
return { args: [], operation: "install" };
|
package/src/openapi/parse.ts
CHANGED
|
@@ -150,16 +150,18 @@ const fetchSpecText = async (spec: string): Promise<string> => {
|
|
|
150
150
|
if ("text" in last) {
|
|
151
151
|
return last.text;
|
|
152
152
|
}
|
|
153
|
-
if (!last.retryable
|
|
154
|
-
|
|
153
|
+
if (!last.retryable) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
157
|
+
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
158
|
+
await sleep(
|
|
159
|
+
Math.min(
|
|
160
|
+
last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
|
|
161
|
+
MAX_RETRY_WAIT_MS
|
|
162
|
+
)
|
|
163
|
+
);
|
|
155
164
|
}
|
|
156
|
-
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
157
|
-
await sleep(
|
|
158
|
-
Math.min(
|
|
159
|
-
last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
|
|
160
|
-
MAX_RETRY_WAIT_MS
|
|
161
|
-
)
|
|
162
|
-
);
|
|
163
165
|
}
|
|
164
166
|
throw last.error;
|
|
165
167
|
};
|
package/src/search/documents.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { contentIndexable } from "../core/manifest.ts";
|
|
|
5
5
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
6
6
|
import { readEntryText } from "../core/sources/read.ts";
|
|
7
7
|
import type { NavNode } from "../core/types.ts";
|
|
8
|
+
import { pageFacets } from "./facets.ts";
|
|
8
9
|
|
|
9
10
|
/** A document indexed by the client-side search providers (Orama, FlexSearch). */
|
|
10
11
|
export interface SearchDocument {
|
|
@@ -18,8 +19,15 @@ export interface SearchDocument {
|
|
|
18
19
|
section: string;
|
|
19
20
|
/** Locale code, so the dialog can filter results to the active language. */
|
|
20
21
|
locale: string;
|
|
22
|
+
/** Resolved page `type` (`doc`, `blog`, a custom `rfc`…), for type filters. */
|
|
23
|
+
contentType: string;
|
|
21
24
|
/** Frontmatter `search.tags`, surfaced for hosted-provider faceting. */
|
|
22
25
|
tags?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Declared facet values (`content.types.<type>.facets`), key → value.
|
|
28
|
+
* Filterable through the MCP tools' `filters` input.
|
|
29
|
+
*/
|
|
30
|
+
facets?: Record<string, string>;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
/**
|
|
@@ -193,10 +201,13 @@ export const buildSearchDocuments = async (
|
|
|
193
201
|
options?.content === "markdown" ? visible.trim() : toPlainText(visible);
|
|
194
202
|
const tags = page?.meta?.search?.tags;
|
|
195
203
|
const crumb = crumbs.get(route.path);
|
|
204
|
+
const facets = page ? pageFacets(page, project.config) : undefined;
|
|
196
205
|
return {
|
|
197
206
|
breadcrumb: crumb?.breadcrumb ?? [],
|
|
198
207
|
content: body,
|
|
208
|
+
contentType: route.contentType,
|
|
199
209
|
description: page?.description ?? "",
|
|
210
|
+
...(facets ? { facets } : {}),
|
|
200
211
|
locale: route.locale,
|
|
201
212
|
route: route.path,
|
|
202
213
|
section: crumb?.section || "Docs",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
import type { PageRecord } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a page's facet values: the subset of its validated custom
|
|
6
|
+
* frontmatter named by its content type's `facets` declaration. Strings facet
|
|
7
|
+
* as-is; numbers and booleans are stringified so an enum-like `priority: 1`
|
|
8
|
+
* or `enforced: true` still filters; any other shape (objects, arrays,
|
|
9
|
+
* transformed dates) is not a facet value and is skipped. Returns `undefined`
|
|
10
|
+
* when nothing facets, so the field stays absent from serialized documents
|
|
11
|
+
* rather than shipping as `{}` on every page.
|
|
12
|
+
*/
|
|
13
|
+
export const pageFacets = (
|
|
14
|
+
page: Pick<PageRecord, "contentType" | "custom">,
|
|
15
|
+
config: ResolvedConfig
|
|
16
|
+
): Record<string, string> | undefined => {
|
|
17
|
+
const declared = config.content.types[page.contentType]?.facets;
|
|
18
|
+
if (!declared || declared.length === 0 || !page.custom) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const facets: Record<string, string> = {};
|
|
22
|
+
for (const key of declared) {
|
|
23
|
+
const value = page.custom[key];
|
|
24
|
+
if (
|
|
25
|
+
typeof value === "string" ||
|
|
26
|
+
typeof value === "number" ||
|
|
27
|
+
typeof value === "boolean"
|
|
28
|
+
) {
|
|
29
|
+
facets[key] = String(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Object.keys(facets).length > 0 ? facets : undefined;
|
|
33
|
+
};
|
|
@@ -13,6 +13,10 @@ export interface OramaDoc {
|
|
|
13
13
|
title: string;
|
|
14
14
|
/** Locale code; indexed as an enum so queries can filter to one language. */
|
|
15
15
|
locale?: string;
|
|
16
|
+
/** Resolved page `type`; indexed as an enum so queries can filter by type. */
|
|
17
|
+
contentType?: string;
|
|
18
|
+
/** Declared facet values (`content.types.<type>.facets`), key → value. */
|
|
19
|
+
facets?: Record<string, string>;
|
|
16
20
|
/** Carried through for the search dialog's breadcrumb + filter pills. Stored
|
|
17
21
|
* but not indexed, so they ride along on the returned document untouched. */
|
|
18
22
|
breadcrumb?: string[];
|
|
@@ -21,13 +25,22 @@ export interface OramaDoc {
|
|
|
21
25
|
|
|
22
26
|
const SCHEMA = {
|
|
23
27
|
content: "string",
|
|
28
|
+
// Enums (not full-text "string") so `where` does an exact-match filter.
|
|
29
|
+
contentType: "enum",
|
|
24
30
|
description: "string",
|
|
25
|
-
//
|
|
31
|
+
// Facets, flattened to `key:value` terms — enum[] so one static schema
|
|
32
|
+
// serves every project's facet keys, with `containsAll` matching a filter
|
|
33
|
+
// set. Derived from `facets` at insert time.
|
|
34
|
+
facetTerms: "enum[]",
|
|
26
35
|
locale: "enum",
|
|
27
36
|
route: "string",
|
|
28
37
|
title: "string",
|
|
29
38
|
} as const;
|
|
30
39
|
|
|
40
|
+
/** Flatten a facet map to the `key:value` terms the `facetTerms` enum holds. */
|
|
41
|
+
const toFacetTerms = (facets: Record<string, string>): string[] =>
|
|
42
|
+
Object.entries(facets).map(([key, value]) => `${key}:${value}`);
|
|
43
|
+
|
|
31
44
|
/** Title and description outrank body text, matching the search dialog. */
|
|
32
45
|
const BOOST = { description: 2, title: 3 };
|
|
33
46
|
|
|
@@ -169,17 +182,36 @@ export const buildOramaIndex = async (
|
|
|
169
182
|
schema: SCHEMA,
|
|
170
183
|
...(tokenizer ? { components: { tokenizer } } : {}),
|
|
171
184
|
});
|
|
172
|
-
await insertMultiple(
|
|
185
|
+
await insertMultiple(
|
|
186
|
+
db,
|
|
187
|
+
documents.map((doc) =>
|
|
188
|
+
doc.facets ? { ...doc, facetTerms: toFacetTerms(doc.facets) } : doc
|
|
189
|
+
)
|
|
190
|
+
);
|
|
173
191
|
return db;
|
|
174
192
|
};
|
|
175
193
|
|
|
176
194
|
/** Orama keeps only documents matching every token at a threshold of 0. */
|
|
177
195
|
const ALL_TOKENS = 0;
|
|
178
196
|
|
|
197
|
+
/** Optional exact-match filters applied to a query via Orama's `where`. */
|
|
198
|
+
export interface OramaQueryFilters {
|
|
199
|
+
/** Keep only documents whose `contentType` is in this list. */
|
|
200
|
+
contentTypes?: string[];
|
|
201
|
+
/**
|
|
202
|
+
* Keep only documents matching every facet, key → required value. Facet
|
|
203
|
+
* keys and values come from the `facets` field on the indexed documents.
|
|
204
|
+
*/
|
|
205
|
+
facets?: Record<string, string>;
|
|
206
|
+
/** Keep only documents in this locale. */
|
|
207
|
+
locale?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
/**
|
|
180
211
|
* Query the index, returning the matching documents (highest-ranked first).
|
|
181
|
-
*
|
|
182
|
-
* `
|
|
212
|
+
* `filters` narrows results by exact `where` matches on the enum fields:
|
|
213
|
+
* `locale` to one language, `contentTypes` to a set of page types, `facets`
|
|
214
|
+
* to documents carrying every requested `key:value` term.
|
|
183
215
|
*
|
|
184
216
|
* On a bigrammed index the strict pass runs first: a term is only meant to
|
|
185
217
|
* match where its bigrams sit together, and scoring them independently lets a
|
|
@@ -191,14 +223,24 @@ export const queryOramaIndex = async (
|
|
|
191
223
|
db: AnyOrama,
|
|
192
224
|
term: string,
|
|
193
225
|
limit: number,
|
|
194
|
-
|
|
226
|
+
filters?: OramaQueryFilters
|
|
195
227
|
): Promise<OramaDoc[]> => {
|
|
228
|
+
const facetTerms = filters?.facets ? toFacetTerms(filters.facets) : [];
|
|
229
|
+
const where = {
|
|
230
|
+
...(filters?.locale ? { locale: { eq: filters.locale } } : {}),
|
|
231
|
+
...(filters?.contentTypes && filters.contentTypes.length > 0
|
|
232
|
+
? { contentType: { in: filters.contentTypes } }
|
|
233
|
+
: {}),
|
|
234
|
+
...(facetTerms.length > 0
|
|
235
|
+
? { facetTerms: { containsAll: facetTerms } }
|
|
236
|
+
: {}),
|
|
237
|
+
};
|
|
196
238
|
const params = {
|
|
197
239
|
boost: BOOST,
|
|
198
240
|
limit,
|
|
199
241
|
properties: ["title", "description", "content"],
|
|
200
242
|
term,
|
|
201
|
-
...(
|
|
243
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
202
244
|
};
|
|
203
245
|
const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
|
|
204
246
|
const strict = bigrammed
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { escape } from "html-escaper";
|
|
2
|
+
|
|
3
|
+
import { prefixBase } from "../components/islands/base-path.ts";
|
|
4
|
+
import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
|
|
5
|
+
import { resolveIcon } from "../theme/icons.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a `search.popular` icon to markup for the Cmd+K island. Accepts the
|
|
9
|
+
* same *inputs* as nav `<Icon>` (built-in name, image path/URL, inline SVG);
|
|
10
|
+
* resolved on the server so the island never loads the icon set.
|
|
11
|
+
*/
|
|
12
|
+
export const resolvePopularIconMarkup = (
|
|
13
|
+
icon?: string,
|
|
14
|
+
/** `deployment.base` / `import.meta.env.BASE_URL` for root-relative image paths. */
|
|
15
|
+
base = "/"
|
|
16
|
+
): string | undefined => {
|
|
17
|
+
if (!icon) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (isInlineSvg(icon)) {
|
|
21
|
+
// Author SVG carries no guaranteed width/height (a viewBox-only <svg>
|
|
22
|
+
// defaults to 300x150), so size it the same way Icon.astro's wrapper does.
|
|
23
|
+
return `<span aria-hidden="true" style="display:inline-flex;width:16px;height:16px">${icon.trim()}</span>`;
|
|
24
|
+
}
|
|
25
|
+
if (isImageIcon(icon)) {
|
|
26
|
+
const src = escape(prefixBase(base, icon.trim()));
|
|
27
|
+
return `<img src="${src}" width="16" height="16" alt="" aria-hidden="true" class="size-4" />`;
|
|
28
|
+
}
|
|
29
|
+
const resolved = resolveIcon(icon);
|
|
30
|
+
return resolved
|
|
31
|
+
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
|
|
32
|
+
: undefined;
|
|
33
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify author-supplied icon strings — built-in Lucide names vs image
|
|
3
|
+
* paths/URLs vs inline SVG. Shared by nav diagnostics, `<Icon>`, and the
|
|
4
|
+
* Cmd+K popular-icon resolver so the three shapes stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const IMAGE_ICON =
|
|
8
|
+
/^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
|
9
|
+
|
|
10
|
+
const INLINE_SVG = /^\s*<svg[\s\S]*<\/svg>\s*$/u;
|
|
11
|
+
|
|
12
|
+
/** Image path, remote URL, or data URI — not a Lucide name or inline SVG. */
|
|
13
|
+
export const isImageIcon = (value: string): boolean => IMAGE_ICON.test(value);
|
|
14
|
+
|
|
15
|
+
/** Full `<svg>…</svg>` markup (whitespace-tolerant). */
|
|
16
|
+
export const isInlineSvg = (value: string): boolean => INLINE_SVG.test(value);
|
|
17
|
+
|
|
18
|
+
/** Image or inline SVG — anything that is not a built-in icon name. */
|
|
19
|
+
export const isAssetIcon = (value: string): boolean =>
|
|
20
|
+
isInlineSvg(value) || isImageIcon(value);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AgentKind } from "../audit/agent.ts";
|
|
2
|
+
import { DISALLOWED_TOOLS } from "../eval/agents.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* argv builders for the translator role. The subprocess machinery
|
|
6
|
+
* (`runAgentHeadless`, `readAgentOutput`, the `HeadlessRunner` test seam) is
|
|
7
|
+
* shared with `blume eval` — only the argument surface differs: a translator
|
|
8
|
+
* is a pure text→text call with no MCP servers and no tools at all, so the
|
|
9
|
+
* agent can neither read the repo nor write files (Blume owns every write).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wall-clock ceiling per file. Generous by design: translating a large page
|
|
14
|
+
* means regenerating the whole file token by token, and a 20KB+ reference
|
|
15
|
+
* page comfortably exceeds the eval reader's 3-minute precedent. The ceiling
|
|
16
|
+
* exists to catch hung agents, not to police slow-but-progressing ones.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_TRANSLATE_TIMEOUT_MS = 600_000;
|
|
19
|
+
|
|
20
|
+
const claudeArgs = (): string[] => [
|
|
21
|
+
"-p",
|
|
22
|
+
"--output-format",
|
|
23
|
+
"json",
|
|
24
|
+
"--strict-mcp-config",
|
|
25
|
+
"--disallowedTools",
|
|
26
|
+
DISALLOWED_TOOLS.join(","),
|
|
27
|
+
"--max-turns",
|
|
28
|
+
"1",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const codexArgs = (lastMessagePath: string): string[] => [
|
|
32
|
+
"exec",
|
|
33
|
+
"--skip-git-repo-check",
|
|
34
|
+
"--ignore-user-config",
|
|
35
|
+
"--ephemeral",
|
|
36
|
+
"--sandbox",
|
|
37
|
+
"read-only",
|
|
38
|
+
"--output-last-message",
|
|
39
|
+
lastMessagePath,
|
|
40
|
+
"-",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the argv for one headless translation call. `lastMessagePath` is where
|
|
45
|
+
* codex writes its final message (its stdout interleaves progress); claude
|
|
46
|
+
* ignores it and answers as JSON on stdout.
|
|
47
|
+
*/
|
|
48
|
+
export const translateAgentArgs = (
|
|
49
|
+
kind: AgentKind,
|
|
50
|
+
lastMessagePath: string
|
|
51
|
+
): string[] => (kind === "claude" ? claudeArgs() : codexArgs(lastMessagePath));
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { writeTextAtomic } from "../core/fs-atomic.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The committed translation ledger: which source files have been translated
|
|
11
|
+
* into which locales, and at what source content. Named "ledger" to avoid
|
|
12
|
+
* colliding with the route manifest (`core/manifest.ts`). It lives at the
|
|
13
|
+
* project root — never inside `.blume/` (init gitignores that dir wholesale,
|
|
14
|
+
* and the whole point is that the ledger is committed alongside the docs).
|
|
15
|
+
*/
|
|
16
|
+
export const LEDGER_FILE = "blume.translations.json";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `files` maps a POSIX root-relative source path to, per locale, the hash of
|
|
20
|
+
* the raw source text at the moment that locale's translation was written.
|
|
21
|
+
*/
|
|
22
|
+
export interface TranslationLedger {
|
|
23
|
+
version: 1;
|
|
24
|
+
files: Record<string, Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ledgerSchema = z.object({
|
|
28
|
+
files: z.record(z.string(), z.record(z.string(), z.string())),
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const emptyLedger = (): TranslationLedger => ({
|
|
33
|
+
files: {},
|
|
34
|
+
version: 1,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hash the raw source text (frontmatter included), so any edit invalidates
|
|
39
|
+
* every locale's stamp. sha256-16 like the audit snapshot's content hash —
|
|
40
|
+
* never `hashText` (djb2), which is an ephemeral-cache-only hash.
|
|
41
|
+
*/
|
|
42
|
+
export const hashSource = (text: string): string =>
|
|
43
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the ledger at `root`, tolerantly: a missing file, unparseable JSON, or
|
|
47
|
+
* an unknown shape/version all resolve to an empty ledger rather than an error
|
|
48
|
+
* (same posture as the dev lock's `parseLock`) — the worst outcome of a
|
|
49
|
+
* corrupt ledger is retranslating files that were already up to date.
|
|
50
|
+
*/
|
|
51
|
+
export const readLedger = async (root: string): Promise<TranslationLedger> => {
|
|
52
|
+
let raw: string;
|
|
53
|
+
try {
|
|
54
|
+
raw = await readFile(join(root, LEDGER_FILE), "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
return emptyLedger();
|
|
57
|
+
}
|
|
58
|
+
let data: unknown;
|
|
59
|
+
try {
|
|
60
|
+
data = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return emptyLedger();
|
|
63
|
+
}
|
|
64
|
+
const parsed = ledgerSchema.safeParse(data);
|
|
65
|
+
return parsed.success ? parsed.data : emptyLedger();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Deterministic serialization: keys sorted at both levels, 2-space indent. */
|
|
69
|
+
export const serializeLedger = (ledger: TranslationLedger): string => {
|
|
70
|
+
const files: Record<string, Record<string, string>> = {};
|
|
71
|
+
for (const source of Object.keys(ledger.files).toSorted()) {
|
|
72
|
+
const locales = ledger.files[source] ?? {};
|
|
73
|
+
files[source] = Object.fromEntries(
|
|
74
|
+
Object.keys(locales)
|
|
75
|
+
.toSorted()
|
|
76
|
+
.map((locale) => [locale, locales[locale] ?? ""])
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return `${JSON.stringify({ files, version: ledger.version }, null, 2)}\n`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write the ledger at `root`, returning whether anything changed on disk. A
|
|
84
|
+
* byte-identical ledger is left untouched (no mtime churn, no git noise);
|
|
85
|
+
* a changed one lands via temp-file-plus-rename so a concurrent reader never
|
|
86
|
+
* observes a half-written file.
|
|
87
|
+
*/
|
|
88
|
+
export const writeLedger = async (
|
|
89
|
+
root: string,
|
|
90
|
+
ledger: TranslationLedger
|
|
91
|
+
): Promise<boolean> => {
|
|
92
|
+
const path = join(root, LEDGER_FILE);
|
|
93
|
+
const content = serializeLedger(ledger);
|
|
94
|
+
let existing: string | null = null;
|
|
95
|
+
try {
|
|
96
|
+
existing = await readFile(path, "utf-8");
|
|
97
|
+
} catch {
|
|
98
|
+
existing = null;
|
|
99
|
+
}
|
|
100
|
+
if (existing === content) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
await writeTextAtomic(path, content);
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Record that `sourceRel` is translated into `locale` at source hash `hash`. */
|
|
108
|
+
export const stampLedger = (
|
|
109
|
+
ledger: TranslationLedger,
|
|
110
|
+
sourceRel: string,
|
|
111
|
+
locale: string,
|
|
112
|
+
hash: string
|
|
113
|
+
): void => {
|
|
114
|
+
const locales = ledger.files[sourceRel] ?? {};
|
|
115
|
+
locales[locale] = hash;
|
|
116
|
+
ledger.files[sourceRel] = locales;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Drop entries for sources that no longer exist and locales that are no longer
|
|
121
|
+
* configured, so deleted pages and removed locales don't linger in the ledger
|
|
122
|
+
* forever. Returns a new ledger.
|
|
123
|
+
*/
|
|
124
|
+
export const pruneLedger = (
|
|
125
|
+
ledger: TranslationLedger,
|
|
126
|
+
knownSources: ReadonlySet<string>,
|
|
127
|
+
knownLocales: ReadonlySet<string>
|
|
128
|
+
): TranslationLedger => {
|
|
129
|
+
const files: Record<string, Record<string, string>> = {};
|
|
130
|
+
for (const [source, locales] of Object.entries(ledger.files)) {
|
|
131
|
+
if (!knownSources.has(source)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const kept = Object.fromEntries(
|
|
135
|
+
Object.entries(locales).filter(([locale]) => knownLocales.has(locale))
|
|
136
|
+
);
|
|
137
|
+
if (Object.keys(kept).length > 0) {
|
|
138
|
+
files[source] = kept;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { files, version: ledger.version };
|
|
142
|
+
};
|