sfora-cli 0.9.0 → 0.11.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 +147 -6
- package/dist/SforaFs.js +278 -10
- package/dist/api-client.d.ts +290 -5
- package/dist/api-client.js +307 -22
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +323 -29
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +81 -0
- package/dist/format/blocks/dropClosure.js +196 -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 +128 -0
- package/dist/format/callout.js +227 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +158 -0
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +19 -4
- package/dist/format/index.js +28 -4
- package/dist/format/lineGeometry.d.ts +100 -0
- package/dist/format/lineGeometry.js +424 -0
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +49 -0
- package/dist/format/lint/index.js +51 -0
- package/dist/format/lint/lintSource.d.ts +56 -0
- package/dist/format/lint/lintSource.js +188 -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/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +11 -0
- package/dist/format/lint/rules/index.js +32 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +88 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +65 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +98 -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/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +116 -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.d.ts +28 -0
- package/dist/format/markdown/slug.js +65 -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 +51 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +78 -0
- package/dist/format/wikiLinks.js +266 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/mcp-server.js +5 -2
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +132 -0
- package/dist/render.js +208 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +7 -6
|
@@ -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,128 @@
|
|
|
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
|
+
* Fifteen types. The first five are GitHub's alert set — note, tip, important,
|
|
13
|
+
* warning, caution — and the other ten are the Obsidian set every vault and
|
|
14
|
+
* every other markdown tool already writes, so a document pasted in from one of
|
|
15
|
+
* them keeps its tone instead of falling back to a plain quote. The marker is
|
|
16
|
+
* written uppercase and read case-insensitively, because people type `[!note]`
|
|
17
|
+
* and pasted GitHub markdown says `[!NOTE]` — both are the same callout, and
|
|
18
|
+
* the first save canonicalizes.
|
|
19
|
+
*
|
|
20
|
+
* Two things beyond the type sit on the marker line, and both are round-tripped
|
|
21
|
+
* rather than normalised away:
|
|
22
|
+
*
|
|
23
|
+
* - an ALIAS. `[!WARN]`, `[!SUMMARY]`, `[!ERROR]` name a type by another
|
|
24
|
+
* word. They resolve to the canonical type for rendering, and the word the
|
|
25
|
+
* author actually typed is carried on `authoredAs` so the bytes come back
|
|
26
|
+
* unchanged. Normalising the spelling would rewrite a line the author did
|
|
27
|
+
* not touch, which is churn in a file two agents and a person share.
|
|
28
|
+
* - a FOLD marker. Obsidian's `[!NOTE]+` and `[!NOTE]-` say the callout is
|
|
29
|
+
* collapsible and whether it starts open. It is one character of grammar
|
|
30
|
+
* and it is the whole difference between a callout and a details block.
|
|
31
|
+
*
|
|
32
|
+
* Pure functions over lines: no DOM, no ProseMirror, no React. The reader
|
|
33
|
+
* (`src/lib/utils/markdown.ts`), the Tiptap node
|
|
34
|
+
* (`src/components/editor/extensions/callout.ts`), and the CLI all read the
|
|
35
|
+
* grammar from here so they cannot disagree about what a callout is.
|
|
36
|
+
*/
|
|
37
|
+
export declare const CALLOUT_TYPES: readonly ["note", "tip", "important", "warning", "caution", "abstract", "info", "todo", "success", "question", "failure", "danger", "bug", "example", "quote"];
|
|
38
|
+
export type CalloutType = (typeof CALLOUT_TYPES)[number];
|
|
39
|
+
/**
|
|
40
|
+
* The other words people write for a type that already exists. Ported from
|
|
41
|
+
* open-knowledge's `callout-transformer.ts:41-71`, which took them from
|
|
42
|
+
* Obsidian, so a vault's markdown lands here meaning what it meant there.
|
|
43
|
+
*
|
|
44
|
+
* An alias is a spelling, not a type: it never widens `CalloutType`, and the
|
|
45
|
+
* authored word survives on `Callout.authoredAs` rather than in the enum.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CALLOUT_ALIASES: Readonly<Record<string, CalloutType>>;
|
|
48
|
+
/** Obsidian's foldable marker: `+` starts open, `-` starts collapsed. */
|
|
49
|
+
export type CalloutFold = "+" | "-";
|
|
50
|
+
export interface Callout {
|
|
51
|
+
type: CalloutType;
|
|
52
|
+
/** Custom heading on the marker line. Absent means "use the type's label". */
|
|
53
|
+
title?: string;
|
|
54
|
+
/** Everything below the marker line, quote markers already removed. */
|
|
55
|
+
body: string;
|
|
56
|
+
/**
|
|
57
|
+
* The token exactly as authored, when it is not the canonical type name —
|
|
58
|
+
* `WARN` for a warning, `Summary` for an abstract. Absent when the author
|
|
59
|
+
* already wrote the canonical word in any case, which is what keeps
|
|
60
|
+
* `[!note]` canonicalizing to `[!NOTE]` as it always has.
|
|
61
|
+
*/
|
|
62
|
+
authoredAs?: string;
|
|
63
|
+
/** Present when the marker carried `+` or `-`, which makes it collapsible. */
|
|
64
|
+
fold?: CalloutFold;
|
|
65
|
+
}
|
|
66
|
+
export interface CalloutMarkerOptions {
|
|
67
|
+
/** The authored spelling to write back instead of the canonical word. */
|
|
68
|
+
authoredAs?: string | null;
|
|
69
|
+
fold?: CalloutFold | null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The marker as written: uppercase, so `[!NOTE]` is what lands on disk — unless
|
|
73
|
+
* the author spelled the type another way, in which case their word goes back.
|
|
74
|
+
*
|
|
75
|
+
* `authoredAs` is checked, not trusted. It is an attribute by the time it gets
|
|
76
|
+
* here (the editor carries it on the node), and an attribute a paste or a
|
|
77
|
+
* command could have set to anything; a marker that no longer resolves to this
|
|
78
|
+
* callout's type would come back as a plain quote with a stray bracket. So a
|
|
79
|
+
* spelling that does not resolve to `type` is discarded and the canonical word
|
|
80
|
+
* is written instead — the tone is preserved and only the churn is paid.
|
|
81
|
+
*/
|
|
82
|
+
export declare function calloutMarker(type: CalloutType, options?: CalloutMarkerOptions): string;
|
|
83
|
+
/** True for the fifteen canonical type names, in any case. Aliases are not types. */
|
|
84
|
+
export declare function isCalloutType(value: string): value is CalloutType;
|
|
85
|
+
/**
|
|
86
|
+
* The token on a marker line — canonical or alias — resolved to the type that
|
|
87
|
+
* renders it, or null when it names nothing. Every caller that has to decide
|
|
88
|
+
* "is this a callout?" asks this rather than `isCalloutType`, because an alias
|
|
89
|
+
* IS a callout and only differs in how it is spelled.
|
|
90
|
+
*/
|
|
91
|
+
export declare function resolveCalloutType(token: string): CalloutType | null;
|
|
92
|
+
/**
|
|
93
|
+
* Drop one level of `>` quoting. A single space after the marker is part of
|
|
94
|
+
* the marker, not the content — `> indented` keeps one space.
|
|
95
|
+
*/
|
|
96
|
+
export declare function stripQuoteMarkers(lines: readonly string[]): string[];
|
|
97
|
+
/**
|
|
98
|
+
* Read a callout out of the lines of a blockquote.
|
|
99
|
+
*
|
|
100
|
+
* Accepts either raw source lines (`> [!NOTE]`) or lines whose quote markers a
|
|
101
|
+
* caller has already stripped (`[!NOTE]`) — the reader strips as it scans, the
|
|
102
|
+
* editor does not. The two are told apart by looking at the block as a whole:
|
|
103
|
+
* if every non-blank line is quoted, one level comes off. That way a callout
|
|
104
|
+
* whose body contains a nested quote survives either way round.
|
|
105
|
+
*
|
|
106
|
+
* Returns null for anything that is not a callout, which is the signal to
|
|
107
|
+
* render an ordinary blockquote.
|
|
108
|
+
*/
|
|
109
|
+
export declare function parseCallout(blockquoteLines: readonly string[]): Callout | null;
|
|
110
|
+
/**
|
|
111
|
+
* Index, within the same lines, of the callout body's first line — or -1 when
|
|
112
|
+
* the block is not a callout. A caller that re-parses `body` as markdown needs
|
|
113
|
+
* it to map a node back to the document line it came from; the reader joins
|
|
114
|
+
* its checkboxes to the document's line scan that way. Kept off `Callout`
|
|
115
|
+
* itself because the type is a value the editor and CLI construct by hand.
|
|
116
|
+
*/
|
|
117
|
+
export declare function calloutBodyLine(blockquoteLines: readonly string[]): number;
|
|
118
|
+
/**
|
|
119
|
+
* Write a callout back out. The inverse of `parseCallout` on the canonical
|
|
120
|
+
* form, and the only place the serialized shape is spelled: one blockquote,
|
|
121
|
+
* marker line first, body quoted line by line. A blank body line is a bare
|
|
122
|
+
* `>` — no trailing space, so the bytes survive editors that strip them.
|
|
123
|
+
*
|
|
124
|
+
* The marker line is rebuilt from the three fields that spell it — type,
|
|
125
|
+
* authored word, fold — rather than kept as a string, so a callout the editor
|
|
126
|
+
* constructed by hand and one parsed off disk are written by the same code.
|
|
127
|
+
*/
|
|
128
|
+
export declare function calloutToMarkdown(callout: Callout): string;
|