blume 1.1.3 → 1.2.0

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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1473 -149
  4. package/dist/cli/index.js.map +47 -36
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +3 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +48 -14
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +119 -48
  26. package/src/astro/templates.ts +173 -37
  27. package/src/audit/checks/duplicates.ts +15 -6
  28. package/src/audit/checks/indexability.ts +11 -2
  29. package/src/audit/checks/network.ts +22 -8
  30. package/src/audit/checks/sitemap.ts +42 -16
  31. package/src/audit/redirects.ts +12 -1
  32. package/src/audit/run.ts +13 -3
  33. package/src/audit/url.ts +21 -2
  34. package/src/cli/commands/audit.ts +21 -6
  35. package/src/cli/commands/dev.ts +19 -2
  36. package/src/cli/commands/eval.ts +291 -0
  37. package/src/cli/commands/init.ts +9 -4
  38. package/src/cli/commands/mcp-stdio.ts +36 -0
  39. package/src/cli/index.ts +4 -0
  40. package/src/cli/required-secrets.ts +1 -1
  41. package/src/components/content/AccordionItem.astro +2 -2
  42. package/src/components/content/Frame.astro +4 -1
  43. package/src/components/content/Prompt.astro +4 -1
  44. package/src/components/content/Tooltip.astro +4 -1
  45. package/src/components/content/TreeFolder.astro +1 -2
  46. package/src/components/content/Update.astro +45 -0
  47. package/src/components/islands/AskAI.astro +9 -2
  48. package/src/components/islands/ask-ai.tsx +23 -4
  49. package/src/components/islands/hooks.ts +48 -15
  50. package/src/components/layout/NavTree.astro +37 -19
  51. package/src/components/layout/ReferenceLayout.astro +4 -0
  52. package/src/components/layout/RootLayout.astro +14 -3
  53. package/src/components/layout/Search.astro +5 -1
  54. package/src/components/layout/head-scripts.ts +22 -5
  55. package/src/components/openapi/SchemaProperty.astro +3 -3
  56. package/src/core/config-input.ts +18 -0
  57. package/src/core/config.ts +4 -0
  58. package/src/core/data.ts +3 -0
  59. package/src/core/deployment-env.ts +7 -2
  60. package/src/core/graph.ts +8 -1
  61. package/src/core/i18n.ts +10 -2
  62. package/src/core/navigation.ts +16 -5
  63. package/src/core/schema.ts +51 -4
  64. package/src/core/server-features.ts +1 -1
  65. package/src/core/sources/normalize.ts +69 -8
  66. package/src/core/sources/notion.ts +4 -2
  67. package/src/core/sources/sanity.ts +5 -3
  68. package/src/core/types.ts +5 -3
  69. package/src/eval/agents.ts +340 -0
  70. package/src/eval/findings.ts +103 -0
  71. package/src/eval/prompts.ts +78 -0
  72. package/src/eval/report.ts +214 -0
  73. package/src/eval/run.ts +290 -0
  74. package/src/eval/schema.ts +124 -0
  75. package/src/markdown/code-title.ts +7 -1
  76. package/src/openapi/model.ts +31 -2
  77. package/src/openapi/references.ts +23 -2
  78. package/src/openapi/render-mdx.ts +39 -11
  79. package/src/openapi/scalar.ts +1 -0
  80. package/src/openapi/source.ts +11 -4
  81. package/src/registry/eject.ts +23 -1
  82. package/src/search/build.ts +4 -3
