sfora-cli 0.8.0 → 0.10.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/README.md +8 -6
- package/dist/SforaFs.js +270 -4
- package/dist/api-client.d.ts +47 -1
- package/dist/api-client.js +60 -3
- package/dist/cli.js +24 -11
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blocks/dropClosure.d.ts +72 -0
- package/dist/format/blocks/dropClosure.js +186 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +66 -0
- package/dist/format/callout.js +130 -0
- package/dist/format/cardMarkdown.d.ts +4 -0
- package/dist/format/cardMarkdown.js +12 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +151 -0
- package/dist/format/index.d.ts +18 -4
- package/dist/format/index.js +24 -4
- package/dist/format/lineGeometry.d.ts +70 -0
- package/dist/format/lineGeometry.js +324 -0
- package/dist/format/lint/index.d.ts +20 -0
- package/dist/format/lint/index.js +22 -0
- package/dist/format/lint/lintSource.d.ts +36 -0
- package/dist/format/lint/lintSource.js +154 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- package/dist/format/lint/rules/index.d.ts +10 -0
- package/dist/format/lint/rules/index.js +26 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +79 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +60 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +93 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- package/dist/format/lint/types.d.ts +80 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.js +2 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +41 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +19 -0
- package/dist/format/wikiLinks.js +80 -0
- package/dist/local/workspace.d.ts +12 -0
- package/dist/local/workspace.js +100 -7
- package/dist/mcp-server.js +11 -5
- package/package.json +7 -6
package/dist/cli.js
CHANGED
|
@@ -10,8 +10,9 @@ import * as readline from "node:readline";
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { readFile as readLocalFile } from "node:fs/promises";
|
|
12
12
|
import { basename } from "node:path";
|
|
13
|
+
import { taskUploadFilename } from "./format/taskUploadFilename.js";
|
|
13
14
|
import { createSforaShell, createLocalShell, SforaApiClient } from "./index.js";
|
|
14
|
-
import { LocalWorkspace, initWorkspace, findWorkspace, } from "./local/workspace.js";
|
|
15
|
+
import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
|
|
15
16
|
import { runMcpServer } from "./mcp-server.js";
|
|
16
17
|
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
17
18
|
const colors = {
|
|
@@ -149,13 +150,14 @@ ${colors.dim}Where things live${colors.reset}
|
|
|
149
150
|
/projects/<slug>/posts/<file>.md published posts
|
|
150
151
|
/projects/<slug>/drafts/ drafts
|
|
151
152
|
/projects/<slug>/board/<col>/ board tasks
|
|
152
|
-
/projects/<slug>/
|
|
153
|
+
/projects/<slug>/library/ documents, files, and repositories
|
|
153
154
|
/inbox/mentions.md your mentions
|
|
154
155
|
/me/api-key who you're signed in as
|
|
155
156
|
|
|
156
157
|
${colors.dim}Try${colors.reset}
|
|
157
158
|
ls /projects
|
|
158
159
|
cat /projects/<slug>/posts/<file>.md
|
|
160
|
+
find /projects/<slug>/library -type f
|
|
159
161
|
grep -ri todo /projects
|
|
160
162
|
echo "# Hello" > /projects/<slug>/posts/hello.md
|
|
161
163
|
|
|
@@ -526,7 +528,8 @@ async function runVerb(args, fs, client) {
|
|
|
526
528
|
const md = await readLocalFile(file, "utf8");
|
|
527
529
|
const project = await resolveProject(fs, args.project, md);
|
|
528
530
|
const base = basename(file);
|
|
529
|
-
const
|
|
531
|
+
const sourceName = base.endsWith(".md") ? base : `${base}.md`;
|
|
532
|
+
const name = args.command === "task" ? taskUploadFilename(sourceName) : sourceName;
|
|
530
533
|
if (args.command === "post") {
|
|
531
534
|
const dir = args.draft ? "drafts" : "posts";
|
|
532
535
|
await fs.writeFile(`/projects/${project}/${dir}/${name}`, md);
|
|
@@ -534,7 +537,7 @@ async function runVerb(args, fs, client) {
|
|
|
534
537
|
return;
|
|
535
538
|
}
|
|
536
539
|
if (args.command === "doc") {
|
|
537
|
-
await fs.writeFile(`/projects/${project}/
|
|
540
|
+
await fs.writeFile(`/projects/${project}/library/documents/${name}`, md);
|
|
538
541
|
ok(`Doc saved to ${project} · ${name}`);
|
|
539
542
|
return;
|
|
540
543
|
}
|
|
@@ -544,7 +547,8 @@ async function runVerb(args, fs, client) {
|
|
|
544
547
|
.catch(() => []);
|
|
545
548
|
if (cols.length === 0)
|
|
546
549
|
throw new Error(`no board columns in ${project}`);
|
|
547
|
-
|
|
550
|
+
// Default to "To do" (02-todo) — new work is up for grabs, not in triage.
|
|
551
|
+
let col = cols.find((c) => c.replace(/^\d+-/, "") === "todo") ?? cols[0];
|
|
548
552
|
if (args.column) {
|
|
549
553
|
const want = args.column
|
|
550
554
|
.toLowerCase()
|
|
@@ -560,6 +564,12 @@ async function runVerb(args, fs, client) {
|
|
|
560
564
|
// ─── Local mode (a .sfora/ directory — no server, no account) ─────
|
|
561
565
|
const LOCAL_ONLY_HINT = "cloud command — run it with --cloud (after `sfora login`), or outside the .sfora/ repo";
|
|
562
566
|
async function runLocalVerb(args, root) {
|
|
567
|
+
// One Flow: silently reshape a legacy board onto the four fixed stage dirs on
|
|
568
|
+
// open (idempotent — a no-op once the board is canonical).
|
|
569
|
+
const reshaped = await migrateWorkspaceStages(root);
|
|
570
|
+
if (reshaped.migrated) {
|
|
571
|
+
console.error(`${colors.dim}· reshaped board to the four stages (triage · to do · in progress · done); moved ${reshaped.moved} task${reshaped.moved === 1 ? "" : "s"}${colors.reset}`);
|
|
572
|
+
}
|
|
563
573
|
const ws = new LocalWorkspace(root);
|
|
564
574
|
const { fs } = createLocalShell(root);
|
|
565
575
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
@@ -644,15 +654,18 @@ Standard tools work on the real files:
|
|
|
644
654
|
ls cat grep find head tail wc sed awk echo mv cd pwd
|
|
645
655
|
|
|
646
656
|
${colors.dim}Where things live${colors.reset}
|
|
647
|
-
/board/<
|
|
648
|
-
|
|
649
|
-
|
|
657
|
+
/board/<NN-stage>/NNNN-<slug>.md tasks — four fixed columns: 01-triage /
|
|
658
|
+
02-todo / 03-in-progress / 04-done. mv
|
|
659
|
+
between them moves a task; mv into 04-done
|
|
660
|
+
marks it done.
|
|
661
|
+
/posts/YYYY-MM-DD-<slug>.md posts
|
|
662
|
+
/docs/<slug>.md docs
|
|
650
663
|
|
|
651
664
|
${colors.dim}Try${colors.reset}
|
|
652
|
-
ls /board/
|
|
665
|
+
ls /board/02-todo
|
|
653
666
|
grep -ri todo /board
|
|
654
|
-
echo "# Fix login" > /board/
|
|
655
|
-
mv /board/
|
|
667
|
+
echo "# Fix login" > /board/02-todo/fix-login.md
|
|
668
|
+
mv /board/02-todo/0003-*.md /board/04-done/
|
|
656
669
|
|
|
657
670
|
Everything is git-versioned with your repo. ${colors.dim}Connect a team later with${colors.reset} ${colors.cyan}sfora login${colors.reset}.
|
|
658
671
|
Type ${colors.cyan}exit${colors.reset} to quit.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type RoundTrip = (source: string) => string;
|
|
2
|
+
export declare const documentRoundTrip: RoundTrip;
|
|
3
|
+
export declare function assertByteStable(source: string, roundTrip?: RoundTrip): void;
|
|
4
|
+
export declare function assertRoundTripIdentity(source: string, roundTrip?: RoundTrip): void;
|
|
5
|
+
export declare function isByteStable(source: string, roundTrip?: RoundTrip): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// Round-trip assertions for the document/entity serializers.
|
|
4
|
+
//
|
|
5
|
+
// Two properties, checked separately, because they are not the same claim:
|
|
6
|
+
//
|
|
7
|
+
// byte-stable — one round trip reaches a fixpoint: roundTrip(roundTrip(s))
|
|
8
|
+
// is byte-identical to roundTrip(s). A serializer is allowed to
|
|
9
|
+
// canonicalize on first contact; it is not allowed to keep
|
|
10
|
+
// changing its mind. This is the property that stops documents
|
|
11
|
+
// growing a heading or a fence per save.
|
|
12
|
+
// identity — roundTrip(s) === s. Only true for sources that are already
|
|
13
|
+
// canonical, i.e. ones our own serializers emitted.
|
|
14
|
+
//
|
|
15
|
+
// No vitest import on purpose: this file lives under src/, so packages/sfora's
|
|
16
|
+
// sync-format copies it into the published CLI tarball and it has to compile
|
|
17
|
+
// with nothing but TypeScript.
|
|
18
|
+
import { buildDocument } from "../markdown/document.js";
|
|
19
|
+
import { serializeFrontmatter } from "../markdown/yaml.js";
|
|
20
|
+
import { parseDocumentWithFallback } from "../parseWithFallback.js";
|
|
21
|
+
// The document envelope round trip: read a file the way /v1/fs reads it, write
|
|
22
|
+
// it back the way /v1/fs writes it. Frontmatter keys keep their source order
|
|
23
|
+
// (object insertion order), so a clean file comes back unchanged.
|
|
24
|
+
export const documentRoundTrip = (source) => {
|
|
25
|
+
const parsed = parseDocumentWithFallback(source);
|
|
26
|
+
const fm = serializeFrontmatter(Object.entries(parsed.frontmatter));
|
|
27
|
+
return buildDocument(fm, parsed.title, parsed.body);
|
|
28
|
+
};
|
|
29
|
+
function describeDivergence(a, b) {
|
|
30
|
+
const limit = Math.min(a.length, b.length);
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < limit && a[i] === b[i])
|
|
33
|
+
i++;
|
|
34
|
+
const window = (s) => JSON.stringify(s.slice(Math.max(0, i - 40), i + 40));
|
|
35
|
+
return `first divergence at char ${i}\n pass 1: ${window(a)}\n pass 2: ${window(b)}`;
|
|
36
|
+
}
|
|
37
|
+
// parse → serialize → parse → serialize; the second pass must be byte-identical
|
|
38
|
+
// to the first.
|
|
39
|
+
export function assertByteStable(source, roundTrip = documentRoundTrip) {
|
|
40
|
+
const once = roundTrip(source);
|
|
41
|
+
const twice = roundTrip(once);
|
|
42
|
+
if (twice !== once) {
|
|
43
|
+
throw new Error(`round trip is not byte-stable: ${describeDivergence(once, twice)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Stronger: the source is already canonical and must survive untouched.
|
|
47
|
+
export function assertRoundTripIdentity(source, roundTrip = documentRoundTrip) {
|
|
48
|
+
const once = roundTrip(source);
|
|
49
|
+
if (once !== source) {
|
|
50
|
+
throw new Error(`round trip changed a canonical source: ${describeDivergence(source, once)}`);
|
|
51
|
+
}
|
|
52
|
+
assertByteStable(source, roundTrip);
|
|
53
|
+
}
|
|
54
|
+
// True when one round trip is a fixpoint — the predicate form, for corpora where
|
|
55
|
+
// some divergence is expected and counted against a baseline rather than failing.
|
|
56
|
+
export function isByteStable(source, roundTrip = documentRoundTrip) {
|
|
57
|
+
try {
|
|
58
|
+
assertByteStable(source, roundTrip);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { StructuredBlockLanguage } from "./structured-block-schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every reason a structured-block parser drops bytes. Adding a case here
|
|
4
|
+
* without adding it to {@link BLOCK_DROP_ADJUDICATIONS} does not compile —
|
|
5
|
+
* that is the "no unadjudicated drop" half of the closure.
|
|
6
|
+
*/
|
|
7
|
+
export type BlockDropReason = "blank-line" | "block-unread" | "entry-bullet-marker" | "status:unsigned-line" | "board:unparsed-line" | "chat:unparsed-line" | "sheet:non-row-line" | "sheet:delimiter-row" | "sheet:excess-cells" | "map:unparsed-line";
|
|
8
|
+
/** A block body that provokes a drop, and the exact bytes it loses. */
|
|
9
|
+
export interface DropWitness {
|
|
10
|
+
lang: StructuredBlockLanguage;
|
|
11
|
+
/** The fence body — what the parser is handed. */
|
|
12
|
+
source: string;
|
|
13
|
+
/** The dropped text the accounting must report, verbatim. */
|
|
14
|
+
dropped: string;
|
|
15
|
+
}
|
|
16
|
+
export type DropAdjudication = {
|
|
17
|
+
kind: "structural-only";
|
|
18
|
+
witness: DropWitness;
|
|
19
|
+
rationale: string;
|
|
20
|
+
/** True when the line still rendered and only part of it was lost. */
|
|
21
|
+
partial?: true;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "format-dof-axis";
|
|
24
|
+
axisIds: readonly string[];
|
|
25
|
+
witness: DropWitness;
|
|
26
|
+
rationale: string;
|
|
27
|
+
partial?: true;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "retained-by-capture";
|
|
30
|
+
witness: DropWitness;
|
|
31
|
+
/** Where the bytes went, as a path into the parsed data. */
|
|
32
|
+
retained: string;
|
|
33
|
+
rationale: string;
|
|
34
|
+
partial?: true;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "documented-residual";
|
|
37
|
+
witnesses: readonly DropWitness[];
|
|
38
|
+
rationale: string;
|
|
39
|
+
partial?: true;
|
|
40
|
+
};
|
|
41
|
+
export declare const BLOCK_DROP_ADJUDICATIONS: Readonly<Record<BlockDropReason, DropAdjudication>>;
|
|
42
|
+
/**
|
|
43
|
+
* True when the drop takes the whole line with it. A partial drop happened on
|
|
44
|
+
* a line that rendered anyway — the bullet marker in front of a chat entry,
|
|
45
|
+
* the cells past the end of a sheet's header — and must NOT be subtracted from
|
|
46
|
+
* the consumed set.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isWholeLineDrop(reason: BlockDropReason): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* True when an author should hear about the drop. Only a documented residual
|
|
51
|
+
* is real loss; the other three verdicts say the bytes were structure, a
|
|
52
|
+
* spelling, or captured elsewhere, and a diagnostic for those would be noise
|
|
53
|
+
* on a block that rendered exactly as written.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isReportableDrop(reason: BlockDropReason): boolean;
|
|
56
|
+
/** Every witness in the ledger, flattened, with the reason it belongs to. */
|
|
57
|
+
export declare function dropWitnesses(): Array<{
|
|
58
|
+
reason: BlockDropReason;
|
|
59
|
+
witness: DropWitness;
|
|
60
|
+
}>;
|
|
61
|
+
export interface ClosureCheckResult {
|
|
62
|
+
/** Reasons the parsers produced that the ledger does not adjudicate. */
|
|
63
|
+
unadjudicated: string[];
|
|
64
|
+
/** Ledger entries no fixture provokes any more. */
|
|
65
|
+
stale: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Close the ledger against what the parsers actually did. `observed` is every
|
|
69
|
+
* reason seen across the whole fixture corpus — one document's drops are never
|
|
70
|
+
* enough to close anything.
|
|
71
|
+
*/
|
|
72
|
+
export declare function checkDropClosure(observed: readonly string[]): ClosureCheckResult;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// The drop ledger: every byte a structured-block parser throws away, signed
|
|
4
|
+
// for.
|
|
5
|
+
//
|
|
6
|
+
// A block parser is a filter — it keeps the lines it understands and returns
|
|
7
|
+
// the rest as nothing at all — so "the block rendered" has never been evidence
|
|
8
|
+
// that everything in it did. The parsers now report what they consumed and
|
|
9
|
+
// what they dropped (see `BlockAccounting` in ./parsers), and this file is the
|
|
10
|
+
// other half of that contract: a closed inventory in which every drop reason
|
|
11
|
+
// carries a verdict, a rationale, and a witness that RUNS.
|
|
12
|
+
//
|
|
13
|
+
// The four verdicts are open-knowledge's, and the discipline they encode is
|
|
14
|
+
// that "lossy" is not one thing:
|
|
15
|
+
//
|
|
16
|
+
// structural-only the bytes carry no content — a blank line, a container
|
|
17
|
+
// format-dof-axis the bytes are one spelling of a thing we keep; the
|
|
18
|
+
// axis id names where a serializer replays that spelling
|
|
19
|
+
// retained-by-capture the bytes are gone from the line but present in the
|
|
20
|
+
// parsed data, and `retained` says exactly where
|
|
21
|
+
// documented-residual real loss, admitted, witnessed. These — and only
|
|
22
|
+
// these — are what lint tells an author about.
|
|
23
|
+
//
|
|
24
|
+
// Closure is checked in both directions. TypeScript proves no reason is
|
|
25
|
+
// unadjudicated (the ledger is a total Record over the reason union); the
|
|
26
|
+
// suite in __tests__/blockDropClosure.test.ts proves no entry is stale, that
|
|
27
|
+
// every witness still provokes its drop, and that consumed plus dropped
|
|
28
|
+
// accounts for every line of every fixture.
|
|
29
|
+
export const BLOCK_DROP_ADJUDICATIONS = {
|
|
30
|
+
"blank-line": {
|
|
31
|
+
kind: "structural-only",
|
|
32
|
+
witness: {
|
|
33
|
+
lang: "chat",
|
|
34
|
+
source: "- @mara: on it\n\n- @ada: shipping\n",
|
|
35
|
+
dropped: "",
|
|
36
|
+
},
|
|
37
|
+
rationale: "the block grammars are line-based and read one entry per line, so a blank line separates nothing and carries nothing",
|
|
38
|
+
},
|
|
39
|
+
"block-unread": {
|
|
40
|
+
kind: "retained-by-capture",
|
|
41
|
+
witness: {
|
|
42
|
+
lang: "sheet",
|
|
43
|
+
source: "this is not a table\n",
|
|
44
|
+
dropped: "this is not a table",
|
|
45
|
+
},
|
|
46
|
+
retained: "the rendered code fence",
|
|
47
|
+
rationale: "a parser that returns null is the renderer's signal to draw a plain code fence, so every byte of an unreadable block stays on screen and copyable — the loss is the STRUCTURE, never the text",
|
|
48
|
+
},
|
|
49
|
+
"entry-bullet-marker": {
|
|
50
|
+
kind: "format-dof-axis",
|
|
51
|
+
axisIds: ["structured-block:entry-bullet"],
|
|
52
|
+
partial: true,
|
|
53
|
+
witness: {
|
|
54
|
+
lang: "chat",
|
|
55
|
+
source: "* @mara: on it\n",
|
|
56
|
+
dropped: "*",
|
|
57
|
+
},
|
|
58
|
+
rationale: "a status or chat entry may be authored bare, with `-`, or with `*`; all three render identically, so which one was typed is a spelling the entry-bullet axis owns rather than content",
|
|
59
|
+
},
|
|
60
|
+
"status:unsigned-line": {
|
|
61
|
+
kind: "documented-residual",
|
|
62
|
+
witnesses: [
|
|
63
|
+
{
|
|
64
|
+
lang: "status",
|
|
65
|
+
source: "state: building\n- @ada: on it\njust a loose note\n",
|
|
66
|
+
dropped: "just a loose note",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
rationale: "a status entry is `[time] @actor: message`; a line that is neither that nor the `state:` header has no field to land in, and the timeline would have to invent an author to show it",
|
|
70
|
+
},
|
|
71
|
+
"board:unparsed-line": {
|
|
72
|
+
kind: "documented-residual",
|
|
73
|
+
witnesses: [
|
|
74
|
+
{
|
|
75
|
+
lang: "board",
|
|
76
|
+
source: "## Todo\n- [ ] item one\nThis note vanishes\n",
|
|
77
|
+
dropped: "This note vanishes",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
rationale: "a board block holds column headings and cards; prose between them belongs to neither and there is no column to hang it under",
|
|
81
|
+
},
|
|
82
|
+
"chat:unparsed-line": {
|
|
83
|
+
kind: "documented-residual",
|
|
84
|
+
witnesses: [
|
|
85
|
+
{
|
|
86
|
+
lang: "chat",
|
|
87
|
+
source: "- @mara: on it\nplain note with no speaker\n",
|
|
88
|
+
dropped: "plain note with no speaker",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
rationale: "a chat entry is signed — `@actor: message` — and an unsigned line cannot be attributed without putting words in someone's mouth",
|
|
92
|
+
},
|
|
93
|
+
"sheet:non-row-line": {
|
|
94
|
+
kind: "documented-residual",
|
|
95
|
+
witnesses: [
|
|
96
|
+
{
|
|
97
|
+
lang: "sheet",
|
|
98
|
+
source: "| A | B |\n| --- | --- |\n| 1 | 2 |\njust a note\n",
|
|
99
|
+
dropped: "just a note",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
rationale: "GFM makes the outer pipes optional, so a row is a line carrying a pipe that would split it; a line with no unescaped, un-code-spanned pipe is not a row and has no cells",
|
|
103
|
+
},
|
|
104
|
+
"sheet:delimiter-row": {
|
|
105
|
+
kind: "retained-by-capture",
|
|
106
|
+
witness: {
|
|
107
|
+
lang: "sheet",
|
|
108
|
+
source: "| A | B |\n| --- | ---: |\n| 1 | 2 |\n",
|
|
109
|
+
dropped: "| --- | ---: |",
|
|
110
|
+
},
|
|
111
|
+
retained: "SheetData.columns[].alignment",
|
|
112
|
+
rationale: "the delimiter row is alignment, not data; its colons are read into every column and its dashes have nothing else to say",
|
|
113
|
+
},
|
|
114
|
+
"sheet:excess-cells": {
|
|
115
|
+
kind: "documented-residual",
|
|
116
|
+
partial: true,
|
|
117
|
+
witnesses: [
|
|
118
|
+
{
|
|
119
|
+
lang: "sheet",
|
|
120
|
+
source: "| A | B |\n| --- | --- |\n| 1 | 2 | 3 |\n",
|
|
121
|
+
dropped: "3",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
rationale: "a table is as wide as its header row, exactly as GFM has it — a cell past the last column is not a column a reader sees, and widening the table on its behalf would move every other cell",
|
|
125
|
+
},
|
|
126
|
+
"map:unparsed-line": {
|
|
127
|
+
kind: "documented-residual",
|
|
128
|
+
witnesses: [
|
|
129
|
+
{
|
|
130
|
+
lang: "map",
|
|
131
|
+
source: "destination: Ship it\n- [ ] Chart the first question\na stray note\n",
|
|
132
|
+
dropped: "a stray note",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
// Only the FIRST destination line is the header; a second one is not a
|
|
136
|
+
// ticket either, so it falls here.
|
|
137
|
+
lang: "map",
|
|
138
|
+
source: "destination: Ship it\ndestination: Ship it twice\n- [ ] Chart it\n",
|
|
139
|
+
dropped: "destination: Ship it twice",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
rationale: "a map block holds one destination header and tickets in the wayfinder line grammar; anything else has no node to draw",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* True when the drop takes the whole line with it. A partial drop happened on
|
|
147
|
+
* a line that rendered anyway — the bullet marker in front of a chat entry,
|
|
148
|
+
* the cells past the end of a sheet's header — and must NOT be subtracted from
|
|
149
|
+
* the consumed set.
|
|
150
|
+
*/
|
|
151
|
+
export function isWholeLineDrop(reason) {
|
|
152
|
+
return BLOCK_DROP_ADJUDICATIONS[reason].partial !== true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* True when an author should hear about the drop. Only a documented residual
|
|
156
|
+
* is real loss; the other three verdicts say the bytes were structure, a
|
|
157
|
+
* spelling, or captured elsewhere, and a diagnostic for those would be noise
|
|
158
|
+
* on a block that rendered exactly as written.
|
|
159
|
+
*/
|
|
160
|
+
export function isReportableDrop(reason) {
|
|
161
|
+
return BLOCK_DROP_ADJUDICATIONS[reason].kind === "documented-residual";
|
|
162
|
+
}
|
|
163
|
+
/** Every witness in the ledger, flattened, with the reason it belongs to. */
|
|
164
|
+
export function dropWitnesses() {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const key of Object.keys(BLOCK_DROP_ADJUDICATIONS)) {
|
|
167
|
+
const entry = BLOCK_DROP_ADJUDICATIONS[key];
|
|
168
|
+
const witnesses = entry.kind === "documented-residual" ? entry.witnesses : [entry.witness];
|
|
169
|
+
for (const witness of witnesses)
|
|
170
|
+
out.push({ reason: key, witness });
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Close the ledger against what the parsers actually did. `observed` is every
|
|
176
|
+
* reason seen across the whole fixture corpus — one document's drops are never
|
|
177
|
+
* enough to close anything.
|
|
178
|
+
*/
|
|
179
|
+
export function checkDropClosure(observed) {
|
|
180
|
+
const seen = new Set(observed);
|
|
181
|
+
const adjudicated = Object.keys(BLOCK_DROP_ADJUDICATIONS);
|
|
182
|
+
return {
|
|
183
|
+
unadjudicated: [...seen].filter((r) => !adjudicated.includes(r)).sort(),
|
|
184
|
+
stale: adjudicated.filter((r) => !seen.has(r)).sort(),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MARKDOWN_BLOCK_IDS } from "./markdown-block-ids.mjs";
|
|
2
|
+
export { MARKDOWN_BLOCK_IDS };
|
|
3
|
+
export type MarkdownBlockId = (typeof MARKDOWN_BLOCK_IDS)[number];
|
|
4
|
+
export interface MarkdownBlockSpec {
|
|
5
|
+
id: MarkdownBlockId;
|
|
6
|
+
label: string;
|
|
7
|
+
group: "Text" | "Structure" | "Rich blocks" | "References" | "Recovery";
|
|
8
|
+
description: string;
|
|
9
|
+
source: string;
|
|
10
|
+
renderMermaid?: boolean;
|
|
11
|
+
interactiveTasks?: boolean;
|
|
12
|
+
}
|
|
13
|
+
declare const POST_ID = "post000000000000000000000001";
|
|
14
|
+
declare const CARD_ID = "card000000000000000000000001";
|
|
15
|
+
export declare const MARKDOWN_BLOCK_CATALOG: readonly MarkdownBlockSpec[];
|
|
16
|
+
export declare function getMarkdownBlockSpec(id?: string): MarkdownBlockSpec | undefined;
|
|
17
|
+
export declare const MARKDOWN_BLOCK_FIXTURE_IDS: `markdown-block-${string}`[];
|
|
18
|
+
export { CARD_ID as MARKDOWN_FIXTURE_CARD_ID, POST_ID as MARKDOWN_FIXTURE_POST_ID };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
import { MARKDOWN_BLOCK_IDS } from "./markdown-block-ids.mjs";
|
|
4
|
+
export { MARKDOWN_BLOCK_IDS };
|
|
5
|
+
const POST_ID = "post000000000000000000000001";
|
|
6
|
+
const CARD_ID = "card000000000000000000000001";
|
|
7
|
+
export const MARKDOWN_BLOCK_CATALOG = [
|
|
8
|
+
{
|
|
9
|
+
id: "typography",
|
|
10
|
+
label: "Typography",
|
|
11
|
+
group: "Text",
|
|
12
|
+
description: "Heading hierarchy and document rhythm.",
|
|
13
|
+
source: "# Document title\n\n## Section heading\n\n### Supporting heading\n\nA paragraph owns the primary reading measure. A second sentence proves wrapping and vertical rhythm without creating another surface.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "inline-formatting",
|
|
17
|
+
label: "Inline formatting",
|
|
18
|
+
group: "Text",
|
|
19
|
+
description: "Emphasis, code, mentions, and links in one line flow.",
|
|
20
|
+
source: "Use **strong emphasis**, *considered emphasis*, ~~retired work~~, and `pnpm design:check` without disrupting the baseline. @[Mara Voss](member-mara) can review the [design evidence](https://developer.apple.com/design/human-interface-guidelines/).",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "lists",
|
|
24
|
+
label: "Lists",
|
|
25
|
+
group: "Structure",
|
|
26
|
+
description: "Sibling items remain one semantic run.",
|
|
27
|
+
source: "- Preserve the reading datum\n- Keep sibling items together\n- Use a boundary only when the object earns one\n\n1. Inspect the real route\n2. Capture compact and desktop\n3. Judge the rendered result",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "tasks",
|
|
31
|
+
label: "Task list",
|
|
32
|
+
group: "Structure",
|
|
33
|
+
description: "Interactive and completed task states.",
|
|
34
|
+
source: "- [x] Inventory the canonical blocks\n- [ ] Verify compact overflow\n- [ ] Review dark-mode boundaries",
|
|
35
|
+
interactiveTasks: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "quote",
|
|
39
|
+
label: "Quote",
|
|
40
|
+
group: "Structure",
|
|
41
|
+
description: "Quoted context is subordinate but still readable.",
|
|
42
|
+
source: "> The document is the primary field.\n> Controls explain state without competing with the argument.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "callout",
|
|
46
|
+
label: "Callout",
|
|
47
|
+
group: "Structure",
|
|
48
|
+
description: "An aside earns a tinted surface, never a colored bar.",
|
|
49
|
+
source: "> [!NOTE]\n> A callout is a blockquote with a type marker, so it stays readable everywhere markdown is read.\n\n> [!WARNING] Run the migration first\n> The deploy assumes the new stage columns already exist.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "divider",
|
|
53
|
+
label: "Divider",
|
|
54
|
+
group: "Structure",
|
|
55
|
+
description: "A quiet transition between related passages.",
|
|
56
|
+
source: "The evidence ends here.\n\n---\n\nThe decision begins on the same reading field.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "table",
|
|
60
|
+
label: "Table",
|
|
61
|
+
group: "Structure",
|
|
62
|
+
description: "Aligned data with compact horizontal recovery.",
|
|
63
|
+
source: "| Route | State | Owner | Last review |\n| --- | :---: | --- | ---: |\n| Home | Ready | Product | Today |\n| Library workbench | Review | Design systems | Yesterday |\n| Post reader | Ready | Documents | 2 days |",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "code",
|
|
67
|
+
label: "Code block",
|
|
68
|
+
group: "Structure",
|
|
69
|
+
description: "Monospace source, syntax color, copy action, and overflow.",
|
|
70
|
+
source: "```typescript\ntype SurfaceRole = \"primary\" | \"grouped\" | \"module\"\n\nexport function ownsBoundary(role: SurfaceRole) {\n return role === \"module\"\n}\n```",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "image",
|
|
74
|
+
label: "Image",
|
|
75
|
+
group: "Structure",
|
|
76
|
+
description: "Authored media keeps its alternative text and document measure.",
|
|
77
|
+
source: "",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "mermaid",
|
|
81
|
+
label: "Mermaid",
|
|
82
|
+
group: "Rich blocks",
|
|
83
|
+
description: "Expandable diagram evidence inside the document flow.",
|
|
84
|
+
source: "```mermaid\nflowchart LR\n Source[Markdown source] --> Parse[Canonical parser]\n Parse --> Reader[Reader]\n Parse --> Editor[Editor]\n Reader --> Capture[Visual acceptance]\n```",
|
|
85
|
+
renderMermaid: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "summary-cards",
|
|
89
|
+
label: "Summary cards",
|
|
90
|
+
group: "Rich blocks",
|
|
91
|
+
description: "Legacy digest summaries resolve into one grouped surface.",
|
|
92
|
+
source: "- **Design graph is live** — Thijs Verreck\n> Every rendered state is now addressable from the system graph.\n- **Compact review passed** — Dogfood Bot\n> The reading hierarchy survives the narrow viewport.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "status",
|
|
96
|
+
label: "Status timeline",
|
|
97
|
+
group: "Rich blocks",
|
|
98
|
+
description: "Current state followed by signed chronological updates.",
|
|
99
|
+
source: "```status #run\nstate: building\n- 2026-08-13T09:02Z integrator: renderer inventory complete\n- 2026-08-13T09:18Z reviewer: compact capture is ready\n```",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "board",
|
|
103
|
+
label: "Board",
|
|
104
|
+
group: "Rich blocks",
|
|
105
|
+
description: "Compact work columns with stable narrow-screen overflow.",
|
|
106
|
+
source: "```board #tickets\n## In progress\n- [ ] Fix status anatomy\n- [ ] Verify card references\n## Review\n- [ ] Inspect compact overflow\n## Done\n- [x] Build the state graph\n```",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "chat",
|
|
110
|
+
label: "Conversation",
|
|
111
|
+
group: "Rich blocks",
|
|
112
|
+
description: "Attributed messages remain sibling rows.",
|
|
113
|
+
source: "```chat #general\n- 2026-08-13T09:04Z @integrator (agent): the renderer matrix is live\n- @Alexandra Very Long Operator Name (reviewer): the reference evidence is attached and the compact reading order remains intact\n- 2026-08-13T09:11Z @Mara: review the dark-mode boundary next\n```",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "sheet",
|
|
117
|
+
label: "Data sheet",
|
|
118
|
+
group: "Rich blocks",
|
|
119
|
+
description: "Structured rows and columns with one owning frame.",
|
|
120
|
+
source: "```sheet #coverage\n| Block | Reader | Editor | Compact |\n| --- | --- | --- | --- |\n| Status | Ready | Ready | Review |\n| Board | Ready | Ready | Ready |\n| References | Review | Ready | Ready |\n```",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "map",
|
|
124
|
+
label: "Map",
|
|
125
|
+
group: "Rich blocks",
|
|
126
|
+
description: "Decision tickets and their blocking edges, drawn as the route to a destination.",
|
|
127
|
+
source: "```map #launch\ndestination: A locked launch checklist\n- [x] Name the destination\n- [~] Pick the payment provider (research) <- Name the destination\n- [ ] Do we need a waitlist page? (prototype) <- Name the destination\n- [ ] Settle the seat model <- Pick the payment provider, Do we need a waitlist page?\n- [-] Native mobile app\n```",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "post-reference",
|
|
131
|
+
label: "Post reference",
|
|
132
|
+
group: "References",
|
|
133
|
+
description: "A linked post previews before canonical navigation.",
|
|
134
|
+
source: `[[${POST_ID}|The reader becomes one intentional work surface]]`,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "card-reference",
|
|
138
|
+
label: "Card reference",
|
|
139
|
+
group: "References",
|
|
140
|
+
description: "A linked task exposes state and project context.",
|
|
141
|
+
source: `[[c:${CARD_ID}|Audit every rendered Markdown block]]`,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "unknown-reference",
|
|
145
|
+
label: "Unknown reference",
|
|
146
|
+
group: "Recovery",
|
|
147
|
+
description: "Unresolved work remains visibly unavailable and does not imply navigation.",
|
|
148
|
+
source: "The evidence points to [[missing000000000000000000001|a post that is no longer available]].",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "malformed-structured",
|
|
152
|
+
label: "Malformed structured source",
|
|
153
|
+
group: "Recovery",
|
|
154
|
+
description: "Invalid enhanced Markdown remains visible and copyable.",
|
|
155
|
+
source: "```sheet #broken\nthis is not a valid table\nand the source must not disappear\n```",
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
export function getMarkdownBlockSpec(id) {
|
|
159
|
+
return MARKDOWN_BLOCK_CATALOG.find((block) => block.id === id);
|
|
160
|
+
}
|
|
161
|
+
export const MARKDOWN_BLOCK_FIXTURE_IDS = MARKDOWN_BLOCK_IDS.map((id) => `markdown-block-${id}`);
|
|
162
|
+
export { CARD_ID as MARKDOWN_FIXTURE_CARD_ID, POST_ID as MARKDOWN_FIXTURE_POST_ID };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MARKDOWN_BLOCK_IDS: string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
export const MARKDOWN_BLOCK_IDS = [
|
|
4
|
+
"typography",
|
|
5
|
+
"inline-formatting",
|
|
6
|
+
"lists",
|
|
7
|
+
"tasks",
|
|
8
|
+
"quote",
|
|
9
|
+
"callout",
|
|
10
|
+
"divider",
|
|
11
|
+
"table",
|
|
12
|
+
"code",
|
|
13
|
+
"image",
|
|
14
|
+
"mermaid",
|
|
15
|
+
"summary-cards",
|
|
16
|
+
"status",
|
|
17
|
+
"board",
|
|
18
|
+
"chat",
|
|
19
|
+
"sheet",
|
|
20
|
+
"map",
|
|
21
|
+
"post-reference",
|
|
22
|
+
"card-reference",
|
|
23
|
+
"unknown-reference",
|
|
24
|
+
"malformed-structured",
|
|
25
|
+
];
|