<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-ask-my-blog
  published:     2026-09-28
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Ask My Blog : transformer 259 articles en 2 160 questions cherchables

> Un LLM local lit chaque article du blog et rédige les questions qu'un développeur taperait vraiment dans une barre de recherche, chacune reliée au titre exact qui y répond. Le résultat est un index de questions généré au build et interrogé dans le navigateur avec un simple BM25 — pas d'embeddings, pas de clé d'API, pas de base vectorielle. Pipeline, plugin et composant complets inclus.

---
![Ask My Blog : transformer 259 articles en 2 160 questions cherchables](/img/v2/ask-my-blog.webp)

**TL;DR**

Une recherche de site ne trouve que les mots que *vous* avez écrits. Un lecteur dont l'image Docker est trop grosse tape « my docker image is huge », pas « optimizing layer caching » — et ne trouve rien. J'ai donc fait lire mes 259 articles à un modèle Ollama local, en lui demandant d'écrire pour chacun les 8 à 12 questions qu'un développeur taperait réellement, chacune reliée au titre qui y répond. Ça a donné 2 160 questions, servies comme un index généré au build et interrogées dans le navigateur avec un simple BM25. Pas d'embeddings, pas de base vectorielle, pas de clé d'API.

La recherche sur un blog personnel est presque toujours décevante, et j'ai mis un moment à comprendre pourquoi. Ce n'est pas la faute du moteur de recherche — le mien indexe parfaitement chaque mot de chaque page. Le problème, c'est qu'il ne peut trouver que les mots que *j'ai* écrits.

J'écris « réduire la taille finale de l'image avec des builds multi-stage ». Le lecteur en galère tape « mon image docker est énorme ». Zéro résultat. L'article dont il avait besoin est là, indexé, comparé à un vocabulaire qu'il n'a jamais employé.

La réponse habituelle, ce sont les embeddings : tout transformer en vecteurs, comparer des sens plutôt que des mots. Mais ça veut dire un modèle dans le navigateur, ou un appel d'API à chaque frappe, pour un blog statique sans backend. Il existe une façon bien moins coûteuse de faire le pont entre deux vocabulaires — et ça commence par remarquer que ce pont ne doit être construit qu'**une seule fois**.