@@ -0,0 +1,124 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { load } from "js-yaml";
4
+ import { z } from "zod";
5
+
6
+ /** Question ids are kebab-case slugs so they read well in reports and CI logs. */
7
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
8
+
9
+ const questionSchema = z.strictObject({
10
+ expected: z
11
+ .array(z.string().min(1))
12
+ .min(1, "expected must list at least one fact"),
13
+ id: z
14
+ .string()
15
+ .regex(ID_PATTERN, "id must be a kebab-case slug (a-z, 0-9, dashes)"),
16
+ question: z.string().min(1),
17
+ routes: z
18
+ .union([z.string(), z.array(z.string())])
19
+ .default([])
20
+ .transform((value) => (typeof value === "string" ? [value] : value)),
21
+ severity: z.enum(["error", "warning"]).default("error"),
22
+ skip: z.boolean().default(false),
23
+ });
24
+
25
+ /** One author-written eval: a question plus the facts a passing answer states. */
26
+ export type EvalQuestion = z.infer<typeof questionSchema>;
27
+
28
+ const fullSchema = z.strictObject({
29
+ questions: z.array(questionSchema).min(1),
30
+ version: z.literal(1).default(1),
31
+ });
32
+
33
+ /**
34
+ * The evals file schema. A bare top-level list of questions is accepted as
35
+ * shorthand — `loadEvalsFile` wraps it before validating, rather than a
36
+ * `z.union`, so schema errors name the offending field instead of collapsing
37
+ * into an opaque "invalid union" issue.
38
+ */
39
+ export const evalsFileSchema = fullSchema.superRefine((value, context) => {
40
+ const seen = new Set<string>();
41
+ for (const question of value.questions) {
42
+ if (seen.has(question.id)) {
43
+ context.addIssue({
44
+ code: z.ZodIssueCode.custom,
45
+ message: `duplicate question id "${question.id}"`,
46
+ path: ["questions"],
47
+ });
48
+ }
49
+ seen.add(question.id);
50
+ }
51
+ });
52
+
53
+ export type EvalsFile = z.infer<typeof evalsFileSchema>;
54
+
55
+ /** A problem loading or validating the evals file, with the path it names. */
56
+ export class EvalsFileError extends Error {
57
+ readonly path: string;
58
+
59
+ constructor(path: string, message: string) {
60
+ super(message);
61
+ this.name = "EvalsFileError";
62
+ this.path = path;
63
+ }
64
+ }
65
+
66
+ const describeIssues = (error: z.ZodError): string =>
67
+ error.issues
68
+ .map((issue) => {
69
+ const at = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
70
+ return `${issue.message}${at}`;
71
+ })
72
+ .join("; ");
73
+
74
+ /**
75
+ * Read and validate an evals file. Returns the parsed questions plus the raw
76
+ * text, kept so findings can anchor to the line a question is defined on.
77
+ */
78
+ export const loadEvalsFile = async (
79
+ path: string
80
+ ): Promise<{ evals: EvalsFile; raw: string }> => {
81
+ let raw: string;
82
+ try {
83
+ raw = await readFile(path, "utf-8");
84
+ } catch {
85
+ throw new EvalsFileError(
86
+ path,
87
+ `No evals file found at ${path}. Run \`blume eval init\` to draft one.`
88
+ );
89
+ }
90
+
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = load(raw);
94
+ } catch (error) {
95
+ const detail = error instanceof Error ? error.message : String(error);
96
+ throw new EvalsFileError(path, `Invalid YAML in ${path}: ${detail}`);
97
+ }
98
+
99
+ // The bare-list shorthand: a top-level sequence of questions.
100
+ const candidate = Array.isArray(parsed) ? { questions: parsed } : parsed;
101
+ const result = evalsFileSchema.safeParse(candidate);
102
+ if (!result.success) {
103
+ throw new EvalsFileError(
104
+ path,
105
+ `Invalid evals file at ${path}: ${describeIssues(result.error)}`
106
+ );
107
+ }
108
+ return { evals: result.data, raw };
109
+ };
110
+
111
+ /**
112
+ * The 1-based line where a question's `id:` entry appears in the raw evals
113
+ * file, so a finding with no route hint can still point somewhere editable.
114
+ */
115
+ export const locateQuestion = (raw: string, id: string): number | undefined => {
116
+ const pattern = new RegExp(`^\\s*-?\\s*id:\\s*["']?${id}["']?\\s*$`, "u");
117
+ const lines = raw.split("\n");
118
+ for (const [index, line] of lines.entries()) {
119
+ if (pattern.test(line)) {
120
+ return index + 1;
121
+ }
122
+ }
123
+ return undefined;
124
+ };
@@ -51,7 +51,13 @@ const parseTitle = (raw: string | undefined): string | undefined => {
51
51
  if (!raw) {
52
52
  return undefined;
53
53
  }
54
- const explicit = raw.match(TITLE_ATTR);
54
+ // Blank every *other* quoted attr first, so a `title="…"` embedded in
55
+ // another attribute's value (`caption='set title="X" here'`) can't be
56
+ // promoted to the block title.
57
+ const scrubbed = raw.replace(QUOTED_ATTR, (attr) =>
58
+ attr.startsWith("title=") ? attr : " "
59
+ );
60
+ const explicit = scrubbed.match(TITLE_ATTR);
55
61
  const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
56
62
  if (attrTitle) {
57
63
  return attrTitle;
@@ -101,6 +101,32 @@ export type OpenApiData = Record<string, ApiSpecData>;
101
101
  const isOperation = (value: unknown): value is OperationObject =>
102
102
  typeof value === "object" && value !== null;
103
103
 
104
+ /**
105
+ * Assign each distinct tag name a unique slug. `slugify` can collapse
106
+ * different names onto one value — any two all-non-ASCII tags (`ペット`,
107
+ * `注文`) both fall through to the `operations` fallback — and a shared slug
108
+ * silently merges the tags' routes, sidebar groups, and overview sections.
109
+ * Collisions gain `-2`, `-3`, … in first-seen order.
110
+ */
111
+ const tagSlugger = (): ((name: string) => string) => {
112
+ const assigned = new Map<string, string>();
113
+ const taken = new Set<string>();
114
+ return (name) => {
115
+ const existing = assigned.get(name);
116
+ if (existing) {
117
+ return existing;
118
+ }
119
+ const base = slugify(name) || "operations";
120
+ let slug = base;
121
+ for (let suffix = 2; taken.has(slug); suffix += 1) {
122
+ slug = `${base}-${suffix}`;
123
+ }
124
+ taken.add(slug);
125
+ assigned.set(name, slug);
126
+ return slug;
127
+ };
128
+ };
129
+
104
130
  /**
105
131
  * Flatten a 3.1 document into a route-mapped operation list and its ordered
106
132
  * tags. Operations inherit the first tag they declare; keys are de-duplicated so
@@ -119,6 +145,7 @@ export const extractOperations = (
119
145
  );
120
146
  const seen = new Set<string>();
121
147
  const warnings: string[] = [];
148
+ const slugForTag = tagSlugger();
122
149
 
123
150
  for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
124
151
  const item = rawItem as PathItemObject | undefined;
@@ -137,7 +164,7 @@ export const extractOperations = (
137
164
  continue;
138
165
  }
139
166
  const tag = operation.tags?.[0] ?? UNTAGGED;
140
- const tagSlug = slugify(tag) || "operations";
167
+ const tagSlug = slugForTag(tag);
141
168
  if (!tagsSeen.has(tag)) {
142
169
  tagsSeen.add(tag);
143
170
  tagOrder.push(tag);
@@ -166,7 +193,9 @@ export const extractOperations = (
166
193
  const tags: ApiTagRef[] = tagOrder.map((name) => ({
167
194
  description: tagMeta.get(name) ?? "",
168
195
  name,
169
- slug: slugify(name) || "operations",
196
+ // The same slugger instance, so every tag resolves to the slug its
197
+ // operations were routed under.
198
+ slug: slugForTag(name),
170
199
  }));
171
200
 
172
201
  return { operations, tags, warnings };
@@ -38,6 +38,12 @@ export interface ReferenceSource {
38
38
  */
39
39
  basePath: string;
40
40
  label: string;
41
+ /** Whether generated pages are included in llms.txt/llms-full.txt. */
42
+ includeInLlms: boolean;
43
+ /** Whether generated pages are included in site search. */
44
+ includeInSearch: boolean;
45
+ /** Whether generated pages emit noindex metadata and stay out of the sitemap. */
46
+ noindex: boolean;
41
47
  /** Local path or `http(s)` URL, verbatim from config. */
42
48
  spec: string;
43
49
  /** Per-block Scalar theme name override, if any (Scalar renderer only). */
@@ -78,10 +84,22 @@ type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
78
84
  /** A spec is a single source (`spec` shorthand prepended to any `sources`). */
79
85
  const sourcesOf = (
80
86
  block: Block
81
- ): { label?: string; route?: string; spec: string }[] => {
87
+ ): {
88
+ includeInLlms: boolean;
89
+ includeInSearch: boolean;
90
+ label?: string;
91
+ noindex: boolean;
92
+ route?: string;
93
+ spec: string;
94
+ }[] => {
82
95
  const sources = [...block.sources];
83
96
  if (block.spec) {
84
- sources.unshift({ spec: block.spec });
97
+ sources.unshift({
98
+ includeInLlms: true,
99
+ includeInSearch: true,
100
+ noindex: false,
101
+ spec: block.spec,
102
+ });
85
103
  }
86
104
  return sources;
87
105
  };
@@ -118,8 +136,11 @@ const referencesFor = (
118
136
  return {
119
137
  basePath,
120
138
  display,
139
+ includeInLlms: source.includeInLlms,
140
+ includeInSearch: source.includeInSearch,
121
141
  kind,
122
142
  label,
143
+ noindex: source.noindex,
123
144
  renderer,
124
145
  route,
125
146
  scalar: block.scalar,
@@ -1,4 +1,5 @@
1
1
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
2
+ import type { ReferenceSource } from "./references.ts";
2
3
 
3
4
  /**
4
5
  * Lower a parsed spec into MDX for the staged content source. Each operation and
@@ -11,13 +12,14 @@ import type { ApiOperationRef, ApiSpecData } from "./model.ts";
11
12
  * omit their own top heading.
12
13
  */
13
14
 
14
- // Neutralize the few characters MDX treats specially (`{` expressions, `<` JSX)
15
- // so an arbitrary spec description can be embedded in the body verbatim without
16
- // breaking compilation. They render as their literal selves.
17
- const MDX_UNSAFE = /[<>{}]/gu;
15
+ // Neutralize the few characters MDX treats specially (`{` expressions, `<`
16
+ // JSX) so an arbitrary spec description can be embedded in the body verbatim
17
+ // without breaking compilation. They render as their literal selves. `>` is
18
+ // deliberately not escaped: it isn't MDX-special on its own, and escaping it
19
+ // turns a `> Note:` blockquote into literal "&gt; Note:" text.
20
+ const MDX_UNSAFE = /[<{}]/gu;
18
21
  const ENTITIES: Record<string, string> = {
19
22
  "<": "&lt;",
20
- ">": "&gt;",
21
23
  "{": "&#123;",
22
24
  "}": "&#125;",
23
25
  };
@@ -28,8 +30,12 @@ const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
28
30
  // Backtick code — inline spans and fences alike — is already literal in MDX,
29
31
  // and entities are NOT decoded inside it, so escaping there would render the
30
32
  // entity text verbatim (`/pets/&#123;petId&#125;`). Matching any balanced
31
- // backtick run covers `code`, ``code``, and ```fences``` in one shot.
32
- const BACKTICK_CODE = /(?<bt>`+)[\s\S]*?\k<bt>/gu;
33
+ // backtick run covers `code`, ``code``, and ```fences``` in one shot. Both
34
+ // runs are pinned by the backtick lookarounds: CommonMark pairs a span only
35
+ // with an *equal-length* run, so without them a lone backtick would "close" on
36
+ // the first backtick of a longer fence run — leaving `{` in the real prose
37
+ // unescaped (a compile error) and escaping entities into the fence body.
38
+ const BACKTICK_CODE = /(?<!`)(?<bt>`+)(?!`)[\s\S]*?(?<!`)\k<bt>(?!`)/gu;
33
39
 
34
40
  const escapeProse = (text: string): string =>
35
41
  text
@@ -121,7 +127,11 @@ const withDescription = (description: string, component: string): string =>
121
127
 
122
128
  export const operationMdx = (
123
129
  spec: ApiSpecData,
124
- operation: ApiOperationRef
130
+ operation: ApiOperationRef,
131
+ reference?: Pick<
132
+ ReferenceSource,
133
+ "includeInLlms" | "includeInSearch" | "noindex"
134
+ >
125
135
  ): RenderedPage => {
126
136
  const method = operation.method.toUpperCase();
127
137
  const title = operation.summary || `${method} ${operation.path}`;
@@ -137,9 +147,16 @@ export const operationMdx = (
137
147
  `<Operation source="${spec.slug}" id="${operation.key}" />`
138
148
  ),
139
149
  data: {
150
+ ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
140
151
  ...(operation.deprecated ? { deprecated: true } : {}),
141
- search: { tags: [operation.tag, method] },
142
- seo: { description: operationDescription(spec, operation) },
152
+ search: {
153
+ ...(reference?.includeInSearch === false ? { exclude: true } : {}),
154
+ tags: [operation.tag, method],
155
+ },
156
+ seo: {
157
+ description: operationDescription(spec, operation),
158
+ ...(reference?.noindex ? { noindex: true } : {}),
159
+ },
143
160
  sidebar: { badge: method, label: operation.summary || operation.path },
144
161
  title,
145
162
  // Signals the two-column API layout (request panel instead of the TOC).
@@ -148,7 +165,13 @@ export const operationMdx = (
148
165
  };
149
166
  };
150
167
 
151
- export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
168
+ export const overviewMdx = (
169
+ spec: ApiSpecData,
170
+ reference?: Pick<
171
+ ReferenceSource,
172
+ "includeInLlms" | "includeInSearch" | "noindex"
173
+ >
174
+ ): RenderedPage => {
152
175
  // Tag sections: declared tags in spec order, then any tag an operation
153
176
  // references that isn't declared under `tags`. The section headings are
154
177
  // emitted as real markdown `##` (not markup inside a component) so the
@@ -200,10 +223,15 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
200
223
  ...tagSections,
201
224
  ].join("\n\n"),
202
225
  data: {
226
+ ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
227
+ ...(reference?.includeInSearch === false
228
+ ? { search: { exclude: true } }
229
+ : {}),
203
230
  seo: {
204
231
  description:
205
232
  clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
206
233
  `${apiName(spec)} API reference.`,
234
+ ...(reference?.noindex ? { noindex: true } : {}),
207
235
  },
208
236
  sidebar: { label: "Overview" },
209
237
  title: apiName(spec),
@@ -167,6 +167,7 @@ export const buildReferenceFiles = async (options: {
167
167
  ...ref.scalar,
168
168
  },
169
169
  dataImport: `${"../".repeat(depth + 1)}generated/data.json`,
170
+ noindex: ref.noindex,
170
171
  route: ref.route,
171
172
  title: ref.label,
172
173
  }),
@@ -53,17 +53,24 @@ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
53
53
  /** All staged entries for one spec: operations first, overview last. */
54
54
  const specEntries = (
55
55
  spec: ApiSpecData,
56
- operations: ApiOperationRef[]
56
+ operations: ApiOperationRef[],
57
+ reference: ReferenceSource
57
58
  ): SourceEntry[] => {
58
59
  const entries = operations.map((operation) =>
59
- toEntry(operationMdx(spec, operation), `${routeToRef(operation.route)}.mdx`)
60
+ toEntry(
61
+ operationMdx(spec, operation, reference),
62
+ `${routeToRef(operation.route)}.mdx`
63
+ )
60
64
  );
61
65
  // Overview last so an operation sets the section's routePath before the index
62
66
  // page is inserted (the group's routePath is derived from its first child).
63
67
  // A root-mounted reference refs `index.mdx`, not `/index.mdx`.
64
68
  const base = routeToRef(spec.route);
65
69
  entries.push(
66
- toEntry(overviewMdx(spec), base ? `${base}/index.mdx` : "index.mdx")
70
+ toEntry(
71
+ overviewMdx(spec, reference),
72
+ base ? `${base}/index.mdx` : "index.mdx"
73
+ )
67
74
  );
68
75
  return entries;
69
76
  };
@@ -149,7 +156,7 @@ export const openApiSource = (
149
156
  ]
150
157
  : []),
151
158
  ],
152
- entries: specEntries(spec, operations),
159
+ entries: specEntries(spec, operations, reference),
153
160
  slug: reference.slug,
154
161
  spec,
155
162
  };
@@ -112,7 +112,15 @@ const askFiles = async (
112
112
  genDir: string
113
113
  ): Promise<{ content: string; path: string }[]> => {
114
114
  const { ask } = project.config.ai;
115
- if (!ask?.enabled) {
115
+ if (!(ask?.enabled && !ask.endpoint)) {
116
+ const endpointPath = join(srcDir, "pages", "api", "ask.ts");
117
+ if (existsSync(endpointPath)) {
118
+ const content = await readFile(endpointPath, "utf-8");
119
+ if (content.startsWith("// Generated by Blume. Do not edit.")) {
120
+ await rm(endpointPath, { force: true });
121
+ }
122
+ }
123
+ await rm(join(genDir, "ask-data.json"), { force: true });
116
124
  return [];
117
125
  }
118
126
  const grounded = ask.provider !== "inkeep";
@@ -271,6 +279,15 @@ const examplesPreviewFiles = (
271
279
  ]
272
280
  : [];
273
281
 
282
+ const ejectIntegrationBridge = (
283
+ config: BlumeProject["config"],
284
+ root: string,
285
+ configFile: string | null
286
+ ): Parameters<typeof astroConfigTemplate>[0]["integrationBridge"] =>
287
+ config.integrations.length > 0 && configFile
288
+ ? { configFile: toPosix(relative(root, configFile)) }
289
+ : undefined;
290
+
274
291
  /**
275
292
  * Promote the generated runtime into the project as an owned Astro app. After
276
293
  * eject the project has a normal `astro.config.mjs` and `src/`, the `blume` CLI
@@ -367,6 +384,11 @@ export const eject = async (
367
384
  dataPath: "./src/generated/data.json",
368
385
  examplesPath: "./src/generated/examples.ts",
369
386
  examplesThemePath: "./src/generated/examples.css",
387
+ integrationBridge: ejectIntegrationBridge(
388
+ config,
389
+ root,
390
+ context.configFile
391
+ ),
370
392
  needsReact,
371
393
  needsSvelte,
372
394
  needsVue,
@@ -1,9 +1,10 @@
1
1
  import { join } from "pathe";
2
2
 
3
3
  /**
4
- * Build a local Pagefind search index over the built site. Pagefind only
5
- * indexes elements marked with `data-pagefind-body`, which Blume adds to the
6
- * content of indexable pages, so nav chrome and excluded pages are skipped.
4
+ * Build a local Pagefind search index over the built site. Pagefind indexes
5
+ * every rendered page except those whose `<html>` carries
6
+ * `data-pagefind-ignore`, which Blume stamps on non-indexable pages
7
+ * (search-excluded, or hidden without the opt-in), so those stay out.
7
8
  *
8
9
  * Returns the number of pages indexed.
9
10
  */