@z29k/notabene 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ the product.**
10
10
 
11
11
  > *nota bene* — the margin mark that means "note this." A comment.
12
12
 
13
- **MIT** · Node ≥ 18.20 · MDX **and** CommonMark/GFM · dev-local, zero backend ·
13
+ **MIT** · Node ≥ 22.12 · MDX **and** CommonMark/GFM · dev-local, zero backend ·
14
14
  _pre-1.0, dogfooded on a real multi-service platform._
15
15
 
16
16
  ---
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "@z29k/notabene",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Navigable docs renderer + human↔agent review loop. Comment your docs like Google Docs; an agent reads .notabene/, applies the feedback, and journals it.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "author": "z29k",
8
+ "homepage": "https://github.com/z29k/notabene#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/z29k/notabene.git",
12
+ "directory": "packages/renderer"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/z29k/notabene/issues"
16
+ },
7
17
  "publishConfig": {
8
18
  "access": "public"
9
19
  },
@@ -28,6 +38,6 @@
28
38
  },
29
39
  "keywords": ["docs", "documentation", "review", "comments", "mdx", "markdown", "astro", "ai", "agent"],
30
40
  "engines": {
31
- "node": ">=18.20.8"
41
+ "node": ">=22.12.0"
32
42
  }
33
43
  }
@@ -1,12 +1,16 @@
1
1
  ---
2
- // Commentaires type Google Docs.
3
- // - Ancrés (sélection) → marge droite (#cmt-rail, fourni par DocLayout) + surlignage.
4
- // - Globaux (page) → section discussion EN BAS de la page (rendue ici).
5
- // Bouton flottant + popover en position:fixed (coords viewport). Persistance /api/comments.
2
+ // Google-Docs-style comments.
3
+ // - Anchored (selection) → right rail (#cmt-rail, provided by DocLayout) + highlight.
4
+ // - Page-wide discussion section at the BOTTOM of the page (rendered here).
5
+ // Floating button + popover in position:fixed (viewport coords). Persisted via /api/comments.
6
+ import { locale } from "../config.mjs";
7
+ import { t } from "../i18n.mjs";
8
+
6
9
  interface Props {
7
10
  page?: string;
8
11
  space?: string;
9
12
  }
13
+ const m = t(locale);
10
14
  const { page, space } = Astro.props;
11
15
  ---
12
16
 
