docs: fold review findings back into the plan

Corrections made during execution as reviews surfaced defects in the plan
itself: duplicate raw-path claim handling and its one-pass orphaned filter,
unescaped-pipe table split, RAW_EXTENSIONS widening, dark accent tracking the
installed theme, the bare node --test command, and test counts.
This commit is contained in:
meels
2026-07-28 13:55:32 +02:00
parent 39d8fffdcb
commit c88a5d002c

View File

@@ -269,6 +269,31 @@ test("derivePipeline treats a page with no raw path as orphaned", () => {
assert.equal(out.unprocessed.length, 1); assert.equal(out.unprocessed.length, 1);
}); });
test("derivePipeline routes a duplicate raw-path claim to orphaned", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }],
sourcePages: [
{ path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/a.md" },
{ path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/a.md" },
],
});
assert.equal(out.processed.length, 1);
assert.equal(out.processed[0].page.name, "first.md");
assert.equal(out.unprocessed.length, 0);
assert.deepEqual(out.orphaned.map((p) => p.name), ["second.md"]);
});
test("derivePipeline lists a page once when it is both a duplicate claim and missing its raw file", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/other.md", name: "other.md", size: 10 }],
sourcePages: [
{ path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/gone.md" },
{ path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/gone.md" },
],
});
assert.deepEqual(out.orphaned.map((p) => p.name), ["first.md", "second.md"]);
});
test("derivePipeline sorts unprocessed by name and processed newest first", () => { test("derivePipeline sorts unprocessed by name and processed newest first", () => {
const out = derivePipeline({ const out = derivePipeline({
rawFiles: [ rawFiles: [
@@ -307,9 +332,16 @@ function extractRawPath(text) {
} }
function derivePipeline({ rawFiles, sourcePages }) { function derivePipeline({ rawFiles, sourcePages }) {
// First claim on a raw path wins. A later page claiming the same file is a
// duplicate claim — real catalog drift — and joins `orphaned` rather than
// being silently dropped. `orphaned` therefore means "source page not paired
// with a raw file", whatever the reason.
const claimed = new Map(); const claimed = new Map();
const duplicates = [];
for (const page of sourcePages) { for (const page of sourcePages) {
if (page.rawPath) claimed.set(page.rawPath, page); if (!page.rawPath) continue;
if (claimed.has(page.rawPath)) duplicates.push(page);
else claimed.set(page.rawPath, page);
} }
const processed = []; const processed = [];
@@ -320,8 +352,15 @@ function derivePipeline({ rawFiles, sourcePages }) {
else unprocessed.push(file); else unprocessed.push(file);
} }
// One pass over sourcePages, so a page appears in `orphaned` at most once no
// matter how many of the three reasons apply to it. Concatenating a separate
// duplicates array here would double-count a losing claimant whose shared raw
// path is also missing from disk.
const rawPaths = new Set(rawFiles.map((f) => f.path)); const rawPaths = new Set(rawFiles.map((f) => f.path));
const orphaned = sourcePages.filter((p) => !p.rawPath || !rawPaths.has(p.rawPath)); const duplicateSet = new Set(duplicates);
const orphaned = sourcePages.filter(
(p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath)
);
unprocessed.sort((a, b) => a.name.localeCompare(b.name)); unprocessed.sort((a, b) => a.name.localeCompare(b.name));
processed.sort((a, b) => b.page.name.localeCompare(a.page.name)); processed.sort((a, b) => b.page.name.localeCompare(a.page.name));
@@ -344,7 +383,7 @@ module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATION
node --test test/pipeline.test.js node --test test/pipeline.test.js
``` ```
Expected: PASS, 7 tests. Expected: PASS, 9 tests.
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
@@ -416,12 +455,19 @@ Values are copied verbatim from `colors_and_type.css`. Scoped to `.webinar-dash`
--wd-border: var(--wd-ink-700); --wd-border: var(--wd-ink-700);
--wd-border-strong: #b8b8b8; --wd-border-strong: #b8b8b8;
--wd-border-subtle: #161616; --wd-border-subtle: #161616;
--wd-accent: #ff4a3d; /* Accent tracks the installed theme at .obsidian/themes/tesanti/theme.css,
--wd-accent-press: #ff6d62; whose dark section reads "Black canvas, same signal red": --accent-h/-s/-l
--wd-accent-on: #0a0a0a; are declared once at :root and never redeclared under .theme-dark, and only
the hover state lifts (#c31c14 light, #ff4d43 dark). Match that exactly. */
--wd-accent: var(--wd-red-500);
--wd-accent-press: #ff4d43;
--wd-accent-on: #ffffff;
/* Status tokens are separate from the brand accent by design-system rule, and
here they carry small text in a dense table. #e1261c on near-black is about
3.8:1, under AA for small text, so these lift where the accent does not. */
--wd-ok: #2fbf6a; --wd-ok: #2fbf6a;
--wd-warn: #e0a516; --wd-warn: #e0a516;
--wd-danger: #ff4a3d; --wd-danger: #ff5c50;
} }
@media (max-width: 820px) { @media (max-width: 820px) {
@@ -503,6 +549,10 @@ Values are copied verbatim from `colors_and_type.css`. Scoped to `.webinar-dash`
Insert into `main.js` above the plugin class. Icons are inline stroke paths — the Lucide CDN is unavailable offline and the design system forbids emoji. Insert into `main.js` above the plugin class. Icons are inline stroke paths — the Lucide CDN is unavailable offline and the design system forbids emoji.
```js ```js
// Source types CLAUDE.md documents for raw/sources. Anything else in that
// folder is not a source and stays out of the queue.
const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]);
const ICONS = { const ICONS = {
terminal: "M4 17l6-6-6-6M12 19h8", terminal: "M4 17l6-6-6-6M12 19h8",
check: "M20 6 9 17l-5-5", check: "M20 6 9 17l-5-5",
@@ -608,8 +658,12 @@ class WebinarDashPlugin extends PluginBase {
const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/"; const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/";
const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/"; const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/";
// CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports".
// Allow-listing only "md" would hide the others from the queue with no
// warning — the same silent-invisibility failure this dashboard exists to
// remove. Wiki source pages below stay markdown-only; those really are .md.
const rawFiles = all const rawFiles = all
.filter((f) => f.path.startsWith(rawDir) && f.extension === "md") .filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension))
.map((f) => ({ path: f.path, name: f.name, size: f.stat.size })); .map((f) => ({ path: f.path, name: f.name, size: f.stat.size }));
const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md"); const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md");
@@ -637,10 +691,14 @@ Expected, against the vault's current state:
```bash ```bash
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash" cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/ node --test
``` ```
Expected: PASS, 6 tests. Bare `node --test` auto-discovers the test files. Do not pass `test/` as an
argument — Node 24 resolves a bare directory path as a module and fails with
`MODULE_NOT_FOUND` before running anything.
Expected: PASS, 9 tests.
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
@@ -820,7 +878,11 @@ function parseCoverageTable(markdown) {
if (!inTable) return; if (!inTable) return;
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1); const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
const cells = body.split("|").map((c) => c.trim()); // Split on unescaped pipes only. Obsidian escapes the pipe of a piped
// wikilink inside a table cell as `\|`, and a plain split("|") tears
// `[[harness\|alias]]` into two cells, shifting every later column left —
// the status cell then reads "The harness]]" and the row is rejected.
const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
if (cells.length < 3) { if (cells.length < 3) {
errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" }); errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" });
@@ -1580,10 +1642,14 @@ This writes to the vault unsupervised. Everything is committed, so `git diff HEA
```bash ```bash
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash" cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/ node --test
``` ```
Expected: PASS, 24 tests across three files. Bare `node --test` auto-discovers the test files. Do not pass `test/` as an
argument — Node 24 resolves a bare directory path as a module and fails with
`MODULE_NOT_FOUND` before running anything.
Expected: PASS, 26 tests across three files.
- [ ] **Step 11: Commit** - [ ] **Step 11: Commit**
@@ -1616,4 +1682,4 @@ The spec's "v1 does not write the coverage file from the plugin" is honored —
**Type consistency.** `rawFiles` items are `{path, name, size}` in Tasks 2 and 3. `sourcePages` items are `{path, name, rawPath}` in both. Coverage rows are `{concept, status, stations, pinned, line}` in Tasks 4, 5, and 6. `onIngest(file, rowEl)` is declared in Task 3 and implemented with the same signature in Task 7. `addIcon(parent, name)` is defined in Task 3 and reused in Task 6. `STATIONS` is defined in Task 1 and consumed by `groupByStation` in Task 4. **Type consistency.** `rawFiles` items are `{path, name, size}` in Tasks 2 and 3. `sourcePages` items are `{path, name, rawPath}` in both. Coverage rows are `{concept, status, stations, pinned, line}` in Tasks 4, 5, and 6. `onIngest(file, rowEl)` is declared in Task 3 and implemented with the same signature in Task 7. `addIcon(parent, name)` is defined in Task 3 and reused in Task 6. `STATIONS` is defined in Task 1 and consumed by `groupByStation` in Task 4.
**Test count.** Task 2 adds 7, Task 4 adds 11, Task 6 adds 2, Task 7 adds 4 — 24 total, matching Step 10 of Task 7. **Test count.** Task 2 adds 9, Task 4 adds 11, Task 6 adds 2, Task 7 adds 4 — 26 total, matching Step 10 of Task 7.