@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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/astro.config.mjs +37 -0
- package/bin/notabene.mjs +116 -0
- package/package.json +33 -0
- package/src/components/Comments.astro +458 -0
- package/src/components/NavTree.astro +44 -0
- package/src/components/Sidebar.astro +57 -0
- package/src/components/SpaceIndex.astro +45 -0
- package/src/components/Toc.astro +29 -0
- package/src/config.mjs +127 -0
- package/src/content.config.ts +16 -0
- package/src/layouts/DocLayout.astro +153 -0
- package/src/lib/comment-types.ts +53 -0
- package/src/lib/comments.ts +141 -0
- package/src/lib/icons.ts +17 -0
- package/src/lib/journal.ts +40 -0
- package/src/lib/nav.ts +132 -0
- package/src/pages/[space]/[...slug].astro +71 -0
- package/src/pages/api/comments.ts +68 -0
- package/src/pages/comments.astro +146 -0
- package/src/pages/index.astro +23 -0
- package/src/pages/journal.astro +50 -0
- package/src/pages/search-index.json.ts +49 -0
- package/src/remark/rewrite-links.mjs +63 -0
- package/src/styles/global.css +1048 -0
- package/templates/notabene.config.mjs +36 -0
- package/tsconfig.json +5 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Config resolver — the SINGLE place that knows the file layout. Imported by
|
|
2
|
+
// astro.config.mjs, content.config.ts, the remark plugin and the runtime libs
|
|
3
|
+
// (server-only: uses node:path/url; NEVER from a client script — the client gets
|
|
4
|
+
// `clientRoots` serialized as JSON).
|
|
5
|
+
//
|
|
6
|
+
// Run-from-package model: the renderer lives in the consumer's node_modules and is
|
|
7
|
+
// pointed at the consumer's repo at runtime. Only the DATA (docs, notabene.config,
|
|
8
|
+
// .notabene store) lives in the consumer repo.
|
|
9
|
+
// NOTABENE_ROOT — consumer repo root (defaults to process.cwd()).
|
|
10
|
+
// NOTABENE_CONFIG — path to notabene.config.mjs (defaults to <root>/notabene.config.mjs).
|
|
11
|
+
// The CLI (bin/notabene.mjs) sets these before invoking astro.
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
|
|
15
|
+
export const REPO_ROOT = process.env.NOTABENE_ROOT
|
|
16
|
+
? path.resolve(process.env.NOTABENE_ROOT)
|
|
17
|
+
: process.cwd();
|
|
18
|
+
|
|
19
|
+
const CONFIG_PATH = process.env.NOTABENE_CONFIG
|
|
20
|
+
? path.resolve(process.env.NOTABENE_CONFIG)
|
|
21
|
+
: path.resolve(REPO_ROOT, "notabene.config.mjs");
|
|
22
|
+
|
|
23
|
+
// Top-level await: load the consumer's config by absolute path. Astro loads this
|
|
24
|
+
// module (via astro.config.mjs / content.config.ts) as ESM, which supports TLA.
|
|
25
|
+
let userConfig;
|
|
26
|
+
try {
|
|
27
|
+
userConfig = (await import(/* @vite-ignore */ pathToFileURL(CONFIG_PATH).href)).default ?? {};
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`notabene: could not load config at ${CONFIG_PATH}. Run \`notabene init\` first, ` +
|
|
31
|
+
`or set NOTABENE_CONFIG. (${err instanceof Error ? err.message : err})`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function slugify(s) {
|
|
36
|
+
return String(s)
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
39
|
+
.replace(/^-+|-+$/g, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Input format (§10.bis) — the renderer supports MDX AND CommonMark/GFM:
|
|
43
|
+
// "mdx" (default): globs .md + .mdx, MDX integration loaded. .mdx parsed
|
|
44
|
+
// STRICT (JSX/expressions), .md as CommonMark/GFM LENIENT. A repo can
|
|
45
|
+
// mix both, by extension.
|
|
46
|
+
// "commonmark" (alias "gfm"/"md"): globs .md + .markdown, MDX NOT loaded. Everything
|
|
47
|
+
// CommonMark/GFM lenient — zero MDX dependency/strictness.
|
|
48
|
+
const FORMAT = String(userConfig.format ?? "mdx").toLowerCase();
|
|
49
|
+
export const format = FORMAT === "gfm" || FORMAT === "md" ? "commonmark" : FORMAT;
|
|
50
|
+
export const mdxEnabled = format === "mdx";
|
|
51
|
+
export const extensions = mdxEnabled ? ["md", "mdx"] : ["md", "markdown"];
|
|
52
|
+
const extGlob = extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`;
|
|
53
|
+
|
|
54
|
+
function normalizeRoot(root) {
|
|
55
|
+
const rel = String(root.path).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
56
|
+
const abs = path.resolve(REPO_ROOT, rel);
|
|
57
|
+
return {
|
|
58
|
+
key: root.key ?? slugify(root.label ?? rel),
|
|
59
|
+
label: root.label ?? rel,
|
|
60
|
+
description: root.description ?? "",
|
|
61
|
+
// Logical repo-relative path (= prefix of a comment's `page` field).
|
|
62
|
+
path: rel,
|
|
63
|
+
// Sidebar sub-title (e.g. "docs/").
|
|
64
|
+
subLabel: `${rel}/`,
|
|
65
|
+
exclude: Array.isArray(root.exclude) ? root.exclude : [],
|
|
66
|
+
abs,
|
|
67
|
+
baseUrl: pathToFileURL(abs),
|
|
68
|
+
// Content-loader glob: format extensions minus the exclusions.
|
|
69
|
+
pattern: [`**/*.${extGlob}`, ...(root.exclude ?? []).map((e) => `!${e}`)],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const siteName = userConfig.siteName ?? "Docs";
|
|
74
|
+
export const tagline = userConfig.tagline ?? "docs";
|
|
75
|
+
export const locale = userConfig.locale ?? "en";
|
|
76
|
+
export const port = userConfig.port ?? 3009;
|
|
77
|
+
export const host = process.env.NOTABENE_HOST ? true : (userConfig.host ?? false);
|
|
78
|
+
export const verify = Array.isArray(userConfig.verify) ? userConfig.verify : [];
|
|
79
|
+
|
|
80
|
+
export const roots = (userConfig.roots ?? [{ label: "Docs", path: "docs" }]).map(
|
|
81
|
+
normalizeRoot,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Store (comments + journal), resolved to absolute. Default: docs/.notabene.
|
|
85
|
+
export const storeRel = (userConfig.store ?? "docs/.notabene")
|
|
86
|
+
.replace(/\\/g, "/")
|
|
87
|
+
.replace(/\/+$/, "");
|
|
88
|
+
export const storeAbs = path.resolve(REPO_ROOT, storeRel);
|
|
89
|
+
|
|
90
|
+
// Serializable roots for client scripts (no absolute paths / node:* leak).
|
|
91
|
+
export const clientRoots = roots.map((r) => ({
|
|
92
|
+
key: r.key,
|
|
93
|
+
label: r.label,
|
|
94
|
+
path: r.path,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Logical `page` path (e.g. "docs/plans/services/x") → site route
|
|
99
|
+
* (e.g. "/workbench/services/x"). Most specific root (longest path) first — a
|
|
100
|
+
* nested space (docs/plans) must win over its parent (docs).
|
|
101
|
+
*/
|
|
102
|
+
export function routeForPage(page) {
|
|
103
|
+
const ordered = [...roots].sort((a, b) => b.path.length - a.path.length);
|
|
104
|
+
for (const r of ordered) {
|
|
105
|
+
if (page === r.path) return `/${r.key}`;
|
|
106
|
+
if (page.startsWith(`${r.path}/`)) return `/${r.key}/${page.slice(r.path.length + 1)}`;
|
|
107
|
+
}
|
|
108
|
+
return "#";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default {
|
|
112
|
+
REPO_ROOT,
|
|
113
|
+
siteName,
|
|
114
|
+
tagline,
|
|
115
|
+
locale,
|
|
116
|
+
format,
|
|
117
|
+
mdxEnabled,
|
|
118
|
+
extensions,
|
|
119
|
+
port,
|
|
120
|
+
host,
|
|
121
|
+
verify,
|
|
122
|
+
roots,
|
|
123
|
+
storeRel,
|
|
124
|
+
storeAbs,
|
|
125
|
+
clientRoots,
|
|
126
|
+
routeForPage,
|
|
127
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineCollection } from "astro:content";
|
|
2
|
+
import { glob } from "astro/loaders";
|
|
3
|
+
import { roots } from "./config.mjs";
|
|
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`.
|
|
9
|
+
const collections: Record<string, ReturnType<typeof defineCollection>> = {};
|
|
10
|
+
for (const root of roots) {
|
|
11
|
+
collections[root.key] = defineCollection({
|
|
12
|
+
loader: glob({ pattern: root.pattern, base: root.baseUrl }),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { collections };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/global.css";
|
|
3
|
+
import Sidebar from "../components/Sidebar.astro";
|
|
4
|
+
import Toc from "../components/Toc.astro";
|
|
5
|
+
import Comments from "../components/Comments.astro";
|
|
6
|
+
import type { Space } from "../lib/nav";
|
|
7
|
+
import { clientRoots, locale, siteName, tagline } from "../config.mjs";
|
|
8
|
+
|
|
9
|
+
interface Heading {
|
|
10
|
+
depth: number;
|
|
11
|
+
slug: string;
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Crumb {
|
|
16
|
+
label: string;
|
|
17
|
+
href?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
title: string;
|
|
22
|
+
current?: string;
|
|
23
|
+
space?: Space;
|
|
24
|
+
/** Chemin logique repo-relatif (sans extension) — support Phase 2 commentaires. */
|
|
25
|
+
page?: string;
|
|
26
|
+
headings?: Heading[];
|
|
27
|
+
breadcrumb?: Crumb[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { title, current = "", space, page, headings = [], breadcrumb = [] } = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<!doctype html>
|
|
34
|
+
<html lang={locale}>
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8" />
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
38
|
+
<title>{title} · {siteName}</title>
|
|
39
|
+
<script type="application/json" id="notabene-roots" set:html={JSON.stringify(clientRoots)} />
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<header class="topbar">
|
|
43
|
+
<a href="/" class="brand">{siteName} <span>· {tagline}</span></a>
|
|
44
|
+
<div class="search">
|
|
45
|
+
<input id="search-input" type="search" placeholder="Search all docs…" autocomplete="off" />
|
|
46
|
+
<div id="search-results" class="search-results" hidden></div>
|
|
47
|
+
</div>
|
|
48
|
+
<a href="/comments" class="topbar-link">Commentaires</a>
|
|
49
|
+
<a href="/journal" class="topbar-link">Journal</a>
|
|
50
|
+
</header>
|
|
51
|
+
|
|
52
|
+
<div class="shell">
|
|
53
|
+
<aside class="sidebar">
|
|
54
|
+
<Sidebar current={current} activeSpace={space} />
|
|
55
|
+
</aside>
|
|
56
|
+
|
|
57
|
+
<main class="content">
|
|
58
|
+
{
|
|
59
|
+
breadcrumb.length > 0 && (
|
|
60
|
+
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
61
|
+
{breadcrumb.map((b, i) => (
|
|
62
|
+
<>
|
|
63
|
+
{i > 0 && <span class="sep">/</span>}
|
|
64
|
+
{b.href ? <a href={b.href}>{b.label}</a> : <span>{b.label}</span>}
|
|
65
|
+
</>
|
|
66
|
+
))}
|
|
67
|
+
</nav>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
<article id="doc-content" class="prose" data-space={space} data-page={page}>
|
|
71
|
+
<slot />
|
|
72
|
+
</article>
|
|
73
|
+
<Comments page={page} space={space} />
|
|
74
|
+
</main>
|
|
75
|
+
|
|
76
|
+
<aside class="rail">
|
|
77
|
+
<Toc headings={headings} />
|
|
78
|
+
<div id="cmt-rail" class="cmt-rail"></div>
|
|
79
|
+
</aside>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<script>
|
|
83
|
+
// Recherche client-side : index JSON (titre + headings + extrait), scoré.
|
|
84
|
+
const input = document.getElementById("search-input") as HTMLInputElement | null;
|
|
85
|
+
const box = document.getElementById("search-results");
|
|
86
|
+
|
|
87
|
+
interface Doc {
|
|
88
|
+
space: string;
|
|
89
|
+
href: string;
|
|
90
|
+
title: string;
|
|
91
|
+
headings: string[];
|
|
92
|
+
text: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ROOTS: { key: string; label: string; path: string }[] = JSON.parse(
|
|
96
|
+
document.getElementById("notabene-roots")?.textContent || "[]",
|
|
97
|
+
);
|
|
98
|
+
const spaceLabel = (key: string): string =>
|
|
99
|
+
ROOTS.find((r) => r.key === key)?.label ?? key;
|
|
100
|
+
|
|
101
|
+
let index: Doc[] | null = null;
|
|
102
|
+
async function ensureIndex(): Promise<Doc[]> {
|
|
103
|
+
if (!index) index = await fetch("/search-index.json").then((r) => r.json());
|
|
104
|
+
return index ?? [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function score(doc: Doc, q: string): number {
|
|
108
|
+
const t = doc.title.toLowerCase();
|
|
109
|
+
if (t === q) return 100;
|
|
110
|
+
if (t.startsWith(q)) return 80;
|
|
111
|
+
if (t.includes(q)) return 60;
|
|
112
|
+
if (doc.headings.some((h) => h.toLowerCase().includes(q))) return 30;
|
|
113
|
+
if (doc.text.toLowerCase().includes(q)) return 10;
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function esc(s: string): string {
|
|
118
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] ?? c);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (input && box) {
|
|
122
|
+
input.addEventListener("input", async () => {
|
|
123
|
+
const q = input.value.trim().toLowerCase();
|
|
124
|
+
if (!q) {
|
|
125
|
+
box.hidden = true;
|
|
126
|
+
box.innerHTML = "";
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const data = await ensureIndex();
|
|
130
|
+
const hits = data
|
|
131
|
+
.map((doc) => ({ doc, s: score(doc, q) }))
|
|
132
|
+
.filter((x) => x.s > 0)
|
|
133
|
+
.sort((a, b) => b.s - a.s)
|
|
134
|
+
.slice(0, 12);
|
|
135
|
+
box.innerHTML = hits.length
|
|
136
|
+
? hits
|
|
137
|
+
.map(
|
|
138
|
+
({ doc }) =>
|
|
139
|
+
`<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
|
+
)
|
|
141
|
+
.join("")
|
|
142
|
+
: `<div class="r-empty">No results</div>`;
|
|
143
|
+
box.hidden = false;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
document.addEventListener("click", (e) => {
|
|
147
|
+
const target = e.target as Node;
|
|
148
|
+
if (!box.contains(target) && target !== input) box.hidden = true;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Types partagés client ⇄ serveur pour les commentaires (pas d'I/O ici).
|
|
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.
|
|
8
|
+
|
|
9
|
+
/** Version du schéma du store (sidecar `<store>/meta.json`). */
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export interface CommentAnchor {
|
|
13
|
+
/** Texte exact sélectionné (W3C TextQuoteSelector). */
|
|
14
|
+
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). */
|
|
17
|
+
prefix: string;
|
|
18
|
+
suffix: string;
|
|
19
|
+
/** Heading le plus proche au-dessus de la sélection (repère lisible). */
|
|
20
|
+
section: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommentReply {
|
|
24
|
+
author: string;
|
|
25
|
+
body: string;
|
|
26
|
+
ts: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CommentStatus = "open" | "addressed" | "resolved";
|
|
30
|
+
/** Clé d'un espace (root de notabene.config) — chaîne libre, plus le duo figé. */
|
|
31
|
+
export type CommentSpace = string;
|
|
32
|
+
export type CommentScope = "selection" | "page";
|
|
33
|
+
|
|
34
|
+
export interface Comment {
|
|
35
|
+
id: string;
|
|
36
|
+
/** Espace = `key` du root de notabene.config (ex. reference, workbench). */
|
|
37
|
+
space: CommentSpace;
|
|
38
|
+
/** Chemin logique repo-relatif de la page (= data-page), ex. docs/architecture/billing. */
|
|
39
|
+
page: string;
|
|
40
|
+
scope: CommentScope;
|
|
41
|
+
/** null pour un commentaire global de page. */
|
|
42
|
+
anchor: CommentAnchor | null;
|
|
43
|
+
thread: CommentReply[];
|
|
44
|
+
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). */
|
|
47
|
+
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). */
|
|
50
|
+
resolution: { note: string; journalEntryId?: string } | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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.
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { storeAbs } from "../config.mjs";
|
|
8
|
+
import type { Comment, CommentAnchor, CommentScope, CommentSpace, CommentStatus } from "./comment-types";
|
|
9
|
+
|
|
10
|
+
const STORE_ROOT = storeAbs;
|
|
11
|
+
// Fichiers réservés du store (jamais des pages de commentaires).
|
|
12
|
+
const RESERVED = new Set(["journal.json", "meta.json"]);
|
|
13
|
+
|
|
14
|
+
function fileFor(page: string): string {
|
|
15
|
+
// Anti-traversal : pas de `..`, pas de slash initial.
|
|
16
|
+
const safe = page.replace(/\\/g, "/").replace(/\.\.+/g, "").replace(/^\/+/, "");
|
|
17
|
+
return path.join(STORE_ROOT, `${safe}.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function read(page: string): Promise<Comment[]> {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await fs.readFile(fileFor(page), "utf8")) as Comment[];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function write(page: string, comments: Comment[]): Promise<void> {
|
|
29
|
+
const f = fileFor(page);
|
|
30
|
+
await fs.mkdir(path.dirname(f), { recursive: true });
|
|
31
|
+
if (comments.length === 0) {
|
|
32
|
+
await fs.rm(f, { force: true });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await fs.writeFile(f, `${JSON.stringify(comments, null, 2)}\n`, "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function newId(): string {
|
|
39
|
+
return `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listComments(page: string): Promise<Comment[]> {
|
|
43
|
+
return read(page);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tous les commentaires, toutes pages (pour la page globale /comments). */
|
|
47
|
+
export async function listAllComments(): Promise<Comment[]> {
|
|
48
|
+
const out: Comment[] = [];
|
|
49
|
+
async function walk(dir: string) {
|
|
50
|
+
let entries: import("node:fs").Dirent[];
|
|
51
|
+
try {
|
|
52
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
53
|
+
} catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const full = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory()) await walk(full);
|
|
59
|
+
// Fichiers réservés (journal/meta) au niveau racine du store → ignorés.
|
|
60
|
+
else if (e.name.endsWith(".json") && !(dir === STORE_ROOT && RESERVED.has(e.name))) {
|
|
61
|
+
try {
|
|
62
|
+
out.push(...(JSON.parse(await fs.readFile(full, "utf8")) as Comment[]));
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore fichier illisible */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await walk(STORE_ROOT);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createComment(input: {
|
|
74
|
+
space: CommentSpace;
|
|
75
|
+
page: string;
|
|
76
|
+
scope: CommentScope;
|
|
77
|
+
anchor: CommentAnchor | null;
|
|
78
|
+
author?: string;
|
|
79
|
+
body: string;
|
|
80
|
+
}): Promise<Comment> {
|
|
81
|
+
const comments = await read(input.page);
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
const comment: Comment = {
|
|
84
|
+
id: newId(),
|
|
85
|
+
space: input.space,
|
|
86
|
+
page: input.page,
|
|
87
|
+
scope: input.scope,
|
|
88
|
+
anchor: input.scope === "selection" ? input.anchor : null,
|
|
89
|
+
thread: [{ author: input.author || "quentin", body: input.body, ts: now }],
|
|
90
|
+
status: "open",
|
|
91
|
+
hold: false,
|
|
92
|
+
resolution: null,
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
};
|
|
96
|
+
comments.push(comment);
|
|
97
|
+
await write(input.page, comments);
|
|
98
|
+
return comment;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function patchComment(
|
|
102
|
+
page: string,
|
|
103
|
+
id: string,
|
|
104
|
+
patch: {
|
|
105
|
+
status?: CommentStatus;
|
|
106
|
+
hold?: boolean;
|
|
107
|
+
resolution?: { note: string; journalEntryId?: string } | null;
|
|
108
|
+
reply?: { author?: string; body: string };
|
|
109
|
+
edit?: { body: string };
|
|
110
|
+
},
|
|
111
|
+
): Promise<Comment | null> {
|
|
112
|
+
const comments = await read(page);
|
|
113
|
+
const c = comments.find((x) => x.id === id);
|
|
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.
|
|
117
|
+
if (patch.edit?.body && c.status === "open" && c.thread.length === 1) {
|
|
118
|
+
c.thread[0].body = patch.edit.body;
|
|
119
|
+
}
|
|
120
|
+
if (patch.status) c.status = patch.status;
|
|
121
|
+
if (patch.hold !== undefined) c.hold = patch.hold;
|
|
122
|
+
if (patch.resolution !== undefined) c.resolution = patch.resolution;
|
|
123
|
+
if (patch.reply?.body) {
|
|
124
|
+
c.thread.push({
|
|
125
|
+
author: patch.reply.author || "quentin",
|
|
126
|
+
body: patch.reply.body,
|
|
127
|
+
ts: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
c.updatedAt = new Date().toISOString();
|
|
131
|
+
await write(page, comments);
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function removeComment(page: string, id: string): Promise<boolean> {
|
|
136
|
+
const comments = await read(page);
|
|
137
|
+
const next = comments.filter((x) => x.id !== id);
|
|
138
|
+
if (next.length === comments.length) return false;
|
|
139
|
+
await write(page, next);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
package/src/lib/icons.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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.
|
|
4
|
+
const svg = (inner: string) =>
|
|
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
|
+
|
|
7
|
+
export const ICONS = {
|
|
8
|
+
check: svg('<path d="M20 6 9 17l-5-5"/>'),
|
|
9
|
+
reopen: svg('<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>'),
|
|
10
|
+
pause: svg('<rect x="14" y="3" width="4" height="18" rx="1"/><rect x="6" y="3" width="4" height="18" rx="1"/>'),
|
|
11
|
+
play: svg('<polygon points="6 3 20 12 6 21 6 3"/>'),
|
|
12
|
+
reply: svg('<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>'),
|
|
13
|
+
edit: svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),
|
|
14
|
+
trash: svg(
|
|
15
|
+
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
|
16
|
+
),
|
|
17
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
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.
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { storeAbs } from "../config.mjs";
|
|
8
|
+
|
|
9
|
+
export interface JournalChange {
|
|
10
|
+
/** Chemin logique de la page modifiée (= data-page), ex. docs/architecture/billing. */
|
|
11
|
+
page: string;
|
|
12
|
+
/** Commentaires traités par cette modification (ids). */
|
|
13
|
+
commentIds: string[];
|
|
14
|
+
/** Ce qui a changé. */
|
|
15
|
+
what: string;
|
|
16
|
+
/** Pourquoi (issu du commentaire). */
|
|
17
|
+
why: string;
|
|
18
|
+
/** Référence optionnelle (sha de commit, etc.). */
|
|
19
|
+
ref?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JournalEntry {
|
|
23
|
+
id: string;
|
|
24
|
+
/** ISO date (YYYY-MM-DD). */
|
|
25
|
+
date: string;
|
|
26
|
+
title: string;
|
|
27
|
+
/** Résumé prose de la passe. */
|
|
28
|
+
summary: string;
|
|
29
|
+
changes: JournalChange[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const JOURNAL_FILE = path.join(storeAbs, "journal.json");
|
|
33
|
+
|
|
34
|
+
export async function readJournal(): Promise<JournalEntry[]> {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await fs.readFile(JOURNAL_FILE, "utf8")) as JournalEntry[];
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/lib/nav.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import { locale } from "../config.mjs";
|
|
3
|
+
|
|
4
|
+
// Un espace = la `key` d'un root de notabene.config (plus le duo figé
|
|
5
|
+
// reference/workbench). Chaîne libre.
|
|
6
|
+
export type Space = string;
|
|
7
|
+
|
|
8
|
+
export interface NavLeaf {
|
|
9
|
+
type: "leaf";
|
|
10
|
+
title: string;
|
|
11
|
+
href: string;
|
|
12
|
+
segment: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NavGroup {
|
|
16
|
+
type: "group";
|
|
17
|
+
label: string;
|
|
18
|
+
segment: string;
|
|
19
|
+
prefix: string;
|
|
20
|
+
children: NavNode[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type NavNode = NavLeaf | NavGroup;
|
|
24
|
+
|
|
25
|
+
// Mots à rendre en capitales (acronymes), et petits mots gardés en minuscule.
|
|
26
|
+
const ACRONYMS = new Set([
|
|
27
|
+
"api", "iam", "dns", "ip", "ips", "url", "ssl", "tls", "ocr", "qr", "mac", "http",
|
|
28
|
+
"https", "sse", "ws", "id", "ttl", "ssrf", "mx", "smtp", "dnsbl", "dbl", "dnswl",
|
|
29
|
+
"fcrdns", "csv", "pdf", "svg", "png", "json", "mdx", "wasm", "cli", "vnc", "ha",
|
|
30
|
+
"db", "s3", "ui", "ux", "seo", "jwt", "mfa", "totp", "rgpd", "gdpr", "ai", "llm",
|
|
31
|
+
"waf", "ecs", "doh", "do53", "svcb",
|
|
32
|
+
]);
|
|
33
|
+
const SMALL_WORDS = new Set([
|
|
34
|
+
"and", "or", "of", "the", "a", "an", "de", "du", "des", "la", "le", "les", "et",
|
|
35
|
+
"à", "pour", "par", "sur", "in",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export function humanize(seg: string): string {
|
|
39
|
+
const words = seg
|
|
40
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // sépare le camelCase (ex. ipAddress)
|
|
41
|
+
.split(/[-_\s]+/)
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
return words
|
|
44
|
+
.map((w, i) => {
|
|
45
|
+
const lw = w.toLowerCase();
|
|
46
|
+
if (ACRONYMS.has(lw)) return lw.toUpperCase();
|
|
47
|
+
if (i > 0 && SMALL_WORDS.has(lw)) return lw;
|
|
48
|
+
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
49
|
+
})
|
|
50
|
+
.join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
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.
|
|
56
|
+
*/
|
|
57
|
+
export function navLabel(id: string): string {
|
|
58
|
+
const seg = id.split("/").pop() ?? id;
|
|
59
|
+
if (/^(readme|index)$/i.test(seg)) return "Overview";
|
|
60
|
+
return humanize(seg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Titre de page = premier H1 (descriptif), sinon le libellé de nav. */
|
|
64
|
+
export function pageTitle(body: string | undefined, id: string): string {
|
|
65
|
+
const m = body?.match(/^#\s+(.+?)\s*$/m);
|
|
66
|
+
if (m) return m[1].replace(/[*_`]/g, "").trim();
|
|
67
|
+
return navLabel(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function entriesOf(space: Space) {
|
|
71
|
+
// Nom de collection = key de l'espace (cf. content.config.ts). Dynamique.
|
|
72
|
+
return getCollection(space as never);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Construit l'arbre de navigation d'un espace à partir des ids slash-séparés. */
|
|
76
|
+
export async function buildNav(space: Space): Promise<NavNode[]> {
|
|
77
|
+
const entries = await entriesOf(space);
|
|
78
|
+
const rootChildren: NavNode[] = [];
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
// README/index RACINE = page d'accueil de l'espace, pas une feuille de nav.
|
|
82
|
+
if (/^(readme|index)$/i.test(entry.id)) continue;
|
|
83
|
+
const parts = entry.id.split("/");
|
|
84
|
+
let children = rootChildren;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
87
|
+
const seg = parts[i];
|
|
88
|
+
let group = children.find(
|
|
89
|
+
(c): c is NavGroup => c.type === "group" && c.segment === seg,
|
|
90
|
+
);
|
|
91
|
+
if (!group) {
|
|
92
|
+
group = {
|
|
93
|
+
type: "group",
|
|
94
|
+
label: humanize(seg),
|
|
95
|
+
segment: seg,
|
|
96
|
+
prefix: `/${space}/${parts.slice(0, i + 1).join("/")}`,
|
|
97
|
+
children: [],
|
|
98
|
+
};
|
|
99
|
+
children.push(group);
|
|
100
|
+
}
|
|
101
|
+
children = group.children;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
children.push({
|
|
105
|
+
type: "leaf",
|
|
106
|
+
title: navLabel(entry.id),
|
|
107
|
+
href: `/${space}/${entry.id}`,
|
|
108
|
+
segment: parts[parts.length - 1],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
sortNodes(rootChildren);
|
|
113
|
+
return rootChildren;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rank(node: NavNode): number {
|
|
117
|
+
// README / index en tête, puis les groupes, puis les autres pages.
|
|
118
|
+
if (node.type === "leaf" && /^(readme|index)$/i.test(node.segment)) return 0;
|
|
119
|
+
if (node.type === "group") return 1;
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sortNodes(nodes: NavNode[]): void {
|
|
124
|
+
nodes.sort((a, b) => {
|
|
125
|
+
const r = rank(a) - rank(b);
|
|
126
|
+
if (r !== 0) return r;
|
|
127
|
+
const an = a.type === "group" ? a.label : a.title;
|
|
128
|
+
const bn = b.type === "group" ? b.label : b.title;
|
|
129
|
+
return an.localeCompare(bn, locale);
|
|
130
|
+
});
|
|
131
|
+
for (const n of nodes) if (n.type === "group") sortNodes(n.children);
|
|
132
|
+
}
|