@@ -14,25 +18,25 @@ const { page, space } = Astro.props;
14
18
  page && (
15
19
  <section id="cmt-page" class="cmt-page" data-page={page} data-space={space}>
16
20
  <header class="cmt-page-head">
17
- <h2>Discussion</h2>
21
+ <h2>{m.discussion}</h2>
18
22
  <span id="cmt-count" class="cmt-count" />
19
23
  </header>
20
24
  <form id="cmt-composer" class="cmt-composer">
21
- <textarea id="cmt-input" rows="3" placeholder="Commentaire sur cette page…" />
22
- <button type="submit">Commenter la page</button>
25
+ <textarea id="cmt-input" rows="3" placeholder={m.pageCommentPlaceholder} />
26
+ <button type="submit">{m.commentPage}</button>
23
27
  </form>
24
28
  <div id="cmt-page-list" class="cmt-list" />
25
29
  </section>
26
30
 
27
31
  <button id="cmt-float" class="cmt-float" hidden type="button">
28
- 💬 Commenter
32
+ {m.floatComment}
29
33
  </button>
30
34
  <div id="cmt-pop" class="cmt-pop" hidden>
31
35
  <div id="cmt-pop-quote" class="cmt-pop-quote" />
32
- <textarea id="cmt-pop-input" rows="3" placeholder="Commentaire sur la sélection…" />
36
+ <textarea id="cmt-pop-input" rows="3" placeholder={m.selectionPlaceholder} />
33
37
  <div class="cmt-pop-actions">
34
- <button type="button" id="cmt-pop-cancel">Annuler</button>
35
- <button type="button" id="cmt-pop-save">Commenter</button>
38
+ <button type="button" id="cmt-pop-cancel">{m.cancel}</button>
39
+ <button type="button" id="cmt-pop-save">{m.comment}</button>
36
40
  </div>
37
41
  </div>
38
42
 
@@ -51,8 +55,11 @@ const { page, space } = Astro.props;
51
55
  const NB_ROOTS: { key: string }[] = JSON.parse(
52
56
  document.getElementById("notabene-roots")?.textContent || "[]",
53
57
  );
58
+ const M: Record<string, string> = JSON.parse(
59
+ document.getElementById("notabene-i18n")?.textContent || "{}",
60
+ );
54
61
  const page = pageSection.dataset.page!;
55
- // data-space est toujours posé par la route [space] ; fallback = 1er espace.
62
+ // data-space is always set by the [space] route; fallback = first space.
56
63
  const space = pageSection.dataset.space || NB_ROOTS[0]?.key || "";
57
64
  const railEl = document.getElementById("cmt-rail");
58
65
  const pageListEl = document.getElementById("cmt-page-list")!;
@@ -171,30 +178,30 @@ const { page, space } = Astro.props;
171
178
  ${
172
179
  c.scope === "selection"
173
180
  ? `<span class="cmt-quote${orphan ? " orphan" : ""}" data-jump="${c.id}">${orphan ? "⚠ " : ""}“${esc((c.anchor?.quote || "").slice(0, 70))}”</span>`
174
- : `<span class="cmt-scope">📄 page</span>`
181
+ : `<span class="cmt-scope">${M.scopePage}</span>`
175
182
  }
176
- ${c.hold ? '<span class="cmt-hold-badge">⏸ en attente</span>' : ""}
183
+ ${c.hold ? `<span class="cmt-hold-badge">${M.onHold}</span>` : ""}
177
184
  </div>
178
185
  <div class="cmt-body"><b>${esc(head.author)}</b> ${esc(head.body)}</div>
179
186
  ${replies.map((r) => `<div class="cmt-reply"><b>${esc(r.author)}</b> ${esc(r.body)}</div>`).join("")}
180
187
  ${c.resolution?.note ? `<div class="cmt-resolution">✔ ${esc(c.resolution.note)}</div>` : ""}
181
188
  <div class="cmt-actions">
182
189
  <span class="cmt-ts">${fmt(head.ts)}</span>
183
- ${c.status === "open" && c.thread.length === 1 ? `<button data-act="edit" data-id="${c.id}" title="Éditer" aria-label="Éditer">${ICONS.edit}</button>` : ""}
190
+ ${c.status === "open" && c.thread.length === 1 ? `<button data-act="edit" data-id="${c.id}" title="${M.tEdit}" aria-label="${M.tEdit}">${ICONS.edit}</button>` : ""}
184
191
  ${
185
192
  c.status === "resolved"
186
- ? `<button data-act="reopen" data-id="${c.id}" title="Rouvrir" aria-label="Rouvrir">${ICONS.reopen}</button>`
187
- : `<button data-act="resolve" data-id="${c.id}" title="Résoudre" aria-label="Résoudre">${ICONS.check}</button>`
193
+ ? `<button data-act="reopen" data-id="${c.id}" title="${M.tReopen}" aria-label="${M.tReopen}">${ICONS.reopen}</button>`
194
+ : `<button data-act="resolve" data-id="${c.id}" title="${M.tResolve}" aria-label="${M.tResolve}">${ICONS.check}</button>`
188
195
  }
189
- <button data-act="${c.hold ? "unhold" : "hold"}" data-id="${c.id}" title="${c.hold ? "Réactiver" : "Mettre en attente"}" aria-label="${c.hold ? "Réactiver" : "Mettre en attente"}">${c.hold ? ICONS.play : ICONS.pause}</button>
190
- <button data-act="reply" data-id="${c.id}" title="Répondre" aria-label="Répondre">${ICONS.reply}</button>
191
- <button data-act="delete" data-id="${c.id}" class="danger" title="Supprimer" aria-label="Supprimer">${ICONS.trash}</button>
196
+ <button data-act="${c.hold ? "unhold" : "hold"}" data-id="${c.id}" title="${c.hold ? M.tUnhold : M.tHold}" aria-label="${c.hold ? M.tUnhold : M.tHold}">${c.hold ? ICONS.play : ICONS.pause}</button>
197
+ <button data-act="reply" data-id="${c.id}" title="${M.tReply}" aria-label="${M.tReply}">${ICONS.reply}</button>
198
+ <button data-act="delete" data-id="${c.id}" class="danger" title="${M.tDelete}" aria-label="${M.tDelete}">${ICONS.trash}</button>
192
199
  </div>
193
200
  <form class="cmt-reply-form" data-id="${c.id}" hidden>
194
- <textarea rows="2" placeholder="Réponse…"></textarea><button type="submit">Envoyer</button>
201
+ <textarea rows="2" placeholder="${M.replyPlaceholder}"></textarea><button type="submit">${M.send}</button>
195
202
  </form>
196
203
  <form class="cmt-edit-form" data-id="${c.id}" hidden>
197
- <textarea rows="2">${esc(head.body)}</textarea><button type="submit">Enregistrer</button>
204
+ <textarea rows="2">${esc(head.body)}</textarea><button type="submit">${M.save}</button>
198
205
  </form>
199
206
  </article>`;
200
207
  }
@@ -203,30 +210,30 @@ const { page, space } = Astro.props;
203
210
  const anchored = comments.filter((c) => c.scope === "selection");
204
211
  const pageWide = comments.filter((c) => c.scope === "page");
205
212
  const openCount = comments.filter((c) => c.status !== "resolved").length;
206
- countEl.textContent = openCount ? `${openCount} ouverts` : `${comments.length}`;
213
+ countEl.textContent = openCount ? `${openCount} ${M.openSuffix}` : `${comments.length}`;
207
214
 
208
- // marge droite : commentaires ancrés
215
+ // right rail: anchored comments
209
216
  if (railEl) {
210
217
  const shown = anchored.filter((c) =>
211
218
  railFilter === "all" ? true : railFilter === "resolved" ? c.status === "resolved" : c.status !== "resolved",
212
219
  );
213
220
  railEl.innerHTML = `<div class="cmt-rail-head">
214
- <span class="cmt-rail-title">Annotations</span>
221
+ <span class="cmt-rail-title">${M.annotations}</span>
215
222
  <span class="cmt-rail-count">${anchored.length}</span>
216
223
  </div>
217
224
  <div class="cmt-filter" id="cmt-filter">
218
- <button data-f="open"${railFilter === "open" ? ' class="active"' : ""}>Ouverts</button>
219
- <button data-f="resolved"${railFilter === "resolved" ? ' class="active"' : ""}>Résolus</button>
220
- <button data-f="all"${railFilter === "all" ? ' class="active"' : ""}>Tous</button>
225
+ <button data-f="open"${railFilter === "open" ? ' class="active"' : ""}>${M.filterOpen}</button>
226
+ <button data-f="resolved"${railFilter === "resolved" ? ' class="active"' : ""}>${M.filterResolved}</button>
227
+ <button data-f="all"${railFilter === "all" ? ' class="active"' : ""}>${M.filterAll}</button>
221
228
  </div>
222
229
  <div class="cmt-list">${
223
- shown.length ? shown.map(card).join("") : `<p class="cmt-empty">Sélectionne du texte pour annoter.</p>`
230
+ shown.length ? shown.map(card).join("") : `<p class="cmt-empty">${M.selectToAnnotate}</p>`
224
231
  }</div>`;
225
232
  }
226
- // bas de page : commentaires de page
233
+ // page bottom: page-wide comments
227
234
  pageListEl.innerHTML = pageWide.length
228
235
  ? pageWide.map(card).join("")
229
- : `<p class="cmt-empty">Aucun commentaire de page.</p>`;
236
+ : `<p class="cmt-empty">${M.noPageComments}</p>`;
230
237
  applyHighlights();
231
238
  }
232
239
 
@@ -236,7 +243,7 @@ const { page, space } = Astro.props;
236
243
  renderView();
237
244
  }
238
245
 
239
- // ── clic sur texte surligné → popover de lecture au niveau du texte ──────
246
+ // ── click on highlighted textreading popover at the text ─────────────
240
247
  function pointAt(x: number, y: number): { node: Node; offset: number } | null {
241
248
  const d = document as any;
242
249
  if (d.caretRangeFromPoint) {
@@ -329,8 +336,8 @@ const { page, space } = Astro.props;
329
336
  pop.hidden = true;
330
337
  if (!window.getSelection()?.toString().trim()) floatBtn.hidden = true;
331
338
  }
332
- // Ferme le popover de lecture sauf clic dedans ou re-clic dans le contenu
333
- // (le handler de clic du contenu le rouvrira si on tape sur du surligné).
339
+ // Close the reading popover unless the click is inside it or back in the
340
+ // content (the content click handler reopens it when hitting a highlight).
334
341
  if (!t.closest("#cmt-view") && !article!.contains(t)) {
335
342
  viewIds = [];
336
343
  viewEl.hidden = true;
@@ -421,7 +428,7 @@ const { page, space } = Astro.props;
421
428
  region.addEventListener("click", onActionClick);
422
429
  region.addEventListener("submit", onFormSubmit);
423
430
  }
424
- // filtre (rail)délégué car re-rendu
431
+ // rail filter delegated because re-rendered
425
432
  railEl?.addEventListener("click", (e) => {
426
433
  const b = (e.target as HTMLElement).closest("#cmt-filter button");
427
434
  if (!b) return;
@@ -429,7 +436,7 @@ const { page, space } = Astro.props;
429
436
  render();
430
437
  });
431
438
 
432
- // Focus d'un commentaire ciblé depuis la page globale (?c=<id>).
439
+ // Focus a comment targeted from the global page (?c=<id>).
433
440
  function focusFromUrl() {
434
441
  const cid = new URLSearchParams(location.search).get("c");
435
442
  if (!cid) return;
@@ -9,9 +9,8 @@ interface Props {
9
9
 
10
10
  const { nodes, current } = Astro.props;
11
11
 
12
- // Un groupe est ouvert s'il contient (à n'importe quelle profondeur) la page
13
- // courante. Marche pour les groupes-dossiers ET les sous-groupes éditoriaux
14
- // synthétiques (cf. nav.config), qui n'ont pas de préfixe de chemin réel.
12
+ // A group is open if it contains (at any depth) the current page. Works for
13
+ // folder groups and synthetic editorial subgroups alike.
15
14
  function containsCurrent(node: NavNode, target: string): boolean {
16
15
  if (node.type === "leaf") return node.href === target;
17
16
  return node.children.some((child) => containsCurrent(child, target));
@@ -1,22 +1,24 @@
1
1
  ---
2
2
  import { buildNav, type Space } from "../lib/nav";
3
3
  import NavTree from "./NavTree.astro";
4
- import { roots } from "../config.mjs";
4
+ import { locale, roots } from "../config.mjs";
5
+ import { t } from "../i18n.mjs";
5
6
 
6
7
  interface Props {
7
8
  current?: string;
8
9
  activeSpace?: Space;
9
10
  }
10
11
 
12
+ const m = t(locale);
11
13
  const { current = "", activeSpace } = Astro.props;
12
- // Un arbre de nav par espace déclaré dans notabene.config (plus le duo figé).
14
+ // One nav tree per space declared in notabene.config (no fixed duo).
13
15
  const spaces = await Promise.all(
14
16
  roots.map(async (root) => ({ root, nodes: await buildNav(root.key) })),
15
17
  );
16
18
  ---
17
19
 
18
20
  <div class="nav-filter">
19
- <input id="nav-filter" type="search" placeholder="Filter…" autocomplete="off" />
21
+ <input id="nav-filter" type="search" placeholder={m.sidebarFilter} autocomplete="off" />
20
22
  </div>
21
23
 
22
24
  <nav class="nav">
@@ -27,7 +29,7 @@ const spaces = await Promise.all(
27
29
  <span class="space-name">{root.label}</span>
28
30
  <span class="space-sub">{root.subLabel}</span>
29
31
  </summary>
30
- <p class="space-link"><a href={`/${root.key}`}>Overview →</a></p>
32
+ <p class="space-link"><a href={`/${root.key}`}>{m.sidebarOverview}</a></p>
31
33
  <NavTree nodes={nodes} current={current} />
32
34
  </details>
33
35
  ))
@@ -35,8 +37,8 @@ const spaces = await Promise.all(
35
37
  </nav>
36
38
 
37
39
  <script>
38
- // Filtre client-side : masque les feuilles dont le titre ne matche pas, et
39
- // déplie tous les groupes pendant la recherche.
40
+ // Client-side filter: hide leaves whose title doesn't match, and expand all
41
+ // groups while searching.
40
42
  const input = document.getElementById("nav-filter") as HTMLInputElement | null;
41
43
  if (input) {
42
44
  input.addEventListener("input", () => {
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  import type { NavNode, NavLeaf, Space } from "../lib/nav";
3
- import { roots } from "../config.mjs";
3
+ import { locale, roots } from "../config.mjs";
4
+ import { t } from "../i18n.mjs";
4
5
 
5
6
  interface Props {
6
7
  nodes: NavNode[];
7
8
  space: Space;
8
9
  }
9
10
 
11
+ const m = t(locale);
10
12
  const { nodes, space } = Astro.props;
11
13
  const root = roots.find((r) => r.key === space);
12
14
  const label = root?.label ?? space;
13
- const blurb = root?.description || `Documentation sourced from ${root?.subLabel ?? space}.`;
15
+ const blurb = root?.description || `${root?.subLabel ?? space}`;
14
16
  ---
15
17
 
16
18
  <h1>{label}</h1>
@@ -31,7 +33,7 @@ const blurb = root?.description || `Documentation sourced from ${root?.subLabel
31
33
  </li>
32
34
  ))}
