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.
Files changed (71) hide show
  1. package/README.md +8 -6
  2. package/dist/SforaFs.js +270 -4
  3. package/dist/api-client.d.ts +47 -1
  4. package/dist/api-client.js +60 -3
  5. package/dist/cli.js +24 -11
  6. package/dist/format/__tests__/byteStable.d.ts +5 -0
  7. package/dist/format/__tests__/byteStable.js +64 -0
  8. package/dist/format/blocks/dropClosure.d.ts +72 -0
  9. package/dist/format/blocks/dropClosure.js +186 -0
  10. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  11. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  12. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  13. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  14. package/dist/format/blocks/parsers.d.ts +105 -0
  15. package/dist/format/blocks/parsers.js +442 -0
  16. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  17. package/dist/format/blocks/structured-block-schema.js +30 -0
  18. package/dist/format/callout.d.ts +66 -0
  19. package/dist/format/callout.js +130 -0
  20. package/dist/format/cardMarkdown.d.ts +4 -0
  21. package/dist/format/cardMarkdown.js +12 -0
  22. package/dist/format/checklist.d.ts +34 -0
  23. package/dist/format/checklist.js +151 -0
  24. package/dist/format/index.d.ts +18 -4
  25. package/dist/format/index.js +24 -4
  26. package/dist/format/lineGeometry.d.ts +70 -0
  27. package/dist/format/lineGeometry.js +324 -0
  28. package/dist/format/lint/index.d.ts +20 -0
  29. package/dist/format/lint/index.js +22 -0
  30. package/dist/format/lint/lintSource.d.ts +36 -0
  31. package/dist/format/lint/lintSource.js +154 -0
  32. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  33. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  34. package/dist/format/lint/rules/index.d.ts +10 -0
  35. package/dist/format/lint/rules/index.js +26 -0
  36. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  37. package/dist/format/lint/rules/malformed-callout.js +79 -0
  38. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  39. package/dist/format/lint/rules/malformed-checklist.js +60 -0
  40. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  41. package/dist/format/lint/rules/malformed-frontmatter.js +93 -0
  42. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  43. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  44. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  45. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  46. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  47. package/dist/format/lint/rules/orphan-reference.js +87 -0
  48. package/dist/format/lint/types.d.ts +80 -0
  49. package/dist/format/lint/types.js +16 -0
  50. package/dist/format/markdown/dates.js +2 -0
  51. package/dist/format/markdown/document.js +2 -0
  52. package/dist/format/markdown/index.js +2 -0
  53. package/dist/format/markdown/mentions.js +2 -0
  54. package/dist/format/markdown/slug.js +2 -0
  55. package/dist/format/markdown/yaml.js +2 -0
  56. package/dist/format/noteMarkdown.js +2 -0
  57. package/dist/format/parseWithFallback.d.ts +13 -0
  58. package/dist/format/parseWithFallback.js +98 -0
  59. package/dist/format/plaintext.d.ts +5 -0
  60. package/dist/format/plaintext.js +41 -0
  61. package/dist/format/postMarkdown.js +3 -1
  62. package/dist/format/taskUploadFilename.d.ts +6 -0
  63. package/dist/format/taskUploadFilename.js +13 -0
  64. package/dist/format/wayfinder.d.ts +50 -0
  65. package/dist/format/wayfinder.js +203 -0
  66. package/dist/format/wikiLinks.d.ts +19 -0
  67. package/dist/format/wikiLinks.js +80 -0
  68. package/dist/local/workspace.d.ts +12 -0
  69. package/dist/local/workspace.js +100 -7
  70. package/dist/mcp-server.js +11 -5
  71. package/package.json +7 -6