**Quick Jump:** [Ce que vous obtenez](#what-you-get) | [Comment les questions sont générées](#generating-the-questions)

## Ce que vous obtenez {#what-you-get}

La [page `/faq`](/faq) s'ouvre sur un hub : des cartes de sujets, une par tag principal — *Docker*, *AI*, *Bash*, *WSL*, et d'autres. Chaque carte indique combien de questions y sont classées. Cliquez sur l'une d'elles et vous arrivez sur une page qui liste toutes les questions de ce sujet, chacune pointant vers le titre d'article exact qui y répond :

> Screenshot — https\://www\.avonture.be/fr/faq
>
> ![Hub FAQ — cartes de sujets avec le nombre de questions par tag](./images/faq-hub.png)

Le même index alimente aussi une boîte de recherche sur cette page. Tapez un problème avec vos propres mots — « mon image docker est trop grosse », « comment installer joomla » — et vous obtenez des questions, pas des titres de page. Chaque résultat pointe vers le titre exact qui y répond :

> Screenshot — https\://www\.avonture.be/fr/faq
>
> ![Résultats de recherche — les questions correspondant à la requête, chacune liée au titre qui y répond](./images/faq-search.png)

C'est toute l'idée, et le reste de cet article explique comment c'est construit.

## Pourquoi ça marche sans modèle dans le navigateur {#why-this-works-without-a-model-in-the-browser}

L'idée est minuscule et c'est tout l'article : **le travail sémantique n'a pas à se faire au moment de la recherche.**

* La requête d'un lecteur et la prose d'un article sont écrites dans deux vocabulaires différents. Quelque chose doit traduire entre les deux.
* Cette traduction est une propriété fixe de l'article. Elle ne dépend ni de qui cherche, ni de quand. Elle peut donc être calculée une fois et stockée.
* Une fois qu'un modèle a déjà formulé « comment empêcher mon image Docker d'être énorme ? » et l'a classée à côté du bon titre, comparer la requête d'un lecteur à **cette** phrase devient un simple problème de similarité de chaînes. Un classement lexical vieux de vingt ans s'en sort parfaitement.
* Ce qui part vers le navigateur, c'est donc une liste de phrases et une fonction de scoring — pas un modèle, pas un index vectoriel, pas un appel réseau.

Le build devient plus lent (environ 11 secondes par article, une seule fois). Chaque lecteur ensuite obtient une réponse instantanée.

## Générer les questions {#generating-the-questions}

Le générateur demande à une instance [Ollama](/blog/ollama-installation) locale des questions sur un article, et les écrit dans un fichier sidecar à côté de celui-ci — la même convention que celle déjà utilisée par mes [tooltips ELI5](/blog/docusaurus-eli5-snippet-tooltips) pour leur propre contenu généré :

```bash
$ time yarn questions blog/2025/09/12/docusaurus-go-top/index.md --force

✅ Written: blog/2025/09/12/docusaurus-go-top/index.md.questions.json
   8 questions.

real    0m11.147s

$ yarn questions:bulk --dry-run --limit 4

🔍 4 article(s) under blog (--limit 4).
  [SKIP] blog/2023/11/02/docker-php-run-script-or-website/index.md
  [SKIP] blog/2023/11/02/keepass-start-rdp/index.md
  [SKIP] blog/2023/11/02/welcome/index.md
  [SKIP] blog/2023/11/02/wslg-rpd-connection/index.md

Dry run — nothing written.
```

Le résultat est un petit fichier JSON, `index.md.questions.json`, committé à côté de l'article :

**blog/2025/09/12/docusaurus-go-top/index.md.questions.json**

```json
{
  "version": 1,
  "model": "task-tiny:latest",
  "generated": "2026-08-12T13:24:09.634Z",
  "source": "index.md",
  "sourceHash": "981d607fb90f06dd35b65cdfa3d549560453a8ea",
  "questions": [
    {
      "question": "How do I add a 'go to the top' button to my Docusaurus blog posts using a custom React component and theme override?",
      "anchor": "what-it-looks-like"
    },
    {
      "question": "What are the steps to implement a 'scroll to top' button in Docusaurus blog posts?",
      "anchor": "how-it-works"
    },
    {
      "question": "How can I add a 'go to the top' button to other pages in my Docusaurus site besides blog posts?",
      "anchor": "you-can-do-this-for-other-pages-for-sure"
    },
    {
      "question": "What is the purpose of overriding the BlogPostItem page in Docusaurus for adding a 'go to the top' button?",
      "anchor": "overriding-the-blogpostitem-page"
    },
    {
      "question": "How do I create and use a custom React component for the 'go to the top' button in Docusaurus blog posts?",
      "anchor": "how-it-works"
    },
    {
      "question": "What is the CSS for styling the 'go to the top' button in Docusaurus blog posts?",
      "anchor": "how-it-works"
    },
    {
      "question": "How do I inject the custom React component for the 'go to the top' button into my Docusaurus pages?",
      "anchor": "overriding-the-blogpostitem-page"
    },
    {
      "question": "What is the purpose of swizzling in Docusaurus and how can it be used to add a 'go to the top' button to blog posts?",
      "anchor": "how-it-works"
    }
  ]
}
```

Trois choses rendent cette sortie utilisable plutôt que simplement plausible.

**On explique au modèle ce que « spécifique » veut dire.** Le system prompt interdit purement et simplement les questions génériques (« Qu'est-ce que Docker ? »), exige un mélange de requêtes courtes façon mots-clés et de phrases complètes, et interdit deux questions qui ne diffèrent que par l'ordre des mots :

```text title="scripts/generate-questions.mjs — system prompt (extract)"
- Every question must be SPECIFIC to this article's actual content — never generic
  ("What is Docker?", "How does Markdown work?").
- Vary the phrasing style: some short keyword-style queries, some full questions.
- Each question maps to exactly one heading by index. Index 0 means "the article as a
  whole / introduction", not tied to a specific heading.
- Do not invent facts not supported by the material given.
```

**La forme de la réponse est imposée, pas espérée.** Ollama accepte un schéma JSON dans `format:`, donc le modèle ne peut pas renvoyer de la prose, une liste markdown ou un champ nommé différemment. Le script valide quand même chaque élément après coup, et fait échouer l'article entier s'il en reste moins de cinq — une entrée maigre est pire qu'aucune entrée.

**Les titres sont résolus en vraies ancres.** Le prompt donne au modèle une liste numérotée de titres et lui demande un index ; le script remappe cet index vers le slug que Docusaurus va lui-même générer, avec le même package `github-slugger` que Docusaurus utilise en interne. Un mauvais index n'est pas fatal — la question retombe en haut de l'article au lieu d'être jetée.

Voici le générateur complet :

**scripts/generate-questions.mjs**

```mjs
#!/usr/bin/env node
/**
 * "Ask my blog" question generation — see .todos/0083-ask-my-blog-question-index.md.
 *
 * For one article (or, with --all, every published article under blog/), asks a local
 * Ollama model to write 8-12 search questions a developer would actually type into a search
 * bar for the problem this article solves, each mapped to the `##`/`###` heading that answers
 * it. Writes `<article>.questions.json` alongside the article — same convention as the
 * existing `.eli5.json` sidecar files (see scripts/lib/eli5-hash.mjs).
 *
 * The semantic work happens once, at build time, on the author's machine — not in every
 * reader's browser. See the TODO's "Ce qu'on ne fait pas, et pourquoi" section for why
 * client-side embeddings were rejected in favor of this precompute-then-lexical-match design.
 *
 * Usage:
 *   node scripts/generate-questions.mjs <article-file> [--force] [--output <path>]
 *   node scripts/generate-questions.mjs --all [--force] [--dir blog/] [--limit N] [--dry-run]
 *   node scripts/generate-questions.mjs --locale fr <article-file>
 *   node scripts/generate-questions.mjs --locale fr --all [--force] [--limit N] [--dry-run]
 *
 * With --locale (TODO 0120), the questions are generated FROM THE TRANSLATED ARTICLE, in that
 * language, and written next to it under `i18n/<locale>/docusaurus-plugin-content-blog/`.
 * Generating from the English text and translating the questions would describe wording — and
 * heading anchors — the reader never sees. Which articles qualify is decided by
 * `scripts/lib/i18n-eligibility.mjs`, never re-derived here. A localized sidecar whose
 * translated article changed is regenerated without --force; a fresh one is left alone.
 *
 * Requires a local Ollama instance — see OLLAMA_URL below. No API key: this never leaves the
 * author's machine (or, in the devcontainer, the host it runs on).
 */

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import GithubSlugger from "github-slugger";
import { hashSource } from "./lib/eli5-hash.mjs";
import { localizedBlogPath, questionCandidates } from "./lib/i18n-eligibility.mjs";
import {
  findPosts,
  parseFrontMatter,
  stripCodeFences,
  toProse,
} from "./lib/blog-corpus.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");

// The devcontainer reaches the host's Ollama through the Docker bridge gateway — same
// address this project's other host-side tooling (AnythingLLM) already uses. Override with
// OLLAMA_URL when running outside the devcontainer (e.g. `http://localhost:11434`).
const OLLAMA_URL = process.env.OLLAMA_URL || "http://172.17.0.1:11434";
// code-quality with thinking, for every locale. Blind bench on Ollama 0.34.3 (2026-09-23),
// 16 English outputs: code-quality 4.5/5 at ~36 s/article, think:false 4.0, code-fast 3.4,
// task-tiny 1.9 — it mapped every question of a 31-heading article to the introduction.
// Only thinking reaches the late headings: the prose below is capped at 4000 chars.
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "code-quality:latest";

// The `code-quality` tag pointed to a different, ~5x slower model before the 2026-09-23 stack
// upgrade. Timings recorded earlier would inflate every bulk-run estimate, so they are ignored.
const TIMINGS_VALID_SINCE = "2026-09-23";

// Seconds of idle between two articles in a bulk run. A full French corpus is hours of
// uninterrupted GPU load, typically left running overnight — the pause costs ~16% of wall time
// and buys a lower sustained temperature and quieter fans. `--pause 0` turns it off.
const DEFAULT_PAUSE_SECONDS = 30;

const MIN_QUESTIONS = 8;
const MAX_QUESTIONS = 12;
// A response can drop a few items to validation (bad index, empty text) without being
// considered malformed. Below this, something is wrong with the prompt/model for this
// article and the whole article must fail rather than ship a thin, low-value entry.
const MIN_VALID_QUESTIONS = 5;

// Output language per locale. The instruction is spelled out in the prompt because a 3B model
// otherwise drifts back to English whenever the article quotes English commands or titles.
//
// `model` stays per locale so one language can diverge again; today both use the default.
// French was on code-quality first: on the Docling pilot (2026-09-17), task-tiny shifted the
// heading index by one on 5 of 12 questions, while code-quality mapped all 10 correctly.
const LANGUAGES = {
  en: { name: "English", extra: "", model: OLLAMA_MODEL },
  fr: {
    name: "French",
    model: OLLAMA_MODEL,
    extra: `
- Write every question in natural French, the way a French-speaking developer would type it
  (tutoiement or impersonal phrasing, never a word-for-word translation of English).
- Keep product names, commands, file names and flags exactly as written (Docker, \`docker
  compose\`, \`.bashrc\`) — never translate them.`,
  },
};

const systemPromptFor = (locale) => {
  const { name, extra } = LANGUAGES[locale];
  return SYSTEM_PROMPT.replace("in English.", `in ${name}.`) + extra;
};

const SYSTEM_PROMPT = `You are helping build a search index for a technical blog. Given an
article's title, description, tags, and section headings, write search questions a developer
would actually type into a search bar when they have the problem this article solves — using
their own vocabulary, which may differ from the article's wording.

Rules:
- 8 to 12 questions, in English.
- Every question must be SPECIFIC to this article's actual content — never generic
  ("What is Docker?", "How does Markdown work?").
- Vary the phrasing style: some short keyword-style queries, some full questions.
- Each question maps to exactly one heading by index. Index 0 means "the article as a whole /
  introduction", not tied to a specific heading.
- Do not invent facts not supported by the material given.
- Every question must be meaningfully different from every other one — no two questions may
  ask the same thing with swapped words (e.g. "convert CSV to Markdown" and "convert Markdown
  to CSV" are different; "how to convert CSV to Markdown" and "convert CSV to Markdown" are
  NOT — pick one).`;

const RESPONSE_SCHEMA = {
  type: "object",
  properties: {
    questions: {
      type: "array",
      minItems: MIN_QUESTIONS,
      maxItems: MAX_QUESTIONS,
      items: {
        type: "object",
        properties: {
          question: { type: "string" },
          headingIndex: { type: "integer" },
        },
        required: ["question", "headingIndex"],
      },
    },
  },
  required: ["questions"],
};

/**
 * Strips the inline formatting a heading may carry before it becomes an anchor/prompt text.
 * Resolves each construct to its inner text (bold/italic/code/link label) rather than
 * dropping it — this must match Docusaurus's own remark-slug algorithm, which keeps a code
 * span's text as part of the heading it slugs. Deliberately NOT toProse() from blog-corpus.mjs:
 * that helper is built for prose/search extraction, where blanking out code spans as "not real
 * prose" is correct — but doing the same here produced anchors Docusaurus never generates
 * (e.g. "## What `ai-test` Does For You" slugged to "what---does-for-you" instead of
 * "what-ai-test-does-for-you"), breaking every link built from it.
 */
function cleanHeadingText(text) {
  return text
    .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
    .replace(/<[^>]+>/g, "")
    .replace(/\*\*([^*]+)\*\*/g, "$1")
    .replace(/\*([^*]+)\*/g, "$1")
    .replace(/`([^`]+)`/g, "$1")
    .trim();
}

/**
 * Collects `##`/`###` headings in document order, resolving each to the anchor slug
 * Docusaurus would generate for it (github-slugger — same package Docusaurus's own MDX
 * pipeline uses), honoring an explicit `{#custom-id}` override when present.
 */
export function extractHeadings(body) {
  const slugger = new GithubSlugger();
  const headings = [];

  for (const line of stripCodeFences(body)) {
    // The custom id accepts anything but a brace or a space: the ids pinned onto translated
    // articles are the slugs github-slugger derives from the English headings, and an emoji
    // heading yields one that starts with a variation selector (`{#\uFE0F-a-final-word-…}`).
    // An `[a-zA-Z0-9-_]+` class missed those, the `{#…}` suffix stayed part of the heading text,
    // and the anchor became the slug of "French heading + English id" — a link to nowhere.
    const match = line.match(/^(#{2,3})\s+(.*?)\s*(?:\{#([^}\s]+)\})?$/);
    if (!match) continue;

    const [, hashes, rawText, customId] = match;
    const text = cleanHeadingText(rawText);
    if (!text) continue;

    const anchor = customId || slugger.slug(text);
    headings.push({ level: hashes.length, text, anchor });
  }

  return headings;
}

function buildUserPrompt({ title, description, tags, headings, prose }) {
  const headingsList = [
    "0: (general / introduction — not tied to a specific heading)",
    ...headings.map((h, i) => `${i + 1}: ${h.text}`),
  ].join("\n");

  // Bounding the prose keeps prompts (and generation time) predictable across the corpus —
  // title/description/headings already carry most of the signal a 3B model needs.
  const MAX_PROSE_CHARS = 4000;
  const boundedProse =
    prose.length > MAX_PROSE_CHARS ? prose.slice(0, MAX_PROSE_CHARS) + " […]" : prose;

  return `Title: ${title}
Description: ${description}
Tags: ${tags.join(", ") || "(none)"}

Headings:
${headingsList}

Article body (for context only — do not quote it verbatim):
${boundedProse}

Write the search questions now.`;
}

async function callOllama(userPrompt, locale, model) {
  let res;
  try {
    res = await fetch(`${OLLAMA_URL}/api/chat`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model,
        stream: false,
        format: RESPONSE_SCHEMA,
        options: { temperature: 0.4 },
        messages: [
          { role: "system", content: systemPromptFor(locale) },
          { role: "user", content: userPrompt },
        ],
      }),
    });
  } catch (err) {
    throw new Error(
      `Could not reach Ollama at ${OLLAMA_URL} (is it running? set OLLAMA_URL to override): ${err.message}`,
      { cause: err },
    );
  }

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Ollama returned HTTP ${res.status}: ${body}`);
  }

  const json = await res.json();
  return json.message?.content ?? "";
}

/** Validates and normalizes the model's raw output into `{ question, anchor }` entries. */
function toValidatedQuestions(raw, headings) {
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new Error(`Model response is not valid JSON:\n${raw}`);
  }

  if (!parsed || !Array.isArray(parsed.questions)) {
    throw new Error(`Model response is missing a "questions" array:\n${raw}`);
  }

  const seen = new Set();
  const questions = [];

  for (const item of parsed.questions) {
    if (!item || typeof item.question !== "string" || !item.question.trim()) continue;
    if (!Number.isInteger(item.headingIndex)) continue;

    const question = item.question.trim();
    // Same normalization as questions-index-plugin's normalizeForDedupe(): accents folded
    // first, or "créer" and "cr er" would be the same key.
    const dedupeKey = question
      .normalize("NFD")
      .replace(/\p{M}/gu, "")
      .toLowerCase()
      .replace(/[^\p{L}\p{N}]+/gu, " ")
      .trim();
    if (seen.has(dedupeKey)) continue;
    seen.add(dedupeKey);

    // A small model miscounting the heading list is a wrong index on an otherwise good,
    // specific question — worth keeping by falling back to "general" (anchor "") rather than
    // discarding it outright. Only a genuinely broken shape (missing text, non-integer index)
    // drops the item entirely, per the "malformed must fail, not silently degrade" rule.
    const inRange = item.headingIndex >= 1 && item.headingIndex <= headings.length;
    const anchor = inRange ? headings[item.headingIndex - 1].anchor : "";
    questions.push({ question, anchor });
  }

  if (questions.length < MIN_VALID_QUESTIONS) {
    throw new Error(
      `Only ${questions.length} valid question(s) survived validation (need >= ${MIN_VALID_QUESTIONS}):\n${raw}`,
    );
  }

  return questions;
}

/**
 * Generates (or skips) the `.questions.json` sidecar for one article.
 * `articleFile` is the `index.md`/`index.mdx` path, absolute or repo-relative.
 */
export async function generateQuestions(
  articleFile,
  { force = false, outputPath = null, locale = "en" } = {},
) {
  if (!LANGUAGES[locale]) {
    throw new Error(
      `Unsupported locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`,
    );
  }

  const absSource =
    locale === "en" ? path.resolve(articleFile) : resolveTranslated(articleFile, locale);

  if (!fs.existsSync(absSource)) {
    throw new Error(`Article not found: ${absSource}`);
  }

  const destPath = outputPath || absSource + ".questions.json";

  // An article excluded during review (scripts/questions-review.mjs, `x`) must never get
  // questions again — not even with --force, which is exactly the run that would otherwise
  // undo the decision. Only `questions review <post>` + `i` lifts it.
  if (fs.existsSync(destPath)) {
    let existing;
    try {
      existing = JSON.parse(fs.readFileSync(destPath, "utf-8"));
    } catch {
      existing = null;
    }
    if (existing?.excluded === true) {
      return { status: "excluded", path: destPath };
    }
    // A localized sidecar follows its translation: stale means regenerate, even without
    // --force — that is the whole point of the hash. The English corpus keeps its historical
    // "exists = skip" behavior (its staleness is reported by questions:check instead).
    const fresh =
      locale === "en" ||
      existing?.sourceHash === hashSource(fs.readFileSync(absSource, "utf-8"));
    if (!force && fresh) {
      return { status: "skipped", path: destPath };
    }
  }

  const raw = fs.readFileSync(absSource, "utf-8");
  const { data, body } = parseFrontMatter(raw);

  if (!data.title) {
    throw new Error(`${path.relative(projectRoot, absSource)} has no frontmatter title.`);
  }

  const headings = extractHeadings(body);
  const prose = toProse(body);
  const tags = Array.isArray(data.tags) ? data.tags : [];

  const userPrompt = buildUserPrompt({
    title: data.title,
    description: data.description || "",
    tags,
    headings,
    prose,
  });

  const model = LANGUAGES[locale].model;
  const startedAt = Date.now();
  const modelRaw = await callOllama(userPrompt, locale, model);
  const questions = toValidatedQuestions(modelRaw, headings);
  const durationMs = Date.now() - startedAt;

  const result = {
    version: 1,
    model,
    generated: new Date().toISOString(),
    // Read back by pastDurations() to estimate how long the next bulk run will take.
    durationMs,
    source: path.basename(absSource),
    ...(locale === "en" ? {} : { locale }),
    sourceHash: hashSource(raw),
    questions,
  };

  fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n");
  return { status: "generated", path: destPath, count: questions.length, durationMs };
}

/**
 * The translated article for a `--locale` run. Accepts either the English source
 * (`blog/…/index.md`) or the translated file itself, so the path the author has at hand works.
 */
function resolveTranslated(articleFile, locale) {
  const abs = path.resolve(articleFile);
  const blogRoot = path.join(projectRoot, "blog") + path.sep;
  if (!abs.startsWith(blogRoot)) return abs;

  const translated = localizedBlogPath(projectRoot, abs, locale);
  if (!fs.existsSync(translated)) {
    throw new Error(
      `${path.relative(projectRoot, abs)} is not translated into ${locale} — translate it first ` +
        `(\`translate ${path.relative(projectRoot, path.dirname(abs))}\`).`,
    );
  }

  // Same condition 2 as the eligibility module: no English questions, no localized ones.
  try {
    const english = JSON.parse(fs.readFileSync(`${abs}.questions.json`, "utf-8"));
    if (english.excluded === true) {
      throw new Error(
        `${path.relative(projectRoot, abs)} is excluded from "Ask my blog".`,
      );
    }
  } catch (err) {
    if (err.code !== "ENOENT" && !(err instanceof SyntaxError)) throw err;
  }
  return translated;
}