33
35
  </ul>
34
- {n.children.some((c) => c.type === "group") && <p class="more">+ subsections…</p>}
36
+ {n.children.some((c) => c.type === "group") && <p class="more">{m.subsections}</p>}
35
37
  </section>
36
38
  ) : (
37
39
  <section class="card card-leaf">
@@ -1,4 +1,7 @@
1
1
  ---
2
+ import { locale } from "../config.mjs";
3
+ import { t } from "../i18n.mjs";
4
+
2
5
  interface Heading {
3
6
  depth: number;
4
7
  slug: string;
@@ -9,14 +12,15 @@ interface Props {
9
12
  headings: Heading[];
10
13
  }
11
14
 
15
+ const m = t(locale);
12
16
  const { headings } = Astro.props;
13
17
  const items = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
14
18
  ---
15
19
 
16
20
  {
17
21
  items.length > 0 && (
18
- <nav class="toc-inner" aria-label="Sur cette page">
19
- <p class="toc-title">On this page</p>
22
+ <nav class="toc-inner" aria-label={m.tocTitle}>
23
+ <p class="toc-title">{m.tocTitle}</p>
20
24
  <ul>
21
25
  {items.map((h) => (
22
26
  <li class:list={[`d${h.depth}`]}>
@@ -2,10 +2,10 @@ import { defineCollection } from "astro:content";
2
2
  import { glob } from "astro/loaders";
3
3
  import { roots } from "./config.mjs";
4
4
 
5
- // Contenu sourcé HORS de l'app (outil de vue par-dessus la doc du repo) : une
6
- // collection par espace déclaré dans notabene.config.mjs (`roots[]`). Le nom de
7
- // collection = la `key` de l'espace ; le `base` du glob = son dossier, résolu
8
- // contre la racine du repo (cf. src/config.mjs). Le glob inclut déjà `.mdx`.
5
+ // Content sourced OUTSIDE the app (a viewer over the repo's docs): one collection
6
+ // per space declared in notabene.config.mjs (`roots[]`). The collection name = the
7
+ // space `key`; the glob `base` = its folder, resolved against the repo root
8
+ // (cf. src/config.mjs). Extensions come from `format` (.md/.mdx or .md/.markdown).
9
9
  const collections: Record<string, ReturnType<typeof defineCollection>> = {};
10
10
  for (const root of roots) {
11
11
  collections[root.key] = defineCollection({
package/src/i18n.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // UI string catalog. The renderer is English-first (OSS); `locale` in
2
+ // notabene.config selects the catalog (default "en"). Server components import
3
+ // `t(locale)`; client scripts read the same object injected as JSON by DocLayout
4
+ // (`#notabene-i18n`). Values are plain strings only (must be JSON-serializable).
5
+ //
6
+ // Add a language by adding a top-level key; missing keys fall back to English.
7
+ const MESSAGES = {
8
+ en: {
9
+ navComments: "Comments",
10
+ navJournal: "Journal",
11
+ searchPlaceholder: "Search all docs…",
12
+ searchNoResults: "No results",
13
+ tocTitle: "On this page",
14
+ sidebarFilter: "Filter…",
15
+ sidebarOverview: "Overview →",
16
+ subsections: "+ subsections…",
17
+
18
+ discussion: "Discussion",
19
+ pageCommentPlaceholder: "Comment on this page…",
20
+ commentPage: "Comment on page",
21
+ floatComment: "💬 Comment",
22
+ selectionPlaceholder: "Comment on the selection…",
23
+ cancel: "Cancel",
24
+ comment: "Comment",
25
+
26
+ annotations: "Annotations",
27
+ filterOpen: "Open",
28
+ filterResolved: "Resolved",
29
+ filterAll: "All",
30
+ selectToAnnotate: "Select text to annotate.",
31
+ noPageComments: "No page comments.",
32
+ scopePage: "📄 page",
33
+ onHold: "⏸ on hold",
34
+ replyPlaceholder: "Reply…",
35
+ send: "Send",
36
+ save: "Save",
37
+ openSuffix: "open",
38
+
39
+ tEdit: "Edit",
40
+ tResolve: "Resolve",
41
+ tReopen: "Reopen",
42
+ tHold: "Put on hold",
43
+ tUnhold: "Reactivate",
44
+ tReply: "Reply",
45
+ tDelete: "Delete",
46
+
47
+ allCommentsTitle: "All comments",
48
+ allCommentsLede:
49
+ "Aggregated view of the whole doc, with filters. Click a page → opens the comment in place.",
50
+ searchCommentsPlaceholder: "Search (text, page, quote)…",
51
+ stActive: "Active",
52
+ stOpen: "Open",
53
+ stResolved: "Resolved",
54
+ stAll: "All statuses",
55
+ spAll: "All spaces",
56
+ holdAll: "Hold: all",
57
+ holdActive: "Not on hold",
58
+ holdOnly: "On hold",
59
+ loading: "Loading…",
60
+ repliesSuffix: "repl.",
61
+ noCommentsFilters: "No comments for these filters.",
62
+
63
+ journalTitle: "Journal",
64
+ journalHeading: "Change journal",
65
+ journalLede:
66
+ "History of review passes: what changed in the docs, and why (linked to the comments that triggered it).",
67
+ journalEmpty:
68
+ "No pass recorded yet. When the agent processes comments, an entry is added here.",
69
+ journalComments: "comments:",
70
+
71
+ homeDocumentation: "documentation",
72
+ homeLede: "Sourced live from the repo.",
73
+ },
74
+ fr: {
75
+ navComments: "Commentaires",
76
+ navJournal: "Journal",
77
+ searchPlaceholder: "Rechercher dans la doc…",
78
+ searchNoResults: "Aucun résultat",
79
+ tocTitle: "Sur cette page",
80
+ sidebarFilter: "Filtrer…",
81
+ sidebarOverview: "Aperçu →",
82
+ subsections: "+ sous-sections…",
83
+
84
+ discussion: "Discussion",
85
+ pageCommentPlaceholder: "Commentaire sur cette page…",
86
+ commentPage: "Commenter la page",
87
+ floatComment: "💬 Commenter",
88
+ selectionPlaceholder: "Commentaire sur la sélection…",
89
+ cancel: "Annuler",
90
+ comment: "Commenter",
91
+
92
+ annotations: "Annotations",
93
+ filterOpen: "Ouverts",
94
+ filterResolved: "Résolus",
95
+ filterAll: "Tous",
96
+ selectToAnnotate: "Sélectionne du texte pour annoter.",
97
+ noPageComments: "Aucun commentaire de page.",
98
+ scopePage: "📄 page",
99
+ onHold: "⏸ en attente",
100
+ replyPlaceholder: "Réponse…",
101
+ send: "Envoyer",
102
+ save: "Enregistrer",
103
+ openSuffix: "ouverts",
104
+
105
+ tEdit: "Éditer",
106
+ tResolve: "Résoudre",
107
+ tReopen: "Rouvrir",
108
+ tHold: "Mettre en attente",
109
+ tUnhold: "Réactiver",
110
+ tReply: "Répondre",
111
+ tDelete: "Supprimer",
112
+
113
+ allCommentsTitle: "Tous les commentaires",
114
+ allCommentsLede:
115
+ "Vue agrégée de toute la doc, avec filtres. Clic sur une page → ouvre le commentaire à son emplacement.",
116
+ searchCommentsPlaceholder: "Rechercher (texte, page, citation)…",
117
+ stActive: "Actifs",
118
+ stOpen: "Open",
119
+ stResolved: "Résolus",
120
+ stAll: "Tous statuts",
121
+ spAll: "Tous espaces",
122
+ holdAll: "Attente : tous",
123
+ holdActive: "Hors attente",
124
+ holdOnly: "En attente",
125
+ loading: "Chargement…",
126
+ repliesSuffix: "rép.",
127
+ noCommentsFilters: "Aucun commentaire pour ces filtres.",
128
+
129
+ journalTitle: "Journal",
130
+ journalHeading: "Journal des modifications",
131
+ journalLede:
132
+ "Historique des passes de revue : ce qui a changé dans la doc, et pourquoi (lié aux commentaires qui l'ont déclenché).",
133
+ journalEmpty:
134
+ "Aucune passe enregistrée. Quand l'agent traite des commentaires, une entrée est ajoutée ici.",
135
+ journalComments: "commentaires :",
136
+
137
+ homeDocumentation: "documentation",
138
+ homeLede: "Sourcé en direct depuis le repo.",
139
+ },
140
+ };
141
+
142
+ /** Message catalog for a locale (falls back to English for unknown locales/keys). */
143
+ export function t(locale) {
144
+ return { ...MESSAGES.en, ...(MESSAGES[locale] ?? {}) };
145
+ }
@@ -5,6 +5,9 @@ import Toc from "../components/Toc.astro";
5
5
  import Comments from "../components/Comments.astro";
6
6
  import type { Space } from "../lib/nav";
7
7
  import { clientRoots, locale, siteName, tagline } from "../config.mjs";
8
+ import { t } from "../i18n.mjs";
9
+
10
+ const m = t(locale);
8
11
 
9
12
  interface Heading {
10
13
  depth: number;
@@ -37,16 +40,17 @@ const { title, current = "", space, page, headings = [], breadcrumb = [] } = Ast
37
40
  <meta name="viewport" content="width=device-width, initial-scale=1" />
38
41
  <title>{title} · {siteName}</title>
39
42
  <script type="application/json" id="notabene-roots" set:html={JSON.stringify(clientRoots)} />
43
+ <script type="application/json" id="notabene-i18n" set:html={JSON.stringify(m)} />
40
44
  </head>
41
45
  <body>
42
46
  <header class="topbar">
43
47
  <a href="/" class="brand">{siteName} <span>· {tagline}</span></a>
44
48
  <div class="search">
45
- <input id="search-input" type="search" placeholder="Search all docs…" autocomplete="off" />
49
+ <input id="search-input" type="search" placeholder={m.searchPlaceholder} autocomplete="off" />
46
50
  <div id="search-results" class="search-results" hidden></div>
47
51
  </div>
48
- <a href="/comments" class="topbar-link">Commentaires</a>
49
- <a href="/journal" class="topbar-link">Journal</a>
52
+ <a href="/comments" class="topbar-link">{m.navComments}</a>
53
+ <a href="/journal" class="topbar-link">{m.navJournal}</a>
50
54
  </header>
51
55
 
52
56
  <div class="shell">
@@ -80,7 +84,7 @@ const { title, current = "", space, page, headings = [], breadcrumb = [] } = Ast
80
84
  </div>
81
85
 
82
86
  <script>
83
- // Recherche client-side : index JSON (titre + headings + extrait), scoré.
87
+ // Client-side search: JSON index (title + headings + excerpt), scored.
84
88
  const input = document.getElementById("search-input") as HTMLInputElement | null;
85
89
  const box = document.getElementById("search-results");
86
90
 
@@ -95,6 +99,9 @@ const { title, current = "", space, page, headings = [], breadcrumb = [] } = Ast
95
99
  const ROOTS: { key: string; label: string; path: string }[] = JSON.parse(
96
100
  document.getElementById("notabene-roots")?.textContent || "[]",
97
101
  );
102
+ const M: Record<string, string> = JSON.parse(
103
+ document.getElementById("notabene-i18n")?.textContent || "{}",
104
+ );
98
105
  const spaceLabel = (key: string): string =>
99
106
  ROOTS.find((r) => r.key === key)?.label ?? key;
100
107
 
@@ -139,7 +146,7 @@ const { title, current = "", space, page, headings = [], breadcrumb = [] } = Ast
139
146
  `<a href="${doc.href}"><span class="r-space ${doc.space}">${esc(spaceLabel(doc.space))}</span><span class="r-title">${esc(doc.title)}</span></a>`,
140
147
  )
141
148
  .join("")
142
- : `<div class="r-empty">No results</div>`;
149
+ : `<div class="r-empty">${M.searchNoResults}</div>`;
143
150
  box.hidden = false;
144
151
  });
145
152
 
@@ -1,22 +1,21 @@
1
- // Types partagés client ⇄ serveur pour les commentaires (pas d'I/O ici).
1
+ // Shared client ⇄ server types for comments (no I/O here).
2
2
  //
3
- // ⚠️ CONTRAT DE DONNÉES (`.notabene` = API publique). Le store est committé en git
4
- // et lu par l'agent → sa forme ne peut plus être cassée en douce. Versionné par un
5
- // sidecar `<store>/meta.json` (`{ "schemaVersion": <n> }`, cf. SCHEMA_VERSION).
6
- // Toute évolution de forme = bump `schemaVersion` + migrateur, jamais de mutation
7
- // silencieuse. Cf. docs/plans/docs-review-oss.mdx §9.
3
+ // ⚠️ DATA CONTRACT (`.notabene` = public API). The store is committed to git and
4
+ // read by the agent → its shape can't be broken silently. Versioned by a sidecar
5
+ // `<store>/meta.json` (`{ "schemaVersion": <n> }`, cf. SCHEMA_VERSION). Any shape
6
+ // change bumps `schemaVersion` + a migrator, never a silent mutation.
8
7
 
9
- /** Version du schéma du store (sidecar `<store>/meta.json`). */
8
+ /** Store schema version (sidecar `<store>/meta.json`). */
10
9
  export const SCHEMA_VERSION = 1;
11
10
 
12
11
  export interface CommentAnchor {
13
- /** Texte exact sélectionné (W3C TextQuoteSelector). */
12
+ /** Exact selected text (W3C TextQuoteSelector). */
14
13
  quote: string;
15
- /** ~32-40 chars de contexte avant/après (désambiguïse les quotes répétées et
16
- * ancre le ré-ancrage rendu→source — load-bearing, ne pas retirer). */
14
+ /** ~32-40 chars of context before/after (disambiguates repeated quotes and
15
+ * anchors the rendered→source re-anchoring — load-bearing, don't remove). */
17
16
  prefix: string;
18
17
  suffix: string;
19
- /** Heading le plus proche au-dessus de la sélection (repère lisible). */
18
+ /** Nearest heading above the selection (readable landmark). */
20
19
  section: string | null;
21
20
  }
22
21
 
@@ -27,26 +26,26 @@ export interface CommentReply {
27
26
  }
28
27
 
29
28
  export type CommentStatus = "open" | "addressed" | "resolved";
30
- /** Clé d'un espace (root de notabene.config) — chaîne libre, plus le duo figé. */
29
+ /** A space key (notabene.config root) — free string, no fixed duo. */
31
30
  export type CommentSpace = string;
32
31
  export type CommentScope = "selection" | "page";
33
32
 
34
33
  export interface Comment {
35
34
  id: string;
36
- /** Espace = `key` du root de notabene.config (ex. reference, workbench). */
35
+ /** Space = the `key` of a notabene.config root (e.g. reference, workbench). */
37
36
  space: CommentSpace;
38
- /** Chemin logique repo-relatif de la page (= data-page), ex. docs/architecture/billing. */
37
+ /** Logical repo-relative page path (= data-page), e.g. docs/architecture/billing. */
39
38
  page: string;
40
39
  scope: CommentScope;
41
- /** null pour un commentaire global de page. */
40
+ /** null for a page-wide comment. */
42
41
  anchor: CommentAnchor | null;
43
42
  thread: CommentReply[];
44
43
  status: CommentStatus;
45
- /** « En attente » : travail en cours côté utilisateur l'agent IGNORE ces
46
- * commentaires lors d'une passe « prends en compte » (jusqu'à réactivation). */
44
+ /** "On hold": work in progress on the user's sidethe agent IGNORES these
45
+ * comments during an "address the comments" pass (until reactivated). */
47
46
  hold: boolean;
48
- /** Rempli par l'agent quand il traite le commentaire. `journalEntryId` lie la
49
- * résolution à l'entrée de `journal.json` qui la décrit (cf. JournalEntry.id). */
47
+ /** Filled by the agent when it processes the comment. `journalEntryId` links the
48
+ * resolution to the `journal.json` entry describing it (cf. JournalEntry.id). */
50
49
  resolution: { note: string; journalEntryId?: string } | null;
51
50
  createdAt: string;
52
51
  updatedAt: string;
@@ -1,18 +1,18 @@
1
- // Store de commentaires (serveur, dev-only). Un fichier JSON par page sous
2
- // `<store>/<page>.json` (committé en git, lisible par l'agent). Le chemin du store
3
- // vient de notabene.config (`store`, cf. src/config.mjs) — jamais en dur. Le glob
4
- // de contenu exclut le store → il n'est jamais interprété comme du contenu.
1
+ // Comment store (server, dev-only). One JSON file per page at `<store>/<page>.json`
2
+ // (committed to git, readable by the agent). The store path comes from
3
+ // notabene.config (`store`, cf. src/config.mjs) — never hardcoded. The content glob
4
+ // excludes the store → it's never interpreted as content.
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { storeAbs } from "../config.mjs";
8
8
  import type { Comment, CommentAnchor, CommentScope, CommentSpace, CommentStatus } from "./comment-types";
9
9
 
10
10
  const STORE_ROOT = storeAbs;
11
- // Fichiers réservés du store (jamais des pages de commentaires).
11
+ // Reserved store files (never comment pages).
12
12
  const RESERVED = new Set(["journal.json", "meta.json"]);
13
13
 
14
14
  function fileFor(page: string): string {
15
- // Anti-traversal : pas de `..`, pas de slash initial.
15
+ // Anti-traversal: no `..`, no leading slash.
16
16
  const safe = page.replace(/\\/g, "/").replace(/\.\.+/g, "").replace(/^\/+/, "");
17
17
  return path.join(STORE_ROOT, `${safe}.json`);
18
18
  }
@@ -43,7 +43,7 @@ export async function listComments(page: string): Promise<Comment[]> {
43
43
  return read(page);
44
44
  }
45
45
 
46
- /** Tous les commentaires, toutes pages (pour la page globale /comments). */
46
+ /** All comments, all pages (for the global /comments view). */
47
47
  export async function listAllComments(): Promise<Comment[]> {
48
48
  const out: Comment[] = [];
49
49
  async function walk(dir: string) {
@@ -56,12 +56,12 @@ export async function listAllComments(): Promise<Comment[]> {
56
56
  for (const e of entries) {
57
57
  const full = path.join(dir, e.name);
58
58
  if (e.isDirectory()) await walk(full);
59
- // Fichiers réservés (journal/meta) au niveau racine du store ignorés.
59
+ // Reserved files (journal/meta) at the store rootskipped.
60
60
  else if (e.name.endsWith(".json") && !(dir === STORE_ROOT && RESERVED.has(e.name))) {
61
61
  try {
62
62
  out.push(...(JSON.parse(await fs.readFile(full, "utf8")) as Comment[]));
63
63
  } catch {
64
- /* ignore fichier illisible */
64
+ /* ignore unreadable file */
65
65
  }
66
66
  }
67
67
  }
@@ -86,7 +86,7 @@ export async function createComment(input: {
86
86
  page: input.page,
87
87
  scope: input.scope,
88
88
  anchor: input.scope === "selection" ? input.anchor : null,
89
- thread: [{ author: input.author || "quentin", body: input.body, ts: now }],
89
+ thread: [{ author: input.author || "you", body: input.body, ts: now }],
90
90
  status: "open",
91
91
  hold: false,
92
92
  resolution: null,
@@ -112,8 +112,8 @@ export async function patchComment(
112
112
  const comments = await read(page);
113
113
  const c = comments.find((x) => x.id === id);
114
114
  if (!c) return null;
115
- // Édition du message original : autorisée UNIQUEMENT si pas encore traité
116
- // (status open) ET sans réponse (thread = 1). Enforcement serveur.
115
+ // Editing the original message: allowed ONLY if not yet processed (status open)
116
+ // AND has no reply (thread = 1). Server-side enforcement.
117
117
  if (patch.edit?.body && c.status === "open" && c.thread.length === 1) {
118
118
  c.thread[0].body = patch.edit.body;
119
119
  }
@@ -122,7 +122,7 @@ export async function patchComment(
122
122
  if (patch.resolution !== undefined) c.resolution = patch.resolution;
123
123
  if (patch.reply?.body) {
124
124
  c.thread.push({
125
- author: patch.reply.author || "quentin",
125
+ author: patch.reply.author || "you",
126
126
  body: patch.reply.body,
127
127
  ts: new Date().toISOString(),
128
128
  });
package/src/lib/icons.ts CHANGED
@@ -1,6 +1,6 @@
1
- // Icônes inline (Lucide, MIT) sous forme de chaînes SVG les cartes de
2
- // commentaires sont rendues en JS (innerHTML), donc pas de composant Astro ici.
3
- // Monochromes (stroke = currentColor) → héritent de la couleur du bouton.
1
+ // Inline icons (Lucide, MIT) as SVG strings comment cards are rendered in JS
2
+ // (innerHTML), so no Astro component here. Monochrome (stroke = currentColor)
3
+ // they inherit the button's color.
4
4
  const svg = (inner: string) =>
5
5
  `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
6
6
 
@@ -1,21 +1,20 @@
1
- // Journal des modifications de la doc (serveur, lecture seule côté site).
2
- // Écrit par l'agent lors d'une passe de revue (édition directe de
3
- // `<store>/journal.json`), affiché par la page /journal. Chemin du store dérivé
4
- // de notabene.config (cf. src/config.mjs) — jamais en dur.
1
+ // Doc change journal (server, read-only from the site). Written by the agent during
2
+ // a review pass (direct edit of `<store>/journal.json`), displayed by the /journal
3
+ // page. Store path derived from notabene.config (cf. src/config.mjs) never hardcoded.
5
4
  import fs from "node:fs/promises";
6
5
  import path from "node:path";
7
6
  import { storeAbs } from "../config.mjs";
8
7
 
9
8
  export interface JournalChange {
10
- /** Chemin logique de la page modifiée (= data-page), ex. docs/architecture/billing. */
9
+ /** Logical path of the changed page (= data-page), e.g. docs/architecture/billing. */
11
10
  page: string;
12
- /** Commentaires traités par cette modification (ids). */
11
+ /** Comments addressed by this change (ids). */
13
12
  commentIds: string[];
14
- /** Ce qui a changé. */
13
+ /** What changed. */
15
14
  what: string;
16
- /** Pourquoi (issu du commentaire). */
15
+ /** Why (from the comment). */
17
16
  why: string;
18
- /** Référence optionnelle (sha de commit, etc.). */
17
+ /** Optional reference (commit sha, etc.). */
19
18
  ref?: string;
20
19
  }
21
20
 
@@ -24,7 +23,7 @@ export interface JournalEntry {
24
23
  /** ISO date (YYYY-MM-DD). */
25
24
  date: string;
26
25
  title: string;
27
- /** Résumé prose de la passe. */
26
+ /** Prose summary of the pass. */
28
27
  summary: string;
29
28
  changes: JournalChange[];
30
29
  }
package/src/lib/nav.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { getCollection } from "astro:content";
2
2
  import { locale } from "../config.mjs";
3
3
 
4
- // Un espace = la `key` d'un root de notabene.config (plus le duo figé
5
- // reference/workbench). Chaîne libre.
4
+ // A space = the `key` of a notabene.config root (no fixed duo). Free string.
6
5
  export type Space = string;
7
6
 
8
7
  export interface NavLeaf {
@@ -22,7 +21,8 @@ export interface NavGroup {
22
21
 
23
22
  export type NavNode = NavLeaf | NavGroup;
24
23
 
25
- // Mots à rendre en capitales (acronymes), et petits mots gardés en minuscule.
24
+ // Words to uppercase (acronyms), and small words kept lowercase. The small-word
25
+ // set is intentionally EN + FR so path segments in either language title-case well.
26
26
  const ACRONYMS = new Set([
27
27
  "api", "iam", "dns", "ip", "ips", "url", "ssl", "tls", "ocr", "qr", "mac", "http",
28
28
  "https", "sse", "ws", "id", "ttl", "ssrf", "mx", "smtp", "dnsbl", "dbl", "dnswl",
@@ -37,7 +37,7 @@ const SMALL_WORDS = new Set([
37
37
 
38
38
  export function humanize(seg: string): string {
39
39
  const words = seg
40
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // sépare le camelCase (ex. ipAddress)
40
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase (e.g. ipAddress)
41
41
  .split(/[-_\s]+/)
42
42
  .filter(Boolean);
43
43
  return words
@@ -51,8 +51,8 @@ export function humanize(seg: string): string {
51
51
  }
52
52
 
53
53
  /**
54
- * Libellé COURT de navigation = nom de fichier humanisé. Le H1 (souvent verbeux,
55
- * pensé pour une lecture standalone) sert au titre de la page, pas à la sidebar.
54
+ * SHORT nav label = humanized file name. The H1 (often verbose, written for
55
+ * standalone reading) is used for the page title, not the sidebar.
56
56
  */
57
57
  export function navLabel(id: string): string {
58
58
  const seg = id.split("/").pop() ?? id;
@@ -60,7 +60,7 @@ export function navLabel(id: string): string {
60
60
  return humanize(seg);
61
61
  }
62
62
 
63
- /** Titre de page = premier H1 (descriptif), sinon le libellé de nav. */
63
+ /** Page title = first H1 (descriptive), else the nav label. */
64
64
  export function pageTitle(body: string | undefined, id: string): string {
65
65
  const m = body?.match(/^#\s+(.+?)\s*$/m);
66
66
  if (m) return m[1].replace(/[*_`]/g, "").trim();
@@ -68,17 +68,17 @@ export function pageTitle(body: string | undefined, id: string): string {
68
68
  }
69
69
 
70
70
  async function entriesOf(space: Space) {
71
- // Nom de collection = key de l'espace (cf. content.config.ts). Dynamique.
71
+ // Collection name = the space key (cf. content.config.ts). Dynamic.
72
72
  return getCollection(space as never);
73
73
  }
74
74
 
75
- /** Construit l'arbre de navigation d'un espace à partir des ids slash-séparés. */
75
+ /** Builds a space's navigation tree from the slash-separated ids. */
76
76
  export async function buildNav(space: Space): Promise<NavNode[]> {
77
77
  const entries = await entriesOf(space);
78
78
  const rootChildren: NavNode[] = [];
79
79
 
80
80
  for (const entry of entries) {
81
- // README/index RACINE = page d'accueil de l'espace, pas une feuille de nav.
81
+ // ROOT README/index = the space home page, not a nav leaf.
82
82
  if (/^(readme|index)$/i.test(entry.id)) continue;
83
83
  const parts = entry.id.split("/");
84
84
  let children = rootChildren;
@@ -114,7 +114,7 @@ export async function buildNav(space: Space): Promise<NavNode[]> {
114
114
  }
115
115
 
116
116
  function rank(node: NavNode): number {
117
- // README / index en tête, puis les groupes, puis les autres pages.
117
+ // README / index first, then groups, then other pages.
118
118
  if (node.type === "leaf" && /^(readme|index)$/i.test(node.segment)) return 0;
119
119
  if (node.type === "group") return 1;
120
120
  return 2;
@@ -13,7 +13,7 @@ export async function getStaticPaths() {
13
13
  params: { space: root.key, slug: entry.id },
14
14
  props: { spaceKey: root.key, entry },
15
15
  }));
16
- // Page d'accueil de l'espace (slug vide → /<key>).
16
+ // Space home page (empty slug → /<key>).
17
17
  return [...docs, { params: { space: root.key, slug: undefined }, props: { spaceKey: root.key, entry: undefined } }];
18
18
  }),
19
19
  );
@@ -42,7 +42,7 @@ if (entry) {
42
42
  for (let i = 0; i < parts.length - 1; i++) breadcrumb.push({ label: humanize(parts[i]) });
43
43
  breadcrumb.push({ label: title });
44
44
  } else {
45
- // Accueil d'espace : on rend le README/index racine s'il existe, sinon des cartes.
45
+ // Space home: render the root README/index if present, else index cards.
46
46
  const all = await getCollection(spaceKey as never);
47
47
  const readme = all.find((e: { id: string }) => /^(readme|index)$/i.test(e.id));
48
48
  if (readme) {
@@ -1,7 +1,7 @@
1
1
  import type { APIRoute } from "astro";
2
2
  import { createComment, listAllComments, listComments, patchComment, removeComment } from "../../lib/comments";
3
3
 
4
- // On-demand (lecture/écriture de fichiers) — pas de prerender.
4
+ // On-demand (reads/writes files) — no prerender.
5
5
  export const prerender = false;
6
6
 
7
7
  const json = (data: unknown, status = 200) =>
@@ -10,9 +10,9 @@ const json = (data: unknown, status = 200) =>
10
10
  headers: { "content-type": "application/json; charset=utf-8" },
11
11
  });
12
12
 
13
- // GARDE-FOU (§3) : la voie d'écriture (elle modifie le git du repo) n'existe qu'en
14
- // `astro dev`. En build/preview (PROD), les mutations sont refuséesl'écriture
15
- // ne fait pas partie de l'artefact déployable. Override explicite : NOTABENE_ALLOW_WRITE=1.
13
+ // SAFETY (§3): the write path (it modifies the repo's git) only exists under
14
+ // `astro dev`. In build/preview (PROD), mutations are refusedwriting is not part
15
+ // of the deployable artifact. Explicit override: NOTABENE_ALLOW_WRITE=1.
16
16
  const WRITABLE = import.meta.env.DEV || process.env.NOTABENE_ALLOW_WRITE === "1";
17
17
  const denyIfReadOnly = () =>
18
18
  WRITABLE ? null : json({ error: "read-only: write API disabled outside dev" }, 403);
@@ -1,33 +1,36 @@
1
1
  ---
2
2
  import DocLayout from "../layouts/DocLayout.astro";
3
- import { roots } from "../config.mjs";
3
+ import { locale, roots } from "../config.mjs";
4
+ import { t } from "../i18n.mjs";
5
+
6
+ const m = t(locale);
4
7
  ---
5
8
 
6
- <DocLayout title="Tous les commentaires">
7
- <h1>Tous les commentaires</h1>
8
- <p class="lede">Vue agrégée de toute la doc, avec filtres. Clic sur une page → ouvre le commentaire à son emplacement.</p>
9
+ <DocLayout title={m.allCommentsTitle}>
10
+ <h1>{m.allCommentsTitle}</h1>
11
+ <p class="lede">{m.allCommentsLede}</p>
9
12
 
10
13
  <div class="caf-filters">
11
- <input id="caf-search" type="search" placeholder="Rechercher (texte, page, citation)…" />
14
+ <input id="caf-search" type="search" placeholder={m.searchCommentsPlaceholder} />
12
15
  <select id="caf-status">
13
- <option value="active">Actifs</option>
14
- <option value="open">Open</option>
15
- <option value="resolved">Résolus</option>
16
- <option value="all">Tous statuts</option>
16
+ <option value="active">{m.stActive}</option>
17
+ <option value="open">{m.stOpen}</option>
18
+ <option value="resolved">{m.stResolved}</option>
19
+ <option value="all">{m.stAll}</option>
17
20
  </select>
18
21
  <select id="caf-space">
19
- <option value="all">Tous espaces</option>
22
+ <option value="all">{m.spAll}</option>
20
23
  {roots.map((r) => <option value={r.key}>{r.label}</option>)}
21
24
  </select>
22
25
  <select id="caf-hold">
23
- <option value="all">Attente : tous</option>
24
- <option value="active">Hors attente</option>
25
- <option value="hold">En attente</option>
26
+ <option value="all">{m.holdAll}</option>
27
+ <option value="active">{m.holdActive}</option>
28
+ <option value="hold">{m.holdOnly}</option>
26
29
  </select>
27
30
  <span id="caf-count" class="cmt-count"></span>
28
31
  </div>
29
32
 
30
- <div id="caf-list" class="caf-list"><p class="cmt-empty">Chargement…</p></div>
33
+ <div id="caf-list" class="caf-list"><p class="cmt-empty">{m.loading}</p></div>
31
34
  </DocLayout>
32
35
 
33
36
  <script>
@@ -44,6 +47,9 @@ import { roots } from "../config.mjs";
44
47
  const ROOTS: { key: string; label: string; path: string }[] = JSON.parse(
45
48
  document.getElementById("notabene-roots")?.textContent || "[]",
46
49
  );
50
+ const M: Record<string, string> = JSON.parse(
51
+ document.getElementById("notabene-i18n")?.textContent || "{}",
52
+ );
47
53
  const spaceLabel = (key: string): string => ROOTS.find((r) => r.key === key)?.label ?? key;
48
54
 
49
55
  const listEl = document.getElementById("caf-list")!;
@@ -63,7 +69,7 @@ import { roots } from "../config.mjs";
63
69
  body: body ? JSON.stringify(body) : undefined,
64
70
  }).then((r) => r.json());
65
71
  const fmt = (s: string) => s.slice(0, 16).replace("T", " ");
66
- // Root le plus spécifique (path le plus long) d'abord — cf. config.routeForPage.
72
+ // Most specific root (longest path) first — cf. config.routeForPage.
67
73
  const ROUTE_ROOTS = [...ROOTS].sort((a, b) => b.path.length - a.path.length);
68
74
  function routeFor(page: string): string {
69
75
  for (const r of ROUTE_ROOTS) {
@@ -104,22 +110,22 @@ import { roots } from "../config.mjs";
104
110
  <span class="cmt-dot ${c.status}"></span>
105
111
  <a class="caf-page" href="${routeFor(c.page)}?c=${c.id}">${esc(c.page)}</a>
106
112
  <span class="r-space ${c.space}">${esc(spaceLabel(c.space))}</span>
107
- ${c.scope === "selection" ? `<span class="caf-quote">“${esc((c.anchor?.quote || "").slice(0, 60))}”</span>` : `<span class="cmt-scope">📄 page</span>`}
108
- ${c.hold ? '<span class="cmt-hold-badge">⏸ en attente</span>' : ""}
109
- ${c.thread.length > 1 ? `<span class="caf-replies">${c.thread.length - 1} rép.</span>` : ""}
113
+ ${c.scope === "selection" ? `<span class="caf-quote">“${esc((c.anchor?.quote || "").slice(0, 60))}”</span>` : `<span class="cmt-scope">${M.scopePage}</span>`}
114
+ ${c.hold ? `<span class="cmt-hold-badge">${M.onHold}</span>` : ""}
115
+ ${c.thread.length > 1 ? `<span class="caf-replies">${c.thread.length - 1} ${M.repliesSuffix}</span>` : ""}
110
116
  </div>
111
117
  <div class="caf-body"><b>${esc(head.author)}</b> ${esc(head.body)}</div>
112
118
  ${c.resolution?.note ? `<div class="cmt-resolution">✔ ${esc(c.resolution.note)}</div>` : ""}
113
119
  <div class="caf-actions">
114
120
  <span class="cmt-ts">${fmt(c.updatedAt)}</span>
115
- ${c.status === "resolved" ? `<button data-act="reopen" title="Rouvrir" aria-label="Rouvrir">${ICONS.reopen}</button>` : `<button data-act="resolve" title="Résoudre" aria-label="Résoudre">${ICONS.check}</button>`}
116
- <button data-act="${c.hold ? "unhold" : "hold"}" title="${c.hold ? "Réactiver" : "Mettre en attente"}" aria-label="${c.hold ? "Réactiver" : "Mettre en attente"}">${c.hold ? ICONS.play : ICONS.pause}</button>
117
- <button data-act="delete" class="danger" title="Supprimer" aria-label="Supprimer">${ICONS.trash}</button>
121
+ ${c.status === "resolved" ? `<button data-act="reopen" title="${M.tReopen}" aria-label="${M.tReopen}">${ICONS.reopen}</button>` : `<button data-act="resolve" title="${M.tResolve}" aria-label="${M.tResolve}">${ICONS.check}</button>`}
122
+ <button data-act="${c.hold ? "unhold" : "hold"}" title="${c.hold ? M.tUnhold : M.tHold}" aria-label="${c.hold ? M.tUnhold : M.tHold}">${c.hold ? ICONS.play : ICONS.pause}</button>
123
+ <button data-act="delete" class="danger" title="${M.tDelete}" aria-label="${M.tDelete}">${ICONS.trash}</button>
118
124
  </div>
119
125
  </article>`;
120
126
  })
121
127
  .join("")
122
- : `<p class="cmt-empty">Aucun commentaire pour ces filtres.</p>`;
128
+ : `<p class="cmt-empty">${M.noCommentsFilters}</p>`;
123
129
  }
124
130
 
125
131
  async function load() {
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  import DocLayout from "../layouts/DocLayout.astro";
3
- import { roots, siteName } from "../config.mjs";
3
+ import { locale, roots, siteName } from "../config.mjs";
4
+ import { t } from "../i18n.mjs";
5
+
6
+ const m = t(locale);
4
7
  ---
5
8
 
6
9
  <DocLayout title="Home">
7
- <h1>{siteName} documentation</h1>
8
- <p class="lede">
9
- {roots.length} space{roots.length > 1 ? "s" : ""}, sourced live from the repo.
10
- </p>
10
+ <h1>{siteName} {m.homeDocumentation}</h1>
11
+ <p class="lede">{m.homeLede}</p>
11
12
 
12
13
  <div class="home-cards">
13
14
  {
@@ -2,23 +2,20 @@
2
2
  export const prerender = false;
3
3
  import DocLayout from "../layouts/DocLayout.astro";
4
4
  import { readJournal } from "../lib/journal";
5
- import { routeForPage as routeFor } from "../config.mjs";
5
+ import { locale, routeForPage as routeFor } from "../config.mjs";
6
+ import { t } from "../i18n.mjs";
6
7
 
7
- const entries = (await readJournal()).slice().reverse(); // plus récent en premier
8
+ const m = t(locale);
9
+ const entries = (await readJournal()).slice().reverse(); // most recent first
8
10
  ---
9
11
 
10
- <DocLayout title="Journal">
11
- <h1>Journal des modifications</h1>
12
- <p class="lede">
13
- Historique des passes de revue : ce qui a changé dans la doc, et <strong>pourquoi</strong>
14
- (lié aux commentaires qui l'ont déclenché).
15
- </p>
12
+ <DocLayout title={m.journalTitle}>
13
+ <h1>{m.journalHeading}</h1>
14
+ <p class="lede">{m.journalLede}</p>
16
15
 
17
16
  {
18
17
  entries.length === 0 ? (
19
- <p class="cmt-empty">
20
- Aucune passe enregistrée. Quand Claude traite des commentaires, une entrée est ajoutée ici.
21
- </p>
18
+ <p class="cmt-empty">{m.journalEmpty}</p>
22
19
  ) : (
23
20
  <div class="journal">
24
21
  {entries.map((e) => (
@@ -37,7 +34,7 @@ const entries = (await readJournal()).slice().reverse(); // plus récent en prem
37
34
  <span class="journal-what"> — {c.what}</span>
38
35
  <div class="journal-why">{c.why}</div>
39
36
  {c.commentIds.length > 0 && (
40
- <div class="journal-cids">commentaires : {c.commentIds.join(", ")}</div>
37
+ <div class="journal-cids">{m.journalComments} {c.commentIds.join(", ")}</div>
41
38
  )}
42
39
  </li>
43
40
  ))}
@@ -1,15 +1,14 @@
1
1
  import path from "node:path";
2
2
 
3
- // Réécrit les liens RELATIFS vers des fichiers .md/.mdx (entre docs) en routes du
4
- // site. Sans ça, les liens inter-doc (`[x](../foo.md)`) pointent vers des fichiers
5
- // et donnent des 404 dans le site.
3
+ // Rewrites RELATIVE links to .md/.mdx files (between docs) into site routes.
4
+ // Without this, inter-doc links (`[x](../foo.md)`) point at files and 404 in the site.
6
5
  //
7
6
  // <root.path>/** → /<root.key>/<slug>
8
7
  //
9
- // Générique : la table de correspondance vient des `roots[]` de notabene.config
10
- // (cf. src/config.mjs), pas de chemins en dur. Les liens externes (http…, mailto),
11
- // absolus (/…), ancres (#…) et non-.md sont laissés intacts. Les cibles .md HORS
12
- // des espaces déclarés sont laissées telles quelles.
8
+ // Generic: the mapping comes from notabene.config `roots[]` (cf. src/config.mjs),
9
+ // no hardcoded paths. External links (http…, mailto), absolute (/…), anchors (#…)
10
+ // and non-.md targets are left intact. .md targets OUTSIDE the declared spaces are
11
+ // left as-is.
13
12
 
14
13
  function slug(rel) {
15
14
  return rel.replace(/\\/g, "/").replace(/\.mdx?$/i, "");
@@ -23,11 +22,11 @@ function visit(node, fn) {
23
22
  }
24
23
 
25
24
  /**
26
- * @param {{ key: string, abs: string }[]} roots — espaces normalisés (config.mjs).
25
+ * @param {{ key: string, abs: string }[]} roots — normalized spaces (config.mjs).
27
26
  */
28
27
  export function remarkRewriteLinks(roots) {
29
- // Root le plus spécifique (chemin absolu le plus long) d'abord : un espace
30
- // imbriqué (docs/plans) doit gagner sur son parent (docs).
28
+ // Most specific root (longest absolute path) first: a nested space (docs/plans)
29
+ // must win over its parent (docs).
31
30
  const ordered = [...roots].sort((a, b) => b.abs.length - a.abs.length);
32
31
 
33
32
  function toRoute(abs) {
@@ -1,4 +1,4 @@
1
- /* Documentation interne Apophis thème lisible, clair/sombre auto. */
1
+ /* notabene rendererreadable theme, auto light/dark. */
2
2
 
3
3
  :root {
4
4
  --bg: #ffffff;
@@ -43,8 +43,8 @@
43
43
  box-sizing: border-box;
44
44
  }
45
45
 
46
- /* L'attribut `hidden` doit toujours l'emporter sur les `display` des composants
47
- (popover commentaires, formulaires de réponse repliés, etc.). */
46
+ /* The `hidden` attribute must always win over component `display` values
47
+ (comment popovers, collapsed reply forms, etc.). */
48
48
  [hidden] {
49
49
  display: none !important;
50
50
  }
@@ -762,7 +762,7 @@ a:hover {
762
762
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
763
763
  }
764
764
 
765
- /* Discussion (commentaires de page) — en bas de la page, aligné sur la prose. */
765
+ /* Discussion (page-wide comments) — at the bottom of the page, aligned with prose. */
766
766
  .cmt-page {
767
767
  max-width: var(--content-max);
768
768
  margin: 3rem auto 0;
@@ -785,7 +785,7 @@ a:hover {
785
785
  max-width: 640px;
786
786
  }
787
787
 
788
- /* Marge droite — annotations ancrées. */
788
+ /* Right railanchored annotations. */
789
789
  .cmt-rail {
790
790
  margin-top: 1.5rem;
791
791
  border-top: 1px solid var(--border);
@@ -809,7 +809,7 @@ a:hover {
809
809
  color: var(--text-faint);
810
810
  }
811
811
 
812
- /* Popover de création sur sélection. */
812
+ /* Creation popover on selection. */
813
813
  .cmt-pop {
814
814
  position: fixed;
815
815
  z-index: 70;
@@ -863,7 +863,7 @@ a:hover {
863
863
  border-color: var(--accent);
864
864
  }
865
865
 
866
- /* Popover de LECTURE (clic sur texte surligné) — affiche le(s) commentaire(s). */
866
+ /* READING popover (click on highlighted text) — shows the comment(s). */
867
867
  .cmt-view {
868
868
  position: fixed;
869
869
  z-index: 70;