sfora-cli 0.10.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 +139 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +243 -4
- package/dist/api-client.js +248 -20
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +317 -26
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- 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 +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- 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 +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- 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 +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- 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/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- 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 +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
package/dist/format/callout.d.ts
CHANGED
|
@@ -9,28 +9,86 @@
|
|
|
9
9
|
* > [!WARNING] Ship blocker
|
|
10
10
|
* > The migration has to run before the deploy.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
/**
|
|
32
|
-
|
|
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;
|
package/dist/format/callout.js
CHANGED
|
@@ -11,10 +11,25 @@
|
|
|
11
11
|
* > [!WARNING] Ship blocker
|
|
12
12
|
* > The migration has to run before the deploy.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Fifteen types. The first five are GitHub's alert set — note, tip, important,
|
|
15
|
+
* warning, caution — and the other ten are the Obsidian set every vault and
|
|
16
|
+
* every other markdown tool already writes, so a document pasted in from one of
|
|
17
|
+
* them keeps its tone instead of falling back to a plain quote. The marker is
|
|
18
|
+
* written uppercase and read case-insensitively, because people type `[!note]`
|
|
19
|
+
* and pasted GitHub markdown says `[!NOTE]` — both are the same callout, and
|
|
20
|
+
* the first save canonicalizes.
|
|
21
|
+
*
|
|
22
|
+
* Two things beyond the type sit on the marker line, and both are round-tripped
|
|
23
|
+
* rather than normalised away:
|
|
24
|
+
*
|
|
25
|
+
* - an ALIAS. `[!WARN]`, `[!SUMMARY]`, `[!ERROR]` name a type by another
|
|
26
|
+
* word. They resolve to the canonical type for rendering, and the word the
|
|
27
|
+
* author actually typed is carried on `authoredAs` so the bytes come back
|
|
28
|
+
* unchanged. Normalising the spelling would rewrite a line the author did
|
|
29
|
+
* not touch, which is churn in a file two agents and a person share.
|
|
30
|
+
* - a FOLD marker. Obsidian's `[!NOTE]+` and `[!NOTE]-` say the callout is
|
|
31
|
+
* collapsible and whether it starts open. It is one character of grammar
|
|
32
|
+
* and it is the whole difference between a callout and a details block.
|
|
18
33
|
*
|
|
19
34
|
* Pure functions over lines: no DOM, no ProseMirror, no React. The reader
|
|
20
35
|
* (`src/lib/utils/markdown.ts`), the Tiptap node
|
|
@@ -22,20 +37,83 @@
|
|
|
22
37
|
* grammar from here so they cannot disagree about what a callout is.
|
|
23
38
|
*/
|
|
24
39
|
export const CALLOUT_TYPES = [
|
|
40
|
+
// GitHub's five.
|
|
25
41
|
"note",
|
|
26
42
|
"tip",
|
|
27
43
|
"important",
|
|
28
44
|
"warning",
|
|
29
45
|
"caution",
|
|
46
|
+
// Obsidian's ten.
|
|
47
|
+
"abstract",
|
|
48
|
+
"info",
|
|
49
|
+
"todo",
|
|
50
|
+
"success",
|
|
51
|
+
"question",
|
|
52
|
+
"failure",
|
|
53
|
+
"danger",
|
|
54
|
+
"bug",
|
|
55
|
+
"example",
|
|
56
|
+
"quote",
|
|
30
57
|
];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The other words people write for a type that already exists. Ported from
|
|
60
|
+
* open-knowledge's `callout-transformer.ts:41-71`, which took them from
|
|
61
|
+
* Obsidian, so a vault's markdown lands here meaning what it meant there.
|
|
62
|
+
*
|
|
63
|
+
* An alias is a spelling, not a type: it never widens `CalloutType`, and the
|
|
64
|
+
* authored word survives on `Callout.authoredAs` rather than in the enum.
|
|
65
|
+
*/
|
|
66
|
+
export const CALLOUT_ALIASES = {
|
|
67
|
+
summary: "abstract",
|
|
68
|
+
tldr: "abstract",
|
|
69
|
+
check: "success",
|
|
70
|
+
done: "success",
|
|
71
|
+
help: "question",
|
|
72
|
+
faq: "question",
|
|
73
|
+
fail: "failure",
|
|
74
|
+
missing: "failure",
|
|
75
|
+
error: "danger",
|
|
76
|
+
cite: "quote",
|
|
77
|
+
idea: "tip",
|
|
78
|
+
hint: "tip",
|
|
79
|
+
warn: "warning",
|
|
80
|
+
attention: "warning",
|
|
81
|
+
};
|
|
82
|
+
const MARKER = /^\[!([A-Za-z]+)\]([+-])?[ \t]*(.*)$/;
|
|
83
|
+
/**
|
|
84
|
+
* The marker as written: uppercase, so `[!NOTE]` is what lands on disk — unless
|
|
85
|
+
* the author spelled the type another way, in which case their word goes back.
|
|
86
|
+
*
|
|
87
|
+
* `authoredAs` is checked, not trusted. It is an attribute by the time it gets
|
|
88
|
+
* here (the editor carries it on the node), and an attribute a paste or a
|
|
89
|
+
* command could have set to anything; a marker that no longer resolves to this
|
|
90
|
+
* callout's type would come back as a plain quote with a stray bracket. So a
|
|
91
|
+
* spelling that does not resolve to `type` is discarded and the canonical word
|
|
92
|
+
* is written instead — the tone is preserved and only the churn is paid.
|
|
93
|
+
*/
|
|
94
|
+
export function calloutMarker(type, options = {}) {
|
|
95
|
+
const authored = options.authoredAs?.trim();
|
|
96
|
+
const token = authored && resolveCalloutType(authored) === type
|
|
97
|
+
? authored
|
|
98
|
+
: type.toUpperCase();
|
|
99
|
+
return `[!${token}]${options.fold ?? ""}`;
|
|
35
100
|
}
|
|
101
|
+
/** True for the fifteen canonical type names, in any case. Aliases are not types. */
|
|
36
102
|
export function isCalloutType(value) {
|
|
37
103
|
return CALLOUT_TYPES.includes(value.toLowerCase());
|
|
38
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The token on a marker line — canonical or alias — resolved to the type that
|
|
107
|
+
* renders it, or null when it names nothing. Every caller that has to decide
|
|
108
|
+
* "is this a callout?" asks this rather than `isCalloutType`, because an alias
|
|
109
|
+
* IS a callout and only differs in how it is spelled.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveCalloutType(token) {
|
|
112
|
+
const lower = token.toLowerCase();
|
|
113
|
+
if (isCalloutType(lower))
|
|
114
|
+
return lower;
|
|
115
|
+
return CALLOUT_ALIASES[lower] ?? null;
|
|
116
|
+
}
|
|
39
117
|
/**
|
|
40
118
|
* Drop one level of `>` quoting. A single space after the marker is part of
|
|
41
119
|
* the marker, not the content — `> indented` keeps one space.
|
|
@@ -85,10 +163,17 @@ function readCallout(blockquoteLines) {
|
|
|
85
163
|
const match = MARKER.exec(lines[first].trim());
|
|
86
164
|
if (!match)
|
|
87
165
|
return null;
|
|
88
|
-
const keyword = match[1]
|
|
89
|
-
|
|
166
|
+
const keyword = match[1];
|
|
167
|
+
const type = resolveCalloutType(keyword);
|
|
168
|
+
if (!type)
|
|
90
169
|
return null;
|
|
91
|
-
|
|
170
|
+
// The author's own spelling, kept only when it is not the canonical word.
|
|
171
|
+
// Same test open-knowledge makes (`callout-transformer.ts:223-224`): a
|
|
172
|
+
// lowercase `[!note]` is the canonical type spelled small, so it still
|
|
173
|
+
// canonicalizes on save and the existing law does not move.
|
|
174
|
+
const authoredAs = keyword.toLowerCase() === type ? undefined : keyword;
|
|
175
|
+
const fold = match[2] === "+" || match[2] === "-" ? match[2] : undefined;
|
|
176
|
+
const title = match[3].trim();
|
|
92
177
|
// Same trim as `trimBlankEdges`, unrolled so the leading blanks it drops can
|
|
93
178
|
// be added to the body's line index rather than silently lost.
|
|
94
179
|
const rest = lines.slice(first + 1).map((line) => line.replace(/\s+$/, ""));
|
|
@@ -100,7 +185,13 @@ function readCallout(blockquoteLines) {
|
|
|
100
185
|
end--;
|
|
101
186
|
const body = rest.slice(start, end).join("\n");
|
|
102
187
|
return {
|
|
103
|
-
callout:
|
|
188
|
+
callout: {
|
|
189
|
+
type,
|
|
190
|
+
...(title ? { title } : {}),
|
|
191
|
+
body,
|
|
192
|
+
...(authoredAs ? { authoredAs } : {}),
|
|
193
|
+
...(fold ? { fold } : {}),
|
|
194
|
+
},
|
|
104
195
|
bodyLine: first + 1 + start,
|
|
105
196
|
};
|
|
106
197
|
}
|
|
@@ -109,12 +200,18 @@ function readCallout(blockquoteLines) {
|
|
|
109
200
|
* form, and the only place the serialized shape is spelled: one blockquote,
|
|
110
201
|
* marker line first, body quoted line by line. A blank body line is a bare
|
|
111
202
|
* `>` — no trailing space, so the bytes survive editors that strip them.
|
|
203
|
+
*
|
|
204
|
+
* The marker line is rebuilt from the three fields that spell it — type,
|
|
205
|
+
* authored word, fold — rather than kept as a string, so a callout the editor
|
|
206
|
+
* constructed by hand and one parsed off disk are written by the same code.
|
|
112
207
|
*/
|
|
113
208
|
export function calloutToMarkdown(callout) {
|
|
114
209
|
const title = callout.title?.trim();
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
:
|
|
210
|
+
const marker = calloutMarker(callout.type, {
|
|
211
|
+
authoredAs: callout.authoredAs,
|
|
212
|
+
fold: callout.fold,
|
|
213
|
+
});
|
|
214
|
+
const head = title ? `> ${marker} ${title}` : `> ${marker}`;
|
|
118
215
|
const body = trimBlankEdges(callout.body.split("\n"));
|
|
119
216
|
if (body.length === 0)
|
|
120
217
|
return head;
|