// ── Timing ───────────────────────────────────────────────────────────────────

/**
 * Past generation times for `model`, read back from the `durationMs` each sidecar records.
 * Sidecars written before that field existed, or before TIMINGS_VALID_SINCE, simply don't count.
 */
function pastDurations(model) {
  const durations = [];
  for (const root of ["blog", "i18n"]) {
    const dir = path.join(projectRoot, root);
    if (!fs.existsSync(dir)) continue;
    for (const rel of fs.readdirSync(dir, { recursive: true })) {
      if (!rel.endsWith(".questions.json")) continue;
      try {
        const sidecar = JSON.parse(fs.readFileSync(path.join(dir, rel), "utf-8"));
        if (
          sidecar.model === model &&
          Number.isFinite(sidecar.durationMs) &&
          sidecar.generated >= TIMINGS_VALID_SINCE
        ) {
          durations.push(sidecar.durationMs);
        }
      } catch {
        /* an unreadable sidecar is reported by questions:check, not here */
      }
    }
  }
  return durations;
}

const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;

function formatDuration(ms) {
  const seconds = Math.round(ms / 1000);
  if (seconds < 60) return `${seconds} s`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes} min`;
  return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, "0")} min`;
}

// ── Bulk mode ────────────────────────────────────────────────────────────────

/**
 * `--pause` between two articles. It spends no less energy on the run — the same work is done
 * either way — but it lowers the sustained GPU temperature and fan noise of an unattended
 * multi-hour batch, which is the point: one process to launch, not a tranche to restart by hand.
 */
const pauseBetweenArticles = (seconds) =>
  new Promise((resolve) => setTimeout(resolve, seconds * 1000));

/** `  3/58` — padded so the counter column stays aligned from the first article to the last. */
const counterOf = (index, total) =>
  `${String(index + 1).padStart(String(total).length)}/${total}`;

/** `--locale` bulk run: the eligibility module decides, this only reports and generates. */
async function runLocalizedBulk({ locale, force, limit, dryRun, pause }) {
  const { eligible, skipped } = questionCandidates(projectRoot, locale);
  // --force re-runs the fresh ones too, never the excluded or the untranslated.
  const fresh = skipped
    .filter((s) => s.reason === "localized sidecar already fresh")
    .map((s) => s.file);
  const all = force ? [...eligible, ...fresh].sort() : eligible;
  const targets = typeof limit === "number" ? all.slice(0, limit) : all;

  console.log(
    `🔍 ${targets.length} translated article(s) to process for "${locale}" ` +
      `(${fresh.length} already fresh, ${skipped.length - fresh.length} not eligible).`,
  );

  // A full run on code-quality takes hours — say so before it starts, not after.
  const model = LANGUAGES[locale]?.model;
  const history = model ? pastDurations(model) : [];
  if (model && targets.length > 0) {
    console.log(
      history.length > 0
        ? `⏱  Estimated time: ~${formatDuration(
            targets.length * mean(history) +
              Math.max(targets.length - 1, 0) * pause * 1000,
          )} ` +
            `(${targets.length} × ${Math.round(mean(history) / 1000)} s average per article ` +
            `for ${model}, measured on ${history.length} article(s)).`
        : `⏱  No timing history yet for ${model} — the next run will show an estimate.`,
    );
    if (pause) {
      console.log(`⏸  Pausing ${pause}s between articles to keep the GPU cooler.`);
    }
  }

  let generated = 0,
    errors = 0;
  for (const [index, file] of targets.entries()) {
    const rel = path.relative(projectRoot, file);
    const counter = counterOf(index, targets.length);
    if (dryRun) {
      console.log(`  [GENERATE] ${counter} ${rel}`);
      continue;
    }
    process.stdout.write(`  📄 ${counter} ${rel} ... `);
    try {
      const result = await generateQuestions(file, { force, locale });
      console.log(
        result.status === "generated"
          ? `✅ ${result.count} questions`
          : `⏭  ${result.status}`,
      );
      if (result.status === "generated") generated++;
    } catch (err) {
      console.log(`❌ ${err.message}`);
      errors++;
    }
    if (pause && index < targets.length - 1) await pauseBetweenArticles(pause);
  }

  if (dryRun) console.log("\nDry run — nothing written.");
  else console.log(`\nGenerated: ${generated}  Errors: ${errors}`);
  if (errors > 0) process.exitCode = 1;
}

