chore: renormalize line endings under the new .gitattributes

Line-ending only. Verified: 3667 insertions against 3667 deletions with
zero content difference under --ignore-cr-at-eol.
This commit is contained in:
meels
2026-07-28 15:20:55 +02:00
parent 51c85e6994
commit 5de50b155a
12 changed files with 3667 additions and 3667 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"id": "webinar-dash",
"name": "Webinar dashboard",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Source pipeline and script coverage for the webinar vault.",
"author": "meels",
"isDesktopOnly": true
}
{
"id": "webinar-dash",
"name": "Webinar dashboard",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Source pipeline and script coverage for the webinar vault.",
"author": "meels",
"isDesktopOnly": true
}

View File

@@ -1,239 +1,239 @@
.webinar-dash {
--wd-ink-000: #ffffff; --wd-ink-050: #f7f7f7; --wd-ink-100: #ececec;
--wd-ink-200: #d9d9d9; --wd-ink-400: #8a8a8a; --wd-ink-500: #5e5e5e;
--wd-ink-700: #262626; --wd-ink-900: #0a0a0a; --wd-ink-999: #000000;
--wd-red-500: #e1261c; --wd-red-600: #c31c14;
--wd-bg: var(--wd-ink-000);
--wd-bg-subtle: var(--wd-ink-050);
--wd-fg: var(--wd-ink-999);
--wd-fg-2: var(--wd-ink-700);
--wd-fg-3: var(--wd-ink-500);
--wd-fg-4: var(--wd-ink-400);
--wd-border: var(--wd-ink-200);
--wd-border-strong: var(--wd-ink-999);
--wd-border-subtle: var(--wd-ink-100);
--wd-accent: var(--wd-red-500);
--wd-accent-press: var(--wd-red-600);
--wd-accent-on: #ffffff;
--wd-ok: #0a8a3f;
--wd-warn: #c68a00;
--wd-danger: var(--wd-red-500);
--wd-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--wd-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
font-family: var(--wd-sans);
color: var(--wd-fg);
display: flex;
flex-direction: column;
gap: 16px;
/* Past roughly this width the two panes stop reading as a pair and table
rows get hard to track across. Cap the whole dashboard, not just the
grid, so the toolbar stays flush with the panes below it. */
max-width: 1600px;
/* Makes this element the reference for the @container query further down,
so the grid collapses on the pane's width rather than the window's. */
container-type: inline-size;
}
.theme-dark .webinar-dash {
--wd-bg: var(--wd-ink-900);
--wd-bg-subtle: #161616;
--wd-fg: var(--wd-ink-000);
--wd-fg-2: var(--wd-ink-200);
--wd-fg-3: var(--wd-ink-400);
--wd-fg-4: var(--wd-ink-500);
--wd-border: var(--wd-ink-700);
--wd-border-strong: #b8b8b8;
--wd-border-subtle: #161616;
/* Accent tracks the installed theme at .obsidian/themes/tesanti/theme.css,
whose dark section reads "Black canvas, same signal red": --accent-h/-s/-l
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-warn: #e0a516;
--wd-danger: #ff5c50;
}
/* ------------------------------------------------------------------
Escape Obsidian's readable-line-length cap.
With "Readable line length" on (the default), Obsidian caps note
content at --file-line-width, roughly 700px. That is right for prose
and wrong for a two-pane dashboard, which gets squeezed into a column.
:has() scopes this to the sizer that actually contains a dashboard, so
every other note in the vault keeps its readable width. Both selectors
are needed: reading view sizes on .markdown-preview-sizer, live preview
on .cm-sizer.
If you would rather not rely on this, the alternatives are Settings ->
Editor -> Readable line length (off, but that widens every note), or
adding `cssclasses: wide-dash` to the note's frontmatter and swapping
the :has() selectors below for `.wide-dash .markdown-preview-sizer`.
------------------------------------------------------------------ */
.markdown-preview-sizer:has(.webinar-dash),
.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) {
max-width: none;
}
.wd-grid {
display: grid;
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
gap: 24px;
}
.wd-grid > * { min-width: 0; }
.wd-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
border: 1px solid var(--wd-border);
border-radius: 4px;
padding: 8px 12px;
background: var(--wd-bg-subtle);
}
.wd-btn-ghost {
background: transparent;
color: var(--wd-fg);
border-color: var(--wd-border-strong);
}
.wd-btn-ghost:hover {
background: var(--wd-bg-muted, var(--wd-bg-subtle));
border-color: var(--wd-border-strong);
}
/* Collapse to one column on the width that actually matters — the pane's,
not the window's. A media query measures the window, so a wide window
with the dashboard in a narrow split pane would keep two cramped
columns. The container query below responds to the pane itself; the
media query stays as a floor for a genuinely narrow window. */
@container (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
}
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
.wd-eyebrow {
font-family: var(--wd-mono); font-size: 11px; line-height: 1;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--wd-fg-3); font-weight: 500;
display: flex; align-items: center; gap: 8px;
}
.wd-eyebrow::before {
content: ""; width: 5px; height: 5px; flex: none; background: var(--wd-accent);
}
.wd-block { display: flex; flex-direction: column; gap: 12px; }
.wd-row {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
border: 1px solid var(--wd-border); border-radius: 4px;
padding: 12px 12px 12px 16px; background: var(--wd-bg);
}
.wd-row + .wd-row { margin-top: -1px; }
.wd-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.wd-row-name {
font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-mono {
font-family: var(--wd-mono); font-size: 11px; color: var(--wd-fg-3);
font-variant-numeric: tabular-nums;
}
.wd-btn {
display: inline-flex; align-items: center; gap: 6px; flex: none;
font-family: var(--wd-sans); font-size: 13px; font-weight: 600;
padding: 6px 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--wd-accent); background: var(--wd-accent);
color: var(--wd-accent-on);
transition: background 120ms cubic-bezier(0.2, 0, 0, 1);
}
.wd-btn:hover { background: var(--wd-accent-press); border-color: var(--wd-accent-press); }
.wd-btn:focus-visible { outline: 2px solid var(--wd-accent); outline-offset: 2px; }
.wd-btn[disabled] {
opacity: 0.45; cursor: not-allowed;
background: transparent; color: var(--wd-fg-3); border-color: var(--wd-border);
}
.wd-btn svg { width: 14px; height: 14px; flex: none; }
.wd-done { display: flex; flex-direction: column; }
.wd-done-row {
display: flex; align-items: baseline; gap: 12px; padding: 5px 0;
border-bottom: 1px solid var(--wd-border-subtle); font-size: 13px;
}
.wd-done-row:last-child { border-bottom: 0; }
.wd-done-name {
color: var(--wd-fg-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-note { font-size: 12px; color: var(--wd-fg-3); }
.wd-error {
font-size: 13px; color: var(--wd-danger);
border: 1px solid var(--wd-danger); border-radius: 4px; padding: 12px;
}
@media (prefers-reduced-motion: reduce) {
.webinar-dash * { transition-duration: 0.01ms !important; }
}
.wd-meter { display: flex; gap: 2px; height: 8px; width: 100%; }
.wd-meter > i { display: block; height: 100%; }
.wd-seg-covered { background: var(--wd-ok); }
.wd-seg-partial { background: var(--wd-warn); }
.wd-seg-absent { background: var(--wd-danger); }
.wd-key { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: var(--wd-fg-2); }
.wd-key > span { display: inline-flex; align-items: center; gap: 6px; }
.wd-key i { width: 8px; height: 8px; flex: none; }
.wd-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.wd-tbl th {
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg-3); font-weight: 500;
text-align: left; padding: 0 12px 8px 0;
border-bottom: 1px solid var(--wd-border-strong);
}
.wd-tbl td {
padding: 6px 12px 6px 0; border-bottom: 1px solid var(--wd-border-subtle);
vertical-align: baseline;
}
.wd-grp td {
padding-top: 16px; border-bottom: 1px solid var(--wd-border-strong);
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg); font-weight: 500;
}
.wd-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; }
.wd-stat svg { width: 13px; height: 13px; flex: none; }
.wd-covered { color: var(--wd-ok); }
.wd-partial { color: var(--wd-warn); }
.wd-absent { color: var(--wd-danger); }
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }
.wd-status { font-family: var(--wd-mono); font-size: 11px; flex: none; }
.wd-status-running { color: var(--wd-warn); }
.wd-status-done { color: var(--wd-ok); }
.wd-status-failed { color: var(--wd-danger); }
.wd-output {
flex-basis: 100%;
width: 100%;
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
max-height: 220px; overflow: auto; margin-top: 8px;
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
color: var(--wd-fg-2);
}
.webinar-dash {
--wd-ink-000: #ffffff; --wd-ink-050: #f7f7f7; --wd-ink-100: #ececec;
--wd-ink-200: #d9d9d9; --wd-ink-400: #8a8a8a; --wd-ink-500: #5e5e5e;
--wd-ink-700: #262626; --wd-ink-900: #0a0a0a; --wd-ink-999: #000000;
--wd-red-500: #e1261c; --wd-red-600: #c31c14;
--wd-bg: var(--wd-ink-000);
--wd-bg-subtle: var(--wd-ink-050);
--wd-fg: var(--wd-ink-999);
--wd-fg-2: var(--wd-ink-700);
--wd-fg-3: var(--wd-ink-500);
--wd-fg-4: var(--wd-ink-400);
--wd-border: var(--wd-ink-200);
--wd-border-strong: var(--wd-ink-999);
--wd-border-subtle: var(--wd-ink-100);
--wd-accent: var(--wd-red-500);
--wd-accent-press: var(--wd-red-600);
--wd-accent-on: #ffffff;
--wd-ok: #0a8a3f;
--wd-warn: #c68a00;
--wd-danger: var(--wd-red-500);
--wd-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--wd-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
font-family: var(--wd-sans);
color: var(--wd-fg);
display: flex;
flex-direction: column;
gap: 16px;
/* Past roughly this width the two panes stop reading as a pair and table
rows get hard to track across. Cap the whole dashboard, not just the
grid, so the toolbar stays flush with the panes below it. */
max-width: 1600px;
/* Makes this element the reference for the @container query further down,
so the grid collapses on the pane's width rather than the window's. */
container-type: inline-size;
}
.theme-dark .webinar-dash {
--wd-bg: var(--wd-ink-900);
--wd-bg-subtle: #161616;
--wd-fg: var(--wd-ink-000);
--wd-fg-2: var(--wd-ink-200);
--wd-fg-3: var(--wd-ink-400);
--wd-fg-4: var(--wd-ink-500);
--wd-border: var(--wd-ink-700);
--wd-border-strong: #b8b8b8;
--wd-border-subtle: #161616;
/* Accent tracks the installed theme at .obsidian/themes/tesanti/theme.css,
whose dark section reads "Black canvas, same signal red": --accent-h/-s/-l
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-warn: #e0a516;
--wd-danger: #ff5c50;
}
/* ------------------------------------------------------------------
Escape Obsidian's readable-line-length cap.
With "Readable line length" on (the default), Obsidian caps note
content at --file-line-width, roughly 700px. That is right for prose
and wrong for a two-pane dashboard, which gets squeezed into a column.
:has() scopes this to the sizer that actually contains a dashboard, so
every other note in the vault keeps its readable width. Both selectors
are needed: reading view sizes on .markdown-preview-sizer, live preview
on .cm-sizer.
If you would rather not rely on this, the alternatives are Settings ->
Editor -> Readable line length (off, but that widens every note), or
adding `cssclasses: wide-dash` to the note's frontmatter and swapping
the :has() selectors below for `.wide-dash .markdown-preview-sizer`.
------------------------------------------------------------------ */
.markdown-preview-sizer:has(.webinar-dash),
.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) {
max-width: none;
}
.wd-grid {
display: grid;
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
gap: 24px;
}
.wd-grid > * { min-width: 0; }
.wd-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
border: 1px solid var(--wd-border);
border-radius: 4px;
padding: 8px 12px;
background: var(--wd-bg-subtle);
}
.wd-btn-ghost {
background: transparent;
color: var(--wd-fg);
border-color: var(--wd-border-strong);
}
.wd-btn-ghost:hover {
background: var(--wd-bg-muted, var(--wd-bg-subtle));
border-color: var(--wd-border-strong);
}
/* Collapse to one column on the width that actually matters — the pane's,
not the window's. A media query measures the window, so a wide window
with the dashboard in a narrow split pane would keep two cramped
columns. The container query below responds to the pane itself; the
media query stays as a floor for a genuinely narrow window. */
@container (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
}
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
.wd-eyebrow {
font-family: var(--wd-mono); font-size: 11px; line-height: 1;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--wd-fg-3); font-weight: 500;
display: flex; align-items: center; gap: 8px;
}
.wd-eyebrow::before {
content: ""; width: 5px; height: 5px; flex: none; background: var(--wd-accent);
}
.wd-block { display: flex; flex-direction: column; gap: 12px; }
.wd-row {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
border: 1px solid var(--wd-border); border-radius: 4px;
padding: 12px 12px 12px 16px; background: var(--wd-bg);
}
.wd-row + .wd-row { margin-top: -1px; }
.wd-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.wd-row-name {
font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-mono {
font-family: var(--wd-mono); font-size: 11px; color: var(--wd-fg-3);
font-variant-numeric: tabular-nums;
}
.wd-btn {
display: inline-flex; align-items: center; gap: 6px; flex: none;
font-family: var(--wd-sans); font-size: 13px; font-weight: 600;
padding: 6px 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--wd-accent); background: var(--wd-accent);
color: var(--wd-accent-on);
transition: background 120ms cubic-bezier(0.2, 0, 0, 1);
}
.wd-btn:hover { background: var(--wd-accent-press); border-color: var(--wd-accent-press); }
.wd-btn:focus-visible { outline: 2px solid var(--wd-accent); outline-offset: 2px; }
.wd-btn[disabled] {
opacity: 0.45; cursor: not-allowed;
background: transparent; color: var(--wd-fg-3); border-color: var(--wd-border);
}
.wd-btn svg { width: 14px; height: 14px; flex: none; }
.wd-done { display: flex; flex-direction: column; }
.wd-done-row {
display: flex; align-items: baseline; gap: 12px; padding: 5px 0;
border-bottom: 1px solid var(--wd-border-subtle); font-size: 13px;
}
.wd-done-row:last-child { border-bottom: 0; }
.wd-done-name {
color: var(--wd-fg-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-note { font-size: 12px; color: var(--wd-fg-3); }
.wd-error {
font-size: 13px; color: var(--wd-danger);
border: 1px solid var(--wd-danger); border-radius: 4px; padding: 12px;
}
@media (prefers-reduced-motion: reduce) {
.webinar-dash * { transition-duration: 0.01ms !important; }
}
.wd-meter { display: flex; gap: 2px; height: 8px; width: 100%; }
.wd-meter > i { display: block; height: 100%; }
.wd-seg-covered { background: var(--wd-ok); }
.wd-seg-partial { background: var(--wd-warn); }
.wd-seg-absent { background: var(--wd-danger); }
.wd-key { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: var(--wd-fg-2); }
.wd-key > span { display: inline-flex; align-items: center; gap: 6px; }
.wd-key i { width: 8px; height: 8px; flex: none; }
.wd-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.wd-tbl th {
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg-3); font-weight: 500;
text-align: left; padding: 0 12px 8px 0;
border-bottom: 1px solid var(--wd-border-strong);
}
.wd-tbl td {
padding: 6px 12px 6px 0; border-bottom: 1px solid var(--wd-border-subtle);
vertical-align: baseline;
}
.wd-grp td {
padding-top: 16px; border-bottom: 1px solid var(--wd-border-strong);
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg); font-weight: 500;
}
.wd-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; }
.wd-stat svg { width: 13px; height: 13px; flex: none; }
.wd-covered { color: var(--wd-ok); }
.wd-partial { color: var(--wd-warn); }
.wd-absent { color: var(--wd-danger); }
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }
.wd-status { font-family: var(--wd-mono); font-size: 11px; flex: none; }
.wd-status-running { color: var(--wd-warn); }
.wd-status-done { color: var(--wd-ok); }
.wd-status-failed { color: var(--wd-danger); }
.wd-output {
flex-basis: 100%;
width: 100%;
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
max-height: 220px; overflow: auto; margin-top: 8px;
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
color: var(--wd-fg-2);
}