@@ -0,0 +1,105 @@
1
+ import { type WayfinderMap } from "../wayfinder.js";
2
+ import { type BlockDropReason } from "./dropClosure.js";
3
+ import type { StructuredBlockLanguage } from "./structured-block-schema.js";
4
+ export interface TimelineEntry {
5
+ time?: string;
6
+ actor: string;
7
+ message: string;
8
+ /**
9
+ * A `state:` line AFTER the header one: a state change, rendered as a
10
+ * marker row rather than a signed update. `actor` is empty for these.
11
+ */
12
+ kind?: "state";
13
+ }
14
+ export interface ChatEntry extends TimelineEntry {
15
+ role?: string;
16
+ }
17
+ export interface BoardColumn {
18
+ title: string;
19
+ items: Array<{
20
+ state: string;
21
+ text: string;
22
+ }>;
23
+ }
24
+ export type SheetAlignment = "left" | "center" | "right";
25
+ export interface SheetData {
26
+ columns: Array<{
27
+ label: string;
28
+ alignment: SheetAlignment;
29
+ }>;
30
+ rows: string[][];
31
+ }
32
+ export interface TimelineData {
33
+ state?: string;
34
+ entries: TimelineEntry[];
35
+ }
36
+ export declare function cleanLines(source: string): string[];
37
+ /** Bytes a parser did not keep. `line` indexes the block body, 0-based. */
38
+ export interface BlockDrop {
39
+ line: number;
40
+ /** The dropped text, trimmed. Empty for a blank line. */
41
+ text: string;
42
+ reason: BlockDropReason;
43
+ }
44
+ /**
45
+ * What a parse produced and what it cost. `consumed` and the whole-line drops
46
+ * partition the body's lines exactly — that is the closure the fixture suite
47
+ * asserts, and the reason a parser can no longer quietly widen what it ignores.
48
+ */
49
+ export interface BlockAccounting<T> {
50
+ data: T | null;
51
+ /** Body line indices that reached the rendered block, ascending. */
52
+ consumed: number[];
53
+ drops: BlockDrop[];
54
+ }
55
+ export declare function parseTimelineWithAccounting(source: string): BlockAccounting<TimelineData>;
56
+ export declare function parseTimeline(source: string): TimelineData | null;
57
+ /**
58
+ * True for a line parseBoard keeps: a column heading or a card. A convenience
59
+ * for callers holding one line — the parser's own accounting is what lint and
60
+ * the closure suite read, so this is never the second opinion.
61
+ */
62
+ export declare function isBoardLine(line: string): boolean;
63
+ export declare function parseBoardWithAccounting(source: string): BlockAccounting<BoardColumn[]>;
64
+ export declare function parseBoard(source: string): BoardColumn[] | null;
65
+ export declare function parseChatWithAccounting(source: string): BlockAccounting<ChatEntry[]>;
66
+ export declare function parseChat(source: string): ChatEntry[] | null;
67
+ /**
68
+ * True for a line parseSheet reads as a table row. GFM makes the outer pipes
69
+ * optional, so the test is "carries a pipe that would split it" — an escaped
70
+ * or code-spanned pipe is content, not structure.
71
+ */
72
+ export declare function isSheetRow(line: string): boolean;
73
+ export declare function parseSheetWithAccounting(source: string): BlockAccounting<SheetData>;
74
+ export declare function parseSheet(source: string): SheetData | null;
75
+ /**
76
+ * True for a line parseMapBlock keeps: the destination header or a ticket in
77
+ * the wayfinder line grammar. Same standing as {@link isBoardLine} — a
78
+ * convenience, not the authority.
79
+ */
80
+ export declare function isMapLine(line: string): boolean;
81
+ /**
82
+ * Parse a ```map block: an optional `destination:` header, then tickets in
83
+ * the wayfinder line grammar (`- [~] Name (type) <- Blocker`). Null when no
84
+ * ticket parses — the renderer's signal to fall back to a visible code fence,
85
+ * same contract as every other structured block.
86
+ */
87
+ export declare function parseMapBlockWithAccounting(source: string): BlockAccounting<WayfinderMap>;
88
+ /**
89
+ * Parse a ```map block: an optional `destination:` header, then tickets in
90
+ * the wayfinder line grammar (`- [~] Name (type) <- Blocker`). Null when no
91
+ * ticket parses — the renderer's signal to fall back to a visible code fence,
92
+ * same contract as every other structured block.
93
+ */
94
+ export declare function parseMapBlock(source: string): WayfinderMap | null;
95
+ /**
96
+ * Parse a fence body as the block its language names, with the ledger. The
97
+ * one door lint and the closure suite use, so neither carries its own table of
98
+ * which parser reads which language.
99
+ */
100
+ export declare function parseStructuredBlock(language: StructuredBlockLanguage, source: string): BlockAccounting<unknown>;
101
+ /**
102
+ * Body line indices whose bytes did not survive the parse at all. Partial
103
+ * drops are excluded by construction — their line rendered.
104
+ */
105
+ export declare function droppedLineNumbers(accounting: BlockAccounting<unknown>): number[];
@@ -0,0 +1,442 @@
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
+ // Parsers for sfora's structured fenced blocks (```status / ```board / ```chat
4
+ // / ```sheet). Pure string -> data: no React, no DOM, no Convex. The renderer
5
+ // (src/components/editor/markdown-structured-block.tsx) imports these and only
6
+ // owns the presentation; the CLI, the agent fs, and tests can read the same
7
+ // blocks without pulling in the editor.
8
+ //
9
+ // Every parser returns `null` when the source doesn't look like its block, which
10
+ // is the renderer's signal to fall back to a plain code fence. That contract is
11
+ // load-bearing — a malformed block must stay visible and copyable, never vanish.
12
+ //
13
+ // Each parser also has a `…WithAccounting` twin that reports which lines it
14
+ // read and which it dropped. Those are the real implementations; the plain
15
+ // parsers are one-line wrappers that keep the data and discard the ledger, so
16
+ // the accounting cannot drift from the parse — it IS the parse. `blocks/
17
+ // dropClosure.ts` adjudicates every reason a drop can carry, and lint reads
18
+ // the drops instead of re-deriving them from a second copy of the grammar.
19
+ import { codeSpanMask } from "../lineGeometry.js";
20
+ import { parseWayfinderTickets, } from "../wayfinder.js";
21
+ import { isWholeLineDrop } from "./dropClosure.js";
22
+ export function cleanLines(source) {
23
+ return source
24
+ .split(/\r?\n/)
25
+ .map((line) => line.trim())
26
+ .filter(Boolean);
27
+ }
28
+ /** Content lines, and the blank-line drops that sit between them. */
29
+ function bodyLines(source) {
30
+ const lines = [];
31
+ const blanks = [];
32
+ source.split(/\r?\n/).forEach((raw, line) => {
33
+ const text = raw.trim();
34
+ if (text === "")
35
+ blanks.push({ line, text: "", reason: "blank-line" });
36
+ else
37
+ lines.push({ line, text });
38
+ });
39
+ return { lines, blanks };
40
+ }
41
+ const byLine = (a, b) => a.line - b.line;
42
+ /**
43
+ * Close one parse's books.
44
+ *
45
+ * A parser that returned null rendered nothing, so nothing was consumed and
46
+ * every content line is a `block-unread` drop — the bytes are still on screen
47
+ * inside the fallback code fence, which is what that verdict says. Partial
48
+ * drops are discarded along with the parse that produced them: there is no
49
+ * half-lost line when the whole block was never read.
50
+ */
51
+ function settle(lines, blanks, data, consumed, drops) {
52
+ if (data === null) {
53
+ const unread = lines.map((line) => ({
54
+ line: line.line,
55
+ text: line.text,
56
+ reason: "block-unread",
57
+ }));
58
+ return { data: null, consumed: [], drops: [...blanks, ...unread].sort(byLine) };
59
+ }
60
+ return {
61
+ data,
62
+ consumed: [...consumed].sort((a, b) => a - b),
63
+ drops: [...blanks, ...drops].sort(byLine),
64
+ };
65
+ }
66
+ /** The status block's header line. Only the first one is the block's state. */
67
+ const STATE_LINE = /^state:\s*(\S.*)$/i;
68
+ /** The `-` / `*` a status or chat entry may be written with. */
69
+ const ENTRY_BULLET = /^([-*])\s+/;
70
+ const TIMELINE_ENTRY = /^(?:(\d{1,2}:\d{2}|\d{4}-\d{2}-\d{2}T\S+)\s+)?@?([^:]+):\s*(.+)$/;
71
+ export function parseTimelineWithAccounting(source) {
72
+ const { lines, blanks } = bodyLines(source);
73
+ const consumed = [];
74
+ const drops = [];
75
+ const entries = [];
76
+ const stateIndex = lines.findIndex((line) => STATE_LINE.test(line.text));
77
+ const stateLine = stateIndex >= 0 ? STATE_LINE.exec(lines[stateIndex].text) : null;
78
+ lines.forEach((line, index) => {
79
+ // The FIRST `state:` line is the block's header. Any later one is a
80
+ // state CHANGE and stays visible as a marker entry in the timeline —
81
+ // neither an update signed by an actor called "state" (the old bug)
82
+ // nor silently dropped (the old fix): the reader shows every line the
83
+ // author wrote.
84
+ const asState = STATE_LINE.exec(line.text);
85
+ if (asState) {
86
+ consumed.push(line.line);
87
+ if (index !== stateIndex) {
88
+ entries.push({ actor: "", message: asState[1].trim(), kind: "state" });
89
+ }
90
+ return;
91
+ }
92
+ const bullet = ENTRY_BULLET.exec(line.text);
93
+ const value = line.text.replace(ENTRY_BULLET, "");
94
+ const match = TIMELINE_ENTRY.exec(value);
95
+ if (!match) {
96
+ drops.push({
97
+ line: line.line,
98
+ text: line.text,
99
+ reason: "status:unsigned-line",
100
+ });
101
+ return;
102
+ }
103
+ consumed.push(line.line);
104
+ if (bullet) {
105
+ drops.push({
106
+ line: line.line,
107
+ text: bullet[1],
108
+ reason: "entry-bullet-marker",
109
+ });
110
+ }
111
+ entries.push({
112
+ time: match[1],
113
+ actor: match[2].trim(),
114
+ message: match[3].trim(),
115
+ });
116
+ });
117
+ const data = !stateLine && entries.length === 0
118
+ ? null
119
+ : { state: stateLine?.[1].trim(), entries };
120
+ return settle(lines, blanks, data, consumed, drops);
121
+ }
122
+ export function parseTimeline(source) {
123
+ return parseTimelineWithAccounting(source).data;
124
+ }
125
+ const BOARD_HEADING = /^#{1,6}\s+(.+)$/;
126
+ const BOARD_ITEM = /^[-*]\s+(?:\[([^\]])\]\s+)?(.+)$/;
127
+ /**
128
+ * True for a line parseBoard keeps: a column heading or a card. A convenience
129
+ * for callers holding one line — the parser's own accounting is what lint and
130
+ * the closure suite read, so this is never the second opinion.
131
+ */
132
+ export function isBoardLine(line) {
133
+ const text = line.trim();
134
+ return BOARD_HEADING.test(text) || BOARD_ITEM.test(text);
135
+ }
136
+ export function parseBoardWithAccounting(source) {
137
+ const { lines, blanks } = bodyLines(source);
138
+ const consumed = [];
139
+ const drops = [];
140
+ const columns = [];
141
+ let current = null;
142
+ for (const line of lines) {
143
+ const heading = BOARD_HEADING.exec(line.text);
144
+ if (heading) {
145
+ consumed.push(line.line);
146
+ current = { title: heading[1].trim(), items: [] };
147
+ columns.push(current);
148
+ continue;
149
+ }
150
+ const item = BOARD_ITEM.exec(line.text);
151
+ if (item) {
152
+ consumed.push(line.line);
153
+ current ??= { title: "Items", items: [] };
154
+ if (!columns.includes(current))
155
+ columns.push(current);
156
+ current.items.push({
157
+ state: item[1]?.trim() ?? "",
158
+ text: item[2].trim(),
159
+ });
160
+ continue;
161
+ }
162
+ drops.push({
163
+ line: line.line,
164
+ text: line.text,
165
+ reason: "board:unparsed-line",
166
+ });
167
+ }
168
+ const data = columns.some((column) => column.items.length > 0)
169
+ ? columns
170
+ : null;
171
+ return settle(lines, blanks, data, consumed, drops);
172
+ }
173
+ export function parseBoard(source) {
174
+ return parseBoardWithAccounting(source).data;
175
+ }
176
+ // The clock-time alternative has to lead: without it the lazy actor group
177
+ // stops at the hour's colon and "09:04 @mara: hi" is signed by "09".
178
+ const CHAT_ENTRY = /^(?:(\d{1,2}:\d{2}|\d{4}-\d{2}-\d{2}T\S+)\s+)?@?(.+?)(?:\s+\(([^)]+)\))?\s*:\s*(.+)$/;
179
+ export function parseChatWithAccounting(source) {
180
+ const { lines, blanks } = bodyLines(source);
181
+ const consumed = [];
182
+ const drops = [];
183
+ const entries = [];
184
+ for (const line of lines) {
185
+ const bullet = ENTRY_BULLET.exec(line.text);
186
+ const value = line.text.replace(ENTRY_BULLET, "");
187
+ const match = CHAT_ENTRY.exec(value);
188
+ if (!match?.[2].trim() || !match[4].trim()) {
189
+ drops.push({
190
+ line: line.line,
191
+ text: line.text,
192
+ reason: "chat:unparsed-line",
193
+ });
194
+ continue;
195
+ }
196
+ consumed.push(line.line);
197
+ if (bullet) {
198
+ drops.push({
199
+ line: line.line,
200
+ text: bullet[1],
201
+ reason: "entry-bullet-marker",
202
+ });
203
+ }
204
+ entries.push({
205
+ time: match[1],
206
+ actor: match[2].trim(),
207
+ role: match[3]?.trim(),
208
+ message: match[4].trim(),
209
+ });
210
+ }
211
+ return settle(lines, blanks, entries.length > 0 ? entries : null, consumed, drops);
212
+ }
213
+ export function parseChat(source) {
214
+ return parseChatWithAccounting(source).data;
215
+ }
216
+ // GFM's delimiter cell: optional colons around one or more dashes. Demanding
217
+ // three would render `| - | - |` as a row of literal dashes.
218
+ const SHEET_SEPARATOR_CELL = /^:?-+:?$/;
219
+ // The escapable set, per CommonMark. A backslash before anything else is a
220
+ // literal backslash — Windows paths and regexes must survive intact.
221
+ const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
222
+ /** True when the character at `index` is preceded by an odd run of backslashes. */
223
+ function isEscapedAt(text, index) {
224
+ let slashes = 0;
225
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i--)
226
+ slashes++;
227
+ return slashes % 2 === 1;
228
+ }
229
+ /**
230
+ * True for a line parseSheet reads as a table row. GFM makes the outer pipes
231
+ * optional, so the test is "carries a pipe that would split it" — an escaped
232
+ * or code-spanned pipe is content, not structure.
233
+ */
234
+ export function isSheetRow(line) {
235
+ const text = line.trim();
236
+ const inCode = codeSpanMask(text);
237
+ for (let i = 0; i < text.length; i++) {
238
+ if (text[i] === "|" && !inCode[i] && !isEscapedAt(text, i))
239
+ return true;
240
+ }
241
+ return false;
242
+ }
243
+ function sheetCells(line) {
244
+ let content = line.trim();
245
+ if (content.startsWith("|"))
246
+ content = content.slice(1);
247
+ if (content.endsWith("|") && !isEscapedAt(content, content.length - 1)) {
248
+ content = content.slice(0, -1);
249
+ }
250
+ const inCode = codeSpanMask(content);
251
+ const result = [];
252
+ let cell = "";
253
+ for (let i = 0; i < content.length; i++) {
254
+ const character = content[i];
255
+ if (character === "\\") {
256
+ const next = content[i + 1];
257
+ // GFM resolves `\|` before inline parsing, so it is a literal pipe even
258
+ // inside a code span — that is the only way to write one there. Every
259
+ // other escape belongs to inline parsing and stops at a code span.
260
+ if (next === "|" ||
261
+ (next !== undefined && !inCode[i] && ASCII_PUNCTUATION.test(next))) {
262
+ cell += next;
263
+ i++;
264
+ continue;
265
+ }
266
+ cell += character;
267
+ continue;
268
+ }
269
+ if (character === "|" && !inCode[i]) {
270
+ result.push(cell.trim());
271
+ cell = "";
272
+ continue;
273
+ }
274
+ cell += character;
275
+ }
276
+ result.push(cell.trim());
277
+ return result;
278
+ }
279
+ export function parseSheetWithAccounting(source) {
280
+ const { lines, blanks } = bodyLines(source);
281
+ const consumed = [];
282
+ const drops = [];
283
+ const rowLines = [];
284
+ for (const line of lines) {
285
+ if (isSheetRow(line.text))
286
+ rowLines.push(line);
287
+ else {
288
+ drops.push({
289
+ line: line.line,
290
+ text: line.text,
291
+ reason: "sheet:non-row-line",
292
+ });
293
+ }
294
+ }
295
+ if (rowLines.length < 2)
296
+ return settle(lines, blanks, null, [], drops);
297
+ const headers = sheetCells(rowLines[0].text);
298
+ const separator = sheetCells(rowLines[1].text);
299
+ const hasSeparator = separator.length === headers.length &&
300
+ separator.every((cell) => SHEET_SEPARATOR_CELL.test(cell));
301
+ const dataLines = rowLines.slice(hasSeparator ? 2 : 1);
302
+ const rows = dataLines.map((line) => sheetCells(line.text));
303
+ if (headers.length === 0 || rows.length === 0) {
304
+ return settle(lines, blanks, null, [], drops);
305
+ }
306
+ consumed.push(rowLines[0].line);
307
+ if (hasSeparator) {
308
+ // The delimiter row is alignment, not data: read into every column below
309
+ // and gone from the grid, which is why it signs as retained-by-capture.
310
+ drops.push({
311
+ line: rowLines[1].line,
312
+ text: rowLines[1].text,
313
+ reason: "sheet:delimiter-row",
314
+ });
315
+ }
316
+ const normalizedRows = rows.map((row, index) => {
317
+ const line = dataLines[index];
318
+ consumed.push(line.line);
319
+ if (row.length > headers.length) {
320
+ drops.push({
321
+ line: line.line,
322
+ text: row.slice(headers.length).join(" | "),
323
+ reason: "sheet:excess-cells",
324
+ });
325
+ }
326
+ return headers.map((_, column) => row[column] ?? "");
327
+ });
328
+ const columns = headers.map((header, index) => {
329
+ const marker = hasSeparator ? separator[index] : "";
330
+ const authoredAlignment = marker.startsWith(":") && marker.endsWith(":")
331
+ ? "center"
332
+ : marker.endsWith(":")
333
+ ? "right"
334
+ : marker.startsWith(":")
335
+ ? "left"
336
+ : null;
337
+ const values = normalizedRows.map((row) => row[index]).filter(Boolean);
338
+ const numericValues = values.filter((value) => /^(?:[-+]?[$€£]?\s*\d[\d,.]*(?:%|[KMBT])?|N\/?A|—)$/i.test(value));
339
+ // First column is the row label, so it always reads left; elsewhere a
340
+ // majority of numeric cells wins right alignment when the source didn't say.
341
+ const inferredAlignment = index > 0 &&
342
+ numericValues.length > 0 &&
343
+ numericValues.length >= Math.ceil(values.length / 2)
344
+ ? "right"
345
+ : "left";
346
+ return {
347
+ label: header || `Column ${index + 1}`,
348
+ alignment: index === 0
349
+ ? "left"
350
+ : (authoredAlignment ?? inferredAlignment),
351
+ };
352
+ });
353
+ return settle(lines, blanks, { columns, rows: normalizedRows }, consumed, drops);
354
+ }
355
+ export function parseSheet(source) {
356
+ return parseSheetWithAccounting(source).data;
357
+ }
358
+ // ── The map block ───────────────────────────────────────────────────
359
+ /** The map block's header line, mirroring the status block's `state:`. */
360
+ const MAP_DESTINATION_LINE = /^destination:\s*(\S.*)$/i;
361
+ /**
362
+ * True for a line parseMapBlock keeps: the destination header or a ticket in
363
+ * the wayfinder line grammar. Same standing as {@link isBoardLine} — a
364
+ * convenience, not the authority.
365
+ */
366
+ export function isMapLine(line) {
367
+ const text = line.trim();
368
+ return (MAP_DESTINATION_LINE.test(text) ||
369
+ parseWayfinderTickets(text).length === 1);
370
+ }
371
+ /**
372
+ * Parse a ```map block: an optional `destination:` header, then tickets in
373
+ * the wayfinder line grammar (`- [~] Name (type) <- Blocker`). Null when no
374
+ * ticket parses — the renderer's signal to fall back to a visible code fence,
375
+ * same contract as every other structured block.
376
+ */
377
+ export function parseMapBlockWithAccounting(source) {
378
+ const { lines, blanks } = bodyLines(source);
379
+ const consumed = [];
380
+ const drops = [];
381
+ const tickets = [];
382
+ let destination;
383
+ for (const line of lines) {
384
+ const header = MAP_DESTINATION_LINE.exec(line.text);
385
+ // Only the first destination line is the header; a second one is not a
386
+ // ticket either, so it falls through to the drop below.
387
+ if (header && destination === undefined) {
388
+ destination = header[1].trim();
389
+ consumed.push(line.line);
390
+ continue;
391
+ }
392
+ // The ticket grammar is line-local and stateless, so reading it a line at
393
+ // a time gives exactly the list a whole-section read would.
394
+ const parsed = parseWayfinderTickets(line.text);
395
+ if (parsed.length === 1) {
396
+ consumed.push(line.line);
397
+ tickets.push(parsed[0]);
398
+ continue;
399
+ }
400
+ drops.push({
401
+ line: line.line,
402
+ text: line.text,
403
+ reason: "map:unparsed-line",
404
+ });
405
+ }
406
+ const data = tickets.length === 0 ? null : { destination, tickets };
407
+ return settle(lines, blanks, data, consumed, drops);
408
+ }
409
+ /**
410
+ * Parse a ```map block: an optional `destination:` header, then tickets in
411
+ * the wayfinder line grammar (`- [~] Name (type) <- Blocker`). Null when no
412
+ * ticket parses — the renderer's signal to fall back to a visible code fence,
413
+ * same contract as every other structured block.
414
+ */
415
+ export function parseMapBlock(source) {
416
+ return parseMapBlockWithAccounting(source).data;
417
+ }
418
+ /** Every structured block's parser, by language. */
419
+ const ACCOUNTING_PARSERS = {
420
+ status: parseTimelineWithAccounting,
421
+ board: parseBoardWithAccounting,
422
+ chat: parseChatWithAccounting,
423
+ sheet: parseSheetWithAccounting,
424
+ map: parseMapBlockWithAccounting,
425
+ };
426
+ /**
427
+ * Parse a fence body as the block its language names, with the ledger. The
428
+ * one door lint and the closure suite use, so neither carries its own table of
429
+ * which parser reads which language.
430
+ */
431
+ export function parseStructuredBlock(language, source) {
432
+ return ACCOUNTING_PARSERS[language](source);
433
+ }
434
+ /**
435
+ * Body line indices whose bytes did not survive the parse at all. Partial
436
+ * drops are excluded by construction — their line rendered.
437
+ */
438
+ export function droppedLineNumbers(accounting) {
439
+ return accounting.drops
440
+ .filter((drop) => isWholeLineDrop(drop.reason))
441
+ .map((drop) => drop.line);
442
+ }
@@ -0,0 +1,8 @@
1
+ export declare const STRUCTURED_BLOCK_LANGUAGES: readonly ["status", "board", "chat", "sheet", "map"];
2
+ export declare const RICH_BLOCK_LANGUAGES: readonly ["mermaid", "status", "board", "chat", "sheet", "map"];
3
+ export type StructuredBlockLanguage = (typeof STRUCTURED_BLOCK_LANGUAGES)[number];
4
+ export type RichBlockLanguage = (typeof RICH_BLOCK_LANGUAGES)[number];
5
+ export declare const STRUCTURED_BLOCK_STARTERS: Record<StructuredBlockLanguage, string>;
6
+ export declare const RICH_BLOCK_STARTERS: Record<RichBlockLanguage, string>;
7
+ export declare function isStructuredMarkdownLanguage(language: string): language is StructuredBlockLanguage;
8
+ export declare function isRichMarkdownLanguage(language: string): language is RichBlockLanguage;
@@ -0,0 +1,30 @@
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 STRUCTURED_BLOCK_LANGUAGES = [
4
+ "status",
5
+ "board",
6
+ "chat",
7
+ "sheet",
8
+ "map",
9
+ ];
10
+ export const RICH_BLOCK_LANGUAGES = [
11
+ "mermaid",
12
+ ...STRUCTURED_BLOCK_LANGUAGES,
13
+ ];
14
+ export const STRUCTURED_BLOCK_STARTERS = {
15
+ status: "state: building\n- @you: Add an update",
16
+ board: "## In progress\n- [ ] Describe the active work\n\n## Review\n- [ ] Add a review item\n\n## Done\n- [x] Capture completed work",
17
+ chat: "- @you: Add a message",
18
+ sheet: "| Field | Value |\n| --- | --- |\n| Status | Building |\n| Owner | You |",
19
+ map: "destination: What shipping looks like\n- [x] Name the destination\n- [ ] Chart the first question <- Name the destination",
20
+ };
21
+ export const RICH_BLOCK_STARTERS = {
22
+ mermaid: "flowchart LR\n Source[Markdown] --> Result[Rendered block]",
23
+ ...STRUCTURED_BLOCK_STARTERS,
24
+ };
25
+ export function isStructuredMarkdownLanguage(language) {
26
+ return STRUCTURED_BLOCK_LANGUAGES.includes(language.toLowerCase());
27
+ }
28
+ export function isRichMarkdownLanguage(language) {
29
+ return RICH_BLOCK_LANGUAGES.includes(language.toLowerCase());
30
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Callouts — the GFM alert grammar, `> [!NOTE]`.
3
+ *
4
+ * A callout is an ordinary blockquote whose first line is a type marker. That
5
+ * is the whole trick: every reader that does not know about callouts still
6
+ * sees quoted prose, and the bytes stay a blockquote on disk. There is no new
7
+ * fence, no new frontmatter key, nothing an agent has to learn.
8
+ *
9
+ * > [!WARNING] Ship blocker
10
+ * > The migration has to run before the deploy.
11
+ *
12
+ * Five types, matching GitHub: note, tip, important, warning, caution. The
13
+ * marker is written uppercase and read case-insensitively, because people type
14
+ * `[!note]` and pasted GitHub markdown says `[!NOTE]` — both are the same
15
+ * callout, and the first save canonicalizes.
16
+ *
17
+ * Pure functions over lines: no DOM, no ProseMirror, no React. The reader
18
+ * (`src/lib/utils/markdown.ts`), the Tiptap node
19
+ * (`src/components/editor/extensions/callout.ts`), and the CLI all read the
20
+ * grammar from here so they cannot disagree about what a callout is.
21
+ */
22
+ export declare const CALLOUT_TYPES: readonly ["note", "tip", "important", "warning", "caution"];
23
+ export type CalloutType = (typeof CALLOUT_TYPES)[number];
24
+ export interface Callout {
25
+ type: CalloutType;
26
+ /** Custom heading on the marker line. Absent means "use the type's label". */
27
+ title?: string;
28
+ /** Everything below the marker line, quote markers already removed. */
29
+ body: string;
30
+ }
31
+ /** The marker as written: uppercase, so `[!NOTE]` is what lands on disk. */
32
+ export declare function calloutMarker(type: CalloutType): string;
33
+ export declare function isCalloutType(value: string): value is CalloutType;
34
+ /**
35
+ * Drop one level of `>` quoting. A single space after the marker is part of
36
+ * the marker, not the content — `> indented` keeps one space.
37
+ */
38
+ export declare function stripQuoteMarkers(lines: readonly string[]): string[];
39
+ /**
40
+ * Read a callout out of the lines of a blockquote.
41
+ *
42
+ * Accepts either raw source lines (`> [!NOTE]`) or lines whose quote markers a
43
+ * caller has already stripped (`[!NOTE]`) — the reader strips as it scans, the
44
+ * editor does not. The two are told apart by looking at the block as a whole:
45
+ * if every non-blank line is quoted, one level comes off. That way a callout
46
+ * whose body contains a nested quote survives either way round.
47
+ *
48
+ * Returns null for anything that is not a callout, which is the signal to
49
+ * render an ordinary blockquote.
50
+ */
51
+ export declare function parseCallout(blockquoteLines: readonly string[]): Callout | null;
52
+ /**
53
+ * Index, within the same lines, of the callout body's first line — or -1 when
54
+ * the block is not a callout. A caller that re-parses `body` as markdown needs
55
+ * it to map a node back to the document line it came from; the reader joins
56
+ * its checkboxes to the document's line scan that way. Kept off `Callout`
57
+ * itself because the type is a value the editor and CLI construct by hand.
58
+ */
59
+ export declare function calloutBodyLine(blockquoteLines: readonly string[]): number;
60
+ /**
61
+ * Write a callout back out. The inverse of `parseCallout` on the canonical
62
+ * form, and the only place the serialized shape is spelled: one blockquote,
63
+ * marker line first, body quoted line by line. A blank body line is a bare
64
+ * `>` — no trailing space, so the bytes survive editors that strip them.
65
+ */
66
+ export declare function calloutToMarkdown(callout: Callout): string;