@z29k/notabene 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ ---
2
+ import { getCollection, render } from "astro:content";
3
+ import DocLayout from "../../layouts/DocLayout.astro";
4
+ import SpaceIndex from "../../components/SpaceIndex.astro";
5
+ import { buildNav, humanize, pageTitle, type NavNode } from "../../lib/nav";
6
+ import { roots } from "../../config.mjs";
7
+
8
+ export async function getStaticPaths() {
9
+ const groups = await Promise.all(
10
+ roots.map(async (root) => {
11
+ const entries = await getCollection(root.key as never);
12
+ const docs = entries.map((entry: { id: string }) => ({
13
+ params: { space: root.key, slug: entry.id },
14
+ props: { spaceKey: root.key, entry },
15
+ }));
16
+ // Page d'accueil de l'espace (slug vide → /<key>).
17
+ return [...docs, { params: { space: root.key, slug: undefined }, props: { spaceKey: root.key, entry: undefined } }];
18
+ }),
19
+ );
20
+ return groups.flat();
21
+ }
22
+
23
+ const { spaceKey, entry } = Astro.props;
24
+ const root = roots.find((r) => r.key === spaceKey)!;
25
+ const spaceLabel = root.label;
26
+ const baseDir = root.path;
27
+
28
+ let Content: Awaited<ReturnType<typeof render>>["Content"] | null = null;
29
+ let headings: { depth: number; slug: string; text: string }[] = [];
30
+ let nodes: NavNode[] = [];
31
+ let page: string | undefined;
32
+ let title = spaceLabel;
33
+ const breadcrumb: { label: string; href?: string }[] = [{ label: spaceLabel, href: `/${spaceKey}` }];
34
+
35
+ if (entry) {
36
+ const rendered = await render(entry);
37
+ Content = rendered.Content;
38
+ headings = rendered.headings;
39
+ title = pageTitle(entry.body, entry.id);
40
+ page = `${baseDir}/${entry.id}`;
41
+ const parts = entry.id.split("/");
42
+ for (let i = 0; i < parts.length - 1; i++) breadcrumb.push({ label: humanize(parts[i]) });
43
+ breadcrumb.push({ label: title });
44
+ } else {
45
+ // Accueil d'espace : on rend le README/index racine s'il existe, sinon des cartes.
46
+ const all = await getCollection(spaceKey as never);
47
+ const readme = all.find((e: { id: string }) => /^(readme|index)$/i.test(e.id));
48
+ if (readme) {
49
+ const rendered = await render(readme);
50
+ Content = rendered.Content;
51
+ headings = rendered.headings;
52
+ title = pageTitle(readme.body, readme.id);
53
+ page = `${baseDir}/${readme.id}`;
54
+ } else {
55
+ nodes = await buildNav(spaceKey);
56
+ }
57
+ }
58
+
59
+ const current = entry ? `/${spaceKey}/${entry.id}` : `/${spaceKey}`;
60
+ ---
61
+
62
+ <DocLayout
63
+ title={title}
64
+ current={current}
65
+ space={spaceKey}
66
+ page={page}
67
+ headings={headings}
68
+ breadcrumb={entry ? breadcrumb : []}
69
+ >
70
+ {Content ? <Content /> : <SpaceIndex nodes={nodes} space={spaceKey} />}
71
+ </DocLayout>
@@ -0,0 +1,68 @@
1
+ import type { APIRoute } from "astro";
2
+ import { createComment, listAllComments, listComments, patchComment, removeComment } from "../../lib/comments";
3
+
4
+ // On-demand (lecture/écriture de fichiers) — pas de prerender.
5
+ export const prerender = false;
6
+
7
+ const json = (data: unknown, status = 200) =>
8
+ new Response(JSON.stringify(data), {
9
+ status,
10
+ headers: { "content-type": "application/json; charset=utf-8" },
11
+ });
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ées — l'écriture
15
+ // ne fait pas partie de l'artefact déployable. Override explicite : NOTABENE_ALLOW_WRITE=1.
16
+ const WRITABLE = import.meta.env.DEV || process.env.NOTABENE_ALLOW_WRITE === "1";
17
+ const denyIfReadOnly = () =>
18
+ WRITABLE ? null : json({ error: "read-only: write API disabled outside dev" }, 403);
19
+
20
+ export const GET: APIRoute = async ({ url }) => {
21
+ if (url.searchParams.get("all")) return json(await listAllComments());
22
+ const page = url.searchParams.get("page");
23
+ if (!page) return json({ error: "page query param required" }, 400);
24
+ return json(await listComments(page));
25
+ };
26
+
27
+ export const POST: APIRoute = async ({ request }) => {
28
+ const denied = denyIfReadOnly();
29
+ if (denied) return denied;
30
+ const b = await request.json().catch(() => null);
31
+ if (!b || !b.page || !b.space || !b.scope || !b.body) {
32
+ return json({ error: "page, space, scope, body required" }, 400);
33
+ }
34
+ const comment = await createComment({
35
+ space: b.space,
36
+ page: b.page,
37
+ scope: b.scope,
38
+ anchor: b.anchor ?? null,
39
+ author: b.author,
40
+ body: b.body,
41
+ });
42
+ return json(comment, 201);
43
+ };
44
+
45
+ export const PATCH: APIRoute = async ({ request }) => {
46
+ const denied = denyIfReadOnly();
47
+ if (denied) return denied;
48
+ const b = await request.json().catch(() => null);
49
+ if (!b || !b.page || !b.id) return json({ error: "page, id required" }, 400);
50
+ const updated = await patchComment(b.page, b.id, {
51
+ status: b.status,
52
+ hold: b.hold,
53
+ resolution: b.resolution,
54
+ reply: b.reply,
55
+ edit: b.edit,
56
+ });
57
+ if (!updated) return json({ error: "not found" }, 404);
58
+ return json(updated);
59
+ };
60
+
61
+ export const DELETE: APIRoute = async ({ request }) => {
62
+ const denied = denyIfReadOnly();
63
+ if (denied) return denied;
64
+ const b = await request.json().catch(() => null);
65
+ if (!b || !b.page || !b.id) return json({ error: "page, id required" }, 400);
66
+ const ok = await removeComment(b.page, b.id);
67
+ return json({ deleted: ok }, ok ? 200 : 404);
68
+ };
@@ -0,0 +1,146 @@
1
+ ---
2
+ import DocLayout from "../layouts/DocLayout.astro";
3
+ import { roots } from "../config.mjs";
4
+ ---
5
+
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
+
10
+ <div class="caf-filters">
11
+ <input id="caf-search" type="search" placeholder="Rechercher (texte, page, citation)…" />
12
+ <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>
17
+ </select>
18
+ <select id="caf-space">
19
+ <option value="all">Tous espaces</option>
20
+ {roots.map((r) => <option value={r.key}>{r.label}</option>)}
21
+ </select>
22
+ <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
+ </select>
27
+ <span id="caf-count" class="cmt-count"></span>
28
+ </div>
29
+
30
+ <div id="caf-list" class="caf-list"><p class="cmt-empty">Chargement…</p></div>
31
+ </DocLayout>
32
+
33
+ <script>
34
+ import { ICONS } from "../lib/icons";
35
+
36
+ interface Anchor { quote: string; prefix: string; suffix: string; section: string | null }
37
+ interface Reply { author: string; body: string; ts: string }
38
+ interface Comment {
39
+ id: string; space: string; page: string; scope: string;
40
+ anchor: Anchor | null; thread: Reply[]; status: string; hold: boolean;
41
+ resolution: { note: string; journalEntryId?: string } | null; createdAt: string; updatedAt: string;
42
+ }
43
+
44
+ const ROOTS: { key: string; label: string; path: string }[] = JSON.parse(
45
+ document.getElementById("notabene-roots")?.textContent || "[]",
46
+ );
47
+ const spaceLabel = (key: string): string => ROOTS.find((r) => r.key === key)?.label ?? key;
48
+
49
+ const listEl = document.getElementById("caf-list")!;
50
+ const countEl = document.getElementById("caf-count")!;
51
+ const searchEl = document.getElementById("caf-search") as HTMLInputElement;
52
+ const statusEl = document.getElementById("caf-status") as HTMLSelectElement;
53
+ const spaceEl = document.getElementById("caf-space") as HTMLSelectElement;
54
+ const holdEl = document.getElementById("caf-hold") as HTMLSelectElement;
55
+
56
+ let all: Comment[] = [];
57
+ const esc = (s: string) =>
58
+ s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] || c);
59
+ const api = (method: string, body?: unknown) =>
60
+ fetch("/api/comments" + (method === "GET" ? "?all=1" : ""), {
61
+ method,
62
+ headers: { "content-type": "application/json" },
63
+ body: body ? JSON.stringify(body) : undefined,
64
+ }).then((r) => r.json());
65
+ 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.
67
+ const ROUTE_ROOTS = [...ROOTS].sort((a, b) => b.path.length - a.path.length);
68
+ function routeFor(page: string): string {
69
+ for (const r of ROUTE_ROOTS) {
70
+ if (page === r.path) return `/${r.key}`;
71
+ if (page.startsWith(`${r.path}/`)) return `/${r.key}/${page.slice(r.path.length + 1)}`;
72
+ }
73
+ return "#";
74
+ }
75
+
76
+ function render() {
77
+ const q = searchEl.value.trim().toLowerCase();
78
+ const fs = statusEl.value;
79
+ const fsp = spaceEl.value;
80
+ const fh = holdEl.value;
81
+ const rows = all
82
+ .filter((c) => {
83
+ if (fsp !== "all" && c.space !== fsp) return false;
84
+ if (fs === "active" && c.status === "resolved") return false;
85
+ if (fs === "open" && c.status !== "open") return false;
86
+ if (fs === "resolved" && c.status !== "resolved") return false;
87
+ if (fh === "active" && c.hold) return false;
88
+ if (fh === "hold" && !c.hold) return false;
89
+ if (q) {
90
+ const hay = (c.page + " " + (c.anchor?.quote || "") + " " + c.thread.map((t) => t.body).join(" ")).toLowerCase();
91
+ if (!hay.includes(q)) return false;
92
+ }
93
+ return true;
94
+ })
95
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
96
+
97
+ countEl.textContent = `${rows.length} / ${all.length}`;
98
+ listEl.innerHTML = rows.length
99
+ ? rows
100
+ .map((c) => {
101
+ const head = c.thread[0];
102
+ return `<article class="caf-row ${c.status}${c.hold ? " held" : ""}" data-id="${c.id}" data-page="${esc(c.page)}">
103
+ <div class="caf-row-top">
104
+ <span class="cmt-dot ${c.status}"></span>
105
+ <a class="caf-page" href="${routeFor(c.page)}?c=${c.id}">${esc(c.page)}</a>
106
+ <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>` : ""}
110
+ </div>
111
+ <div class="caf-body"><b>${esc(head.author)}</b> ${esc(head.body)}</div>
112
+ ${c.resolution?.note ? `<div class="cmt-resolution">✔ ${esc(c.resolution.note)}</div>` : ""}
113
+ <div class="caf-actions">
114
+ <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>
118
+ </div>
119
+ </article>`;
120
+ })
121
+ .join("")
122
+ : `<p class="cmt-empty">Aucun commentaire pour ces filtres.</p>`;
123
+ }
124
+
125
+ async function load() {
126
+ all = await api("GET");
127
+ render();
128
+ }
129
+
130
+ for (const el of [searchEl, statusEl, spaceEl, holdEl]) el.addEventListener("input", render);
131
+
132
+ listEl.addEventListener("click", async (e) => {
133
+ const btn = (e.target as HTMLElement).closest("button[data-act]") as HTMLElement | null;
134
+ if (!btn) return;
135
+ const row = btn.closest(".caf-row") as HTMLElement;
136
+ const id = row.dataset.id!;
137
+ const page = row.dataset.page!;
138
+ const act = btn.dataset.act!;
139
+ if (act === "resolve" || act === "reopen") await api("PATCH", { page, id, status: act === "resolve" ? "resolved" : "open" });
140
+ else if (act === "hold" || act === "unhold") await api("PATCH", { page, id, hold: act === "hold" });
141
+ else if (act === "delete") await api("DELETE", { page, id });
142
+ await load();
143
+ });
144
+
145
+ load();
146
+ </script>
@@ -0,0 +1,23 @@
1
+ ---
2
+ import DocLayout from "../layouts/DocLayout.astro";
3
+ import { roots, siteName } from "../config.mjs";
4
+ ---
5
+
6
+ <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>
11
+
12
+ <div class="home-cards">
13
+ {
14
+ roots.map((root) => (
15
+ <a class="home-card" href={`/${root.key}`}>
16
+ <h2>{root.label}</h2>
17
+ <p><code>{root.subLabel}</code></p>
18
+ {root.description && <p>{root.description}</p>}
19
+ </a>
20
+ ))
21
+ }
22
+ </div>
23
+ </DocLayout>
@@ -0,0 +1,50 @@
1
+ ---
2
+ export const prerender = false;
3
+ import DocLayout from "../layouts/DocLayout.astro";
4
+ import { readJournal } from "../lib/journal";
5
+ import { routeForPage as routeFor } from "../config.mjs";
6
+
7
+ const entries = (await readJournal()).slice().reverse(); // plus récent en premier
8
+ ---
9
+
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>
16
+
17
+ {
18
+ 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>
22
+ ) : (
23
+ <div class="journal">
24
+ {entries.map((e) => (
25
+ <section class="journal-entry">
26
+ <header class="journal-head">
27
+ <span class="journal-date">{e.date}</span>
28
+ <h2>{e.title}</h2>
29
+ </header>
30
+ <p class="journal-summary">{e.summary}</p>
31
+ <ul class="journal-changes">
32
+ {e.changes.map((c) => (
33
+ <li>
34
+ <a href={routeFor(c.page)} class="journal-page">
35
+ {c.page}
36
+ </a>
37
+ <span class="journal-what"> — {c.what}</span>
38
+ <div class="journal-why">{c.why}</div>
39
+ {c.commentIds.length > 0 && (
40
+ <div class="journal-cids">commentaires : {c.commentIds.join(", ")}</div>
41
+ )}
42
+ </li>
43
+ ))}
44
+ </ul>
45
+ </section>
46
+ ))}
47
+ </div>
48
+ )
49
+ }
50
+ </DocLayout>
@@ -0,0 +1,49 @@
1
+ import type { APIRoute } from "astro";
2
+ import { getCollection } from "astro:content";
3
+ import { roots } from "../config.mjs";
4
+
5
+ /** Markdown → texte brut approximatif (pour l'index de recherche). */
6
+ function strip(md: string): string {
7
+ return md
8
+ .replace(/```[\s\S]*?```/g, " ")
9
+ .replace(/`[^`]*`/g, " ")
10
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
11
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
12
+ .replace(/^[#>|]+/gm, " ")
13
+ .replace(/[*_~]/g, " ")
14
+ .replace(/\s+/g, " ")
15
+ .trim();
16
+ }
17
+
18
+ export const GET: APIRoute = async () => {
19
+ const out: {
20
+ space: string;
21
+ href: string;
22
+ title: string;
23
+ headings: string[];
24
+ text: string;
25
+ }[] = [];
26
+
27
+ for (const root of roots) {
28
+ const space = root.key;
29
+ const entries = await getCollection(space as never);
30
+ for (const entry of entries) {
31
+ const body = entry.body ?? "";
32
+ const titleMatch = body.match(/^#\s+(.+?)\s*$/m);
33
+ const headings = [...body.matchAll(/^#{2,4}\s+(.+?)\s*$/gm)].map((m) =>
34
+ m[1].replace(/[*_`]/g, "").trim(),
35
+ );
36
+ out.push({
37
+ space,
38
+ href: `/${space}/${entry.id}`,
39
+ title: titleMatch ? titleMatch[1].replace(/[*_`]/g, "").trim() : entry.id,
40
+ headings,
41
+ text: strip(body).slice(0, 1500),
42
+ });
43
+ }
44
+ }
45
+
46
+ return new Response(JSON.stringify(out), {
47
+ headers: { "content-type": "application/json; charset=utf-8" },
48
+ });
49
+ };
@@ -0,0 +1,63 @@
1
+ import path from "node:path";
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.
6
+ //
7
+ // <root.path>/** → /<root.key>/<slug>
8
+ //
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.
13
+
14
+ function slug(rel) {
15
+ return rel.replace(/\\/g, "/").replace(/\.mdx?$/i, "");
16
+ }
17
+
18
+ function visit(node, fn) {
19
+ fn(node);
20
+ if (Array.isArray(node.children)) {
21
+ for (const child of node.children) visit(child, fn);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {{ key: string, abs: string }[]} roots — espaces normalisés (config.mjs).
27
+ */
28
+ 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).
31
+ const ordered = [...roots].sort((a, b) => b.abs.length - a.abs.length);
32
+
33
+ function toRoute(abs) {
34
+ for (const r of ordered) {
35
+ if (abs === r.abs || abs.startsWith(r.abs + path.sep)) {
36
+ return `/${r.key}/${slug(path.relative(r.abs, abs))}`;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ return (tree, file) => {
43
+ const from = file?.path ?? file?.history?.[0];
44
+ if (!from) return;
45
+ const fromDir = path.dirname(from);
46
+
47
+ visit(tree, (node) => {
48
+ if (node.type !== "link" && node.type !== "definition") return;
49
+ const url = node.url;
50
+ if (typeof url !== "string") return;
51
+ if (/^[a-z]+:/i.test(url) || url.startsWith("/") || url.startsWith("#")) return;
52
+
53
+ const hashIdx = url.indexOf("#");
54
+ const target = hashIdx === -1 ? url : url.slice(0, hashIdx);
55
+ const hash = hashIdx === -1 ? "" : url.slice(hashIdx);
56
+ if (!/\.mdx?$/i.test(target)) return;
57
+
58
+ const abs = path.resolve(fromDir, target);
59
+ const route = toRoute(abs);
60
+ if (route) node.url = route + hash;
61
+ });
62
+ };
63
+ }