@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 +1 -1
- package/package.json +12 -2
- package/src/components/Comments.astro +43 -36
- package/src/components/NavTree.astro +2 -3
- package/src/components/Sidebar.astro +8 -6
- package/src/components/SpaceIndex.astro +5 -3
- package/src/components/Toc.astro +6 -2
- package/src/content.config.ts +4 -4
- package/src/i18n.mjs +145 -0
- package/src/layouts/DocLayout.astro +12 -5
- package/src/lib/comment-types.ts +18 -19
- package/src/lib/comments.ts +13 -13
- package/src/lib/icons.ts +3 -3
- package/src/lib/journal.ts +9 -10
- package/src/lib/nav.ts +11 -11
- package/src/pages/[space]/[...slug].astro +2 -2
- package/src/pages/api/comments.ts +4 -4
- package/src/pages/comments.astro +28 -22
- package/src/pages/index.astro +6 -5
- package/src/pages/journal.astro +9 -12
- package/src/remark/rewrite-links.mjs +9 -10
- package/src/styles/global.css +7 -7
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 ≥
|
|
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.
|
|
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": ">=
|
|
41
|
+
"node": ">=22.12.0"
|
|
32
42
|
}
|
|
33
43
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
-
//
|
|
3
|
-
// -
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
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>
|
|
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=
|
|
22
|
-
<button type="submit">
|
|
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
|
-
|
|
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=
|
|
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">
|
|
35
|
-
<button type="button" id="cmt-pop-save">
|
|
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
|
|
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"
|
|
181
|
+
: `<span class="cmt-scope">${M.scopePage}</span>`
|
|
175
182
|
}
|
|
176
|
-
${c.hold ?
|
|
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="
|
|
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="
|
|
187
|
-
: `<button data-act="resolve" data-id="${c.id}" title="
|
|
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 ?
|
|
190
|
-
<button data-act="reply" data-id="${c.id}" title="
|
|
191
|
-
<button data-act="delete" data-id="${c.id}" class="danger" title="
|
|
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="
|
|
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"
|
|
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}
|
|
213
|
+
countEl.textContent = openCount ? `${openCount} ${M.openSuffix}` : `${comments.length}`;
|
|
207
214
|
|
|
208
|
-
//
|
|
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"
|
|
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"' : ""}
|
|
219
|
-
<button data-f="resolved"${railFilter === "resolved" ? ' class="active"' : ""}
|
|
220
|
-
<button data-f="all"${railFilter === "all" ? ' class="active"' : ""}
|
|
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"
|
|
230
|
+
shown.length ? shown.map(card).join("") : `<p class="cmt-empty">${M.selectToAnnotate}</p>`
|
|
224
231
|
}</div>`;
|
|
225
232
|
}
|
|
226
|
-
//
|
|
233
|
+
// page bottom: page-wide comments
|
|
227
234
|
pageListEl.innerHTML = pageWide.length
|
|
228
235
|
? pageWide.map(card).join("")
|
|
229
|
-
: `<p class="cmt-empty"
|
|
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
|
-
// ──
|
|
246
|
+
// ── click on highlighted text → reading 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
|
-
//
|
|
333
|
-
// (
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
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=
|
|
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}`}>
|
|
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
|
-
//
|
|
39
|
-
//
|
|
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 ||
|
|
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"
|
|
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">
|
package/src/components/Toc.astro
CHANGED
|
@@ -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=
|
|
19
|
-
<p class="toc-title">
|
|
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}`]}>
|
package/src/content.config.ts
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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=
|
|
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">
|
|
49
|
-
<a href="/journal" class="topbar-link">
|
|
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
|
-
//
|
|
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"
|
|
149
|
+
: `<div class="r-empty">${M.searchNoResults}</div>`;
|
|
143
150
|
box.hidden = false;
|
|
144
151
|
});
|
|
145
152
|
|
package/src/lib/comment-types.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Shared client ⇄ server types for comments (no I/O here).
|
|
2
2
|
//
|
|
3
|
-
// ⚠️
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
/**
|
|
8
|
+
/** Store schema version (sidecar `<store>/meta.json`). */
|
|
10
9
|
export const SCHEMA_VERSION = 1;
|
|
11
10
|
|
|
12
11
|
export interface CommentAnchor {
|
|
13
|
-
/**
|
|
12
|
+
/** Exact selected text (W3C TextQuoteSelector). */
|
|
14
13
|
quote: string;
|
|
15
|
-
/** ~32-40 chars
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
35
|
+
/** Space = the `key` of a notabene.config root (e.g. reference, workbench). */
|
|
37
36
|
space: CommentSpace;
|
|
38
|
-
/**
|
|
37
|
+
/** Logical repo-relative page path (= data-page), e.g. docs/architecture/billing. */
|
|
39
38
|
page: string;
|
|
40
39
|
scope: CommentScope;
|
|
41
|
-
/** null
|
|
40
|
+
/** null for a page-wide comment. */
|
|
42
41
|
anchor: CommentAnchor | null;
|
|
43
42
|
thread: CommentReply[];
|
|
44
43
|
status: CommentStatus;
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
44
|
+
/** "On hold": work in progress on the user's side — the agent IGNORES these
|
|
45
|
+
* comments during an "address the comments" pass (until reactivated). */
|
|
47
46
|
hold: boolean;
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
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;
|
package/src/lib/comments.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
59
|
+
// Reserved files (journal/meta) at the store root → skipped.
|
|
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
|
|
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 || "
|
|
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
|
-
//
|
|
116
|
-
//
|
|
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 || "
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
package/src/lib/journal.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
/**
|
|
9
|
+
/** Logical path of the changed page (= data-page), e.g. docs/architecture/billing. */
|
|
11
10
|
page: string;
|
|
12
|
-
/**
|
|
11
|
+
/** Comments addressed by this change (ids). */
|
|
13
12
|
commentIds: string[];
|
|
14
|
-
/**
|
|
13
|
+
/** What changed. */
|
|
15
14
|
what: string;
|
|
16
|
-
/**
|
|
15
|
+
/** Why (from the comment). */
|
|
17
16
|
why: string;
|
|
18
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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") //
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
71
|
+
// Collection name = the space key (cf. content.config.ts). Dynamic.
|
|
72
72
|
return getCollection(space as never);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
14
|
-
// `astro dev`.
|
|
15
|
-
//
|
|
13
|
+
// SAFETY (§3): the write path (it modifies the repo's git) only exists under
|
|
14
|
+
// `astro dev`. In build/preview (PROD), mutations are refused — writing 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);
|
package/src/pages/comments.astro
CHANGED
|
@@ -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=
|
|
7
|
-
<h1>
|
|
8
|
-
<p class="lede">
|
|
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=
|
|
14
|
+
<input id="caf-search" type="search" placeholder={m.searchCommentsPlaceholder} />
|
|
12
15
|
<select id="caf-status">
|
|
13
|
-
<option value="active">
|
|
14
|
-
<option value="open">
|
|
15
|
-
<option value="resolved">
|
|
16
|
-
<option value="all">
|
|
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">
|
|
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">
|
|
24
|
-
<option value="active">
|
|
25
|
-
<option value="hold">
|
|
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">
|
|
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
|
-
//
|
|
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"
|
|
108
|
-
${c.hold ?
|
|
109
|
-
${c.thread.length > 1 ? `<span class="caf-replies">${c.thread.length - 1}
|
|
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="
|
|
116
|
-
<button data-act="${c.hold ? "unhold" : "hold"}" title="${c.hold ?
|
|
117
|
-
<button data-act="delete" class="danger" title="
|
|
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"
|
|
128
|
+
: `<p class="cmt-empty">${M.noCommentsFilters}</p>`;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
async function load() {
|
package/src/pages/index.astro
CHANGED
|
@@ -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}
|
|
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
|
{
|
package/src/pages/journal.astro
CHANGED
|
@@ -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
|
|
8
|
+
const m = t(locale);
|
|
9
|
+
const entries = (await readJournal()).slice().reverse(); // most recent first
|
|
8
10
|
---
|
|
9
11
|
|
|
10
|
-
<DocLayout title=
|
|
11
|
-
<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">
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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 —
|
|
25
|
+
* @param {{ key: string, abs: string }[]} roots — normalized spaces (config.mjs).
|
|
27
26
|
*/
|
|
28
27
|
export function remarkRewriteLinks(roots) {
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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) {
|
package/src/styles/global.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* notabene renderer — readable 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
|
-
/*
|
|
47
|
-
(
|
|
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 (
|
|
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
|
-
/*
|
|
788
|
+
/* Right rail — anchored 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
|
-
/*
|
|
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
|
-
/*
|
|
866
|
+
/* READING popover (click on highlighted text) — shows the comment(s). */
|
|
867
867
|
.cmt-view {
|
|
868
868
|
position: fixed;
|
|
869
869
|
z-index: 70;
|