View File

@@ -1,122 +1,122 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseCoverageTable, groupByStation } = require("../main.js").__test__;
const DOC = [
"# Script coverage",
"",
"#coverage",
"",
"## Metadata",
"",
"- **Script:** `raw/sources/Webinar script.md`",
"- **Last synced:** 2026-07-28",
"",
"## Coverage",
"",
"| Concept | Status | Station | Pinned |",
"|---|---|---|---|",
"| [[harness]] | covered | Tools | |",
"| [[agentic-loops]] | partial | Process | |",
"| [[levels-of-ai-usage]] | partial | all | |",
"| [[connections-as-moat]] | absent | — | yes |",
].join("\n");
test("parseCoverageTable reads metadata", () => {
const { meta } = parseCoverageTable(DOC);
assert.equal(meta.script, "raw/sources/Webinar script.md");
assert.equal(meta.lastSynced, "2026-07-28");
});
test("parseCoverageTable reads every data row and skips the header", () => {
const { rows, errors } = parseCoverageTable(DOC);
assert.equal(errors.length, 0);
assert.equal(rows.length, 4);
assert.deepEqual(rows.map((r) => r.concept), [
"harness", "agentic-loops", "levels-of-ai-usage", "connections-as-moat",
]);
});
test("parseCoverageTable normalises stations", () => {
const { rows } = parseCoverageTable(DOC);
assert.deepEqual(rows[0].stations, ["Tools"]);
assert.deepEqual(rows[2].stations, ["all"]);
assert.deepEqual(rows[3].stations, []);
});
test("parseCoverageTable reads the pinned flag", () => {
const { rows } = parseCoverageTable(DOC);
assert.equal(rows[0].pinned, false);
assert.equal(rows[3].pinned, true);
});
test("parseCoverageTable strips a wikilink alias", () => {
const doc = "| Concept | Status | Station |\n|---|---|---|\n| [[harness\\|The harness]] | covered | Tools |";
const { rows } = parseCoverageTable(doc);
assert.equal(rows[0].concept, "harness");
});
test("parseCoverageTable splits a multi-station cell", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered | Tools, Memory |";
const { rows } = parseCoverageTable(doc);
assert.deepEqual(rows[0].stations, ["Tools", "Memory"]);
});
test("parseCoverageTable rejects an invalid status instead of coercing it", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | maybe | Tools |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /invalid status/);
assert.equal(errors[0].line, 3);
});
test("parseCoverageTable reports a row with too few columns", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /at least 3 columns/);
});
test("parseCoverageTable returns empty results for a document with no table", () => {
const { rows, errors } = parseCoverageTable("# Nothing\n\nJust prose.");
assert.equal(rows.length, 0);
assert.equal(errors.length, 0);
});
test("groupByStation orders all-stations first and no-station last", () => {
const { rows } = parseCoverageTable(DOC);
const groups = groupByStation(rows);
assert.deepEqual(groups.map((g) => g.station), [
"All stations", "Tools", "Process", "No station",
]);
assert.equal(groups[1].rows[0].concept, "harness");
});
test("groupByStation places a multi-station row under its first station only", () => {
const rows = [{ concept: "x", status: "covered", stations: ["Memory", "Skills"], pinned: false, line: 1 }];
const groups = groupByStation(rows);
assert.equal(groups.length, 1);
assert.equal(groups[0].station, "Memory");
});
const { reconcileConcepts } = require("../main.js").__test__;
test("reconcileConcepts finds concept pages with no row", () => {
const rows = [{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 }];
const out = reconcileConcepts(rows, ["harness", "brand-new-concept"]);
assert.deepEqual(out.unsynced, ["brand-new-concept"]);
assert.deepEqual(out.stale, []);
});
test("reconcileConcepts finds rows whose concept page is gone", () => {
const rows = [
{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 },
{ concept: "deleted-idea", status: "absent", stations: [], pinned: false, line: 2 },
];
const out = reconcileConcepts(rows, ["harness"]);
assert.deepEqual(out.stale.map((r) => r.concept), ["deleted-idea"]);
assert.deepEqual(out.unsynced, []);
});
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseCoverageTable, groupByStation } = require("../main.js").__test__;
const DOC = [
"# Script coverage",
"",
"#coverage",
"",
"## Metadata",
"",
"- **Script:** `raw/sources/Webinar script.md`",
"- **Last synced:** 2026-07-28",
"",
"## Coverage",
"",
"| Concept | Status | Station | Pinned |",
"|---|---|---|---|",
"| [[harness]] | covered | Tools | |",
"| [[agentic-loops]] | partial | Process | |",
"| [[levels-of-ai-usage]] | partial | all | |",
"| [[connections-as-moat]] | absent | — | yes |",
].join("\n");
test("parseCoverageTable reads metadata", () => {
const { meta } = parseCoverageTable(DOC);
assert.equal(meta.script, "raw/sources/Webinar script.md");
assert.equal(meta.lastSynced, "2026-07-28");
});
test("parseCoverageTable reads every data row and skips the header", () => {
const { rows, errors } = parseCoverageTable(DOC);
assert.equal(errors.length, 0);
assert.equal(rows.length, 4);
assert.deepEqual(rows.map((r) => r.concept), [
"harness", "agentic-loops", "levels-of-ai-usage", "connections-as-moat",
]);
});
test("parseCoverageTable normalises stations", () => {
const { rows } = parseCoverageTable(DOC);
assert.deepEqual(rows[0].stations, ["Tools"]);
assert.deepEqual(rows[2].stations, ["all"]);
assert.deepEqual(rows[3].stations, []);
});
test("parseCoverageTable reads the pinned flag", () => {
const { rows } = parseCoverageTable(DOC);
assert.equal(rows[0].pinned, false);
assert.equal(rows[3].pinned, true);
});
test("parseCoverageTable strips a wikilink alias", () => {
const doc = "| Concept | Status | Station |\n|---|---|---|\n| [[harness\\|The harness]] | covered | Tools |";
const { rows } = parseCoverageTable(doc);
assert.equal(rows[0].concept, "harness");
});
test("parseCoverageTable splits a multi-station cell", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered | Tools, Memory |";
const { rows } = parseCoverageTable(doc);
assert.deepEqual(rows[0].stations, ["Tools", "Memory"]);
});
test("parseCoverageTable rejects an invalid status instead of coercing it", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | maybe | Tools |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /invalid status/);
assert.equal(errors[0].line, 3);
});
test("parseCoverageTable reports a row with too few columns", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /at least 3 columns/);
});
test("parseCoverageTable returns empty results for a document with no table", () => {
const { rows, errors } = parseCoverageTable("# Nothing\n\nJust prose.");
assert.equal(rows.length, 0);
assert.equal(errors.length, 0);
});
test("groupByStation orders all-stations first and no-station last", () => {
const { rows } = parseCoverageTable(DOC);
const groups = groupByStation(rows);
assert.deepEqual(groups.map((g) => g.station), [
"All stations", "Tools", "Process", "No station",
]);
assert.equal(groups[1].rows[0].concept, "harness");
});
test("groupByStation places a multi-station row under its first station only", () => {
const rows = [{ concept: "x", status: "covered", stations: ["Memory", "Skills"], pinned: false, line: 1 }];
const groups = groupByStation(rows);
assert.equal(groups.length, 1);
assert.equal(groups[0].station, "Memory");
});
const { reconcileConcepts } = require("../main.js").__test__;
test("reconcileConcepts finds concept pages with no row", () => {
const rows = [{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 }];
const out = reconcileConcepts(rows, ["harness", "brand-new-concept"]);
assert.deepEqual(out.unsynced, ["brand-new-concept"]);
assert.deepEqual(out.stale, []);
});
test("reconcileConcepts finds rows whose concept page is gone", () => {
const rows = [
{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 },
{ concept: "deleted-idea", status: "absent", stations: [], pinned: false, line: 2 },
];
const out = reconcileConcepts(rows, ["harness"]);
assert.deepEqual(out.stale.map((r) => r.concept), ["deleted-idea"]);
assert.deepEqual(out.unsynced, []);
});

