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.
Files changed (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +15 -1
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. package/src/deploy/xml.ts +0 -8
@@ -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, string> = {
29
- error: COLORS.yellow,
30
- fail: COLORS.red,
31
- pass: COLORS.green,
32
- skip: COLORS.dim,
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 = `${color}${GLYPH[result.status]}${COLORS.reset}`;
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} ${COLORS.dim}skipped${COLORS.reset}`;
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
- `${color}${result.status}${COLORS.reset}`,
54
+ color(result.status),
63
55
  score,
64
- `${COLORS.dim}${seconds(result.durationMs)}${COLORS.reset}`,
65
- `${COLORS.dim}${money(result.costUsd)}${COLORS.reset}`,
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(` ${COLORS.dim}missing: ${fact}${COLORS.reset}`);
72
+ lines.push(` ${colors.dim(`missing: ${fact}`)}`);
81
73
  }
82
74
  }
83
75
  if (result.status === "error" && result.detail) {
84
- lines.push(` ${COLORS.dim}${result.detail}${COLORS.reset}`);
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) => ` ${COLORS.dim}> ${line}${COLORS.reset}`)
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
- `${COLORS.bold}blume eval${COLORS.reset} ${total} question(s) · ${AGENTS[agent].name}`;
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
- ` ${COLORS.dim}▸ ${id} (${index + 1}/${total})${COLORS.reset}`;
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 ` ${COLORS.cyan}fix:${COLORS.reset} ${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
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 ` ${COLORS.yellow}⚠${COLORS.reset}${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
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
- title ??= textOf(label) || undefined;
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
 
@@ -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("&", "&amp;")
113
- .replaceAll("<", "&lt;")
114
- .replaceAll(">", "&gt;");
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" };
@@ -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 || attempt === MAX_ATTEMPTS - 1) {
154
- throw last.error;
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
  };
@@ -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
- // Enum (not full-text "string") so `where` does an exact-match filter.
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(db, documents);
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
- * When `locale` is given, results are filtered to that language via an exact
182
- * `where` match on the `locale` enum.
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
- locale?: string
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
- ...(locale ? { where: { locale: { eq: locale } } } : {}),
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
+ };