async function runBulk({ force, dir, limit, dryRun, pause }) {
  // Drafts (blog/ with draft: true, or anything under .unpublished/) aren't public yet — the
  // aggregation plugin already excludes them from the shipped index, but generating sidecars
  // for them here would still burn Ollama time and commit sidecar files for unpublished
  // content. Mirrors the same draft check questions-index-plugin applies at aggregation time.
  const isDraft = (file) => {
    const { data } = parseFrontMatter(fs.readFileSync(file, "utf-8"));
    return data.draft === true || data.draft === "true";
  };
  const posts = findPosts(dir)
    .filter((file) => !isDraft(file))
    .sort();
  const targets = typeof limit === "number" ? posts.slice(0, limit) : posts;

  console.log(
    `🔍 ${targets.length} article(s) under ${path.relative(projectRoot, dir)}${
      typeof limit === "number" ? ` (--limit ${limit})` : ""
    }.`,
  );
  if (pause && !dryRun) {
    console.log(`⏸  Pausing ${pause}s between articles to keep the GPU cooler.`);
  }

  if (dryRun) {
    for (const [index, file] of targets.entries()) {
      const rel = path.relative(projectRoot, file);
      const sidecarPath = file + ".questions.json";
      const exists = fs.existsSync(sidecarPath);
      let label = exists && !force ? "SKIP" : "GENERATE";
      if (exists) {
        try {
          if (JSON.parse(fs.readFileSync(sidecarPath, "utf-8")).excluded === true) {
            label = "EXCLUDED";
          }
        } catch {
          /* an unreadable sidecar is reported by questions:check, not here */
        }
      }
      console.log(`  [${label}] ${counterOf(index, targets.length)} ${rel}`);
    }
    console.log("\nDry run — nothing written.");
    return;
  }

  let generated = 0,
    skipped = 0,
    excluded = 0,
    errors = 0;

  for (const [index, file] of targets.entries()) {
    const rel = path.relative(projectRoot, file);
    process.stdout.write(`  📄 ${counterOf(index, targets.length)} ${rel} ... `);
    // Only a real generation heated anything up — a skipped article never reached Ollama.
    let calledOllama = false;
    try {
      const result = await generateQuestions(file, { force });
      if (result.status === "excluded") {
        console.log("🚫 excluded");
        excluded++;
      } else if (result.status === "skipped") {
        console.log("⏭  skipped");
        skipped++;
      } else {
        console.log(`✅ ${result.count} questions`);
        generated++;
        calledOllama = true;
      }
    } catch (err) {
      console.log(`❌ ${err.message}`);
      errors++;
    }
    if (pause && calledOllama && index < targets.length - 1) {
      await pauseBetweenArticles(pause);
    }
  }

  console.log(`\n──────────────────────────────────────`);
  console.log(
    `Generated: ${generated}  Skipped: ${skipped}  Excluded: ${excluded}  Errors: ${errors}`,
  );
  if (errors > 0) process.exitCode = 1;
}

// ── CLI ──────────────────────────────────────────────────────────────────────

const { fileURLToPath: _fileURLToPath } = await import("url");
const _isMain = process.argv[1] === _fileURLToPath(import.meta.url);

if (_isMain) {
  const args = process.argv.slice(2);

  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
    console.log(`
Usage:
  node scripts/generate-questions.mjs <article-file> [options]
  node scripts/generate-questions.mjs --all [options]

Options:
  --force          Regenerate even if <article>.questions.json already exists
  --output <path>  (single-file mode) write to a custom path
  --all            Process every published article under --dir
  --dir <path>     Directory to scan in --all mode (default: blog/)
  --limit <n>      (--all mode) only process the first n articles — useful for a
                    hand-reviewed pilot batch before a full corpus run
  --dry-run        (--all mode) show what would be generated without calling Ollama
  --pause <sec>    (--all mode) wait <sec> between two articles (default: ${DEFAULT_PAUSE_SECONDS}).
                    Same total work, but a lower sustained GPU temperature on an
                    unattended multi-hour batch. --pause 0 disables it
  --locale <code>  Generate from the translated article, in that language (e.g. fr).
                    With --all, only the eligible translated articles are processed.
  --help, -h       Show this help

Environment:
  OLLAMA_URL    Ollama endpoint (default: http://172.17.0.1:11434 — the devcontainer's
                bridge to the host)
  OLLAMA_MODEL  Model name (default: code-quality:latest)
`);
    process.exit(0);
  }

  const force = args.includes("--force");
  const localeIdx = args.indexOf("--locale");
  const locale = localeIdx !== -1 ? args[localeIdx + 1] : "en";
  if (localeIdx !== -1 && !LANGUAGES[locale]) {
    console.error(`Error: unsupported --locale "${locale}".`);
    process.exit(1);
  }

  if (args.includes("--all")) {
    const dirIdx = args.indexOf("--dir");
    const dir =
      dirIdx !== -1
        ? path.resolve(projectRoot, args[dirIdx + 1])
        : path.resolve(projectRoot, "blog");
    const limitIdx = args.indexOf("--limit");
    const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : undefined;
    const dryRun = args.includes("--dry-run");
    const pauseIdx = args.indexOf("--pause");
    const pause = pauseIdx !== -1 ? Number(args[pauseIdx + 1]) : DEFAULT_PAUSE_SECONDS;
    if (pauseIdx !== -1 && (!Number.isFinite(pause) || pause < 0)) {
      console.error(`--pause needs a number of seconds, got "${args[pauseIdx + 1]}".`);
      process.exit(1);
    }

    if (locale !== "en") {
      await runLocalizedBulk({ locale, force, limit, dryRun, pause });
    } else if (!fs.existsSync(dir)) {
      console.error(`Error: directory not found: ${dir}`);
      process.exit(1);
    } else {
      await runBulk({ force, dir, limit, dryRun, pause });
    }
  } else {
    const outputIdx = args.indexOf("--output");
    const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
    const articleFile = args.find(
      (a, i) =>
        !a.startsWith("--") &&
        !(outputIdx !== -1 && i === outputIdx + 1) &&
        !(localeIdx !== -1 && i === localeIdx + 1),
    );

    if (!articleFile) {
      console.error("Error: no article file specified. Run with --help for usage.");
      process.exit(1);
    }

    try {
      const result = await generateQuestions(articleFile, { force, outputPath, locale });
      if (result.status === "excluded") {
        console.log(`🚫 Excluded from "Ask my blog": ${path.basename(result.path)}`);
        console.log(
          `   Lift it with: node scripts/questions-review.mjs ${articleFile}  (then press i)`,
        );
      } else if (result.status === "skipped") {
        console.log(`⏭  Skipped (already exists): ${path.basename(result.path)}`);
        console.log(`   Use --force to regenerate.`);
      } else {
        console.log(`✅ Written: ${result.path}\n   ${result.count} questions.`);
      }
    } catch (err) {
      console.error("❌ Error:", err.message);
      process.exit(1);
    }
  }
}
```

> **Choisir un petit modèle, volontairement:**
>
> Ça tourne sur `task-tiny`, un modèle instruct de 3B. Les modèles locaux plus gros ne produisaient pas de meilleures questions en comparaison directe et tournaient environ dix fois plus lentement. Écrire des questions de recherche à partir d'un titre, d'une description et d'une liste de titres est une tâche d'*extraction*, pas de raisonnement — et 259 articles à 11 secondes chacun, c'est une pause café, alors que 259 à deux minutes chacun, c'est un après-midi.

## Servir 2 160 questions de trois manières différentes {#serving-2160-questions-three-different-ways}

Un plugin Docusaurus agrège les 259 sidecars en un seul index. Le point intéressant, c'est qu'il n'expédie **pas** cet index une fois — il l'expédie trois fois, sous trois formes différentes, parce qu'il a trois consommateurs aux besoins incompatibles :

| Consommateur             | Ce dont il a besoin                                                                      | Comment il l'obtient                                   |
| ------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Le hub `/faq`            | 40 noms de sujets et leurs compteurs, groupés par [tag principal](/blog/docusaurus-tags) | `setGlobalData` — quelques centaines d'octets          |
| Les pages `/faq/<topic>` | Les questions d'un seul sujet, crawlables                                                | `addRoute` + `createData`, code-split par route        |
| La boîte de recherche    | Tout le corpus                                                                           | Un simple fichier JSON statique, récupéré à la demande |

C'est la troisième ligne qui compte. Le corpus complet pèse **468 Ko** (63 Ko gzippés), et la boîte de recherche vit dans un composant monté sur *chaque* page du site. L'exposer via `setGlobalData` aurait embarqué les 468 Ko dans le JavaScript de chaque page, que quelqu'un tape un caractère ou non.

Le plugin l'écrit donc directement sur disque comme asset statique, et le client le `fetch` la première fois que quelqu'un ouvre vraiment la boîte de recherche :

**plugins/questions-index-plugin/index.cjs**

```cjs
/**
 * @fileoverview Docusaurus Plugin: questions-index-plugin
 *
 * Purpose (TODO 0083 — "Ask my blog"):
 * Aggregates every article's `<article>.questions.json` sidecar (see
 * scripts/generate-questions.mjs) into one corpus-wide, build-time question index — roughly
 * 8-12 "how would a developer search for this" questions per article, each pointing at
 * (article permalink, heading anchor).
 *
 * The corpus is heavy at scale (2000+ questions once the whole blog is covered) and has three
 * different consumers with three different performance needs, so it's shaped three ways
 * rather than shipped once as one blob:
 *
 * 1. `themes` (tiny — a few dozen rows) via `setGlobalData`, for the `/faq` hub's
 *    table-of-contents. Cheap enough to embed globally like any other small plugin data.
 * 2. One route per theme (`/faq/<theme>`, via `addRoute` + `createData`), each carrying only
 *    that theme's slice. Docusaurus code-splits per route automatically, so this is
 *    server-rendered, crawlable HTML without any one page paying for the whole corpus.
 * 3. The full corpus as a plain static JSON asset (`/questions-index.json`, written straight
 *    to disk and served via `staticDirectories`, see docusaurus.config.js), for
 *    `AskMyBlog`/`CommandPalette`'s cross-theme search. Deliberately NOT exposed via
 *    `setGlobalData`: that component tree is mounted on every page (`src/theme/Layout`), so a
 *    `usePluginData` reference — even an unused one — would have gotten the whole ~470 KB
 *    corpus bundled into every page's JS. A plain `fetch()` (same bridge pattern
 *    `CommandPalette/utils.ts` already uses for Pagefind) keeps that cost opt-in: only a
 *    reader who actually opens the search box pays for it, once, cached after that.
 *
 * An article with no `.questions.json` yet (not generated) simply contributes nothing — the
 * index degrades gracefully rather than erroring, so this plugin can ship before the corpus
 * is fully processed (mirrors the "activation progressive" idea in TODO 0084).
 *
 * Locales (TODO 0120): under a non-default locale the corpus is read from
 * `i18n/<locale>/docusaurus-plugin-content-blog/` — the translated articles and the question
 * sidecars generated from them (`yarn questions --locale fr`). There is deliberately NO fallback
 * to the English sidecars: a French reader types a French query, and an English index cannot
 * answer it. An untranslated article therefore contributes nothing under `/fr/`.
 */

