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.
- package/README.md +174 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +344 -4
- package/dist/api-client.js +289 -21
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/chat.d.ts +89 -0
- package/dist/chat.js +189 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.js +88 -0
- package/dist/cli.js +530 -88
- 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 +162 -0
- package/dist/render.js +280 -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,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
|
-
|
|
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;
|