View File

@@ -1,109 +1,109 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractRawPath, derivePipeline } = require("../main.js").__test__;
test("extractRawPath pulls the backticked path", () => {
const page = [
"# You're reading way too much code",
"",
"#source",
"",
"## Source Metadata",
"",
"- **Date:** YouTube video, 24:11",
"- **Raw path:** `raw/sources/You're reading way too much code.md`",
"- **Source type:** video essay",
].join("\n");
assert.equal(extractRawPath(page), "raw/sources/You're reading way too much code.md");
});
test("extractRawPath handles cyrillic and em dashes", () => {
const page = "- **Raw path:** `raw/sources/Скиллы на базе git — новая память AI-агентов.md`";
assert.equal(extractRawPath(page), "raw/sources/Скиллы на базе git — новая память AI-агентов.md");
});
test("extractRawPath returns null when the line is absent", () => {
assert.equal(extractRawPath("# A page\n\n#source\n\nNo metadata here."), null);
});
test("derivePipeline splits claimed from unclaimed raw files", () => {
const rawFiles = [
{ path: "raw/sources/Nina interview.md", name: "Nina interview.md", size: 6614 },
{ path: "raw/sources/Webinar script.md", name: "Webinar script.md", size: 15841 },
];
const sourcePages = [
{
path: "wiki/sources/2026-07-14-nina-interview.md",
name: "2026-07-14-nina-interview.md",
rawPath: "raw/sources/Nina interview.md",
},
];
const out = derivePipeline({ rawFiles, sourcePages });
assert.equal(out.processed.length, 1);
assert.equal(out.processed[0].name, "Nina interview.md");
assert.equal(out.processed[0].page.name, "2026-07-14-nina-interview.md");
assert.equal(out.unprocessed.length, 1);
assert.equal(out.unprocessed[0].name, "Webinar script.md");
assert.equal(out.orphaned.length, 0);
});
test("derivePipeline reports source pages whose raw file is gone", () => {
const out = derivePipeline({
rawFiles: [],
sourcePages: [
{ path: "wiki/sources/x.md", name: "x.md", rawPath: "raw/sources/deleted.md" },
],
});
assert.equal(out.orphaned.length, 1);
assert.equal(out.orphaned[0].name, "x.md");
});
test("derivePipeline treats a page with no raw path as orphaned", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }],
sourcePages: [{ path: "wiki/sources/y.md", name: "y.md", rawPath: null }],
});
assert.equal(out.orphaned.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", () => {
const out = derivePipeline({
rawFiles: [
{ path: "raw/sources/b.md", name: "b.md", size: 1 },
{ path: "raw/sources/a.md", name: "a.md", size: 1 },
{ path: "raw/sources/c.md", name: "c.md", size: 1 },
],
sourcePages: [
{ path: "wiki/sources/2026-07-14-x.md", name: "2026-07-14-x.md", rawPath: "raw/sources/c.md" },
],
});
assert.deepEqual(out.unprocessed.map((f) => f.name), ["a.md", "b.md"]);
assert.equal(out.processed.length, 1);
});
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractRawPath, derivePipeline } = require("../main.js").__test__;
test("extractRawPath pulls the backticked path", () => {
const page = [
"# You're reading way too much code",
"",
"#source",
"",
"## Source Metadata",
"",
"- **Date:** YouTube video, 24:11",
"- **Raw path:** `raw/sources/You're reading way too much code.md`",
"- **Source type:** video essay",
].join("\n");
assert.equal(extractRawPath(page), "raw/sources/You're reading way too much code.md");
});
test("extractRawPath handles cyrillic and em dashes", () => {
const page = "- **Raw path:** `raw/sources/Скиллы на базе git — новая память AI-агентов.md`";
assert.equal(extractRawPath(page), "raw/sources/Скиллы на базе git — новая память AI-агентов.md");
});
test("extractRawPath returns null when the line is absent", () => {
assert.equal(extractRawPath("# A page\n\n#source\n\nNo metadata here."), null);
});
test("derivePipeline splits claimed from unclaimed raw files", () => {
const rawFiles = [
{ path: "raw/sources/Nina interview.md", name: "Nina interview.md", size: 6614 },
{ path: "raw/sources/Webinar script.md", name: "Webinar script.md", size: 15841 },
];
const sourcePages = [
{
path: "wiki/sources/2026-07-14-nina-interview.md",
name: "2026-07-14-nina-interview.md",
rawPath: "raw/sources/Nina interview.md",
},
];
const out = derivePipeline({ rawFiles, sourcePages });
assert.equal(out.processed.length, 1);
assert.equal(out.processed[0].name, "Nina interview.md");
assert.equal(out.processed[0].page.name, "2026-07-14-nina-interview.md");
assert.equal(out.unprocessed.length, 1);
assert.equal(out.unprocessed[0].name, "Webinar script.md");
assert.equal(out.orphaned.length, 0);
});
test("derivePipeline reports source pages whose raw file is gone", () => {
const out = derivePipeline({
rawFiles: [],
sourcePages: [
{ path: "wiki/sources/x.md", name: "x.md", rawPath: "raw/sources/deleted.md" },
],
});
assert.equal(out.orphaned.length, 1);
assert.equal(out.orphaned[0].name, "x.md");
});
test("derivePipeline treats a page with no raw path as orphaned", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }],
sourcePages: [{ path: "wiki/sources/y.md", name: "y.md", rawPath: null }],
});
assert.equal(out.orphaned.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", () => {
const out = derivePipeline({
rawFiles: [
{ path: "raw/sources/b.md", name: "b.md", size: 1 },
{ path: "raw/sources/a.md", name: "a.md", size: 1 },
{ path: "raw/sources/c.md", name: "c.md", size: 1 },
],
sourcePages: [
{ path: "wiki/sources/2026-07-14-x.md", name: "2026-07-14-x.md", rawPath: "raw/sources/c.md" },
],
});
assert.deepEqual(out.unprocessed.map((f) => f.name), ["a.md", "b.md"]);
assert.equal(out.processed.length, 1);
});