const fs = require("fs");
const path = require("path");
const { normalizeUrl } = require("@docusaurus/utils");
const frontMatter = require("front-matter");
const yaml = require("js-yaml");

const BLOG_DIR = "blog";
const I18N_BLOG_DIR = "docusaurus-plugin-content-blog";
const GENERATED_DIR_NAME = "questions-index-plugin";
// Public path (under the site's baseUrl) the client fetches for the full corpus — kept in
// sync with the `staticDirectories` entry in docusaurus.config.js pointing at this plugin's
// generated static folder.
const SEARCH_INDEX_FILENAME = "questions-index.json";

// Mirrors plugins/markdown-export-plugin/index.cjs's findPosts()/permalinkFor() — duplicated
// rather than imported from scripts/lib/blog-corpus.mjs, which is ESM and cannot be loaded by
// a plain CJS require() here (same rationale as that file's own comment on the topic).
function findPosts(dir) {
  const found = [];
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const target = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      found.push(...findPosts(target));
    } else if (entry.name === "index.md" || entry.name === "index.mdx") {
      found.push(target);
    }
  }
  return found;
}

// Deliberately NOT prefixed with the locale's baseUrl: every consumer navigates through
// `<Link to>` (or `withBaseUrl` in the command palette), which adds `/fr/` itself. Prefixing
// here would produce `/fr/fr/blog/…`. A translation copies `slug` byte for byte, so the same
// function serves both locales. See .claude/rules/i18n-locale-safety.md.
function permalinkFor(attributes, dir) {
  if (attributes.slug) {
    return attributes.slug.startsWith("/")
      ? attributes.slug
      : `/blog/${attributes.slug.replace(/^\//, "")}`;
  }
  return `/blog/${dir}/`;
}

// Accents are folded before the ASCII filter: without it, "créer" became "cr er" and two
// different French questions could collapse into the same key.
function normalizeForDedupe(question) {
  return question
    .normalize("NFD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, " ")
    .trim();
}

/**
 * Drops any question whose normalized text collides with another article's — see the TODO's
 * Risk section ("si 40 articles génèrent 'How do I install Docker?', l'index est cassé").
 * Every colliding instance is dropped, including the first: a question that isn't specific to
 * exactly one article failed the generation prompt's own "specific to this article" rule, so
 * keeping one copy would still ship a too-generic entry.
 */
function dedupeAcrossCorpus(entries) {
  const counts = new Map();
  for (const entry of entries) {
    const key = normalizeForDedupe(entry.question);
    counts.set(key, (counts.get(key) || 0) + 1);
  }

  const kept = entries.filter(
    (entry) => counts.get(normalizeForDedupe(entry.question)) === 1,
  );
  return { kept, droppedCount: entries.length - kept.length };
}

/**
 * The directory the corpus is read from: `blog/` on the default locale, the translated tree
 * otherwise. The sidecars live next to the article they were generated from, so the same walk
 * finds both.
 */
function corpusDirFor(siteDir, i18n) {
  return i18n.currentLocale === i18n.defaultLocale
    ? path.join(siteDir, BLOG_DIR)
    : path.join(siteDir, "i18n", i18n.currentLocale, I18N_BLOG_DIR);
}

function collectEntries(blogDir, siteDir) {
  if (!fs.existsSync(blogDir)) return { entries: [], articlesWithQuestions: 0 };

  const entries = [];
  let articlesWithQuestions = 0;

  for (const file of findPosts(blogDir)) {
    const raw = fs.readFileSync(file, "utf-8");
    const { attributes } = frontMatter(raw);

    if (!attributes.title || attributes.draft === true || attributes.draft === "true")
      continue;

    const sidecarPath = `${file}.questions.json`;
    if (!fs.existsSync(sidecarPath)) continue;

    let sidecar;
    try {
      sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf-8"));
    } catch {
      console.warn(
        `⚠  questions-index-plugin: unreadable sidecar, skipped — ${path.relative(siteDir, sidecarPath)}`,
      );
      continue;
    }

    if (!Array.isArray(sidecar.questions) || sidecar.questions.length === 0) continue;

    const dir = path.relative(blogDir, path.dirname(file)).split(path.sep).join("/");
    const permalink = permalinkFor(attributes, dir);
    articlesWithQuestions += 1;

    for (const q of sidecar.questions) {
      if (!q || typeof q.question !== "string" || !q.question.trim()) continue;
      entries.push({
        question: q.question.trim(),
        anchor: typeof q.anchor === "string" ? q.anchor : "",
        permalink,
        title: attributes.title,
        mainTag: attributes.mainTag || null,
      });
    }
  }

  const { kept, droppedCount } = dedupeAcrossCorpus(entries);
  if (droppedCount > 0) {
    console.log(
      `questions-index-plugin: dropped ${droppedCount} cross-article duplicate question(s).`,
    );
  }

  return { entries: kept, articlesWithQuestions };
}

function readTags(tagsYamlPath) {
  try {
    return yaml.load(fs.readFileSync(tagsYamlPath, "utf-8")) ?? {};
  } catch {
    return {};
  }
}

/**
 * Tag labels for the theme headings. The localized `tags.yml` is the one Docusaurus already
 * renders `/fr/blog/tags/` from (same source as `src/components/Blog/utils/tagsI18n.ts`); the
 * English file fills any key it lacks.
 */
function loadTagLabels(siteDir, blogDir) {
  const english = readTags(path.join(siteDir, BLOG_DIR, "tags.yml"));
  const localized = readTags(path.join(blogDir, "tags.yml"));
  return { ...english, ...localized };
}

/** Groups entries by mainTag, sorted alphabetically by display label — same grouping the
 * `/faq` hub and each `/faq/<theme>` page render. */
// The one theme label that does not come from `tags.yml` (articles with no `mainTag`).
const OTHER_LABEL = { en: "Other", fr: "Autres" };

function buildThemes(entries, tagLabels, locale) {
  const groups = new Map();

  for (const entry of entries) {
    const key = entry.mainTag || "other";
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(entry);
  }

  return [...groups.entries()]
    .map(([key, items]) => ({
      key,
      label:
        key === "other"
          ? (OTHER_LABEL[locale] ?? OTHER_LABEL.en)
          : (tagLabels[key]?.label ?? key),
      items: [...items].sort((a, b) => a.question.localeCompare(b.question)),
    }))
    .sort((a, b) => a.label.localeCompare(b.label));
}

function buildIndex(siteDir, i18n) {
  const blogDir = corpusDirFor(siteDir, i18n);
  const { entries, articlesWithQuestions } = collectEntries(blogDir, siteDir);
  const tagLabels = loadTagLabels(siteDir, blogDir);
  const themes = buildThemes(entries, tagLabels, i18n.currentLocale);

  return {
    // No build timestamp: this index ships in the client bundle and in
    // build/questions-index.json, and a wall-clock value would change
    // main.js's hash on every build (see plugins/blog-graph-plugin).
    entries,
    themes,
    meta: {
      articleCount: articlesWithQuestions,
      questionCount: entries.length,
    },
  };
}

module.exports = function questionsIndexPlugin(context) {
  const { siteDir, generatedFilesDir } = context;

  return {
    name: "questions-index-plugin",

    async loadContent() {
      return buildIndex(siteDir, context.i18n);
    },

    async contentLoaded({ content, actions }) {
      const { entries, themes, meta } = content;

      // 3. Full corpus, as a plain static asset — see the file header for why this is
      // deliberately NOT setGlobalData(). Written directly to disk (not via createData, which
      // produces a webpack-bundled module) so it's a plain HTTP resource the client fetches
      // on demand; served via the `staticDirectories` entry in docusaurus.config.js.
      const staticDir = path.join(generatedFilesDir, GENERATED_DIR_NAME, "static");
      fs.mkdirSync(staticDir, { recursive: true });
      fs.writeFileSync(
        path.join(staticDir, SEARCH_INDEX_FILENAME),
        JSON.stringify(entries),
      );

      // 2. One route per theme, each carrying only its own slice — code-split per route by
      // Docusaurus, so no page other than that theme's own pays for the others' questions.
      for (const theme of themes) {
        const dataPath = await actions.createData(
          `${theme.key}.json`,
          JSON.stringify(theme),
        );
        await actions.addRoute({
          // Through `baseUrl` — see docusaurus-plugin-tag-route. Hardcoding the path leaves
          // the route unreachable under a non-default locale.
          path: normalizeUrl([context.baseUrl, "faq", theme.key]),
          component: "@site/src/components/FaqThemePage",
          modules: { theme: dataPath },
          exact: true,
        });
      }

      // 1. Small summary for the `/faq` hub's table of contents — cheap enough to embed
      // globally like any other small plugin data.
      actions.setGlobalData({
        meta,
        themes: themes.map((theme) => ({
          key: theme.key,
          label: theme.label,
          permalink: `/faq/${theme.key}`,
          count: theme.items.length,
        })),
      });
    },

    getPathsToWatch() {
      // Rebuild the index in `yarn start` whenever an article or its question sidecar changes.
      return [
        path.join(siteDir, "blog/**/index.{md,mdx}"),
        path.join(siteDir, "blog/**/*.questions.json"),
        path.join(siteDir, "blog/tags.yml"),
        path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/index.{md,mdx}`),
        path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/*.questions.json`),
        path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/tags.yml`),
      ];
    },
  };
};
```

Le fetch est mis en cache au niveau du module, donc la palette et la page `/faq` partagent un seul téléchargement — et un fetch raté vide le cache au lieu de l'empoisonner, pour qu'un lecteur ayant cherché avant la fin du build du serveur de dev puisse simplement réessayer :

**src/components/AskMyBlog/questionsIndex.ts**

```typescript
/**
 * Lazy-loads the full "Ask my blog" question corpus (see plugins/questions-index-plugin) as a
 * plain HTTP fetch against a static JSON asset, instead of `usePluginData` — that corpus is
 * ~470 KB uncompressed / ~60 KB gzip once the whole blog is covered, and `AskMyBlog` /
 * `CommandPalette`'s `?` mode are the only two places that need the *full*, cross-theme
 * corpus. `CommandPalette` in particular is mounted on every page (`src/theme/Layout`), so a
 * `usePluginData` reference there would have shipped that whole corpus in every page's JS —
 * see the plugin's own header comment for the measured impact.
 *
 * Fetched once and cached at module scope (shared between `AskMyBlog` and `CommandPalette`,
 * whichever opens search first) — same "fetch a static build-time asset" bridge
 * `CommandPalette/utils.ts` already uses for Pagefind.
 */

import type { QuestionEntry } from "./utils";

let inFlight: Promise<QuestionEntry[]> | null = null;

/** `questionsIndexUrl` should be the baseUrl-aware absolute path (see `useBaseUrl`/
 * `useBaseUrlUtils` — this module can't call that hook itself, it isn't a component). */
export function loadQuestionsIndex(questionsIndexUrl: string): Promise<QuestionEntry[]> {
  if (!inFlight) {
    inFlight = fetch(questionsIndexUrl)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<QuestionEntry[]>;
      })
      .catch((err) => {
        // Let the next caller retry instead of caching a permanent failure — e.g. a reader
        // opening search before the dev server has finished its first build.
        inFlight = null;
        throw err;
      });
  }
  return inFlight;
}
```

Et la boîte de recherche elle-même :

**Project: src/components/AskMyBlog**

**src/components/AskMyBlog/index.tsx**

```typescript
/**
 * AskMyBlog — a search-as-you-type box over the build-time "ask my blog" question index
 * (see .todos/0083-ask-my-blog-question-index.md and plugins/questions-index-plugin).
 *
 * Lazy-fetches the full `{ question, anchor, permalink, title, mainTag }` corpus on mount
 * (./questionsIndex.js — a plain static JSON asset, not `usePluginData`; see that module's
 * comment for why) and ranks it against the reader's query with plain BM25 (./utils.js),
 * entirely client-side: the semantic work already happened once at build time, when Ollama
 * phrased each question in a developer's own search vocabulary.
 */

import { useEffect, useMemo, useState, type JSX } from "react";
import Link from "@docusaurus/Link";
import { useBaseUrlUtils } from "@docusaurus/useBaseUrl";
import { buildSearchIndex, search, type QuestionEntry } from "./utils";
import { loadQuestionsIndex } from "./questionsIndex";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";

interface Props {
  placeholder?: string;
  maxResults?: number;
  showLabel?: boolean;
}

export default function AskMyBlog({
  placeholder,
  maxResults = 8,
  showLabel = true,
}: Props): JSX.Element {
  const { withBaseUrl } = useBaseUrlUtils();
  // null = not fetched yet, [] = loaded (possibly empty), only ever read after loading.
  const [questions, setQuestions] = useState<QuestionEntry[] | null>(null);
  const [loadFailed, setLoadFailed] = useState(false);
  const [query, setQuery] = useState("");

  useEffect(() => {
    let cancelled = false;
    loadQuestionsIndex(withBaseUrl("/questions-index.json"))
      .then((data) => {
        if (!cancelled) setQuestions(data);
      })
      .catch(() => {
        if (!cancelled) setLoadFailed(true);
      });
    return () => {
      cancelled = true;
    };
  }, [withBaseUrl]);

  const searchIndex = useMemo(() => buildSearchIndex(questions ?? []), [questions]);
  const trimmed = query.trim();

  const results = useMemo(
    () => (trimmed && questions ? search(searchIndex, trimmed, maxResults) : []),
    [searchIndex, trimmed, maxResults, questions],
  );

  const status = !trimmed
    ? null
    : !questions && !loadFailed
      ? translate({ id: "askMyBlog.loading", message: "Loading…" })
      : loadFailed
        ? translate({
            id: "askMyBlog.unavailable",
            message: "Search is unavailable right now.",
          })
        : results.length === 1
          ? translate({ id: "askMyBlog.resultCount.one", message: "1 result" })
          : translate(
              { id: "askMyBlog.resultCount.other", message: "{count} results" },
              { count: results.length },
            );

  // Resolved inside the component, not as a parameter default: a default value is evaluated at

  // module scope, where translate() would freeze the locale of the first bundle load.

  const resolvedPlaceholder =
    placeholder ??
    translate({
      id: "blog.askMyBlog.defaultPlaceholder",

      message: 'Ask a question, e.g. "how do I reduce my image size?"',
    });

  return (
    <div className={styles.container}>
      <label
        htmlFor="ask-my-blog-input"
        className={showLabel ? styles.label : styles.srOnly}
      >
        <Translate id="blog.askMyBlog.label">Ask my blog</Translate>
      </label>
      <input
        id="ask-my-blog-input"
        type="search"
        className={styles.input}
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder={resolvedPlaceholder}
        autoComplete="off"
        aria-describedby="ask-my-blog-status"
      />
      <div
        id="ask-my-blog-status"
        className={styles.status}
        role="status"
        aria-live="polite"
      >
        {status}
      </div>
      {results.length > 0 && (
        <ul className={styles.results}>
          {results.map((entry, index) => (
            <li
              key={`${entry.permalink}#${entry.anchor}-${index}`}
              className={styles.resultItem}
            >
              <Link
                to={entry.anchor ? `${entry.permalink}#${entry.anchor}` : entry.permalink}
                className={styles.resultLink}
              >
                {entry.question}
              </Link>
              <span className={styles.resultSource}>{entry.title}</span>
            </li>
          ))}
        </ul>
      )}
      {trimmed && questions && !loadFailed && results.length === 0 && (
        <p className={styles.empty}>
          {/* Deliberately small: this state comes and goes on every keystroke,
              so a large illustration would flash while the reader types. */}
          <img
            src={withBaseUrl("/img/meerkat/emojis/activity_searching.webp")}
            alt={translate({
              id: "blog.askMyBlog.emptyImageAlt",
              message: "A meerkat searching with a magnifying glass.",
            })}
            className={styles.emptyImage}
            width={56}
            height={56}
            loading="lazy"
          />
          <Translate id="blog.askMyBlog.empty">
            No matching article yet — try different words.
          </Translate>
        </p>
      )}
    </div>
  );
}
```

**src/components/AskMyBlog/utils.ts**

```typescript
/**
 * Pure BM25 lexical scoring over the build-time question index — see the plugin
 * (plugins/questions-index-plugin) for where `questions` comes from and ../../../.todos
 * (0083) for why this is plain lexical matching rather than embeddings: the semantic bridge
 * between a reader's vocabulary and an article's wording was already built once, at build
 * time, by the model that phrased each question — nothing here needs to "understand" the
 * query, only find the closest wording among the ~2500 precomputed ones.
 */

