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.
Files changed (68) hide show
  1. package/README.md +139 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +243 -4
  4. package/dist/api-client.js +248 -20
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +317 -26
  8. package/dist/format/blockSplice.d.ts +135 -0
  9. package/dist/format/blockSplice.js +330 -0
  10. package/dist/format/blocks/dropClosure.d.ts +10 -1
  11. package/dist/format/blocks/dropClosure.js +11 -1
  12. package/dist/format/callout.d.ts +69 -7
  13. package/dist/format/callout.js +112 -15
  14. package/dist/format/checklist.js +11 -4
  15. package/dist/format/formatAxes.d.ts +228 -0
  16. package/dist/format/formatAxes.js +454 -0
  17. package/dist/format/index.d.ts +1 -0
  18. package/dist/format/index.js +4 -0
  19. package/dist/format/lineGeometry.d.ts +34 -4
  20. package/dist/format/lineGeometry.js +140 -40
  21. package/dist/format/lint/appliesTo.d.ts +92 -0
  22. package/dist/format/lint/appliesTo.js +369 -0
  23. package/dist/format/lint/config.d.ts +106 -0
  24. package/dist/format/lint/config.js +205 -0
  25. package/dist/format/lint/fixAll.d.ts +62 -0
  26. package/dist/format/lint/fixAll.js +107 -0
  27. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  28. package/dist/format/lint/frontmatterSchema.js +660 -0
  29. package/dist/format/lint/index.d.ts +34 -5
  30. package/dist/format/lint/index.js +34 -5
  31. package/dist/format/lint/lintSource.d.ts +27 -7
  32. package/dist/format/lint/lintSource.js +67 -33
  33. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  34. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  35. package/dist/format/lint/rules/index.d.ts +2 -1
  36. package/dist/format/lint/rules/index.js +7 -1
  37. package/dist/format/lint/rules/malformed-callout.js +25 -16
  38. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  39. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  40. package/dist/format/lint/severity.d.ts +15 -0
  41. package/dist/format/lint/severity.js +50 -0
  42. package/dist/format/lint/textEdits.d.ts +86 -0
  43. package/dist/format/lint/textEdits.js +162 -0
  44. package/dist/format/lint/types.d.ts +44 -8
  45. package/dist/format/markdown/slug.d.ts +28 -0
  46. package/dist/format/markdown/slug.js +63 -0
  47. package/dist/format/plaintext.js +13 -3
  48. package/dist/format/sheetCellSpans.d.ts +95 -0
  49. package/dist/format/sheetCellSpans.js +223 -0
  50. package/dist/format/sheetSelection.d.ts +136 -0
  51. package/dist/format/sheetSelection.js +282 -0
  52. package/dist/format/textStats.d.ts +23 -0
  53. package/dist/format/textStats.js +80 -0
  54. package/dist/format/wikiLinks.d.ts +60 -1
  55. package/dist/format/wikiLinks.js +195 -9
  56. package/dist/index.d.ts +26 -1
  57. package/dist/index.js +20 -3
  58. package/dist/opener.d.ts +23 -0
  59. package/dist/opener.js +26 -0
  60. package/dist/render.d.ts +132 -0
  61. package/dist/render.js +208 -0
  62. package/dist/shell-commands.d.ts +34 -0
  63. package/dist/shell-commands.js +108 -0
  64. package/dist/watch.d.ts +79 -0
  65. package/dist/watch.js +113 -0
  66. package/dist/web-url.d.ts +39 -0
  67. package/dist/web-url.js +63 -0
  68. 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
- 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;
@@ -11,10 +11,25 @@
11
11
  * > [!WARNING] Ship blocker
12
12
  * > The migration has to run before the deploy.
13
13
  *
14
- * Five types, matching GitHub: note, tip, important, warning, caution. The
15
- * marker is written uppercase and read case-insensitively, because people type
16
- * `[!note]` and pasted GitHub markdown says `[!NOTE]` — both are the same
17
- * callout, and the first save canonicalizes.
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
- const MARKER = /^\[!([A-Za-z]+)\][ \t]*(.*)$/;
32
- /** The marker as written: uppercase, so `[!NOTE]` is what lands on disk. */
33
- export function calloutMarker(type) {
34
- return `[!${type.toUpperCase()}]`;
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].toLowerCase();
89
- if (!isCalloutType(keyword))
166
+ const keyword = match[1];
167
+ const type = resolveCalloutType(keyword);
168
+ if (!type)
90
169
  return null;
91
- const title = match[2].trim();
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: title ? { type: keyword, title, body } : { type: keyword, body },
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 head = title
116
- ? `> ${calloutMarker(callout.type)} ${title}`
117
- : `> ${calloutMarker(callout.type)}`;
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;