sfora-cli 0.10.0 → 0.12.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 (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * One block of a source string: its byte range, and its identity.
3
+ *
4
+ * `key` is what "unchanged" means. Two blocks with the same key are the same
5
+ * block for splice purposes and the old one's bytes win. A caller that keys by
6
+ * raw text gets a byte-exact diff (and no canonicalization softening at all);
7
+ * a caller that keys by a position-stripped parse gets the semantic diff this
8
+ * module exists to serve.
9
+ */
10
+ export interface SourceBlock {
11
+ /** Byte offset of the block's first byte. */
12
+ start: number;
13
+ /** One past the block's last byte. */
14
+ end: number;
15
+ /** Identity under whatever equality the caller decided on. */
16
+ key: string;
17
+ }
18
+ /** Why a splice gave up and handed back the new source whole. */
19
+ export type BlockSpliceFallback = "none"
20
+ /** One side has no blocks at all — there is nothing to preserve or to keep. */
21
+ | "no-blocks"
22
+ /** A block list was out of order, overlapping, or out of bounds. */
23
+ | "unusable-blocks";
24
+ export interface BlockSpliceResult {
25
+ /** The bytes to store. */
26
+ source: string;
27
+ /** Old blocks that kept their bytes. */
28
+ kept: number;
29
+ /** Blocks written from the new source. */
30
+ rewritten: number;
31
+ /** Set when the result is just `newSource`. */
32
+ fallback: BlockSpliceFallback;
33
+ }
34
+ /**
35
+ * Above this many DP cells the middle region is treated as one changed run
36
+ * instead of being aligned properly.
37
+ *
38
+ * The prefix/suffix trim below already handles the shape a real edit has — one
39
+ * changed region, everything before and after it identical — in linear time,
40
+ * so the quadratic pass only ever runs on a genuinely scattered diff. 250k
41
+ * cells is a 500-block-by-500-block middle, which is a document nobody has;
42
+ * past it the splice degrades to exactly what open-knowledge's
43
+ * `map-driven-splice.ts` does at every size (one over-wide contiguous splice),
44
+ * which is correct, merely less preserving.
45
+ *
46
+ * Exported so the test that pins the boundary can build the block lists either
47
+ * side of it from this number rather than from a second copy of it: a budget
48
+ * quietly raised past a hard-coded 500×500 would leave a test that still
49
+ * passes while asserting nothing about the fallback.
50
+ */
51
+ export declare const ALIGNMENT_CELL_BUDGET = 250000;
52
+ /**
53
+ * A run of blocks that is either kept from old or written from new.
54
+ *
55
+ * `kept: true` means every block in the range keyed identically on both sides,
56
+ * one for one, so `oldTo - oldFrom === newTo - newFrom` and the i-th old block
57
+ * IS the i-th new block. `kept: false` means the two ranges are the region the
58
+ * alignment could not explain: they may be different lengths, and nothing here
59
+ * says which old block became which new one — that judgement belongs to the
60
+ * consumer, because the splice does not need it and a rebinding ledger does.
61
+ */
62
+ export interface BlockRun {
63
+ kept: boolean;
64
+ /** Half-open index range into the old block list. */
65
+ oldFrom: number;
66
+ oldTo: number;
67
+ /** Half-open index range into the new block list. */
68
+ newFrom: number;
69
+ newTo: number;
70
+ }
71
+ /**
72
+ * How much of the correspondence was actually computed.
73
+ *
74
+ * `"exact"` is a real alignment: every block that survives unchanged is inside
75
+ * a kept run. `"budget"` says the middle region was past
76
+ * `ALIGNMENT_CELL_BUDGET` and was handed back as one changed run without being
77
+ * looked at, so blocks that did survive are sitting inside it unrecognised.
78
+ * The splice does not care — an over-wide changed run is merely less
79
+ * preserving — but a consumer that reads a changed run as "these blocks were
80
+ * edited" would be stating something the alignment never checked, so the
81
+ * degradation is reported rather than inferred from the run shapes.
82
+ */
83
+ export type BlockAlignmentQuality = "exact" | "budget";
84
+ /** The old↔new block correspondence, and how far it was actually worked out. */
85
+ export interface BlockAlignment {
86
+ /**
87
+ * Alternating kept/changed runs covering both lists end to end: every index
88
+ * of `oldBlocks` appears in exactly one run's old range, every index of
89
+ * `newBlocks` in exactly one run's new range, both in ascending order.
90
+ */
91
+ runs: BlockRun[];
92
+ quality: BlockAlignmentQuality;
93
+ }
94
+ /**
95
+ * Assemble the bytes to store from `oldSource` and `newSource`.
96
+ *
97
+ * The contract, in order of how much it matters:
98
+ *
99
+ * 1. If every key matches, the result is `oldSource` byte-for-byte. Not
100
+ * "equivalent to" — identical, including its trailing newline or lack of
101
+ * one. A save that changed nothing must reach the store as no change at
102
+ * all, or the whole exercise is decorative.
103
+ * 2. A block whose key changed contributes its NEW bytes, together with the
104
+ * gap bytes on either side of it, because a new or deleted block has to be
105
+ * able to bring its own blank lines.
106
+ * 3. A block whose key did not change contributes its OLD bytes, and so do
107
+ * the gaps between two such blocks.
108
+ * 4. The bytes outside every block — leading whitespace, the trailing
109
+ * newline — belong to no block, so they follow the block nearest them: old
110
+ * if that first/last block was kept, new if it was rewritten. Kept-at-the-
111
+ * edge is what makes rule 1 exact; rewritten-at-the-edge is what lets a
112
+ * document that really was replaced arrive with its own final newline
113
+ * instead of inheriting the absence of one.
114
+ */
115
+ export declare function spliceBlocks(oldSource: string, newSource: string, oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockSpliceResult;
116
+ /**
117
+ * Split both block lists into alternating kept/changed runs — the old↔new
118
+ * correspondence, on its own, with no bytes involved.
119
+ *
120
+ * Common prefix and suffix first — that is the entire diff for a normal edit
121
+ * and it costs one pass. What is left in the middle gets a longest-common-
122
+ * subsequence alignment so that two edits with untouched blocks between them
123
+ * keep those blocks' bytes; open-knowledge's version collapses that case into
124
+ * one over-wide splice, and it is the one thing here worth doing better than
125
+ * the reference, because "find and replace in three places" is an ordinary
126
+ * afternoon and it should not rewrite the paragraphs in between.
127
+ *
128
+ * Public because it is most of a rebinding ledger and it already runs on every
129
+ * write. `spliceBlocks` reads it for bytes; a caller that has derived an
130
+ * identity from the same keys reads it for what became what. Only `key` is
131
+ * consulted, so a caller holding keys and no offsets can ask this directly —
132
+ * the offset sanity `spliceBlocks` insists on is a slicing concern, not an
133
+ * alignment one.
134
+ */
135
+ export declare function alignBlockRuns(oldBlocks: readonly SourceBlock[], newBlocks: readonly SourceBlock[]): BlockAlignment;
@@ -0,0 +1,330 @@
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 block-aligned splice: write only the blocks that changed.
4
+ //
5
+ // Every save in sfora goes through a serializer, and a serializer has
6
+ // opinions — `*em*` becomes `_em_`, a setext heading becomes an ATX one, a
7
+ // table's padding is re-laid. Those opinions are correct for the block the
8
+ // author just edited and pure vandalism everywhere else: change one word in
9
+ // paragraph nine and the naive write path hands the store a document in which
10
+ // all forty blocks have been re-spelled. The author's diff is unreadable, the
11
+ // activity trail lies about what changed, and every byte-form the serializer
12
+ // cannot represent is gone for good.
13
+ //
14
+ // This module is the fix, and it is deliberately the dumbest possible one.
15
+ // It knows nothing about serializers, editors or markdown syntax. It takes
16
+ // two strings and two lists of BLOCKS — byte ranges plus an identity key —
17
+ // and returns a third string assembled from old bytes wherever the keys agree
18
+ // and new bytes wherever they do not. The key is where the intelligence
19
+ // lives, and it lives in the caller: `blockSpliceMdast.ts` derives one by
20
+ // parsing and stripping positions, so `*em*` and `_em_` key the same and the
21
+ // OLD bytes survive. Nothing in this file can tell the difference, which is
22
+ // exactly why it will survive the serializer swap wave 2 is heading for.
23
+ //
24
+ // There are two exits. `spliceBlocks` is the one the write paths call and
25
+ // returns bytes. `alignBlockRuns` is the correspondence underneath it —
26
+ // which old block became which new one — returned on its own, because that
27
+ // question has a second consumer: a document's derived block ids change when
28
+ // their blocks change, and the ledger that carries an anchor across an edit
29
+ // needs exactly this answer and nothing else from this file. It was already
30
+ // being computed on every save and thrown away.
31
+ //
32
+ // Zero dependencies, no parser, no I/O — it ships in the CLI.
33
+ /**
34
+ * Above this many DP cells the middle region is treated as one changed run
35
+ * instead of being aligned properly.
36
+ *
37
+ * The prefix/suffix trim below already handles the shape a real edit has — one
38
+ * changed region, everything before and after it identical — in linear time,
39
+ * so the quadratic pass only ever runs on a genuinely scattered diff. 250k
40
+ * cells is a 500-block-by-500-block middle, which is a document nobody has;
41
+ * past it the splice degrades to exactly what open-knowledge's
42
+ * `map-driven-splice.ts` does at every size (one over-wide contiguous splice),
43
+ * which is correct, merely less preserving.
44
+ *
45
+ * Exported so the test that pins the boundary can build the block lists either
46
+ * side of it from this number rather than from a second copy of it: a budget
47
+ * quietly raised past a hard-coded 500×500 would leave a test that still
48
+ * passes while asserting nothing about the fallback.
49
+ */
50
+ export const ALIGNMENT_CELL_BUDGET = 250_000;
51
+ /**
52
+ * Assemble the bytes to store from `oldSource` and `newSource`.
53
+ *
54
+ * The contract, in order of how much it matters:
55
+ *
56
+ * 1. If every key matches, the result is `oldSource` byte-for-byte. Not
57
+ * "equivalent to" — identical, including its trailing newline or lack of
58
+ * one. A save that changed nothing must reach the store as no change at
59
+ * all, or the whole exercise is decorative.
60
+ * 2. A block whose key changed contributes its NEW bytes, together with the
61
+ * gap bytes on either side of it, because a new or deleted block has to be
62
+ * able to bring its own blank lines.
63
+ * 3. A block whose key did not change contributes its OLD bytes, and so do
64
+ * the gaps between two such blocks.
65
+ * 4. The bytes outside every block — leading whitespace, the trailing
66
+ * newline — belong to no block, so they follow the block nearest them: old
67
+ * if that first/last block was kept, new if it was rewritten. Kept-at-the-
68
+ * edge is what makes rule 1 exact; rewritten-at-the-edge is what lets a
69
+ * document that really was replaced arrive with its own final newline
70
+ * instead of inheriting the absence of one.
71
+ */
72
+ export function spliceBlocks(oldSource, newSource, oldBlocks, newBlocks) {
73
+ if (oldBlocks.length === 0 || newBlocks.length === 0) {
74
+ return { source: newSource, kept: 0, rewritten: 0, fallback: "no-blocks" };
75
+ }
76
+ if (!blocksAreUsable(oldBlocks, oldSource.length) ||
77
+ !blocksAreUsable(newBlocks, newSource.length)) {
78
+ return {
79
+ source: newSource,
80
+ kept: 0,
81
+ rewritten: 0,
82
+ fallback: "unusable-blocks",
83
+ };
84
+ }
85
+ const { runs } = alignBlockRuns(oldBlocks, newBlocks);
86
+ const parts = [];
87
+ // The head: everything before the first block, from whichever side owns it.
88
+ parts.push(runs[0].kept
89
+ ? oldSource.slice(0, oldBlocks[0].start)
90
+ : newSource.slice(0, newBlocks[0].start));
91
+ let kept = 0;
92
+ let rewritten = 0;
93
+ for (const run of runs) {
94
+ if (run.kept) {
95
+ parts.push(oldSource.slice(oldBlocks[run.oldFrom].start, oldBlocks[run.oldTo - 1].end));
96
+ kept += run.oldTo - run.oldFrom;
97
+ continue;
98
+ }
99
+ // A changed run owns the gaps on both sides of it in the NEW source: the
100
+ // bytes from the end of the last kept new block to the start of the next
101
+ // one. At an edge of the document it stops at the edge block's own
102
+ // boundary; the head/tail beyond it is added once, outside this loop,
103
+ // under rule 4.
104
+ const from = run.newFrom > 0 ? newBlocks[run.newFrom - 1].end : newBlocks[0].start;
105
+ const to = run.newTo < newBlocks.length
106
+ ? newBlocks[run.newTo].start
107
+ : newBlocks[newBlocks.length - 1].end;
108
+ parts.push(newSource.slice(from, to));
109
+ rewritten += run.newTo - run.newFrom;
110
+ }
111
+ // The tail: everything after the last block, from whichever side owns it.
112
+ parts.push(runs[runs.length - 1].kept
113
+ ? oldSource.slice(oldBlocks[oldBlocks.length - 1].end)
114
+ : newSource.slice(newBlocks[newBlocks.length - 1].end));
115
+ return { source: parts.join(""), kept, rewritten, fallback: "none" };
116
+ }
117
+ /**
118
+ * Offsets have to be ascending, non-overlapping and inside the string, because
119
+ * the assembly above slices without re-checking and a bad list would silently
120
+ * produce scrambled bytes rather than an error. Callers feed these from a
121
+ * parser; a parser that ever emits a tree whose top-level children overlap is
122
+ * a bug we would rather see as a whole-document write than as a corrupted one.
123
+ */
124
+ function blocksAreUsable(blocks, length) {
125
+ let previousEnd = 0;
126
+ for (const block of blocks) {
127
+ if (!Number.isInteger(block.start) || !Number.isInteger(block.end)) {
128
+ return false;
129
+ }
130
+ if (block.start < previousEnd)
131
+ return false;
132
+ if (block.end < block.start)
133
+ return false;
134
+ if (block.end > length)
135
+ return false;
136
+ previousEnd = block.end;
137
+ }
138
+ return true;
139
+ }
140
+ /**
141
+ * Split both block lists into alternating kept/changed runs — the old↔new
142
+ * correspondence, on its own, with no bytes involved.
143
+ *
144
+ * Common prefix and suffix first — that is the entire diff for a normal edit
145
+ * and it costs one pass. What is left in the middle gets a longest-common-
146
+ * subsequence alignment so that two edits with untouched blocks between them
147
+ * keep those blocks' bytes; open-knowledge's version collapses that case into
148
+ * one over-wide splice, and it is the one thing here worth doing better than
149
+ * the reference, because "find and replace in three places" is an ordinary
150
+ * afternoon and it should not rewrite the paragraphs in between.
151
+ *
152
+ * Public because it is most of a rebinding ledger and it already runs on every
153
+ * write. `spliceBlocks` reads it for bytes; a caller that has derived an
154
+ * identity from the same keys reads it for what became what. Only `key` is
155
+ * consulted, so a caller holding keys and no offsets can ask this directly —
156
+ * the offset sanity `spliceBlocks` insists on is a slicing concern, not an
157
+ * alignment one.
158
+ */
159
+ export function alignBlockRuns(oldBlocks, newBlocks) {
160
+ const shorter = Math.min(oldBlocks.length, newBlocks.length);
161
+ let prefix = 0;
162
+ while (prefix < shorter && oldBlocks[prefix].key === newBlocks[prefix].key) {
163
+ prefix++;
164
+ }
165
+ let suffix = 0;
166
+ while (suffix < shorter - prefix &&
167
+ oldBlocks[oldBlocks.length - 1 - suffix].key ===
168
+ newBlocks[newBlocks.length - 1 - suffix].key) {
169
+ suffix++;
170
+ }
171
+ const runs = [];
172
+ if (prefix > 0) {
173
+ runs.push({ kept: true, oldFrom: 0, oldTo: prefix, newFrom: 0, newTo: prefix });
174
+ }
175
+ const oldMid = oldBlocks.slice(prefix, oldBlocks.length - suffix);
176
+ const newMid = newBlocks.slice(prefix, newBlocks.length - suffix);
177
+ const middle = alignMiddle(oldMid, newMid);
178
+ for (const run of middle.runs) {
179
+ runs.push({
180
+ kept: run.kept,
181
+ oldFrom: run.oldFrom + prefix,
182
+ oldTo: run.oldTo + prefix,
183
+ newFrom: run.newFrom + prefix,
184
+ newTo: run.newTo + prefix,
185
+ });
186
+ }
187
+ if (suffix > 0) {
188
+ runs.push({
189
+ kept: true,
190
+ oldFrom: oldBlocks.length - suffix,
191
+ oldTo: oldBlocks.length,
192
+ newFrom: newBlocks.length - suffix,
193
+ newTo: newBlocks.length,
194
+ });
195
+ }
196
+ return { runs: mergeAdjacent(runs), quality: middle.quality };
197
+ }
198
+ /** Runs for the region the prefix/suffix trim could not explain. */
199
+ function alignMiddle(oldMid, newMid) {
200
+ if (oldMid.length === 0 && newMid.length === 0) {
201
+ return { runs: [], quality: "exact" };
202
+ }
203
+ const whole = [
204
+ {
205
+ kept: false,
206
+ oldFrom: 0,
207
+ oldTo: oldMid.length,
208
+ newFrom: 0,
209
+ newTo: newMid.length,
210
+ },
211
+ ];
212
+ // A pure insertion or deletion in the middle: one side is empty, so there is
213
+ // nothing to match against and the single changed run IS the exact answer.
214
+ if (oldMid.length === 0 || newMid.length === 0) {
215
+ return { runs: whole, quality: "exact" };
216
+ }
217
+ // Past the budget the same shape means something weaker — "not looked at" —
218
+ // and the quality field is the only place that difference is recorded.
219
+ if (oldMid.length * newMid.length > ALIGNMENT_CELL_BUDGET) {
220
+ return { runs: whole, quality: "budget" };
221
+ }
222
+ return {
223
+ runs: runsFromPairs(longestCommonSubsequence(oldMid, newMid), oldMid.length, newMid.length),
224
+ quality: "exact",
225
+ };
226
+ }
227
+ /**
228
+ * Index pairs of a longest common subsequence of the two key sequences,
229
+ * ascending. Plain O(n·m) DP over an Int32Array — the budget above is what
230
+ * keeps that honest, and the keys are interned to integers first so the inner
231
+ * loop compares numbers instead of strings.
232
+ */
233
+ function longestCommonSubsequence(oldMid, newMid) {
234
+ const ids = new Map();
235
+ const intern = (key) => {
236
+ const seen = ids.get(key);
237
+ if (seen !== undefined)
238
+ return seen;
239
+ const id = ids.size;
240
+ ids.set(key, id);
241
+ return id;
242
+ };
243
+ const a = oldMid.map((block) => intern(block.key));
244
+ const b = newMid.map((block) => intern(block.key));
245
+ const width = b.length + 1;
246
+ const table = new Int32Array((a.length + 1) * width);
247
+ for (let i = a.length - 1; i >= 0; i--) {
248
+ for (let j = b.length - 1; j >= 0; j--) {
249
+ table[i * width + j] =
250
+ a[i] === b[j]
251
+ ? table[(i + 1) * width + j + 1] + 1
252
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
253
+ }
254
+ }
255
+ const pairs = [];
256
+ let i = 0;
257
+ let j = 0;
258
+ while (i < a.length && j < b.length) {
259
+ if (a[i] === b[j]) {
260
+ pairs.push([i, j]);
261
+ i++;
262
+ j++;
263
+ }
264
+ else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
265
+ i++;
266
+ }
267
+ else {
268
+ j++;
269
+ }
270
+ }
271
+ return pairs;
272
+ }
273
+ /** Turn matched index pairs into the alternating run list. */
274
+ function runsFromPairs(pairs, oldLength, newLength) {
275
+ const runs = [];
276
+ let oldAt = 0;
277
+ let newAt = 0;
278
+ for (const [oldIndex, newIndex] of pairs) {
279
+ if (oldIndex > oldAt || newIndex > newAt) {
280
+ runs.push({
281
+ kept: false,
282
+ oldFrom: oldAt,
283
+ oldTo: oldIndex,
284
+ newFrom: newAt,
285
+ newTo: newIndex,
286
+ });
287
+ }
288
+ runs.push({
289
+ kept: true,
290
+ oldFrom: oldIndex,
291
+ oldTo: oldIndex + 1,
292
+ newFrom: newIndex,
293
+ newTo: newIndex + 1,
294
+ });
295
+ oldAt = oldIndex + 1;
296
+ newAt = newIndex + 1;
297
+ }
298
+ if (oldAt < oldLength || newAt < newLength) {
299
+ runs.push({
300
+ kept: false,
301
+ oldFrom: oldAt,
302
+ oldTo: oldLength,
303
+ newFrom: newAt,
304
+ newTo: newLength,
305
+ });
306
+ }
307
+ return runs;
308
+ }
309
+ /**
310
+ * Fuse touching runs of the same kind.
311
+ *
312
+ * The LCS emits one run per matched block, and the prefix/suffix trim adds its
313
+ * own on both sides. Left as-is, two adjacent kept runs would each slice from
314
+ * their own first block's start, which DROPS the gap bytes between them —
315
+ * blank lines between untouched paragraphs would vanish on every save. Merging
316
+ * is not tidying; it is what makes rule 3 true.
317
+ */
318
+ function mergeAdjacent(runs) {
319
+ const merged = [];
320
+ for (const run of runs) {
321
+ const last = merged[merged.length - 1];
322
+ if (last && last.kept === run.kept) {
323
+ last.oldTo = run.oldTo;
324
+ last.newTo = run.newTo;
325
+ continue;
326
+ }
327
+ merged.push({ ...run });
328
+ }
329
+ return merged;
330
+ }
@@ -1,3 +1,4 @@
1
+ import type { FormatAxisId } from "../formatAxes.js";
1
2
  import type { StructuredBlockLanguage } from "./structured-block-schema.js";
2
3
  /**
3
4
  * Every reason a structured-block parser drops bytes. Adding a case here
@@ -21,7 +22,15 @@ export type DropAdjudication = {
21
22
  partial?: true;
22
23
  } | {
23
24
  kind: "format-dof-axis";
24
- axisIds: readonly string[];
25
+ /**
26
+ * Ids from `../formatAxes`, not free text. Card #288 turned the axis
27
+ * vocabulary into a closed union precisely so this field cannot name a
28
+ * spelling nobody defined — before it, `format-dof-axis` was a verdict
29
+ * that pointed at a string, which is a shrug with a citation on it.
30
+ * `__tests__/formatAxes.test.ts` closes the reference the other way too:
31
+ * an axis no ledger entry spends is stale and fails.
32
+ */
33
+ axisIds: readonly FormatAxisId[];
25
34
  witness: DropWitness;
26
35
  rationale: string;
27
36
  partial?: true;
@@ -15,7 +15,17 @@
15
15
  //
16
16
  // structural-only the bytes carry no content — a blank line, a container
17
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
18
+ // axis id names WHICH degree of freedom, and it is an id
19
+ // from `../formatAxes` rather than a phrase — that file
20
+ // is the finite list, and it carries the DISCRIMINATION
21
+ // witness that proves the two spellings mean one thing.
22
+ // Not a round-trip witness, which is what this line used
23
+ // to call it: a discrimination witness shows the two
24
+ // spellings parse alike and that the capture layer can
25
+ // tell them apart, and says nothing about whether either
26
+ // survives a save. `formatAxes.ts`'s replay scoreboard is
27
+ // where that question is answered, and for most axes the
28
+ // answer today is no
19
29
  // retained-by-capture the bytes are gone from the line but present in the
20
30
  // parsed data, and `retained` says exactly where
21
31
  // documented-residual real loss, admitted, witnessed. These — and only
@@ -9,28 +9,86 @@
9
9
  * > [!WARNING] Ship blocker
10
10
  * > The migration has to run before the deploy.
11
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.
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.
16
31
  *
17
32
  * Pure functions over lines: no DOM, no ProseMirror, no React. The reader
18
33
  * (`src/lib/utils/markdown.ts`), the Tiptap node
19
34
  * (`src/components/editor/extensions/callout.ts`), and the CLI all read the
20
35
  * grammar from here so they cannot disagree about what a callout is.
21
36
  */
22
- export declare const CALLOUT_TYPES: readonly ["note", "tip", "important", "warning", "caution"];
37
+ export declare const CALLOUT_TYPES: readonly ["note", "tip", "important", "warning", "caution", "abstract", "info", "todo", "success", "question", "failure", "danger", "bug", "example", "quote"];
23
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 = "+" | "-";
24
50
  export interface Callout {
25
51
  type: CalloutType;
26
52
  /** Custom heading on the marker line. Absent means "use the type's label". */
27
53
  title?: string;
28
54
  /** Everything below the marker line, quote markers already removed. */
29
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;
30
70
  }
31
- /** The marker as written: uppercase, so `[!NOTE]` is what lands on disk. */
32
- export declare function calloutMarker(type: CalloutType): string;
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. */
33
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;
34
92
  /**
35
93
  * Drop one level of `>` quoting. A single space after the marker is part of
36
94
  * the marker, not the content — `> indented` keeps one space.
@@ -62,5 +120,9 @@ export declare function calloutBodyLine(blockquoteLines: readonly string[]): num
62
120
  * form, and the only place the serialized shape is spelled: one blockquote,
63
121
  * marker line first, body quoted line by line. A blank body line is a bare
64
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.
65
127
  */
66
128
  export declare function calloutToMarkdown(callout: Callout): string;