// Standard Okapi BM25 constants — term-frequency saturation and length-normalization strength.
const K1 = 1.5;
const B = 0.75;

export interface QuestionEntry {
  question: string;
  anchor?: string;
  permalink: string;
  title: string;
  mainTag?: string;
}

export interface SearchIndex {
  questions: QuestionEntry[];
  docs: string[][];
  docFrequency: Map<string, number>;
  avgDocLength: number;
  docCount: number;
}

/** Lowercase word tokens — good enough for short question strings; hyphenated terms like
 * "php-cs-fixer" split into individual words, which still matches a query typed either way.
 * CamelCase/compound product names (e.g. "CaesiumCLT") get a boundary inserted before
 * lowercasing, so a query for just "caesium" still matches the "caesium" part of the token —
 * without this, "caesiumclt" is one indivisible token that a partial name never matches.
 * The *unsplit* form is kept too (union, not replace): "WordPress" would otherwise only
 * yield "word" + "press", and a reader typing "wordpress" as one word would match neither.
 *
 * Letters are matched as Unicode (`\p{L}`), then folded to their unaccented form: an ASCII-only
 * class split the French corpus mid-word ("déployer" became "d" + "ployer"), and folding lets a
 * reader typing "deployer" find "déployer". Applied to both the index and the query, since both
 * go through this function. A no-op on the English corpus. See TODO 0120. */
export function tokenize(text: string): string[] {
  const folded = text.normalize("NFD").replace(/\p{M}/gu, "");
  const withBoundaries = folded.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2");
  const split = withBoundaries.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
  const whole = folded.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
  return [...new Set([...split, ...whole])];
}

/**
 * Precomputes everything BM25 needs to score `questions` against future queries: per-document
 * tokens, document length, and per-term document frequency. Call once when the index loads,
 * not per keystroke — scoring itself (in `search`) is the only per-keystroke work.
 */
export function buildSearchIndex(questions: QuestionEntry[]): SearchIndex {
  const docs = questions.map((entry) => tokenize(entry.question));
  const docFrequency = new Map<string, number>();

  for (const tokens of docs) {
    for (const term of new Set(tokens)) {
      docFrequency.set(term, (docFrequency.get(term) || 0) + 1);
    }
  }

  const avgDocLength =
    docs.reduce((sum, tokens) => sum + tokens.length, 0) / (docs.length || 1);

  return { questions, docs, docFrequency, avgDocLength, docCount: docs.length };
}

function idf(docFrequency: Map<string, number>, docCount: number, term: string) {
  const n = docFrequency.get(term) || 0;
  return Math.log((docCount - n + 0.5) / (n + 0.5) + 1);
}

