@cr8rcho/alkahest 0.1.92 → 0.1.94
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/dist/cli.js +1 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/presets.d.ts +1 -0
- package/dist/commands/presets.js +28 -2
- package/dist/commands/presets.js.map +1 -1
- package/dist/core/presetUpdate.d.ts +10 -1
- package/dist/core/presetUpdate.js +211 -12
- package/dist/core/presetUpdate.js.map +1 -1
- package/dist/core/presets.d.ts +8 -1
- package/dist/core/presets.js +2 -2
- package/dist/core/presets.js.map +1 -1
- package/dist/mcp/server.js +5 -2
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
- package/presets/as-built/CHANGES.md +10 -0
- package/presets/as-built/CLAUDE-snippet.md +3 -2
- package/presets/as-built/history/c8301f8ce329dd96 +196 -0
- package/presets/as-built/history/fa58e822a0e3f4f4 +26 -0
- package/presets/as-built/history.json +8 -0
- package/presets/as-built/preset.json +7 -1
- package/presets/as-built/sync-docs-maps.config.mjs +31 -0
- package/presets/as-built/sync-docs-maps.mjs +138 -72
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// alkahest as-built preset — docs sync ENGINE. Do not edit this file: `alkahest preset update`
|
|
3
|
+
// replaces it with the preset's current version. Everything repo-specific (project, which docs
|
|
4
|
+
// go to which map, title rules, extra files) lives in scripts/sync-docs-maps.config.mjs, which
|
|
5
|
+
// the update never touches.
|
|
6
|
+
//
|
|
7
|
+
// Mirrors docs/ into the project's note maps:
|
|
8
|
+
// docs/decisions/NNN-*.md → note map `adr` (flat)
|
|
9
|
+
// docs/{system,components,features,modules}/*.md → note map `as-built` (folder = category)
|
|
10
|
+
//
|
|
11
|
+
// Staging (the importer's rules: filename = title, frontmatter passes through):
|
|
12
|
+
// - title from the first H1 ("ADR-NNN: rest — tail" → "ADR-NNN rest"; as-built strips a
|
|
13
|
+
// "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized — or the
|
|
14
|
+
// config's `title()` when it returns one;
|
|
15
|
+
// - the H1 line is dropped from the body (the note title renders it);
|
|
16
|
+
// - relative .md links to another synced doc become [[wikilinks]] (within the same map by
|
|
17
|
+
// default; `links: "all"` also across maps);
|
|
18
|
+
// - the ORIGINAL repo path rides in as `source_path:` frontmatter, which keeps identity through
|
|
19
|
+
// a retitle (the importer matches source_path before title and renames the note in place).
|
|
20
|
+
//
|
|
21
|
+
// Re-running is safe: import is idempotent by source_path, then title.
|
|
22
|
+
// Usage: node scripts/sync-docs-maps.mjs [--stage-only | --dry-run]
|
|
23
|
+
// --stage-only write the staged files and stop (the dir is kept and printed)
|
|
24
|
+
// --dry-run stage, then ask the importer for its plan without writing anything
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const ROOT = join(HERE, "..");
|
|
33
|
+
const CONFIG_FILE = join(HERE, "sync-docs-maps.config.mjs");
|
|
34
|
+
const config = existsSync(CONFIG_FILE) ? ((await import(pathToFileURL(CONFIG_FILE).href)).default ?? {}) : {};
|
|
35
|
+
|
|
36
|
+
const DOCS = join(ROOT, config.docsDir ?? "docs");
|
|
37
|
+
const PROJECT = config.project ?? process.env.ALKAHEST_PROJECT;
|
|
38
|
+
const STAGE_ONLY = process.argv.includes("--stage-only");
|
|
39
|
+
const DRY_RUN = process.argv.includes("--dry-run");
|
|
40
|
+
const LINKS = config.links ?? "set";
|
|
41
|
+
const SETS = config.sets ?? [
|
|
42
|
+
{ map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
|
|
43
|
+
{ map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ---- helpers -----------------------------------------------------------------
|
|
47
|
+
const sanitize = (t) =>
|
|
48
|
+
t.replace(/\(\//g, "(").replace(/[/\\:]/g, "-").replace(/`/g, "").replace(/\s+/g, " ").trim();
|
|
49
|
+
// A config title is kept as written — only what can't live in a filename is replaced.
|
|
50
|
+
const fileSafe = (t) => t.replace(/[/\\]/g, "-").trim();
|
|
51
|
+
const posix = (p) => p.split(sep).join("/");
|
|
52
|
+
const firstH1 = (src) => (src.match(/^#\s+(.+)$/m) ?? [null, ""])[1].trim();
|
|
53
|
+
// Frontmatter rides through to the staged file — the importer strips it from the stored body
|
|
54
|
+
// and harvests its keys into note props (`tags:` is the reserved one). Split it off first so
|
|
55
|
+
// stripH1 still sees the H1 at the head of what's left.
|
|
56
|
+
const splitFm = (src) => {
|
|
57
|
+
const m = src.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n+/);
|
|
58
|
+
return m ? [m[0], src.slice(m[0].length)] : ["", src];
|
|
59
|
+
};
|
|
60
|
+
const stripH1 = (src) => {
|
|
61
|
+
const [fm, body] = splitFm(src);
|
|
62
|
+
return fm + body.replace(/^#\s+.+\n+/, "");
|
|
63
|
+
};
|
|
64
|
+
const withSource = (staged, rel) => {
|
|
65
|
+
const [fm, body] = splitFm(staged);
|
|
66
|
+
const line = `source_path: ${rel}\n`;
|
|
67
|
+
return fm ? fm.replace(/---\r?\n*$/, (close) => line + close) + body : `---\n${line}---\n\n${staged}`;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The preset's own title rules, per set kind. */
|
|
71
|
+
function defaultTitle(kind, file, h1) {
|
|
72
|
+
if (kind === "adr") {
|
|
73
|
+
// Convention "ADR-NNN: title — tail"; the em-dash variant "ADR-NNN — title" also parses.
|
|
74
|
+
const m = h1.match(/^ADR-(\d{3})(?::|\s+—)\s*(.+)$/);
|
|
75
|
+
const num = m ? m[1] : basename(file).slice(0, 3);
|
|
76
|
+
return sanitize(`ADR-${num} ${(m ? m[2] : h1).split(" — ")[0].trim()}`);
|
|
77
|
+
}
|
|
78
|
+
if (kind === "as-built") {
|
|
79
|
+
const t = h1.replace(/^(System|Component|Feature|Module)\s*—\s*/i, "").split(" — ")[0].trim();
|
|
80
|
+
return sanitize(t || h1);
|
|
81
|
+
}
|
|
82
|
+
return sanitize(h1 || basename(file, ".md"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- collect -----------------------------------------------------------------
|
|
86
|
+
const docs = []; // { rel (to DOCS), map, folder, src, title }
|
|
87
|
+
for (const set of SETS) {
|
|
88
|
+
for (const d of set.dirs ?? []) {
|
|
89
|
+
const dir = join(DOCS, d);
|
|
90
|
+
if (!existsSync(dir)) continue;
|
|
91
|
+
for (const f of readdirSync(dir).sort()) {
|
|
92
|
+
if (!f.endsWith(".md") || (set.match && !set.match.test(f))) continue;
|
|
93
|
+
docs.push({ rel: posix(join(d, f)), map: set.map, kind: set.kind, folder: set.folders ? d : null });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const f of config.extra?.[set.map] ?? []) {
|
|
97
|
+
if (existsSync(join(DOCS, f))) docs.push({ rel: posix(f), map: set.map, kind: set.kind, folder: null });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const doc of docs) {
|
|
101
|
+
doc.src = readFileSync(join(DOCS, doc.rel), "utf8");
|
|
102
|
+
const h1 = firstH1(doc.src);
|
|
103
|
+
const fallback = defaultTitle(doc.kind, doc.rel, h1);
|
|
104
|
+
const custom = config.title?.({ map: doc.map, kind: doc.kind, path: doc.rel, h1, defaultTitle: fallback });
|
|
105
|
+
doc.title = custom ? fileSafe(custom) : fallback;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- links → [[wikilinks]] -----------------------------------------------------
|
|
109
|
+
const byRel = new Map(docs.map((d) => [d.rel, d]));
|
|
110
|
+
const byName = new Map(); // `${map}/${basename}` → docs (the fallback for bare `name.md` links)
|
|
111
|
+
for (const d of docs) {
|
|
112
|
+
const k = `${d.map}/${basename(d.rel)}`;
|
|
113
|
+
byName.set(k, [...(byName.get(k) ?? []), d]);
|
|
114
|
+
}
|
|
115
|
+
const LINK = /\[([^\]]*)\]\(([^)\s]+\.md)(#[^)\s]*)?\)/g;
|
|
116
|
+
function linkify(doc, body) {
|
|
117
|
+
return body.replace(LINK, (whole, _label, href) => {
|
|
118
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("/")) return whole;
|
|
119
|
+
const rel = posix(relative(DOCS, resolve(DOCS, dirname(doc.rel), href)));
|
|
120
|
+
let target = byRel.get(rel);
|
|
121
|
+
if (!target) {
|
|
122
|
+
const same = byName.get(`${doc.map}/${basename(href)}`);
|
|
123
|
+
if (same?.length === 1) target = same[0];
|
|
124
|
+
}
|
|
125
|
+
// ADRs are addressed by number: a link whose slug went stale after a retitle still names
|
|
126
|
+
// the right record (`./030-old-slug.md` → ADR-030).
|
|
127
|
+
const num = !target && /^(\d{3})-/.exec(basename(href))?.[1];
|
|
128
|
+
if (num) {
|
|
129
|
+
const adr = docs.filter((d) => d.kind === "adr" && basename(d.rel).startsWith(`${num}-`));
|
|
130
|
+
if (adr.length === 1) target = adr[0];
|
|
131
|
+
}
|
|
132
|
+
if (!target || (LINKS !== "all" && target.map !== doc.map)) return whole;
|
|
133
|
+
return `[[${target.title}]]`;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- stage -----------------------------------------------------------------------
|
|
138
|
+
// A fresh dir per run: a fixed shared path let another repo's copy of this script, running at the
|
|
139
|
+
// same time, restage its own docs under us, and our import pushed them into this project's maps.
|
|
140
|
+
const OUT = mkdtempSync(join(tmpdir(), "alkahest-docs-staging-"));
|
|
141
|
+
const maps = [...new Set(SETS.map((s) => s.map))];
|
|
142
|
+
for (const doc of docs) {
|
|
143
|
+
const dir = join(OUT, doc.map, ...(doc.folder ? doc.folder.split("/") : []));
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const docsRel = posix(relative(ROOT, join(DOCS, doc.rel)));
|
|
146
|
+
writeFileSync(join(dir, `${doc.title}.md`), withSource(linkify(doc, stripH1(doc.src)), docsRel));
|
|
147
|
+
}
|
|
148
|
+
for (const m of maps) mkdirSync(join(OUT, m), { recursive: true });
|
|
149
|
+
console.log(`staged ${maps.map((m) => `${docs.filter((d) => d.map === m).length} ${m}`).join(" + ")} docs → ${OUT}`);
|
|
150
|
+
if (STAGE_ONLY) process.exit(0);
|
|
151
|
+
|
|
152
|
+
// ---- import ------------------------------------------------------------------------
|
|
153
|
+
const scope = ["--path", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])];
|
|
154
|
+
if (PROJECT) console.log(`project: ${PROJECT}`);
|
|
155
|
+
|
|
156
|
+
// A note map that doesn't exist yet is created (the preset install makes them, but a repo can
|
|
157
|
+
// add a set or move projects). Skipped on --dry-run: a plan must not create anything.
|
|
158
|
+
if (!DRY_RUN && config.createMaps !== false) {
|
|
159
|
+
const list = spawnSync("alkahest", ["maps", "list", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])], { encoding: "utf8" });
|
|
160
|
+
const have = list.status === 0 ? list.stdout : null;
|
|
161
|
+
for (const m of maps) {
|
|
162
|
+
if (have === null || new RegExp(`(^|[^\\w-])${m.replace(/[-]/g, "\\-")}([^\\w-]|$)`, "m").test(have)) continue;
|
|
163
|
+
console.log(`[sync] creating note map '${m}'`);
|
|
164
|
+
spawnSync("alkahest", ["maps", "create", m, "--type", "note", ...scope], { stdio: "inherit" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A full mirror is many sequential POSTs, so a transient failure is a question of when, not
|
|
169
|
+
// if. The CLI handles it per file (keeps going, prints `✗ <file>: <message>`, exits 1) — act
|
|
170
|
+
// on the exit code and retry the whole set: rows that already landed come back as updates.
|
|
171
|
+
const ATTEMPTS = DRY_RUN ? 1 : 3;
|
|
172
|
+
const failed = [];
|
|
173
|
+
for (const m of maps) {
|
|
174
|
+
let ok = false;
|
|
175
|
+
for (let attempt = 1; attempt <= ATTEMPTS && !ok; attempt++) {
|
|
176
|
+
console.log(`\n== notes import ${m} → map ${m}${DRY_RUN ? " (dry run)" : ""}${attempt > 1 ? ` (retry ${attempt - 1})` : ""} ==`);
|
|
177
|
+
const run = spawnSync("alkahest", ["notes", "import", join(OUT, m), "--map", m, ...scope, ...(DRY_RUN ? ["--dry-run"] : [])], {
|
|
178
|
+
stdio: "inherit",
|
|
179
|
+
});
|
|
180
|
+
if (run.error) console.error(`[sync] could not run alkahest: ${run.error.message}`);
|
|
181
|
+
ok = !run.error && run.status === 0;
|
|
182
|
+
if (!ok && attempt < ATTEMPTS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
|
|
183
|
+
}
|
|
184
|
+
if (!ok) failed.push(m);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (failed.length) {
|
|
188
|
+
console.error(`\n[sync] FAILED after ${ATTEMPTS} attempt(s): ${failed.join(", ")}`);
|
|
189
|
+
console.error("[sync] scroll up for the per-file '✗ <file>: <message>' lines from the importer.");
|
|
190
|
+
console.error(`[sync] staged files kept for inspection: ${OUT}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
// The staging dir is this run's own, so removing it touches no other sync. Kept on
|
|
194
|
+
// --stage-only (that's what it's for) and on failure (above).
|
|
195
|
+
rmSync(OUT, { recursive: true, force: true });
|
|
196
|
+
console.log(DRY_RUN ? "\n[sync] dry run done — nothing written." : `\n[sync] ${maps.join(" + ")} up to date.`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!-- alkahest as-built preset — installed by `alkahest preset install as-built`. Owned by this repo: edit freely; `alkahest preset update` merges later preset changes into your edits. -->
|
|
2
|
+
## Documentation — as-built docs + ADR
|
|
3
|
+
|
|
4
|
+
This repo keeps **as-built documentation** under `docs/` (four layers: system / components /
|
|
5
|
+
features / modules — see docs/README.md) and **ADRs** under `docs/decisions/`. The writing
|
|
6
|
+
instructions live in the account skills `alkahest/as-built-docs` and `alkahest/adr` — read them via the
|
|
7
|
+
alkahest MCP `skills` tool before writing docs.
|
|
8
|
+
|
|
9
|
+
1. **After finishing any code change, update the affected docs in the same session.**
|
|
10
|
+
The layer mapping and the update checklist are in the `alkahest/as-built-docs` skill. Write an
|
|
11
|
+
ADR only for decisions whose "why" a code diff cannot reconstruct (criteria and the
|
|
12
|
+
template are in the `alkahest/adr` skill).
|
|
13
|
+
2. **First documentation pass (repo has no docs yet)?** Follow the bootstrap protocol in
|
|
14
|
+
the `alkahest/as-built-docs` skill: one system map + 2–3 core modules + ADR-001 (architecture
|
|
15
|
+
snapshot) — small first, then mirror and hand the user the note-map link. Grow the rest
|
|
16
|
+
incrementally with later work.
|
|
17
|
+
3. **After changing `docs/`, mirror it to the hosted note maps** by running
|
|
18
|
+
`node scripts/sync-docs-maps.mjs` (background recommended — one POST per document).
|
|
19
|
+
The script stages the docs (title from the first H1, H1 line stripped, intra-set
|
|
20
|
+
relative links → `[[wikilinks]]`, the original repo path injected as `source_path:`
|
|
21
|
+
frontmatter) and uploads with `alkahest notes import --map <adr|as-built>`. The import
|
|
22
|
+
is idempotent by source_path first, title second — re-runs and retitles update notes in
|
|
23
|
+
place. The script is the preset's engine — don't edit it (`alkahest preset update` replaces
|
|
24
|
+
it). This repo's settings (project, extra files, title rules, which folders go to which map)
|
|
25
|
+
live in `scripts/sync-docs-maps.config.mjs`, which updates never touch.
|
|
26
|
+
<!-- /alkahest as-built preset -->
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
{
|
|
41
41
|
"sha": "2eb353c13db7e5a7",
|
|
42
42
|
"since": "0.1.92"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"sha": "c8301f8ce329dd96",
|
|
46
|
+
"since": "0.1.94"
|
|
43
47
|
}
|
|
44
48
|
],
|
|
45
49
|
"CLAUDE-snippet.md": [
|
|
@@ -54,6 +58,10 @@
|
|
|
54
58
|
{
|
|
55
59
|
"sha": "96497831c905de56",
|
|
56
60
|
"since": "0.1.91"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"sha": "fa58e822a0e3f4f4",
|
|
64
|
+
"since": "0.1.94"
|
|
57
65
|
}
|
|
58
66
|
]
|
|
59
67
|
}
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
"scripts": [
|
|
18
18
|
{
|
|
19
19
|
"file": "sync-docs-maps.mjs",
|
|
20
|
-
"dest": "scripts/sync-docs-maps.mjs"
|
|
20
|
+
"dest": "scripts/sync-docs-maps.mjs",
|
|
21
|
+
"owner": "preset"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"file": "sync-docs-maps.config.mjs",
|
|
25
|
+
"dest": "scripts/sync-docs-maps.config.mjs",
|
|
26
|
+
"owner": "repo"
|
|
21
27
|
}
|
|
22
28
|
],
|
|
23
29
|
"maps": [
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Settings for scripts/sync-docs-maps.mjs (alkahest as-built preset) — this file is the REPO'S.
|
|
2
|
+
// `alkahest preset update` replaces the sync engine next to it but never touches this file, so
|
|
3
|
+
// everything that makes this repo's docs sync its own goes here. Every key is optional: an empty
|
|
4
|
+
// object is the preset's default behaviour.
|
|
5
|
+
export default {
|
|
6
|
+
// The alkahest project to sync into. Default: the project this folder is linked to
|
|
7
|
+
// (`.alkahest/project.json`), or the ALKAHEST_PROJECT environment variable.
|
|
8
|
+
// project: "my-project-1a2b3c",
|
|
9
|
+
|
|
10
|
+
// Where the docs live, relative to the repo root.
|
|
11
|
+
// docsDir: "docs",
|
|
12
|
+
|
|
13
|
+
// Extra files (relative to docsDir) to mirror into a map, beyond its folders.
|
|
14
|
+
// extra: { "as-built": ["README.md"] },
|
|
15
|
+
|
|
16
|
+
// Note titles. Return a string to replace the preset's title for a doc, or nothing to keep it.
|
|
17
|
+
// Titles are how existing notes are matched, so change them deliberately (then run --stage-only).
|
|
18
|
+
// title: ({ map, kind, path, h1, defaultTitle }) => (map === "adr" ? h1 : undefined),
|
|
19
|
+
|
|
20
|
+
// Relative .md links become [[wikilinks]] within each map ("set", default) or across maps ("all").
|
|
21
|
+
// links: "set",
|
|
22
|
+
|
|
23
|
+
// Which folders go to which note map. Default:
|
|
24
|
+
// sets: [
|
|
25
|
+
// { map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
|
|
26
|
+
// { map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
|
|
27
|
+
// ],
|
|
28
|
+
|
|
29
|
+
// Create a missing note map before importing (default true).
|
|
30
|
+
// createMaps: true,
|
|
31
|
+
};
|
|
@@ -1,36 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// alkahest as-built preset — docs sync ENGINE. Do not edit this file: `alkahest preset update`
|
|
3
|
+
// replaces it with the preset's current version. Everything repo-specific (project, which docs
|
|
4
|
+
// go to which map, title rules, extra files) lives in scripts/sync-docs-maps.config.mjs, which
|
|
5
|
+
// the update never touches.
|
|
6
|
+
//
|
|
7
|
+
// Mirrors docs/ into the project's note maps:
|
|
4
8
|
// docs/decisions/NNN-*.md → note map `adr` (flat)
|
|
5
9
|
// docs/{system,components,features,modules}/*.md → note map `as-built` (folder = category)
|
|
6
10
|
//
|
|
7
|
-
// Staging
|
|
11
|
+
// Staging (the importer's rules: filename = title, frontmatter passes through):
|
|
8
12
|
// - title from the first H1 ("ADR-NNN: rest — tail" → "ADR-NNN rest"; as-built strips a
|
|
9
|
-
// "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized
|
|
13
|
+
// "System|Component|Feature|Module — " class prefix and a " — " tail), sanitized — or the
|
|
14
|
+
// config's `title()` when it returns one;
|
|
10
15
|
// - the H1 line is dropped from the body (the note title renders it);
|
|
11
|
-
// - relative .md links
|
|
12
|
-
//
|
|
13
|
-
// - the ORIGINAL repo path rides in as `source_path:` frontmatter
|
|
14
|
-
//
|
|
15
|
-
// matches source_path before title and renames the note in place).
|
|
16
|
+
// - relative .md links to another synced doc become [[wikilinks]] (within the same map by
|
|
17
|
+
// default; `links: "all"` also across maps);
|
|
18
|
+
// - the ORIGINAL repo path rides in as `source_path:` frontmatter, which keeps identity through
|
|
19
|
+
// a retitle (the importer matches source_path before title and renames the note in place).
|
|
16
20
|
//
|
|
17
21
|
// Re-running is safe: import is idempotent by source_path, then title.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
22
|
+
// Usage: node scripts/sync-docs-maps.mjs [--stage-only | --dry-run]
|
|
23
|
+
// --stage-only write the staged files and stop (the dir is kept and printed)
|
|
24
|
+
// --dry-run stage, then ask the importer for its plan without writing anything
|
|
21
25
|
import { spawnSync } from "node:child_process";
|
|
22
|
-
import {
|
|
23
|
-
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
24
27
|
import { tmpdir } from "node:os";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
30
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const ROOT = join(HERE, "..");
|
|
33
|
+
const CONFIG_FILE = join(HERE, "sync-docs-maps.config.mjs");
|
|
34
|
+
const config = existsSync(CONFIG_FILE) ? ((await import(pathToFileURL(CONFIG_FILE).href)).default ?? {}) : {};
|
|
35
|
+
|
|
36
|
+
const DOCS = join(ROOT, config.docsDir ?? "docs");
|
|
37
|
+
const PROJECT = config.project ?? process.env.ALKAHEST_PROJECT;
|
|
38
|
+
const STAGE_ONLY = process.argv.includes("--stage-only");
|
|
39
|
+
const DRY_RUN = process.argv.includes("--dry-run");
|
|
40
|
+
const LINKS = config.links ?? "set";
|
|
41
|
+
const SETS = config.sets ?? [
|
|
42
|
+
{ map: "adr", kind: "adr", dirs: ["decisions"], match: /^\d{3}-.*\.md$/, folders: false },
|
|
43
|
+
{ map: "as-built", kind: "as-built", dirs: ["system", "components", "features", "modules"], folders: true },
|
|
44
|
+
];
|
|
31
45
|
|
|
46
|
+
// ---- helpers -----------------------------------------------------------------
|
|
32
47
|
const sanitize = (t) =>
|
|
33
48
|
t.replace(/\(\//g, "(").replace(/[/\\:]/g, "-").replace(/`/g, "").replace(/\s+/g, " ").trim();
|
|
49
|
+
// A config title is kept as written — only what can't live in a filename is replaced.
|
|
50
|
+
const fileSafe = (t) => t.replace(/[/\\]/g, "-").trim();
|
|
51
|
+
const posix = (p) => p.split(sep).join("/");
|
|
34
52
|
const firstH1 = (src) => (src.match(/^#\s+(.+)$/m) ?? [null, ""])[1].trim();
|
|
35
53
|
// Frontmatter rides through to the staged file — the importer strips it from the stored body
|
|
36
54
|
// and harvests its keys into note props (`tags:` is the reserved one). Split it off first so
|
|
@@ -43,88 +61,136 @@ const stripH1 = (src) => {
|
|
|
43
61
|
const [fm, body] = splitFm(src);
|
|
44
62
|
return fm + body.replace(/^#\s+.+\n+/, "");
|
|
45
63
|
};
|
|
46
|
-
// Rename-safe identity: inject the ORIGINAL repo path as source_path: frontmatter (merged
|
|
47
|
-
// into an existing block, or a new block is minted).
|
|
48
64
|
const withSource = (staged, rel) => {
|
|
49
65
|
const [fm, body] = splitFm(staged);
|
|
50
66
|
const line = `source_path: ${rel}\n`;
|
|
51
67
|
return fm ? fm.replace(/---\r?\n*$/, (close) => line + close) + body : `---\n${line}---\n\n${staged}`;
|
|
52
68
|
};
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
adrTitle[num] ? `[[${adrTitle[num]}]]` : all);
|
|
68
|
-
mkdirSync(join(OUT, "adr"), { recursive: true });
|
|
69
|
-
for (const f of adrFiles) {
|
|
70
|
-
const t = adrTitle[f.slice(0, 3)];
|
|
71
|
-
const staged = linkAdrs(stripH1(readFileSync(join(adrDir, f), "utf8")));
|
|
72
|
-
writeFileSync(join(OUT, "adr", `${t}.md`), withSource(staged, `docs/decisions/${f}`));
|
|
70
|
+
/** The preset's own title rules, per set kind. */
|
|
71
|
+
function defaultTitle(kind, file, h1) {
|
|
72
|
+
if (kind === "adr") {
|
|
73
|
+
// Convention "ADR-NNN: title — tail"; the em-dash variant "ADR-NNN — title" also parses.
|
|
74
|
+
const m = h1.match(/^ADR-(\d{3})(?::|\s+—)\s*(.+)$/);
|
|
75
|
+
const num = m ? m[1] : basename(file).slice(0, 3);
|
|
76
|
+
return sanitize(`ADR-${num} ${(m ? m[2] : h1).split(" — ")[0].trim()}`);
|
|
77
|
+
}
|
|
78
|
+
if (kind === "as-built") {
|
|
79
|
+
const t = h1.replace(/^(System|Component|Feature|Module)\s*—\s*/i, "").split(" — ")[0].trim();
|
|
80
|
+
return sanitize(t || h1);
|
|
81
|
+
}
|
|
82
|
+
return sanitize(h1 || basename(file, ".md"));
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
// ----
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// ---- collect -----------------------------------------------------------------
|
|
86
|
+
const docs = []; // { rel (to DOCS), map, folder, src, title }
|
|
87
|
+
for (const set of SETS) {
|
|
88
|
+
for (const d of set.dirs ?? []) {
|
|
89
|
+
const dir = join(DOCS, d);
|
|
90
|
+
if (!existsSync(dir)) continue;
|
|
91
|
+
for (const f of readdirSync(dir).sort()) {
|
|
92
|
+
if (!f.endsWith(".md") || (set.match && !set.match.test(f))) continue;
|
|
93
|
+
docs.push({ rel: posix(join(d, f)), map: set.map, kind: set.kind, folder: set.folders ? d : null });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const f of config.extra?.[set.map] ?? []) {
|
|
97
|
+
if (existsSync(join(DOCS, f))) docs.push({ rel: posix(f), map: set.map, kind: set.kind, folder: null });
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
for (const doc of docs) {
|
|
101
|
+
doc.src = readFileSync(join(DOCS, doc.rel), "utf8");
|
|
102
|
+
const h1 = firstH1(doc.src);
|
|
103
|
+
const fallback = defaultTitle(doc.kind, doc.rel, h1);
|
|
104
|
+
const custom = config.title?.({ map: doc.map, kind: doc.kind, path: doc.rel, h1, defaultTitle: fallback });
|
|
105
|
+
doc.title = custom ? fileSafe(custom) : fallback;
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
// ---- links → [[wikilinks]] -----------------------------------------------------
|
|
109
|
+
const byRel = new Map(docs.map((d) => [d.rel, d]));
|
|
110
|
+
const byName = new Map(); // `${map}/${basename}` → docs (the fallback for bare `name.md` links)
|
|
111
|
+
for (const d of docs) {
|
|
112
|
+
const k = `${d.map}/${basename(d.rel)}`;
|
|
113
|
+
byName.set(k, [...(byName.get(k) ?? []), d]);
|
|
114
|
+
}
|
|
115
|
+
const LINK = /\[([^\]]*)\]\(([^)\s]+\.md)(#[^)\s]*)?\)/g;
|
|
116
|
+
function linkify(doc, body) {
|
|
117
|
+
return body.replace(LINK, (whole, _label, href) => {
|
|
118
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("/")) return whole;
|
|
119
|
+
const rel = posix(relative(DOCS, resolve(DOCS, dirname(doc.rel), href)));
|
|
120
|
+
let target = byRel.get(rel);
|
|
121
|
+
if (!target) {
|
|
122
|
+
const same = byName.get(`${doc.map}/${basename(href)}`);
|
|
123
|
+
if (same?.length === 1) target = same[0];
|
|
124
|
+
}
|
|
125
|
+
// ADRs are addressed by number: a link whose slug went stale after a retitle still names
|
|
126
|
+
// the right record (`./030-old-slug.md` → ADR-030).
|
|
127
|
+
const num = !target && /^(\d{3})-/.exec(basename(href))?.[1];
|
|
128
|
+
if (num) {
|
|
129
|
+
const adr = docs.filter((d) => d.kind === "adr" && basename(d.rel).startsWith(`${num}-`));
|
|
130
|
+
if (adr.length === 1) target = adr[0];
|
|
131
|
+
}
|
|
132
|
+
if (!target || (LINKS !== "all" && target.map !== doc.map)) return whole;
|
|
133
|
+
return `[[${target.title}]]`;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- stage -----------------------------------------------------------------------
|
|
138
|
+
// A fresh dir per run: a fixed shared path let another repo's copy of this script, running at the
|
|
139
|
+
// same time, restage its own docs under us, and our import pushed them into this project's maps.
|
|
140
|
+
const OUT = mkdtempSync(join(tmpdir(), "alkahest-docs-staging-"));
|
|
141
|
+
const maps = [...new Set(SETS.map((s) => s.map))];
|
|
142
|
+
for (const doc of docs) {
|
|
143
|
+
const dir = join(OUT, doc.map, ...(doc.folder ? doc.folder.split("/") : []));
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const docsRel = posix(relative(ROOT, join(DOCS, doc.rel)));
|
|
146
|
+
writeFileSync(join(dir, `${doc.title}.md`), withSource(linkify(doc, stripH1(doc.src)), docsRel));
|
|
147
|
+
}
|
|
148
|
+
for (const m of maps) mkdirSync(join(OUT, m), { recursive: true });
|
|
149
|
+
console.log(`staged ${maps.map((m) => `${docs.filter((d) => d.map === m).length} ${m}`).join(" + ")} docs → ${OUT}`);
|
|
150
|
+
if (STAGE_ONLY) process.exit(0);
|
|
151
|
+
|
|
152
|
+
// ---- import ------------------------------------------------------------------------
|
|
153
|
+
const scope = ["--path", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])];
|
|
154
|
+
if (PROJECT) console.log(`project: ${PROJECT}`);
|
|
155
|
+
|
|
156
|
+
// A note map that doesn't exist yet is created (the preset install makes them, but a repo can
|
|
157
|
+
// add a set or move projects). Skipped on --dry-run: a plan must not create anything.
|
|
158
|
+
if (!DRY_RUN && config.createMaps !== false) {
|
|
159
|
+
const list = spawnSync("alkahest", ["maps", "list", ROOT, ...(PROJECT ? ["--slug", PROJECT] : [])], { encoding: "utf8" });
|
|
160
|
+
const have = list.status === 0 ? list.stdout : null;
|
|
161
|
+
for (const m of maps) {
|
|
162
|
+
if (have === null || new RegExp(`(^|[^\\w-])${m.replace(/[-]/g, "\\-")}([^\\w-]|$)`, "m").test(have)) continue;
|
|
163
|
+
console.log(`[sync] creating note map '${m}'`);
|
|
164
|
+
spawnSync("alkahest", ["maps", "create", m, "--type", "note", ...scope], { stdio: "inherit" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
98
167
|
|
|
99
168
|
// A full mirror is many sequential POSTs, so a transient failure is a question of when, not
|
|
100
169
|
// if. The CLI handles it per file (keeps going, prints `✗ <file>: <message>`, exits 1) — act
|
|
101
170
|
// on the exit code and retry the whole set: rows that already landed come back as updates.
|
|
102
|
-
const ATTEMPTS = 3;
|
|
171
|
+
const ATTEMPTS = DRY_RUN ? 1 : 3;
|
|
103
172
|
const failed = [];
|
|
104
|
-
|
|
105
|
-
for (const [dir, map] of [["adr", "adr"], ["as-built", "as-built"]]) {
|
|
173
|
+
for (const m of maps) {
|
|
106
174
|
let ok = false;
|
|
107
175
|
for (let attempt = 1; attempt <= ATTEMPTS && !ok; attempt++) {
|
|
108
|
-
console.log(`\n== notes import ${
|
|
109
|
-
const run = spawnSync("alkahest", ["notes", "import", join(OUT,
|
|
176
|
+
console.log(`\n== notes import ${m} → map ${m}${DRY_RUN ? " (dry run)" : ""}${attempt > 1 ? ` (retry ${attempt - 1})` : ""} ==`);
|
|
177
|
+
const run = spawnSync("alkahest", ["notes", "import", join(OUT, m), "--map", m, ...scope, ...(DRY_RUN ? ["--dry-run"] : [])], {
|
|
110
178
|
stdio: "inherit",
|
|
111
179
|
});
|
|
112
180
|
if (run.error) console.error(`[sync] could not run alkahest: ${run.error.message}`);
|
|
113
181
|
ok = !run.error && run.status === 0;
|
|
114
182
|
if (!ok && attempt < ATTEMPTS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
|
|
115
183
|
}
|
|
116
|
-
if (!ok) failed.push(
|
|
184
|
+
if (!ok) failed.push(m);
|
|
117
185
|
}
|
|
118
186
|
|
|
119
187
|
if (failed.length) {
|
|
120
|
-
console.error(`\n[sync] FAILED after ${ATTEMPTS}
|
|
188
|
+
console.error(`\n[sync] FAILED after ${ATTEMPTS} attempt(s): ${failed.join(", ")}`);
|
|
121
189
|
console.error("[sync] scroll up for the per-file '✗ <file>: <message>' lines from the importer.");
|
|
122
190
|
console.error(`[sync] staged files kept for inspection: ${OUT}`);
|
|
123
191
|
process.exit(1);
|
|
124
192
|
}
|
|
125
|
-
// The staging dir is this run's own
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
(await import("node:fs")).rmSync(OUT, { recursive: true, force: true });
|
|
130
|
-
console.log("\n[sync] both maps up to date.");
|
|
193
|
+
// The staging dir is this run's own, so removing it touches no other sync. Kept on
|
|
194
|
+
// --stage-only (that's what it's for) and on failure (above).
|
|
195
|
+
rmSync(OUT, { recursive: true, force: true });
|
|
196
|
+
console.log(DRY_RUN ? "\n[sync] dry run done — nothing written." : `\n[sync] ${maps.join(" + ")} up to date.`);
|