View File

@@ -1,47 +1,47 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isSafeFilename } = require("../main.js").__test__;
test("isSafeFilename accepts every filename currently in the vault", () => {
const real = [
"Agentic Engineering, explained by a 10x developer.md",
"Webinar Plan - From Chat Box to Your Own OS.md",
"Webinar script.md",
"You're reading way too much code.md",
"ИИ глупый!.md",
"Скиллы на базе git — новая память AI-агентов.md",
"sebastian interview - conclusions and insights.md",
"In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md",
];
for (const name of real) {
assert.equal(isSafeFilename(name), true, `should accept: ${name}`);
}
});
test("isSafeFilename rejects shell metacharacters", () => {
for (const bad of ['a".md', "a`b.md", "a$b.md", "a&b.md", "a|b.md", "a;b.md",
"a<b.md", "a>b.md", "a%b.md", "a\nb.md", "a\rb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects the cmd.exe escape character and control characters", () => {
// `^` escapes the next character in cmd.exe, so it can defuse the closing
// quote. NUL additionally makes spawn() throw synchronously.
for (const bad of ["a^b.md", "a\u0000b.md", "a\u001bb.md", "a\u007fb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects path traversal", () => {
assert.equal(isSafeFilename("../secrets.md"), false);
assert.equal(isSafeFilename("a/../../b.md"), false);
});
test("isSafeFilename rejects empty and non-string input", () => {
assert.equal(isSafeFilename(""), false);
assert.equal(isSafeFilename(null), false);
assert.equal(isSafeFilename(undefined), false);
assert.equal(isSafeFilename(42), false);
});
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isSafeFilename } = require("../main.js").__test__;
test("isSafeFilename accepts every filename currently in the vault", () => {
const real = [
"Agentic Engineering, explained by a 10x developer.md",
"Webinar Plan - From Chat Box to Your Own OS.md",
"Webinar script.md",
"You're reading way too much code.md",
"ИИ глупый!.md",
"Скиллы на базе git — новая память AI-агентов.md",
"sebastian interview - conclusions and insights.md",
"In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md",
];
for (const name of real) {
assert.equal(isSafeFilename(name), true, `should accept: ${name}`);
}
});
test("isSafeFilename rejects shell metacharacters", () => {
for (const bad of ['a".md', "a`b.md", "a$b.md", "a&b.md", "a|b.md", "a;b.md",
"a<b.md", "a>b.md", "a%b.md", "a\nb.md", "a\rb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects the cmd.exe escape character and control characters", () => {
// `^` escapes the next character in cmd.exe, so it can defuse the closing
// quote. NUL additionally makes spawn() throw synchronously.
for (const bad of ["a^b.md", "a\u0000b.md", "a\u001bb.md", "a\u007fb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects path traversal", () => {
assert.equal(isSafeFilename("../secrets.md"), false);
assert.equal(isSafeFilename("a/../../b.md"), false);
});
test("isSafeFilename rejects empty and non-string input", () => {
assert.equal(isSafeFilename(""), false);
assert.equal(isSafeFilename(null), false);
assert.equal(isSafeFilename(undefined), false);
assert.equal(isSafeFilename(42), false);
});