/** Expands one query token to the indexed terms it should match: itself, if it's already an
 * indexed term, or — for a still-being-typed prefix like "docus" — every indexed term it's a
 * prefix of (e.g. "docusaurus"). Below 3 characters a prefix matches too much of the vocabulary
 * to be useful, so short unindexed tokens are dropped instead of expanded. */
function expandTerm(docFrequency: Map<string, number>, term: string): string[] {
  if (docFrequency.has(term)) return [term];
  if (term.length < 3) return [];
  const matches = [];
  for (const key of docFrequency.keys()) {
    if (key.startsWith(term)) matches.push(key);
  }
  return matches;
}

/** Scores and ranks `index` against `query`, returning the top `limit` matches with score > 0. */
export function search(index: SearchIndex, query: string, limit = 8): QuestionEntry[] {
  const queryTerms = tokenize(query);
  if (queryTerms.length === 0) return [];

  const { questions, docs, docFrequency, avgDocLength, docCount } = index;
  // Expand once per query, not per document — the doc loop below only ever scores against
  // real indexed terms.
  const expandedTerms = queryTerms.flatMap((term) => expandTerm(docFrequency, term));
  if (expandedTerms.length === 0) return [];

  const scored: { entry: QuestionEntry; score: number }[] = [];

  for (let i = 0; i < docs.length; i += 1) {
    const tokens = docs[i];
    if (tokens.length === 0) continue;

    let score = 0;
    for (const term of expandedTerms) {
      const freq = tokens.filter((t) => t === term).length;
      if (freq === 0) continue;

      const numerator = freq * (K1 + 1);
      const denominator = freq + K1 * (1 - B + (B * tokens.length) / avgDocLength);
      score += idf(docFrequency, docCount, term) * (numerator / denominator);
    }

    if (score > 0) scored.push({ entry: questions[i], score });
  }

  scored.sort((a, b) => b.score - a.score);
  return scored.slice(0, limit).map((s) => s.entry);
}
```

**src/components/AskMyBlog/styles.module.css**

```css
.container {
  margin: 1.5rem 0;
}

