@glw907/cairn-cms 0.59.0 → 0.60.1
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/CHANGELOG.md +60 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +12 -41
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +486 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +786 -918
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +688 -789
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +355 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +768 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +148 -0
- package/dist/components/spellcheck.js +553 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/delivery/CairnHead.svelte +8 -11
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +11 -2
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +693 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- package/src/lib/sveltekit/tidy-prompt.ts +153 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// The local tidy category taxonomy and the because-line builder (spec 2.5, decision 9). The tidy
|
|
2
|
+
// action returns a corrected STRING; the diff (Task 12) turns it into changes; this module infers each
|
|
3
|
+
// change's category and safety rank LOCALLY from the diff shape and the enabled config, never from a
|
|
4
|
+
// claim the model made and never from a count of the author's own usage. It is pure: the inputs are a
|
|
5
|
+
// change, the captured original, and the resolved conventions, and the outputs are a category and an
|
|
6
|
+
// optional because-line. Approximate by design, so it is unit-tested rather than trusted.
|
|
7
|
+
//
|
|
8
|
+
// The safety rank is the spine. Objective categories (spelling, typo, doubled word, whitespace) read
|
|
9
|
+
// quiet and are swept by Accept-fixes. Judgment categories (a declared normalization, or a grammar fix
|
|
10
|
+
// that reworded more than one token) carry the review-this treatment and are never swept until the
|
|
11
|
+
// author confirms each. The category alone decides the rank, so the surface and the bulk action agree.
|
|
12
|
+
|
|
13
|
+
import type { Change } from './tidy-diff.js';
|
|
14
|
+
import type { TidyConventions } from '../nav/site-config.js';
|
|
15
|
+
|
|
16
|
+
/** A change's locally-inferred category. The first four are objective (safe to sweep); `normalization`
|
|
17
|
+
* and `grammar` are judgment (held undecided, never swept by Accept-fixes). `normalization` carries
|
|
18
|
+
* the convention key that authorized it, so the surface can name the setting and label the badge. */
|
|
19
|
+
export type TidyCategory =
|
|
20
|
+
| { kind: 'spelling' }
|
|
21
|
+
| { kind: 'typo' }
|
|
22
|
+
| { kind: 'doubled' }
|
|
23
|
+
| { kind: 'whitespace' }
|
|
24
|
+
| { kind: 'normalization'; convention: NormalizationKey }
|
|
25
|
+
| { kind: 'grammar' };
|
|
26
|
+
|
|
27
|
+
/** True for the objective categories: the safe, pre-kept, Accept-fixes-swept rank. A judgment
|
|
28
|
+
* category (`normalization` or `grammar`) returns false. The bulk action and the surface both read
|
|
29
|
+
* this, so the safety rank is one source of truth. */
|
|
30
|
+
export function isObjective(category: TidyCategory): boolean {
|
|
31
|
+
return (
|
|
32
|
+
category.kind === 'spelling' ||
|
|
33
|
+
category.kind === 'typo' ||
|
|
34
|
+
category.kind === 'doubled' ||
|
|
35
|
+
category.kind === 'whitespace'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The enabled-convention keys a normalization can be attributed to. Each maps to one config field on
|
|
40
|
+
* TidyConventions and to a because-line. A change is only ever labelled a normalization when it matches
|
|
41
|
+
* one of these AND the config has the matching variant enabled; otherwise it is never a normalization. */
|
|
42
|
+
export type NormalizationKey =
|
|
43
|
+
| 'oxfordComma'
|
|
44
|
+
| 'numberStyle'
|
|
45
|
+
| 'measurements'
|
|
46
|
+
| 'percent'
|
|
47
|
+
| 'emDash'
|
|
48
|
+
| 'enDashRanges'
|
|
49
|
+
| 'ellipsis'
|
|
50
|
+
| 'timeFormat'
|
|
51
|
+
| 'smartQuotes';
|
|
52
|
+
|
|
53
|
+
// The token boundary the diff uses, so a change's word/non-word token count here matches the diff's.
|
|
54
|
+
const TOKEN = /[A-Za-z0-9_]+(?:['’][A-Za-z0-9_]+)*|[^A-Za-z0-9_]+/g;
|
|
55
|
+
// The en-dash and em-dash code points, named here so the comments below never type the literal glyph
|
|
56
|
+
// (the prose-guard rejects a literal dash even in a comment). Used by the punctuation conventions.
|
|
57
|
+
const EN_DASH = '–';
|
|
58
|
+
const EM_DASH = '—';
|
|
59
|
+
|
|
60
|
+
function tokens(text: string): string[] {
|
|
61
|
+
return text.match(TOKEN) ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The spelled-out number words the numberStyle convention recognizes against a numeral, the conservative
|
|
65
|
+
// clear cases only. A swap is claimed as a numberStyle normalization only when one side is one of these
|
|
66
|
+
// words and the other side is a plain integer numeral; a compound spelled number ("twenty-five") or any
|
|
67
|
+
// word outside this set is left to the shape rules, never falsely claimed.
|
|
68
|
+
const NUMBER_WORDS = new Set([
|
|
69
|
+
'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
|
|
70
|
+
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen',
|
|
71
|
+
'nineteen', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety',
|
|
72
|
+
'hundred', 'thousand', 'million', 'billion',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// The unit notation pairs the measurements convention recognizes: each spelled-out unit and its
|
|
76
|
+
// abbreviation, in both singular and plural where the word inflects. A swap is claimed as a measurements
|
|
77
|
+
// normalization only when one side is a known abbreviation and the other its spelled-out form, the number
|
|
78
|
+
// untouched (the diff isolates the unit token). The list is deliberately a curated common set, so a unit
|
|
79
|
+
// outside it is left to the shape rules rather than guessed at.
|
|
80
|
+
const UNIT_FORMS: Array<{ abbr: string; words: string[] }> = [
|
|
81
|
+
{ abbr: 'cm', words: ['centimeter', 'centimeters', 'centimetre', 'centimetres'] },
|
|
82
|
+
{ abbr: 'mm', words: ['millimeter', 'millimeters', 'millimetre', 'millimetres'] },
|
|
83
|
+
{ abbr: 'm', words: ['meter', 'meters', 'metre', 'metres'] },
|
|
84
|
+
{ abbr: 'km', words: ['kilometer', 'kilometers', 'kilometre', 'kilometres'] },
|
|
85
|
+
{ abbr: 'in', words: ['inch', 'inches'] },
|
|
86
|
+
{ abbr: 'ft', words: ['foot', 'feet'] },
|
|
87
|
+
{ abbr: 'yd', words: ['yard', 'yards'] },
|
|
88
|
+
{ abbr: 'mi', words: ['mile', 'miles'] },
|
|
89
|
+
{ abbr: 'g', words: ['gram', 'grams', 'gramme', 'grammes'] },
|
|
90
|
+
{ abbr: 'kg', words: ['kilogram', 'kilograms', 'kilogramme', 'kilogrammes'] },
|
|
91
|
+
{ abbr: 'mg', words: ['milligram', 'milligrams'] },
|
|
92
|
+
{ abbr: 'lb', words: ['pound', 'pounds'] },
|
|
93
|
+
{ abbr: 'oz', words: ['ounce', 'ounces'] },
|
|
94
|
+
{ abbr: 'l', words: ['liter', 'liters', 'litre', 'litres'] },
|
|
95
|
+
{ abbr: 'ml', words: ['milliliter', 'milliliters', 'millilitre', 'millilitres'] },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// True when `a` and `b` are the two notations of one measurement unit (one the abbreviation, the other a
|
|
99
|
+
// spelled-out form). Case-insensitive on the word side; the abbreviation is compared exactly so a stray
|
|
100
|
+
// word like "in" the preposition is not mistaken for the inch abbreviation unless the other side is its
|
|
101
|
+
// spelled-out unit. Order-independent: either side may be the abbreviation.
|
|
102
|
+
function isUnitNotationPair(a: string, b: string): boolean {
|
|
103
|
+
for (const u of UNIT_FORMS) {
|
|
104
|
+
const aAbbr = a === u.abbr;
|
|
105
|
+
const bAbbr = b === u.abbr;
|
|
106
|
+
const aWord = u.words.includes(a.toLowerCase());
|
|
107
|
+
const bWord = u.words.includes(b.toLowerCase());
|
|
108
|
+
if ((aAbbr && bWord) || (bAbbr && aWord)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The clock-time signature for a token: its digits and meridiem reduced to a canonical key, or null when
|
|
114
|
+
// the token does not read as a time. Whitespace and the periods in "p.m." are dropped and the letters are
|
|
115
|
+
// lowercased, so "5pm", "5 PM", and "5 p.m." all reduce to "5pm" and a reshape between any two of them is
|
|
116
|
+
// recognized as the same time in a different format.
|
|
117
|
+
function timeKey(token: string): string | null {
|
|
118
|
+
const compact = token.replace(/[\s.]/g, '').toLowerCase();
|
|
119
|
+
const m = /^(\d{1,2})(:\d{2})?(am|pm)$/.exec(compact);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
return `${m[1]}${m[2] ?? ''}${m[3]}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function words(text: string): string[] {
|
|
125
|
+
return tokens(text).filter((t) => /[A-Za-z0-9_]/.test(t));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isWhitespaceOnly(text: string): boolean {
|
|
129
|
+
return text.length > 0 && /^\s+$/.test(text);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isPunctuationOnly(text: string): boolean {
|
|
133
|
+
return text.length > 0 && /^[^A-Za-z0-9_\s]+$/.test(text);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The word ending immediately before `offset` in `text`, skipping any whitespace just before the
|
|
137
|
+
* offset, or null when none. The doubled-word rule reads it to confirm the deleted word repeats the
|
|
138
|
+
* one before it. Pure text inspection, never a count. */
|
|
139
|
+
function precedingWord(text: string, offset: number): string | null {
|
|
140
|
+
let i = offset;
|
|
141
|
+
while (i > 0 && /\s/.test(text[i - 1])) i--;
|
|
142
|
+
let j = i;
|
|
143
|
+
while (j > 0 && /[A-Za-z0-9_'’]/.test(text[j - 1])) j--;
|
|
144
|
+
return j < i ? text.slice(j, i) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The word starting immediately after `offset` in `text`, skipping any whitespace just after the
|
|
148
|
+
* offset, or null when none. The doubled-word rule reads it as the other half of the look-around. */
|
|
149
|
+
function followingWord(text: string, offset: number): string | null {
|
|
150
|
+
let i = offset;
|
|
151
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
152
|
+
let j = i;
|
|
153
|
+
while (j < text.length && /[A-Za-z0-9_'’]/.test(text[j])) j++;
|
|
154
|
+
return j > i ? text.slice(i, j) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Categorize one change against the captured original and the resolved conventions. The rules are
|
|
159
|
+
* deterministic and ordered by safety, objective first:
|
|
160
|
+
* 1. a pure whitespace change (both sides whitespace, or a whitespace insert/delete) is whitespace;
|
|
161
|
+
* 2. a removed repeated word (the original run is two of the same word collapsing to one) is doubled;
|
|
162
|
+
* 3. a single-token punctuation-only change is a typo;
|
|
163
|
+
* 4. a single word replaced by another single word is spelling;
|
|
164
|
+
* 5. a change matching an ENABLED config convention's signature is that convention's normalization;
|
|
165
|
+
* 6. anything else (a multi-token reword) is grammar.
|
|
166
|
+
* A change that looks like a normalization but whose convention is not enabled falls through to typo,
|
|
167
|
+
* spelling, or grammar by its shape, never to a normalization it cannot name. So the surface never
|
|
168
|
+
* offers a normalization that cannot cite an enabled setting.
|
|
169
|
+
*/
|
|
170
|
+
export function categorize(change: Change, original: string, conventions: TidyConventions): TidyCategory {
|
|
171
|
+
const removed = original.slice(change.from, change.to);
|
|
172
|
+
const added = change.replacement;
|
|
173
|
+
|
|
174
|
+
// Whitespace: the removed and added runs are each whitespace-only or empty, and at least one is
|
|
175
|
+
// non-empty whitespace. A trailing-space trim (whitespace removed, nothing added) or a run
|
|
176
|
+
// collapsed to a single space both land here.
|
|
177
|
+
const removedWs = removed === '' || isWhitespaceOnly(removed);
|
|
178
|
+
const addedWs = added === '' || isWhitespaceOnly(added);
|
|
179
|
+
if (removedWs && addedWs && (isWhitespaceOnly(removed) || isWhitespaceOnly(added))) {
|
|
180
|
+
return { kind: 'whitespace' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Doubled word: a repeated word collapses to one. The diff keeps the first copy and deletes the
|
|
184
|
+
// second, so the change reads as a deletion of "<whitespace><word>" with an empty replacement, where
|
|
185
|
+
// that word equals the word immediately before the change in the original. The look-back at the
|
|
186
|
+
// preceding word is what tells a doubled word from a plain deletion; it reads the original text, never
|
|
187
|
+
// a usage count. (A change whose own run already holds both copies, "word word" to "word", is the
|
|
188
|
+
// fallback form, handled by the same word-equality test.)
|
|
189
|
+
const removedWords = words(removed);
|
|
190
|
+
const addedWords = words(added);
|
|
191
|
+
if (removedWords.length === 1 && addedWords.length === 0 && /\S/.test(removed)) {
|
|
192
|
+
const w = removedWords[0].toLowerCase();
|
|
193
|
+
// The diff may delete either copy of the pair: the surviving copy is the word just before or just
|
|
194
|
+
// after the deleted run in the original. Either match confirms a doubled word.
|
|
195
|
+
const before = precedingWord(original, change.from);
|
|
196
|
+
const after = followingWord(original, change.to);
|
|
197
|
+
if ((before && before.toLowerCase() === w) || (after && after.toLowerCase() === w)) {
|
|
198
|
+
return { kind: 'doubled' };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
removedWords.length === 2 &&
|
|
203
|
+
addedWords.length === 1 &&
|
|
204
|
+
removedWords[0].toLowerCase() === removedWords[1].toLowerCase() &&
|
|
205
|
+
addedWords[0].toLowerCase() === removedWords[0].toLowerCase()
|
|
206
|
+
) {
|
|
207
|
+
return { kind: 'doubled' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The single-token shape: exactly one token removed and one token added (a clean replacement),
|
|
211
|
+
// which is how a typo fix and a spelling fix both read.
|
|
212
|
+
const removedTokens = tokens(removed);
|
|
213
|
+
const addedTokens = tokens(added);
|
|
214
|
+
const singleSwap = removedTokens.length === 1 && addedTokens.length === 1;
|
|
215
|
+
|
|
216
|
+
// A declared normalization: the change matches an enabled convention's signature. Checked before the
|
|
217
|
+
// single-word spelling rule only when the convention applies (a punctuation or notation change), so a
|
|
218
|
+
// plain misspelling is never miscategorized as a normalization. A normalization is offered ONLY when
|
|
219
|
+
// its config variant is enabled.
|
|
220
|
+
const norm = matchNormalization(removed, added, conventions);
|
|
221
|
+
if (norm) return { kind: 'normalization', convention: norm };
|
|
222
|
+
|
|
223
|
+
// A single-token punctuation-only change (a stray or wrong mark fixed) is a typo. Reached only after
|
|
224
|
+
// the normalization check, so an enabled punctuation convention claims its change first.
|
|
225
|
+
if (singleSwap && isPunctuationOnly(removed) && isPunctuationOnly(added)) {
|
|
226
|
+
return { kind: 'typo' };
|
|
227
|
+
}
|
|
228
|
+
// A punctuation insert or delete (a missing period added, say) with no other token is also a typo.
|
|
229
|
+
if (
|
|
230
|
+
(removed === '' && addedTokens.length === 1 && isPunctuationOnly(added)) ||
|
|
231
|
+
(added === '' && removedTokens.length === 1 && isPunctuationOnly(removed))
|
|
232
|
+
) {
|
|
233
|
+
return { kind: 'typo' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A single word replaced by another single word is a spelling fix.
|
|
237
|
+
if (singleSwap && removedWords.length === 1 && addedWords.length === 1) {
|
|
238
|
+
return { kind: 'spelling' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Anything else is a grammar reword: a multi-token change the author should review.
|
|
242
|
+
return { kind: 'grammar' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Match a change against the enabled conventions' signatures. Returns the convention key when the
|
|
246
|
+
// change's shape is what that convention produces AND the config has it enabled, else null. The
|
|
247
|
+
// signatures are deliberately narrow: each recognizes only the unambiguous form of its convention, so
|
|
248
|
+
// a false match is rare and a missed match falls to the shape-based category (never to a normalization
|
|
249
|
+
// the config did not authorize). Never counts the author's own usage; the only gate is the config.
|
|
250
|
+
function matchNormalization(
|
|
251
|
+
removed: string,
|
|
252
|
+
added: string,
|
|
253
|
+
c: TidyConventions,
|
|
254
|
+
): NormalizationKey | null {
|
|
255
|
+
// Oxford comma: a serial comma added before the final conjunction (a space becomes a comma then a
|
|
256
|
+
// space) or removed. The diff isolates the punctuation run, so the signature is a comma appearing or
|
|
257
|
+
// disappearing with the surrounding space.
|
|
258
|
+
if (c.oxfordComma === 'always' && /^\s*$/.test(removed) && /^,\s*$/.test(added)) {
|
|
259
|
+
return 'oxfordComma';
|
|
260
|
+
}
|
|
261
|
+
if (c.oxfordComma === 'never' && /^,\s*$/.test(removed) && /^\s*$/.test(added)) {
|
|
262
|
+
return 'oxfordComma';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Percent: the word to the sign or back, the whole token swapped.
|
|
266
|
+
if (c.percent === 'sign' && /^percent$/i.test(removed.trim()) && added.trim() === '%') {
|
|
267
|
+
return 'percent';
|
|
268
|
+
}
|
|
269
|
+
if (c.percent === 'word' && removed.trim() === '%' && /^percent$/i.test(added.trim())) {
|
|
270
|
+
return 'percent';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Ellipsis: three dots to the single character or back.
|
|
274
|
+
if (c.ellipsis === 'single-char' && removed.includes('...') && added.includes('…')) {
|
|
275
|
+
return 'ellipsis';
|
|
276
|
+
}
|
|
277
|
+
if (c.ellipsis === 'three-dots' && removed.includes('…') && added.includes('...')) {
|
|
278
|
+
return 'ellipsis';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// En-dash ranges: a hyphen between two numbers becomes an en dash. The diff isolates the separator
|
|
282
|
+
// token between the numbers, so the signature is a hyphen run becoming an en-dash run.
|
|
283
|
+
if (c.enDashRanges && removed.trim() === '-' && added.trim() === EN_DASH) {
|
|
284
|
+
return 'enDashRanges';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Em-dash spacing: the spacing around an em dash changes. The dash stays; only the whitespace around
|
|
288
|
+
// it moves, so the change run is the dash-plus-spacing token and the dash count is preserved.
|
|
289
|
+
if (c.emDash !== undefined && removed.includes(EM_DASH) && added.includes(EM_DASH) && removed !== added) {
|
|
290
|
+
if (removed.replace(/\s/g, '') === added.replace(/\s/g, '')) return 'emDash';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Smart quotes: a straight quote becomes a curly one (or an apostrophe). The signature is a straight
|
|
294
|
+
// quote in the removed run and its curly counterpart in the added run, the letters preserved.
|
|
295
|
+
if (
|
|
296
|
+
c.smartQuotes &&
|
|
297
|
+
/['"]/.test(removed) &&
|
|
298
|
+
/[‘’“”]/.test(added) &&
|
|
299
|
+
removed.replace(/['"]/g, '') === added.replace(/[‘’“”]/g, '')
|
|
300
|
+
) {
|
|
301
|
+
return 'smartQuotes';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Number style: a spelled-out number word swapped for a plain integer numeral, or back. The diff
|
|
305
|
+
// isolates the single number token, so the signature is one trimmed side a known number word and the
|
|
306
|
+
// other a digit run. Only the clear single-word cases are claimed; a compound spelled number is left to
|
|
307
|
+
// the shape rules. The always-numeral exception sets (ages, dates, measurements, percentages) are the
|
|
308
|
+
// model's job in the prompt; this categorizer only labels the swap that landed.
|
|
309
|
+
if (c.numberStyle !== undefined) {
|
|
310
|
+
const r = removed.trim().toLowerCase();
|
|
311
|
+
const a = added.trim().toLowerCase();
|
|
312
|
+
const wordToNumeral = NUMBER_WORDS.has(r) && /^\d+$/.test(a);
|
|
313
|
+
const numeralToWord = /^\d+$/.test(r) && NUMBER_WORDS.has(a);
|
|
314
|
+
if (wordToNumeral || numeralToWord) return 'numberStyle';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Measurements: a unit abbreviation swapped for its spelled-out form, or back, the number untouched.
|
|
318
|
+
// The diff isolates the unit token, so the signature is the two trimmed sides forming one unit's
|
|
319
|
+
// notation pair. Notation only, never the system and never the number, exactly the convention's scope.
|
|
320
|
+
if (c.measurements !== undefined && isUnitNotationPair(removed.trim(), added.trim())) {
|
|
321
|
+
return 'measurements';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Time format: a clock time reshaped between "5pm", "5 PM", and "5 p.m." styles. This claims only the
|
|
325
|
+
// case where the diff isolates the whole time as one change, so both sides reduce to the same time key.
|
|
326
|
+
// A reshape that adds or moves a space the diff splits into a separate whitespace and letter hunk
|
|
327
|
+
// (for example "5 PM" to "5 p.m."); that case is left to the shape rules, where it stays a judgment
|
|
328
|
+
// hunk that defaults to undecided, so it is still never swept by Accept-fixes.
|
|
329
|
+
if (c.timeFormat !== undefined) {
|
|
330
|
+
const rKey = timeKey(removed.trim());
|
|
331
|
+
const aKey = timeKey(added.trim());
|
|
332
|
+
if (rKey !== null && rKey === aKey && removed.trim() !== added.trim()) return 'timeFormat';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The because-line data for a hunk: the convention's display name and the variant phrasing, both pure
|
|
339
|
+
* strings derived from the config. The surface renders "Your <label> setting is <variant>, ..." from
|
|
340
|
+
* these. Only a normalization carries a because-line; an objective or grammar hunk returns null (a
|
|
341
|
+
* grammar hunk's rationale, when shown, is the local subject-verb note the surface composes, not a
|
|
342
|
+
* config citation). */
|
|
343
|
+
export interface BecauseLine {
|
|
344
|
+
/** The convention's display label, e.g. "Oxford-comma". */
|
|
345
|
+
label: string;
|
|
346
|
+
/** The setting's variant phrasing, e.g. "always" or "5 PM". */
|
|
347
|
+
variant: string;
|
|
348
|
+
/** The trailing clause describing what tidy did, e.g. the serial-comma effect. */
|
|
349
|
+
effect: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build the because-line for a normalization category. Its ONLY data source is the config-declared
|
|
354
|
+
* setting that authorized the hunk: the convention key indexes the enabled variant on the conventions,
|
|
355
|
+
* and the line names that setting and variant. It NEVER counts the author's own usage. Counting the
|
|
356
|
+
* author's habit to justify a change is the harmonize-to-author judgment cairn must never make, so no
|
|
357
|
+
* code path here reads the buffer or any usage statistic; the conventions are the sole input. Returns
|
|
358
|
+
* null when the convention is somehow not enabled (defensive: categorize never produces such a hunk).
|
|
359
|
+
*/
|
|
360
|
+
export function buildBecause(key: NormalizationKey, conventions: TidyConventions): BecauseLine | null {
|
|
361
|
+
switch (key) {
|
|
362
|
+
case 'oxfordComma': {
|
|
363
|
+
if (conventions.oxfordComma === undefined) return null;
|
|
364
|
+
const variant = conventions.oxfordComma;
|
|
365
|
+
let effect: string;
|
|
366
|
+
switch (variant) {
|
|
367
|
+
case 'always':
|
|
368
|
+
effect = 'tidy adds the serial comma before the final "and"';
|
|
369
|
+
break;
|
|
370
|
+
case 'never':
|
|
371
|
+
effect = 'tidy removes the serial comma before the final "and"';
|
|
372
|
+
break;
|
|
373
|
+
default:
|
|
374
|
+
effect = 'tidy applies the serial comma to a complex series';
|
|
375
|
+
}
|
|
376
|
+
return { label: 'Oxford-comma', variant, effect };
|
|
377
|
+
}
|
|
378
|
+
case 'numberStyle': {
|
|
379
|
+
if (conventions.numberStyle === undefined) return null;
|
|
380
|
+
return { label: 'number-style', variant: conventions.numberStyle, effect: 'tidy applies your number style' };
|
|
381
|
+
}
|
|
382
|
+
case 'measurements': {
|
|
383
|
+
if (conventions.measurements === undefined) return null;
|
|
384
|
+
return {
|
|
385
|
+
label: 'measurement',
|
|
386
|
+
variant: conventions.measurements,
|
|
387
|
+
effect: 'tidy applies your measurement notation',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
case 'percent': {
|
|
391
|
+
if (conventions.percent === undefined) return null;
|
|
392
|
+
const variant = conventions.percent === 'sign' ? 'the sign' : 'the word';
|
|
393
|
+
const effect = conventions.percent === 'sign' ? 'tidy uses the "%" sign' : 'tidy uses the word "percent"';
|
|
394
|
+
return { label: 'percent', variant, effect };
|
|
395
|
+
}
|
|
396
|
+
case 'emDash': {
|
|
397
|
+
if (conventions.emDash === undefined) return null;
|
|
398
|
+
return { label: 'em-dash', variant: conventions.emDash, effect: 'tidy applies your em-dash spacing' };
|
|
399
|
+
}
|
|
400
|
+
case 'enDashRanges': {
|
|
401
|
+
if (!conventions.enDashRanges) return null;
|
|
402
|
+
return { label: 'number-range', variant: 'en dash', effect: 'tidy uses an en dash between numbers' };
|
|
403
|
+
}
|
|
404
|
+
case 'ellipsis': {
|
|
405
|
+
if (conventions.ellipsis === undefined) return null;
|
|
406
|
+
return { label: 'ellipsis', variant: conventions.ellipsis, effect: 'tidy applies your ellipsis style' };
|
|
407
|
+
}
|
|
408
|
+
case 'timeFormat': {
|
|
409
|
+
if (conventions.timeFormat === undefined) return null;
|
|
410
|
+
return { label: 'time-format', variant: conventions.timeFormat, effect: 'tidy renders the time that way' };
|
|
411
|
+
}
|
|
412
|
+
case 'smartQuotes': {
|
|
413
|
+
if (!conventions.smartQuotes) return null;
|
|
414
|
+
return { label: 'smart-quotes', variant: 'on', effect: 'tidy curls the straight quote' };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The human badge label for a category, the word shown in the hunk's category pill. A normalization's
|
|
420
|
+
* label is the convention's display name (its comma style, its time format), never "consistency" and
|
|
421
|
+
* never a count. */
|
|
422
|
+
export function categoryLabel(category: TidyCategory): string {
|
|
423
|
+
switch (category.kind) {
|
|
424
|
+
case 'spelling':
|
|
425
|
+
return 'Spelling';
|
|
426
|
+
case 'typo':
|
|
427
|
+
return 'Punctuation';
|
|
428
|
+
case 'doubled':
|
|
429
|
+
return 'Doubled word';
|
|
430
|
+
case 'whitespace':
|
|
431
|
+
return 'Whitespace';
|
|
432
|
+
case 'grammar':
|
|
433
|
+
return 'Grammar';
|
|
434
|
+
case 'normalization':
|
|
435
|
+
return normalizationLabel(category.convention);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizationLabel(key: NormalizationKey): string {
|
|
440
|
+
switch (key) {
|
|
441
|
+
case 'oxfordComma':
|
|
442
|
+
return 'Comma style';
|
|
443
|
+
case 'numberStyle':
|
|
444
|
+
return 'Number style';
|
|
445
|
+
case 'measurements':
|
|
446
|
+
return 'Measurements';
|
|
447
|
+
case 'percent':
|
|
448
|
+
return 'Percent';
|
|
449
|
+
case 'emDash':
|
|
450
|
+
return 'Em-dash style';
|
|
451
|
+
case 'enDashRanges':
|
|
452
|
+
return 'Number range';
|
|
453
|
+
case 'ellipsis':
|
|
454
|
+
return 'Ellipsis';
|
|
455
|
+
case 'timeFormat':
|
|
456
|
+
return 'Time format';
|
|
457
|
+
case 'smartQuotes':
|
|
458
|
+
return 'Smart quotes';
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The tidy diff: a Longest Common Subsequence over tokens, poplar's DiffRanges model rebuilt in
|
|
2
|
+
// TypeScript (spec 2.4). A small pure module, not a diff library. The tidy action returns only the
|
|
3
|
+
// corrected string; this module owns every range, offset, and line label. It is the sole source of
|
|
4
|
+
// positional truth for the review surface (Tasks 13 and 14 consume its output), so all positions are
|
|
5
|
+
// computed locally from this diff against the captured original, never taken from the model.
|
|
6
|
+
//
|
|
7
|
+
// Token granularity is the right unit for a copy-edit: a one-letter fix like "it's" to "its" reads
|
|
8
|
+
// as a whole-word replacement an author accepts or rejects as a unit, not a confusing single-character
|
|
9
|
+
// flip. The diff is computed against the original captured at request time; tidy is single-author and
|
|
10
|
+
// on-demand, so there is no rebasing and no three-way merge.
|
|
11
|
+
|
|
12
|
+
/** One run of the token diff. A run is contiguous tokens of a single kind. */
|
|
13
|
+
export interface DiffRange {
|
|
14
|
+
/**
|
|
15
|
+
* `equal` for tokens kept from the original, `deleted` for tokens removed from the original,
|
|
16
|
+
* `inserted` for tokens that appear only in the corrected text.
|
|
17
|
+
*/
|
|
18
|
+
kind: 'equal' | 'inserted' | 'deleted';
|
|
19
|
+
/**
|
|
20
|
+
* The offset into the captured ORIGINAL where this run begins. For `equal` and `deleted` runs
|
|
21
|
+
* this is the start of the run's text in the original. For an `inserted` run there is no original
|
|
22
|
+
* span, so `from === to`: the offset is the insertion point in the original.
|
|
23
|
+
*/
|
|
24
|
+
from: number;
|
|
25
|
+
/** The offset into the captured ORIGINAL where this run ends. For an `inserted` run, equal to `from`. */
|
|
26
|
+
to: number;
|
|
27
|
+
/** The actual token text of this run (original text for equal/deleted, corrected text for inserted). */
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A change: the unit the review UI accepts and rejects. A change is a deletion, an insertion, or a
|
|
33
|
+
* deletion immediately followed by an insertion that reads as a replacement. Each change is a faithful
|
|
34
|
+
* edit recipe against the captured original: splice `replacement` over the original span `[from, to)`.
|
|
35
|
+
*/
|
|
36
|
+
export interface Change {
|
|
37
|
+
/** A stable, gap-free index (0, 1, 2, ...) assigned in document order. */
|
|
38
|
+
index: number;
|
|
39
|
+
/** The start offset of the change's span in the captured ORIGINAL. */
|
|
40
|
+
from: number;
|
|
41
|
+
/**
|
|
42
|
+
* The end offset of the change's span in the captured ORIGINAL. A pure insertion has a zero-width
|
|
43
|
+
* span (`from === to`); a pure deletion has a non-empty span with an empty `replacement`.
|
|
44
|
+
*/
|
|
45
|
+
to: number;
|
|
46
|
+
/** The text to splice over `[from, to)`. Empty for a pure deletion. */
|
|
47
|
+
replacement: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A token is either a word (a run of word characters, apostrophes kept inside so "it's" is one token)
|
|
51
|
+
// or a non-word run (whitespace and punctuation between words). Splitting at the word boundary gives
|
|
52
|
+
// whole-word granularity: a homophone or typo fix lands on the word, not a single character.
|
|
53
|
+
const TOKEN = /[A-Za-z0-9_]+(?:['’][A-Za-z0-9_]+)*|[^A-Za-z0-9_]+/g;
|
|
54
|
+
|
|
55
|
+
interface Token {
|
|
56
|
+
text: string;
|
|
57
|
+
/** The offset of this token's start in the source string it was tokenized from. */
|
|
58
|
+
offset: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokenize(text: string): Token[] {
|
|
62
|
+
const tokens: Token[] = [];
|
|
63
|
+
for (const m of text.matchAll(TOKEN)) {
|
|
64
|
+
tokens.push({ text: m[0], offset: m.index });
|
|
65
|
+
}
|
|
66
|
+
return tokens;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Diff the original against the corrected text and return runs of equal, inserted, and deleted tokens.
|
|
71
|
+
* Both strings are tokenized into words plus the whitespace and punctuation between them, an LCS over
|
|
72
|
+
* the token sequences finds the kept tokens, and the gaps become deleted and inserted runs.
|
|
73
|
+
*
|
|
74
|
+
* Run offsets index the captured ORIGINAL: an `equal` or `deleted` run spans its original text, an
|
|
75
|
+
* `inserted` run carries a zero-width original span at the insertion point. Concatenating the equal
|
|
76
|
+
* and deleted runs rebuilds the original; concatenating the equal and inserted runs rebuilds the
|
|
77
|
+
* corrected text.
|
|
78
|
+
*/
|
|
79
|
+
export function diffTokens(original: string, corrected: string): DiffRange[] {
|
|
80
|
+
const a = tokenize(original);
|
|
81
|
+
const b = tokenize(corrected);
|
|
82
|
+
const n = a.length;
|
|
83
|
+
const m = b.length;
|
|
84
|
+
|
|
85
|
+
// Standard LCS table over the token sequences.
|
|
86
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
87
|
+
for (let i = 1; i <= n; i++) {
|
|
88
|
+
for (let j = 1; j <= m; j++) {
|
|
89
|
+
if (a[i - 1].text === b[j - 1].text) {
|
|
90
|
+
lcs[i][j] = lcs[i - 1][j - 1] + 1;
|
|
91
|
+
} else if (lcs[i - 1][j] >= lcs[i][j - 1]) {
|
|
92
|
+
lcs[i][j] = lcs[i - 1][j];
|
|
93
|
+
} else {
|
|
94
|
+
lcs[i][j] = lcs[i][j - 1];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Trace the table back from the bottom-right corner, then reverse, so the ops come out in document
|
|
100
|
+
// order. A diagonal step (equal tokens) is a kept token; a step up the original is a deletion; a
|
|
101
|
+
// step left along the corrected is an insertion. The backward walk follows the path the table
|
|
102
|
+
// encodes, which a forward greedy walk does not in general.
|
|
103
|
+
type Op = { kind: DiffRange['kind']; from: number; to: number; text: string };
|
|
104
|
+
const reversed: Op[] = [];
|
|
105
|
+
let i = n;
|
|
106
|
+
let j = m;
|
|
107
|
+
while (i > 0 || j > 0) {
|
|
108
|
+
if (i > 0 && j > 0 && a[i - 1].text === b[j - 1].text) {
|
|
109
|
+
reversed.push({ kind: 'equal', from: a[i - 1].offset, to: a[i - 1].offset + a[i - 1].text.length, text: a[i - 1].text });
|
|
110
|
+
i--;
|
|
111
|
+
j--;
|
|
112
|
+
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
|
|
113
|
+
// The original offset of an insertion is the start of the next kept original token (the one
|
|
114
|
+
// at index i), or the end of the original when nothing more remains.
|
|
115
|
+
const at = i < n ? a[i].offset : original.length;
|
|
116
|
+
reversed.push({ kind: 'inserted', from: at, to: at, text: b[j - 1].text });
|
|
117
|
+
j--;
|
|
118
|
+
} else {
|
|
119
|
+
reversed.push({ kind: 'deleted', from: a[i - 1].offset, to: a[i - 1].offset + a[i - 1].text.length, text: a[i - 1].text });
|
|
120
|
+
i--;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const ops = reversed.reverse();
|
|
124
|
+
|
|
125
|
+
// Coalesce adjacent ops of the same kind into runs. A run's text is the concatenation of its
|
|
126
|
+
// tokens; offsets span from the first token's `from` to the last token's `to`.
|
|
127
|
+
const runs: DiffRange[] = [];
|
|
128
|
+
for (const op of ops) {
|
|
129
|
+
const last = runs[runs.length - 1];
|
|
130
|
+
if (last && last.kind === op.kind && last.to === op.from) {
|
|
131
|
+
last.to = op.to;
|
|
132
|
+
last.text += op.text;
|
|
133
|
+
} else {
|
|
134
|
+
runs.push({ kind: op.kind, from: op.from, to: op.to, text: op.text });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return runs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Group the token diff into changes, the unit the review UI accepts and rejects. A run of deletions,
|
|
142
|
+
* a run of insertions, or a deletion run immediately followed by an insertion run (a replacement) all
|
|
143
|
+
* collapse into one change. Equal runs separate changes. Each change carries the original span to
|
|
144
|
+
* replace and the replacement text, with a stable index in document order.
|
|
145
|
+
*/
|
|
146
|
+
export function diffChanges(original: string, corrected: string): Change[] {
|
|
147
|
+
const runs = diffTokens(original, corrected);
|
|
148
|
+
const changes: Change[] = [];
|
|
149
|
+
let k = 0;
|
|
150
|
+
while (k < runs.length) {
|
|
151
|
+
const run = runs[k];
|
|
152
|
+
if (run.kind === 'equal') {
|
|
153
|
+
k++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Start a change at the first non-equal run and absorb the contiguous deleted/inserted block.
|
|
157
|
+
// A deletion immediately followed by an insertion reads as a replacement; either alone is a
|
|
158
|
+
// pure deletion or insertion.
|
|
159
|
+
let from = run.from;
|
|
160
|
+
let to = run.from;
|
|
161
|
+
let replacement = '';
|
|
162
|
+
while (k < runs.length && runs[k].kind !== 'equal') {
|
|
163
|
+
const r = runs[k];
|
|
164
|
+
if (r.kind === 'deleted') {
|
|
165
|
+
// A deleted run spans original text; extend the original span to cover it.
|
|
166
|
+
if (replacement === '' && to === from) from = r.from;
|
|
167
|
+
to = r.to;
|
|
168
|
+
} else {
|
|
169
|
+
// An inserted run contributes replacement text and pins the span start at its insertion
|
|
170
|
+
// point when no deletion has set it yet (a pure insertion is zero-width).
|
|
171
|
+
if (to === from) {
|
|
172
|
+
from = r.from;
|
|
173
|
+
to = r.from;
|
|
174
|
+
}
|
|
175
|
+
replacement += r.text;
|
|
176
|
+
}
|
|
177
|
+
k++;
|
|
178
|
+
}
|
|
179
|
+
changes.push({ index: changes.length, from, to, replacement });
|
|
180
|
+
}
|
|
181
|
+
return changes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The 1-based line number of an offset in the original, computed by counting newlines before it. The
|
|
186
|
+
* review surface derives every line label this way, from the offset against the captured original, so
|
|
187
|
+
* a label can never drift from the source or depend on a count the model supplied.
|
|
188
|
+
*/
|
|
189
|
+
export function lineLabel(original: string, offset: number): number {
|
|
190
|
+
let line = 1;
|
|
191
|
+
const end = Math.min(offset, original.length);
|
|
192
|
+
for (let i = 0; i < end; i++) {
|
|
193
|
+
if (original[i] === '\n') line++;
|
|
194
|
+
}
|
|
195
|
+
return line;
|
|
196
|
+
}
|