/* Was a hand-rolled eyebrow (0.85rem / 600 / 0.04em). */
.label {
  composes: mk-eyebrow from global;
  display: block;
  margin-bottom: 0.4rem;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.input {
  width: 100%;
  padding: 0.75rem 1rem;
  font-family: inherit;
  font-size: 1.05rem;
  color: var(--mk-ink);
  background: var(--mk-surface);
  border: 1px solid var(--mk-border-strong);
  border-radius: var(--mk-radius-md);
  transition:
    border-color 0.15s ease,
    box-shadow 0.15s ease;
}

.input::placeholder {
  color: var(--mk-n-500);
}

.input:focus {
  outline: none;
  border-color: var(--mk-primary);
  box-shadow: 0 0 0 3px var(--mk-key-bg);
}

.status {
  margin-top: 0.4rem;
  font-size: 0.85rem;
  color: var(--mk-ink-soft);
  min-height: 1.2em;
}

.results {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.resultItem {
  display: flex;
  flex-direction: column;
  padding: 0.6rem 0.9rem;
  background: var(--mk-surface);
  border: 1px solid var(--mk-border);
  border-radius: var(--mk-radius-md);
}

.resultLink {
  font-weight: 600;
}

.resultSource {
  margin-top: 0.15rem;
  font-size: 0.8rem;
  color: var(--mk-ink-soft);
}

/* The italic is dropped: it is a status line, and italics on a warm neutral at
   this size read as an error rather than as "nothing found". */
.empty {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.75rem;
  color: var(--mk-ink-soft);
}

/* The thumbnail ships on an opaque cream background, so round it off and let it
   sit on its own tint rather than cutting a pale square out of a dark page. */
.emptyImage {
  flex: 0 0 auto;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  object-fit: cover;
}
```

## Sous le capot (passez votre chemin si vous voulez juste les questions) {#under-the-hood-skip-this-if-you-just-want-the-questions}

### Interroger l'index depuis la ligne de commande {#querying-the-index-from-the-command-line}

Le fichier JSON est un simple tableau, vous pouvez donc le sonder hors du navigateur en quelques lignes. Pratique pour déboguer l'index avant de le livrer, ou pour vérifier si une formulation précise correspond :

```bash
$ npx tsx --eval "
import { buildSearchIndex, search } from './src/components/AskMyBlog/utils.ts';
import fs from 'fs';
const rel = 'questions-index-plugin/static/questions-index.json';
const p = ['.docusaurus', '.docusaurus-dev'].map(d => d + '/' + rel).find(fs.existsSync);
if (p === undefined) throw new Error('Run yarn build or start the dev server first');
const corpus = JSON.parse(fs.readFileSync(p, 'utf8'));
const index = buildSearchIndex(corpus);
for (const r of search(index, process.argv[1], 4)) {
  console.log(r.question);
  console.log('    ' + r.title + '  ->  ' + r.permalink + (r.anchor ? '#' + r.anchor : ''));
}" "run docusaurus in docker"

How do I run my Docusaurus site using Docker?
    Running Docusaurus with Docker  ->  /blog/docusaurus-docker#run-docusaurus
What commands are used to build and run the production Docker image for Docusaurus?
    Running Docusaurus using Docker  ->  /blog/running-docusaurus-with-docker#build-and-run-the-production-image
How do I run Quarto Markdown in Docker?
    Running Quarto Markdown in Docker  ->  /blog/docker-quarto
How do I run Firefox in a Docker container?
    Docker - Run Graphical User Interfaces - Firefox, Chrome & GIMP  ->  /blog/docker-run-linux-gui#creating-our-own-chrome-docker-image
```

### BM25 en quarante lignes {#bm25-in-forty-lines}

Le classement, c'est Okapi BM25 — fréquence des termes avec saturation, plus normalisation par la longueur, pondérée par la fréquence inverse de document. C'est ce qu'utilisent Lucene et Elasticsearch, et ça tient dans un petit fichier parce que chaque « document » ici est une seule question d'une douzaine de mots.

Deux adaptations étaient nécessaires pour cette longueur de document inhabituellement courte.

**Le matching par préfixe, développé une fois par requête.** Un lecteur qui tape `docus` n'a pas fini son mot, donc un token inconnu de trois caractères ou plus est développé vers tous les termes indexés qu'il préfixe. En dessous de trois caractères, un préfixe correspond à trop de vocabulaire pour signifier quoi que ce soit, donc les tokens inconnus courts sont simplement écartés. Point crucial : l'expansion a lieu une fois par requête, pas une fois par document — la boucle de scoring ne voit jamais que de vrais termes indexés.

**Un tokenizer qui découpe les mots composés sans les perdre.** `CaesiumCLT` tokenisé naïvement est un seul token indivisible, donc un lecteur qui tape « caesium » ne trouve rien. Insérer une frontière avant de passer en minuscules règle ça — mais alors `WordPress` devient `word` + `press`, et quelqu'un qui tape « wordpress » en un mot ne correspond à aucune des deux moitiés. La solution est une union plutôt qu'un remplacement : garder à la fois la forme découpée et la forme entière.

```ts title="src/components/AskMyBlog/utils.ts"
export function tokenize(text: string): string[] {
  const withBoundaries = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
  const split = withBoundaries.toLowerCase().match(/[a-z0-9]+/g) || [];
  const whole = text.toLowerCase().match(/[a-z0-9]+/g) || [];
  return [...new Set([...split, ...whole])];
}
```

### Le problème des doublons {#the-duplicate-problem}

Si quarante articles génèrent chacun « Comment installer Docker ? », l'index ne sert à rien — le lecteur obtient quarante lignes identiques et aucun signal. Le plugin normalise chaque question et supprime **toutes** les instances en collision, y compris la première. Ce dernier détail est volontaire : une question qui n'est pas spécifique à exactement un article a déjà enfreint la règle du prompt, donc en garder une copie reviendrait quand même à livrer une mauvaise entrée. Sur mon corpus, ça retire 5 questions sur 2 055. Le prompt fait l'essentiel du travail ; ceci n'est que le filet de sécurité.

### Les sidecars sont faits pour être édités à la main {#sidecars-are-meant-to-be-hand-edited}

La génération est un premier jet. La plupart des entrées sont correctes, certaines sont une blague reprise telle quelle dans la prose d'un article et reformatée en question. Un petit élagueur interactif les retrouve par mot-clé et permet de supprimer par numéro :

```console
$ yarn questions:edit "dinosaur"

blog/2024/05/17/some-article/index.md.questions.json
  [0] How do I install the CLI? → #installation
  [1] How can I play with a green dinosaur?
  [2] What does the --force flag do? → #options
Delete which number(s)? (space-separated, Enter to skip): 1
  ✅ Removed 1 question(s), 11 left.
```

Supprimer une question ne touche jamais à l'article lui-même, ce qui compte pour la dernière pièce.

### Détecter la dérive {#detecting-drift}

Une question générée peut pourrir en silence : renommez un titre et son ancre casse ; réécrivez une section et les questions cessent de la décrire. `yarn questions:check` compare le `sourceHash` stocké dans chaque sidecar au contenu actuel de l'article et signale `STALE`, plus les articles qui n'ont aucun sidecar. Par défaut il se contente de rapporter ; `--strict` échoue sur les entrées périmées mais jamais sur la couverture manquante, parce qu'un article fraîchement écrit n'a légitimement aucune question tant que vous n'avez pas lancé le générateur dessus.

**scripts/check-questions-freshness.mjs**

```mjs
#!/usr/bin/env node
/**
 * Detect drift between *.questions.json sidecar files and the article they describe, and
 * flag published articles that have no sidecar at all yet (a new article, or one written
 * before this pipeline existed). A source edit can invalidate generated questions (a removed
 * heading breaks an anchor, a rewritten section makes a question stop matching) without
 * anything catching it — this flags that drift the same way check-eli5-freshness.mjs does
 * for .eli5.json. Coverage (missing sidecars) has no equivalent in the eli5 checker, since
 * eli5 annotations are opt-in per snippet, not expected for every article.
 *
 * Usage:
 *   node scripts/check-questions-freshness.mjs [--strict] [--quiet]
 *
 * With --quiet: print nothing at all unless something is actually actionable (a stale or
 * orphaned sidecar, or a published article with no sidecar yet). Counts and review progress
 * are suppressed. This is what the pre-commit hook runs — the hook is verbose, so anything
 * the script prints shows up on every single commit. Run it by hand (`yarn questions:check`)
 * for the full report.
 *
 * Without --strict: report-only, always exits 0.
 * With --strict: exits 1 if any STALE or ORPHANED sidecar is found. Missing coverage never
 * fails --strict — a freshly written article legitimately has no questions yet until someone
 * runs `yarn questions` for it; this check only surfaces that, it doesn't gate commits on it.
 */

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { execFileSync } from "child_process";
import { hashSource } from "./lib/eli5-hash.mjs";
import { loadPosts } from "./lib/blog-corpus.mjs";
import { questionCandidates } from "./lib/i18n-eligibility.mjs";
import { cmd } from "./lib/cheatsheet-hint.mjs";
import { orphanFixHint } from "./lib/orphan-hint.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");

const strict = process.argv.includes("--strict");
const quiet = process.argv.includes("--quiet");

// Enumerate via git rather than a filesystem walk — see check-eli5-freshness.mjs for why
// (sibling worktrees under the project root would otherwise be double-counted).
const gitListArgs = [
  "ls-files",
  "--cached",
  "--others",
  "--exclude-standard",
  "-z",
  "--",
  "blog/*.questions.json",
  // Localized sidecars (TODO 0120) sit next to the translated article, so the same
  // "strip the suffix, hash the source" check applies: a retranslation makes them stale.
  "i18n/*/docusaurus-plugin-content-blog/*.questions.json",
];
const relFiles = execFileSync("git", gitListArgs, { cwd: projectRoot, encoding: "utf-8" })
  .split("\0")
  .filter(Boolean)
  // An article can *show* a sample sidecar in its own files/ folder
  // (blog/2026/09/28/docusaurus-ask-my-blog/files/demo.questions.json): documentation, not a
  // corpus sidecar, which always sits next to index.md. Scoping to blog/ was not enough — once
  // the draft was published it was reported ORPHANED again, and the printed `rm` hint got the
  // file deleted, breaking the build on a dangling <Snippet source>.
  .filter((f) => !f.split("/").includes("files"));
const files = relFiles.map((f) => path.resolve(projectRoot, f));

let fresh = 0,
  stale = 0,
  excluded = 0,
  orphaned = 0;

// Collected so the summary can offer one copy-pasteable command covering every stale sidecar
// — a batch of edited articles routinely produces eight or nine of these at once.
const staleSources = [];
// Localized stale sidecars are regenerated per locale by the eligibility-driven bulk run,
// never with the English one-file command.
const staleLocales = new Set();

for (const jsonPath of files) {
  const relJson = path.relative(projectRoot, jsonPath);
  const sourcePath = jsonPath.slice(0, -".questions.json".length);
  const relSource = path.relative(projectRoot, sourcePath);

  if (!fs.existsSync(sourcePath)) {
    console.warn(`⚠  ORPHANED — source missing for ${relJson}`);
    for (const line of orphanFixHint(relSource, relJson, projectRoot)) console.warn(line);
    orphaned++;
    continue;
  }

  let record;
  try {
    record = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
  } catch {
    console.warn(`⚠  UNREADABLE — ${relJson} is not valid JSON`);
    console.warn(`   Fix with: ${cmd("questions", "--force", relSource)}`);
    orphaned++;
    continue;
  }

  // Articles deliberately excluded during review (scripts/questions-review.mjs) carry no
  // questions on purpose, so there is nothing to be stale about — editing the article must
  // not put them back on the "regenerate me" list.
  if (record.excluded === true) {
    excluded++;
    continue;
  }

  const currentHash = hashSource(fs.readFileSync(sourcePath, "utf-8"));
  if (currentHash !== record.sourceHash) {
    // No per-file warning here — staleSources feeds the single batched command printed
    // below, so nothing tempts a one-by-one `questions --force` per file.
    const localeMatch = relSource.match(/^i18n\/([^/]+)\//);
    if (localeMatch) staleLocales.add(localeMatch[1]);
    else staleSources.push(relSource);
    stale++;
  } else {
    fresh++;
  }
}

// Coverage — published articles (drafts already excluded by loadPosts()) with no sidecar at
// all yet: a new article, or one written before this pipeline existed. Not a freshness
// problem (nothing to compare against), just a gap worth surfacing.
const missing = loadPosts().filter(
  (post) => !fs.existsSync(`${post.file}.questions.json`),
);

// Localized coverage — translated articles whose English questions exist but whose localized
// ones do not yet. Stale ones are already counted above.
const i18nRoot = path.join(projectRoot, "i18n");
const locales = fs.existsSync(i18nRoot)
  ? fs
      .readdirSync(i18nRoot, { withFileTypes: true })
      .filter((e) => e.isDirectory())
      .map((e) => e.name)
  : [];
const missingLocalized = locales.flatMap((locale) =>
  questionCandidates(projectRoot, locale)
    .eligible.filter((file) => !fs.existsSync(`${file}.questions.json`))
    .map((file) => ({ locale, file })),
);

// In quiet mode the warnings above (if any) are the whole message — the counts below are
// context that only makes sense when someone asked for a report.
const actionable =
  stale > 0 || orphaned > 0 || missing.length > 0 || missingLocalized.length > 0;
if (!quiet || actionable) {
  console.log(
    `\nquestions freshness: ${fresh} fresh, ${stale} stale, ${excluded} excluded, ${orphaned} orphaned (of ${files.length} sidecar file(s)).`,
  );
}
if (staleSources.length > 0) {
  const list = staleSources.map((f) => `"${f}"`).join(" ");
  console.log(
    `⚠  STALE — ${staleSources.length} file(s) changed since their sidecar was generated:`,
  );
  console.log(`   for f in ${list}; do ${cmd("questions", "--force", '"$f"')}; done`);
}

for (const locale of staleLocales) {
  console.log(`⚠  STALE — "${locale}" question sidecar(s) behind their translation:`);
  console.log(`   ${cmd("questions", "--locale", locale, "--all")}`);
}
if (missingLocalized.length > 0) {
  const byLocale = new Map();
  for (const { locale } of missingLocalized)
    byLocale.set(locale, (byLocale.get(locale) || 0) + 1);
  for (const [locale, count] of byLocale) {
    console.log(
      `questions coverage (${locale}): ${count} translated article(s) with no sidecar yet — ` +
        cmd("questions", "--locale", locale, "--all"),
    );
  }
}

if (!quiet || missing.length > 0) {
  console.log(
    `questions coverage: ${missing.length} published article(s) with no sidecar yet.`,
  );
}
if (missing.length > 0) {
  // Capped — this is a report-only hook that can run on every commit; a 235-line dump (the
  // corpus-wide count before the bulk run has caught up) would drown the real signal.
  const MAX_LISTED = 10;
  for (const post of missing.slice(0, MAX_LISTED)) {
    console.log(`  - ${cmd("questions", path.relative(projectRoot, post.file))}`);
  }
  if (missing.length > MAX_LISTED) {
    console.log(
      `  … and ${missing.length - MAX_LISTED} more — cover them all with: ` +
        cmd("questions:bulk"),
    );
  }
}

// Human review progress — the sidecars are a first draft from a small local model until
// someone has read them (see scripts/questions-review.mjs).
const reviewed = files.filter((jsonPath) => {
  try {
    return Boolean(JSON.parse(fs.readFileSync(jsonPath, "utf-8")).reviewed);
  } catch {
    return false;
  }
}).length;

if (!quiet) {
  console.log(
    `questions review: ${reviewed}/${files.length} sidecar(s) reviewed by hand.` +
      (reviewed < files.length ? `  Continue with: ${cmd("questions:review")}` : ""),
  );
}

if (strict && (stale > 0 || orphaned > 0)) {
  process.exit(1);
}
```

### Les pages de sujet embarquent des données structurées FAQ {#the-topic-pages-carry-faq-structured-data}

Chaque page `/faq/<topic>` émet du JSON-LD `FAQPage`. Autant être honnête sur le retour : Google a restreint les rich results FAQ en août 2023 à un ensemble étroit de sites gouvernementaux et de santé faisant autorité, donc un blog personnel n'obtiendra pas l'affichage dépliable. Ça reste du balisage correct et conforme aux standards, que d'autres consommateurs lisent — mais pas un gain garanti dans la page de résultats.

## Conclusion {#conclusion}

J'ai commencé en voulant de la recherche sémantique et j'ai fini sans avoir besoin de la moindre sémantique à l'exécution. Le modèle a fait le plus dur une fois, hors ligne, sur ma propre machine, gratuitement — et ce qu'il a laissé derrière lui, ce sont 2 160 phrases en anglais courant qu'un classement lexical ordinaire traite à merveille.

Ce recadrage vaut la peine d'être transposé ailleurs. « Ça nécessite un modèle » signifie très souvent « ça a nécessité un modèle, une fois, et maintenant ça nécessite un fichier ». La version de votre fonctionnalité qui fait tourner un modèle de langage dans le navigateur de chaque visiteur et celle qui livre un fichier JSON calculé au build peuvent produire la même réponse — et une seule des deux fonctionne encore quand la clé d'API expire.

Mon index complet, généré localement et committé dans Git, pèse 63 Ko sur le réseau. C'est moins que l'image de bannière en haut de cet article.
