@jpeakai/prose-gates 0.1.1 → 0.1.2

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/dist/index.js CHANGED
@@ -9981,7 +9981,6 @@ var buildDocModel = (src) => {
9981
9981
  texts.push({
9982
9982
  value: t.value,
9983
9983
  line: t.position?.start.line ?? 0,
9984
- offset: start,
9985
9984
  start,
9986
9985
  end,
9987
9986
  block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell")
@@ -10008,24 +10007,109 @@ var buildDocModel = (src) => {
10008
10007
  }
10009
10008
  return { src, tree, paragraphs, texts, fences };
10010
10009
  };
10010
+
10011
+ // src/text.ts
10012
+ var first = (xs) => xs[0];
10013
+ var last = (xs) => xs[xs.length - 1];
10014
+ var within = (r, span) => r[0] >= span[0] && r[1] <= span[1];
10015
+ var collapse = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10016
+ var countOf = (text4, pattern) => [...text4.matchAll(pattern)].length;
10017
+ var spansBetween = (cuts, end) => cuts.map((cut, i) => {
10018
+ const next = cuts[i + 1];
10019
+ return [cut[1], next ? next[0] : end];
10020
+ });
10021
+ var words = (text4) => text4.split(/\s+/).filter(Boolean).length;
10011
10022
  var inRanges = (offset, ranges) => ranges.some(([a, b]) => offset >= a && offset < b);
10012
10023
  var sentences = (text4) => text4.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 0);
10013
10024
 
10014
- // src/rules/interpunct.ts
10025
+ // src/query.ts
10026
+ var proseParagraphs = (doc) => doc.paragraphs.filter((view) => !view.inTable);
10027
+ var proseSentences = (doc) => proseParagraphs(doc).flatMap((view) => sentences(view.prose).map((sentence) => ({ view, sentence })));
10028
+ var textsContaining = (doc, glyph) => doc.texts.filter((text4) => text4.value.includes(glyph));
10029
+ var matchesIn = (src, text4, pattern) => {
10030
+ const slice = src.slice(text4.start, text4.end);
10031
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
10032
+ const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
10033
+ const inValue = [...text4.value.matchAll(new RegExp(pattern.source, flags))];
10034
+ if (inSlice.length !== inValue.length)
10035
+ return null;
10036
+ return inSlice.map((m) => [text4.start + (m.index ?? 0), text4.start + (m.index ?? 0) + m[0].length]);
10037
+ };
10038
+ var matchesInAll = (src, texts, pattern) => {
10039
+ const out = [];
10040
+ for (const t of texts) {
10041
+ const found = matchesIn(src, t, pattern);
10042
+ if (found === null)
10043
+ return null;
10044
+ out.push(...found);
10045
+ }
10046
+ return out;
10047
+ };
10048
+ var escaped = (src, offset) => src[offset - 1] === "\\";
10049
+ var splittableSeparators = (src, view, separator, glyph) => {
10050
+ const found = matchesInAll(src, view.directTexts, separator);
10051
+ if (found === null)
10052
+ return null;
10053
+ if (found.length !== countOf(view.prose, glyph))
10054
+ return null;
10055
+ if (found.some(([start]) => escaped(src, start)))
10056
+ return null;
10057
+ return found;
10058
+ };
10059
+ var boundaries = (src, view) => {
10060
+ const out = [];
10061
+ for (const t of view.directTexts) {
10062
+ const slice = src.slice(t.start, t.end);
10063
+ for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
10064
+ const at = t.start + (m.index ?? 0);
10065
+ out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
10066
+ }
10067
+ }
10068
+ return out.sort((a, b) => a[0] - b[0]);
10069
+ };
10070
+ var sentenceSpan = (src, view, from, to) => {
10071
+ let start = view.startOffset;
10072
+ let end = view.endOffset;
10073
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10074
+ if (nextStart <= from)
10075
+ start = nextStart;
10076
+ if (terminalEnd >= to && end === view.endOffset)
10077
+ end = terminalEnd;
10078
+ }
10079
+ return [start, end];
10080
+ };
10081
+ var sentenceSpans = (src, view) => {
10082
+ const spans = [];
10083
+ let start = view.startOffset;
10084
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10085
+ spans.push([start, terminalEnd]);
10086
+ start = nextStart;
10087
+ }
10088
+ spans.push([start, view.endOffset]);
10089
+ return spans;
10090
+ };
10091
+ var colonsIn = (src, view) => matchesInAll(src, view.directTexts, /:/);
10092
+ var colonsBefore = (colons, range, item) => colons.filter((colon) => within(colon, range) && colon[1] <= item);
10093
+
10094
+ // src/interpunct.ts
10095
+ var INTERPUNCT = "·";
10096
+ var GLYPH = /·/g;
10097
+ var RUN_SEPARATOR = /[ \t]*·[ \t]*/;
10015
10098
  var interpunctRuns = (doc) => {
10016
10099
  const runs = [];
10017
- for (const p of doc.paragraphs) {
10018
- if (p.inTable)
10100
+ for (const view of proseParagraphs(doc)) {
10101
+ const separators = countOf(view.prose, GLYPH);
10102
+ if (separators < 2)
10019
10103
  continue;
10020
- const separators = (p.prose.match(/·/g) ?? []).length;
10021
- if (separators >= 2) {
10022
- const lines = p.raw.split(`
10023
- `).filter((l) => l.includes("·")).length;
10024
- runs.push({ range: [p.startOffset, p.endOffset], line: p.line, separators, lines });
10025
- }
10104
+ const lines = view.raw.split(`
10105
+ `).filter((line) => line.includes(INTERPUNCT)).length;
10106
+ runs.push({ range: [view.startOffset, view.endOffset], line: view.line, separators, lines });
10026
10107
  }
10027
10108
  return runs;
10028
10109
  };
10110
+ var isFlat = (run) => run.lines === 1;
10111
+ var isStacked = (run) => run.lines >= 2;
10112
+ var paragraphsOf = (doc, runs) => doc.paragraphs.filter((view) => runs.some((run) => run.range[0] === view.startOffset));
10029
10113
 
10030
10114
  // src/rules/types.ts
10031
10115
  var RULE = {
@@ -10043,56 +10127,68 @@ var CATEGORIES = ["sentence", "list", "punctuation"];
10043
10127
 
10044
10128
  // src/rules/pg001-wrap.ts
10045
10129
  var TERMINALS = new Set([".", "!", "?", ":", ";"]);
10046
- var check = ({ doc, file }) => {
10047
- const findings = [];
10048
- for (const p of doc.paragraphs) {
10049
- if (p.inTable)
10050
- continue;
10051
- for (let i = 0;i < p.raw.length; i++) {
10052
- if (p.raw[i] !== `
10130
+ var TRAILING = new Set([" ", "\t", "\r", "\\"]);
10131
+ var SENTENCE_BREAK = /[.!?]\s+(?=[A-Z`"'([])/g;
10132
+ var wrapsIn = (view) => {
10133
+ const wraps = [];
10134
+ for (let i = 0;i < view.raw.length; i++) {
10135
+ if (view.raw[i] !== `
10053
10136
  `)
10054
- continue;
10055
- if (inRanges(p.startOffset + i, p.codeRanges))
10056
- continue;
10057
- let j = i - 1;
10058
- while (j >= 0 && (p.raw[j] === " " || p.raw[j] === "\t" || p.raw[j] === "\r" || p.raw[j] === "\\"))
10059
- j--;
10060
- if (j >= 0 && !TERMINALS.has(p.raw[j])) {
10061
- const line = p.line + p.raw.slice(0, i).split(`
10062
- `).length - 1;
10063
- findings.push({ file, line, rule: RULE.WRAP, message: "mid-sentence line wrap; write one sentence per line" });
10064
- }
10065
- }
10137
+ continue;
10138
+ if (inRanges(view.startOffset + i, view.codeRanges))
10139
+ continue;
10140
+ let j = i - 1;
10141
+ while (j >= 0 && TRAILING.has(view.raw.charAt(j)))
10142
+ j--;
10143
+ if (j >= 0 && !TERMINALS.has(view.raw.charAt(j)))
10144
+ wraps.push(i);
10066
10145
  }
10067
- return findings;
10146
+ return wraps;
10068
10147
  };
10069
- var fix = (doc) => {
10070
- const edits = [];
10071
- const stacked = interpunctRuns(doc).filter((r) => r.lines >= 2).map((r) => r.range[0]);
10072
- for (const p of doc.paragraphs) {
10073
- if (p.inTable || p.inBlockquote || p.hasBreak || stacked.includes(p.startOffset))
10074
- continue;
10075
- const indent2 = " ".repeat(p.startColumn - 1);
10076
- const eol = p.raw.includes(`\r
10148
+ var reflow = (view) => {
10149
+ const indent2 = " ".repeat(view.startColumn - 1);
10150
+ const eol = view.raw.includes(`\r
10077
10151
  `) ? `\r
10078
10152
  ` : `
10079
10153
  `;
10080
- const masked = [...p.raw].map((ch, i) => inRanges(p.startOffset + i, p.codeRanges) && !/\s/.test(ch) ? "x" : ch).join("");
10081
- const join = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10082
- const joined = join(p.raw);
10083
- const joinedMask = join(masked);
10084
- const out = [];
10085
- let last = 0;
10086
- for (const m of joinedMask.matchAll(/[.!?]\s+(?=[A-Z`"'([])/g)) {
10087
- const at = m.index ?? 0;
10088
- out.push(joined.slice(last, at + 1));
10089
- last = at + m[0].length;
10090
- }
10091
- out.push(joined.slice(last));
10092
- const text4 = out.map((s, i) => i === 0 ? s.trim() : indent2 + s.trim()).join(eol);
10093
- if (text4 !== p.raw) {
10094
- edits.push({ rule: RULE.WRAP, start: p.startOffset, end: p.endOffset, text: text4, expect: { kind: "same-tree" } });
10095
- }
10154
+ const masked = [...view.raw].map((ch, i) => inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch).join("");
10155
+ const joined = collapse(view.raw);
10156
+ const joinedMask = collapse(masked);
10157
+ const lines = [];
10158
+ let from = 0;
10159
+ for (const match of joinedMask.matchAll(SENTENCE_BREAK)) {
10160
+ const at = match.index ?? 0;
10161
+ lines.push(joined.slice(from, at + 1));
10162
+ from = at + match[0].length;
10163
+ }
10164
+ lines.push(joined.slice(from));
10165
+ return lines.map((line, i) => i === 0 ? line.trim() : indent2 + line.trim()).join(eol);
10166
+ };
10167
+ var check = ({ doc, file }) => proseParagraphs(doc).flatMap((view) => wrapsIn(view).map((at) => ({
10168
+ file,
10169
+ line: view.line + view.raw.slice(0, at).split(`
10170
+ `).length - 1,
10171
+ rule: RULE.WRAP,
10172
+ message: "mid-sentence line wrap; write one sentence per line"
10173
+ })));
10174
+ var fix = ({ doc, runs }) => {
10175
+ const edits = [];
10176
+ const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
10177
+ for (const view of proseParagraphs(doc)) {
10178
+ if (view.inBlockquote || view.hasBreak)
10179
+ continue;
10180
+ if (stacked.has(view))
10181
+ continue;
10182
+ const text4 = reflow(view);
10183
+ if (text4 === view.raw)
10184
+ continue;
10185
+ edits.push({
10186
+ rule: RULE.WRAP,
10187
+ start: view.startOffset,
10188
+ end: view.endOffset,
10189
+ text: text4,
10190
+ expect: { kind: "same-tree" }
10191
+ });
10096
10192
  }
10097
10193
  return edits;
10098
10194
  };
@@ -10107,106 +10203,46 @@ var pg001 = {
10107
10203
  // src/rules/pg002-length.ts
10108
10204
  var CLAUSE_BOUNDARY = /[;:](?=\s+\S)|,\s+(?:and|but|or|so|yet|nor)\b|\b(?:because|although|though|whereas|unless|whenever|wherever|while|when|if|which|who|whom|whose)\b/gi;
10109
10205
  var countClauses = (sentence) => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
10110
- var excerpt = (s) => s.length > 60 ? `${s.slice(0, 60)}...` : s;
10206
+ var excerpt = (sentence) => sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence;
10111
10207
  var lengthMessage = (sentence, maxWords) => {
10112
- const words = sentence.split(/\s+/).length;
10208
+ const count = words(sentence);
10113
10209
  const clauses = countClauses(sentence);
10114
- const target = Math.max(2, clauses, Math.ceil(words / maxWords));
10210
+ const target = Math.max(2, clauses, Math.ceil(count / maxWords));
10115
10211
  const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
10116
- return `sentence has ${words} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
10212
+ return `sentence has ${count} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
10117
10213
  };
10214
+ var check2 = ({ doc, file, maxWords }) => proseSentences(doc).filter(({ sentence }) => words(sentence) > maxWords).map(({ view, sentence }) => ({
10215
+ file,
10216
+ line: view.line,
10217
+ rule: RULE.LENGTH,
10218
+ message: lengthMessage(sentence, maxWords)
10219
+ }));
10118
10220
  var pg002 = {
10119
10221
  id: RULE.LENGTH,
10120
10222
  category: "sentence",
10121
10223
  summary: "sentence longer than the word budget (default 25)",
10122
- check: ({ doc, file, maxWords }) => {
10123
- const findings = [];
10124
- for (const p of doc.paragraphs) {
10125
- if (p.inTable)
10126
- continue;
10127
- for (const s of sentences(p.prose)) {
10128
- if (s.split(/\s+/).length > maxWords) {
10129
- findings.push({ file, line: p.line, rule: RULE.LENGTH, message: lengthMessage(s, maxWords) });
10130
- }
10131
- }
10132
- }
10133
- return findings;
10134
- }
10135
- };
10136
-
10137
- // src/fix/spans.ts
10138
- var matchesIn = (src, text4, pattern) => {
10139
- const slice = src.slice(text4.start, text4.end);
10140
- const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
10141
- const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
10142
- const inValue = [...text4.value.matchAll(new RegExp(pattern.source, flags))];
10143
- if (inSlice.length !== inValue.length)
10144
- return null;
10145
- return inSlice.map((m) => [text4.start + (m.index ?? 0), text4.start + (m.index ?? 0) + m[0].length]);
10146
- };
10147
- var matchesInAll = (src, texts, pattern) => {
10148
- const out = [];
10149
- for (const t of texts) {
10150
- const found = matchesIn(src, t, pattern);
10151
- if (found === null)
10152
- return null;
10153
- out.push(...found);
10154
- }
10155
- return out;
10156
- };
10157
- var boundaries = (src, view) => {
10158
- const out = [];
10159
- for (const t of view.directTexts) {
10160
- const slice = src.slice(t.start, t.end);
10161
- for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
10162
- const at = t.start + (m.index ?? 0);
10163
- out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
10164
- }
10165
- }
10166
- return out.sort((a, b) => a[0] - b[0]);
10224
+ check: check2
10167
10225
  };
10168
- var sentenceSpan = (src, view, from, to) => {
10169
- let start = view.startOffset;
10170
- let end = view.endOffset;
10171
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10172
- if (nextStart <= from)
10173
- start = nextStart;
10174
- if (terminalEnd >= to && end === view.endOffset)
10175
- end = terminalEnd;
10176
- }
10177
- return [start, end];
10178
- };
10179
- var sentenceSpans = (src, view) => {
10180
- const spans = [];
10181
- let start = view.startOffset;
10182
- for (const [terminalEnd, nextStart] of boundaries(src, view)) {
10183
- spans.push([start, terminalEnd]);
10184
- start = nextStart;
10185
- }
10186
- spans.push([start, view.endOffset]);
10187
- return spans;
10188
- };
10189
- var within = (r, span) => r[0] >= span[0] && r[1] <= span[1];
10190
- var collapse = (s) => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
10191
- var escaped = (src, offset) => src[offset - 1] === "\\";
10192
10226
 
10193
10227
  // src/fix/promote.ts
10194
10228
  var UNSAFE_START = /^(?:[-+*](?:\s|$)|#{1,6}(?:\s|$)|>|\d{1,9}[.)](?:\s|$)|`{3}|~{3}|<|\||[-_*=](?:\s*[-_*=]){2,}\s*$)/;
10195
10229
  var CONJUNCTION_HEAD = /^(?:and|or)\s+/i;
10196
10230
  var CONJUNCTION_TAIL = /\s+(?:and|or)$/i;
10197
- var cleanItem = (raw, { last }) => {
10231
+ var cleanItem = (raw, { last: last2 }) => {
10198
10232
  let s = collapse(raw).trim();
10199
10233
  for (let prev = "";prev !== s; ) {
10200
10234
  prev = s;
10201
10235
  s = s.replace(/[;,]+$/, "").trim();
10202
10236
  s = s.replace(CONJUNCTION_TAIL, "").trim();
10203
- if (last)
10237
+ if (last2)
10204
10238
  s = s.replace(CONJUNCTION_HEAD, "").trim();
10205
10239
  }
10206
- if (last)
10240
+ if (last2)
10207
10241
  s = s.replace(/(?<!\.)\.$/, "").trim();
10208
10242
  return s;
10209
10243
  };
10244
+ var itemsOf = (src, spans) => spans.map((span, i) => cleanItem(src.slice(span[0], span[1]), { last: i + 1 === spans.length }));
10245
+ var itemsBetween = (src, cuts, end) => itemsOf(src, spansBetween(cuts, end));
10210
10246
  var cleanLeadIn = (raw) => {
10211
10247
  const s = collapse(raw).trim().replace(/[\s:;,—-]+$/, "").trim();
10212
10248
  return s.length > 0 ? `${s}:` : null;
@@ -10259,52 +10295,35 @@ ${list2}`;
10259
10295
  };
10260
10296
 
10261
10297
  // src/rules/pg003-semicolon-list.ts
10262
- var check2 = ({ doc, file }) => {
10263
- const findings = [];
10264
- for (const p of doc.paragraphs) {
10265
- if (p.inTable)
10266
- continue;
10267
- for (const s of sentences(p.prose)) {
10268
- const semis = (s.match(/;/g) ?? []).length;
10269
- if (semis >= 2) {
10270
- findings.push({
10271
- file,
10272
- line: p.line,
10273
- rule: RULE.SEMICOLON_LIST,
10274
- message: `semicolon-delimited list (${semis} ';'); promote to a bullet list`
10275
- });
10276
- }
10277
- }
10278
- }
10279
- return findings;
10280
- };
10281
- var fix2 = (doc) => {
10282
- const edits = [];
10298
+ var SEMICOLON = /;/g;
10299
+ var check3 = ({ doc, file }) => proseSentences(doc).map(({ view, sentence }) => ({ view, semicolons: countOf(sentence, SEMICOLON) })).filter(({ semicolons }) => semicolons >= 2).map(({ view, semicolons }) => ({
10300
+ file,
10301
+ line: view.line,
10302
+ rule: RULE.SEMICOLON_LIST,
10303
+ message: `semicolon-delimited list (${semicolons} ';'); promote to a bullet list`
10304
+ }));
10305
+ var fix2 = ({ doc }) => {
10283
10306
  const { src } = doc;
10307
+ const edits = [];
10284
10308
  for (const view of doc.paragraphs) {
10285
- const semis = matchesInAll(src, view.directTexts, /;/);
10286
- const colons = matchesInAll(src, view.directTexts, /:/);
10287
- if (semis === null || colons === null)
10309
+ const semicolons = matchesInAll(src, view.directTexts, SEMICOLON);
10310
+ const colons = colonsIn(src, view);
10311
+ if (semicolons === null || colons === null)
10288
10312
  continue;
10289
10313
  for (const span of sentenceSpans(src, view)) {
10290
- const inside = semis.filter((r) => within(r, span));
10314
+ const inside = semicolons.filter((semicolon) => within(semicolon, span));
10291
10315
  if (inside.length < 2)
10292
10316
  continue;
10293
- const colon = colons.find((r) => within(r, span) && r[1] <= inside[0][0]);
10317
+ const colon = colonsBefore(colons, span, first(inside)[0])[0];
10294
10318
  if (!colon)
10295
10319
  continue;
10296
- const cuts = [colon, ...inside];
10297
- const items = cuts.map((cut, i) => {
10298
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : span[1];
10299
- return { text: cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length }) };
10300
- });
10301
10320
  const edit = promote({
10302
10321
  rule: RULE.SEMICOLON_LIST,
10303
10322
  src,
10304
10323
  view,
10305
10324
  span,
10306
10325
  leadIn: cleanLeadIn(src.slice(span[0], colon[0])),
10307
- items,
10326
+ items: itemsBetween(src, [colon, ...inside], span[1]).map((text4) => ({ text: text4 })),
10308
10327
  ordered: false
10309
10328
  });
10310
10329
  if (edit)
@@ -10318,115 +10337,124 @@ var pg003 = {
10318
10337
  id: RULE.SEMICOLON_LIST,
10319
10338
  category: "list",
10320
10339
  summary: "semicolon-delimited list (2+ ';' in one sentence)",
10321
- check: check2,
10340
+ check: check3,
10322
10341
  fix: fix2
10323
10342
  };
10324
10343
 
10325
10344
  // src/rules/pg004-em-dash.ts
10326
- var DASH = /[\p{Zs}\t\u200b]*—[\p{Zs}\t\u200b]*/u;
10345
+ var EM_DASH = "—";
10346
+ var DASH = /[\p{Zs}\t​]*—[\p{Zs}\t​]*/u;
10327
10347
  var MARKER_LIKE = /^(?:[a-z]|[ivx]+|\d+)$/i;
10328
- var fix3 = (doc) => {
10329
- const { src } = doc;
10348
+ var blocksOf = (doc) => {
10330
10349
  const byBlock = new Map;
10331
- for (const t of doc.texts) {
10332
- if (t.block === undefined || t.start === undefined || t.end === undefined)
10350
+ for (const { block, start, end, value: value2 } of doc.texts) {
10351
+ if (block === undefined || start === undefined || end === undefined)
10333
10352
  continue;
10334
- const entry = byBlock.get(t.block) ?? { texts: [], dashes: [], refused: false };
10335
- byBlock.set(t.block, entry);
10336
- entry.texts.push(t);
10337
- const found = matchesIn(src, { value: t.value, start: t.start, end: t.end }, DASH);
10353
+ let entry = byBlock.get(block);
10354
+ if (!entry) {
10355
+ entry = { texts: [], dashes: [], refused: false };
10356
+ byBlock.set(block, entry);
10357
+ }
10358
+ const text4 = { value: value2, start, end };
10359
+ entry.texts.push(text4);
10360
+ const found = matchesIn(doc.src, text4, DASH);
10338
10361
  if (found === null)
10339
10362
  entry.refused = true;
10340
10363
  else
10341
- entry.dashes.push(...found.map((range) => ({ range, text: t })));
10364
+ entry.dashes.push(...found.map((range) => ({ range, text: text4 })));
10342
10365
  }
10343
- const edits = [];
10344
- for (const { texts, dashes, refused } of byBlock.values()) {
10345
- if (refused || dashes.length === 0)
10346
- continue;
10347
- const terminals = [];
10348
- const colons = [];
10349
- for (const t of texts) {
10350
- const slice = src.slice(t.start, t.end);
10351
- for (const m of slice.matchAll(/[.!?](?=\s)/g))
10352
- terminals.push(t.start + (m.index ?? 0));
10353
- for (const m of slice.matchAll(/:/g))
10354
- colons.push(t.start + (m.index ?? 0));
10355
- }
10356
- const sentenceOf = (offset) => terminals.filter((x) => x < offset).length;
10357
- const groups = new Map;
10358
- for (const d of dashes) {
10359
- const key = sentenceOf(d.range[0]);
10360
- groups.set(key, [...groups.get(key) ?? [], d]);
10361
- }
10362
- for (const [sentence, group] of groups) {
10363
- const edit = rewrite(src, group, sentence, sentenceOf, colons);
10364
- if (edit)
10365
- edits.push(edit);
10366
- }
10366
+ return [...byBlock.values()];
10367
+ };
10368
+ var offsetsIn = (src, texts, pattern) => texts.flatMap((text4) => [...src.slice(text4.start, text4.end).matchAll(pattern)].map((m) => text4.start + (m.index ?? 0)));
10369
+ var asidesIn = (src, { texts, dashes }) => {
10370
+ const terminals = offsetsIn(src, texts, /[.!?](?=\s)/g);
10371
+ const colons = offsetsIn(src, texts, /:/g);
10372
+ const sentenceOf = (offset) => terminals.filter((terminal) => terminal < offset).length;
10373
+ const bySentence = new Map;
10374
+ for (const dash of dashes) {
10375
+ const sentence = sentenceOf(dash.range[0]);
10376
+ const group = bySentence.get(sentence);
10377
+ if (group)
10378
+ group.push(dash);
10379
+ else
10380
+ bySentence.set(sentence, [dash]);
10367
10381
  }
10368
- return edits;
10382
+ return [...bySentence].map(([sentence, group]) => ({
10383
+ dashes: group,
10384
+ hasColon: colons.some((colon) => sentenceOf(colon) === sentence)
10385
+ }));
10369
10386
  };
10370
- var rewrite = (src, group, sentence, sentenceOf, colons) => {
10371
- const first = group[0];
10372
- const last = group[group.length - 1];
10373
- const touchesEdge = (d) => d.range[0] === d.text.start || d.range[1] === d.text.end || /[\r\n.!?:;,(\\]/.test(src[d.range[0] - 1] ?? `
10374
- `) || /[\r\n.!?:;,)]/.test(src[d.range[1]] ?? `
10387
+ var touchesEdge = (src, dash) => dash.range[0] === dash.text.start || dash.range[1] === dash.text.end || /[\r\n.!?:;,(\\]/.test(src[dash.range[0] - 1] ?? `
10388
+ `) || /[\r\n.!?:;,)]/.test(src[dash.range[1]] ?? `
10375
10389
  `);
10376
- if (group.some(touchesEdge))
10390
+ var replacementFor = (src, { dashes, hasColon }) => {
10391
+ const opening = first(dashes);
10392
+ const closing = last(dashes);
10393
+ if (dashes.length === 2) {
10394
+ const inner = src.slice(opening.range[1], closing.range[0]);
10395
+ return MARKER_LIKE.test(inner.trim()) ? null : ` (${inner}) `;
10396
+ }
10397
+ if (dashes.length === 1) {
10398
+ const numericRange = /\d/.test(src[opening.range[0] - 1] ?? "") && /\d/.test(src[opening.range[1]] ?? "");
10399
+ return numericRange || hasColon ? null : ": ";
10400
+ }
10401
+ return null;
10402
+ };
10403
+ var rewrite = (src, aside) => {
10404
+ if (aside.dashes.some((dash) => touchesEdge(src, dash)))
10377
10405
  return null;
10378
- let text4;
10379
- if (group.length === 2) {
10380
- const inner = src.slice(first.range[1], last.range[0]);
10381
- if (MARKER_LIKE.test(inner.trim()))
10382
- return null;
10383
- text4 = ` (${inner}) `;
10384
- } else if (group.length === 1) {
10385
- if (/\d/.test(src[first.range[0] - 1] ?? "") && /\d/.test(src[first.range[1]] ?? ""))
10386
- return null;
10387
- if (colons.some((c) => sentenceOf(c) === sentence))
10388
- return null;
10389
- text4 = ": ";
10390
- } else {
10406
+ const text4 = replacementFor(src, aside);
10407
+ if (text4 === null)
10391
10408
  return null;
10392
- }
10393
10409
  return {
10394
10410
  rule: RULE.EM_DASH,
10395
- start: first.range[0],
10396
- end: last.range[1],
10411
+ start: first(aside.dashes).range[0],
10412
+ end: last(aside.dashes).range[1],
10397
10413
  text: text4,
10398
10414
  expect: { kind: "same-shape" }
10399
10415
  };
10400
10416
  };
10417
+ var check4 = ({ doc, file }) => textsContaining(doc, EM_DASH).map((text4) => ({
10418
+ file,
10419
+ line: text4.line,
10420
+ rule: RULE.EM_DASH,
10421
+ message: "em-dash in prose; use comma, colon, parentheses, or two sentences"
10422
+ }));
10423
+ var fix3 = ({ doc }) => blocksOf(doc).filter((block) => !block.refused && block.dashes.length > 0).flatMap((block) => asidesIn(doc.src, block)).map((aside) => rewrite(doc.src, aside)).filter((edit) => edit !== null);
10401
10424
  var pg004 = {
10402
10425
  id: RULE.EM_DASH,
10403
10426
  category: "punctuation",
10404
10427
  summary: "em-dash U+2014 in prose",
10405
- check: ({ doc, file }) => doc.texts.filter((t) => t.value.includes("—")).map((t) => ({
10406
- file,
10407
- line: t.line,
10408
- rule: RULE.EM_DASH,
10409
- message: "em-dash in prose; use comma, colon, parentheses, or two sentences"
10410
- })),
10428
+ check: check4,
10411
10429
  fix: fix3
10412
10430
  };
10413
10431
 
10414
10432
  // src/rules/pg005-interpunct.ts
10415
- var fix4 = (doc) => {
10416
- const runs = interpunctRuns(doc).map((r) => r.range);
10433
+ var SEPARATOR = /[ \t]+·[ \t]+/;
10434
+ var check5 = ({ doc, file, runs }) => {
10435
+ const reported = runs.map((run) => run.range);
10436
+ return textsContaining(doc, INTERPUNCT).filter((text4) => text4.start === undefined || !inRanges(text4.start, reported)).map((text4) => ({
10437
+ file,
10438
+ line: text4.line,
10439
+ rule: RULE.INTERPUNCT,
10440
+ message: "interpunct in prose; use a comma, slash, or a list"
10441
+ }));
10442
+ };
10443
+ var fix4 = ({ doc, runs }) => {
10444
+ const reported = runs.map((run) => run.range);
10417
10445
  const edits = [];
10418
- for (const t of doc.texts) {
10419
- if (t.start === undefined || t.end === undefined || !t.value.includes("·"))
10446
+ for (const { start, end, value: value2 } of textsContaining(doc, INTERPUNCT)) {
10447
+ if (start === undefined || end === undefined)
10420
10448
  continue;
10421
- if (inRanges(t.start, runs))
10449
+ if (inRanges(start, reported))
10422
10450
  continue;
10423
- const text4 = { value: t.value, start: t.start, end: t.end };
10424
- const glyphs = matchesIn(doc.src, text4, /·/);
10425
- const spaced = matchesIn(doc.src, text4, /[ \t]+·[ \t]+/);
10426
- if (glyphs === null || spaced === null || glyphs.length !== spaced.length)
10451
+ const text4 = { value: value2, start, end };
10452
+ const glyphs = matchesIn(doc.src, text4, GLYPH);
10453
+ const separators = matchesIn(doc.src, text4, SEPARATOR);
10454
+ if (glyphs === null || separators === null || glyphs.length !== separators.length)
10427
10455
  continue;
10428
- for (const [start, end] of spaced) {
10429
- edits.push({ rule: RULE.INTERPUNCT, start, end, text: ", ", expect: { kind: "same-shape" } });
10456
+ for (const [from, to] of separators) {
10457
+ edits.push({ rule: RULE.INTERPUNCT, start: from, end: to, text: ", ", expect: { kind: "same-shape" } });
10430
10458
  }
10431
10459
  }
10432
10460
  return edits;
@@ -10435,51 +10463,33 @@ var pg005 = {
10435
10463
  id: RULE.INTERPUNCT,
10436
10464
  category: "punctuation",
10437
10465
  summary: "interpunct U+00B7 in prose",
10438
- check: ({ doc, file, runs }) => {
10439
- const suppressed = runs.map((r) => r.range);
10440
- return doc.texts.filter((t) => t.value.includes("·") && !(t.offset != null && inRanges(t.offset, suppressed))).map((t) => ({
10441
- file,
10442
- line: t.line,
10443
- rule: RULE.INTERPUNCT,
10444
- message: "interpunct in prose; use a comma, slash, or a list"
10445
- }));
10446
- },
10466
+ check: check5,
10447
10467
  fix: fix4
10448
10468
  };
10449
10469
 
10450
10470
  // src/rules/pg006-interpunct-run.ts
10451
- var words = (s) => s.split(/\s+/).filter(Boolean).length;
10452
- var tailIsProse = (items) => {
10453
- const last = words(items[items.length - 1] ?? "");
10454
- const others = Math.max(...items.slice(0, -1).map(words));
10455
- return last > Math.max(8, 3 * others);
10456
- };
10457
- var headIsProse = (items) => {
10458
- const first = words(items[0] ?? "");
10459
- return first > Math.max(...items.slice(1).map(words));
10460
- };
10461
- var fix5 = (doc) => {
10471
+ var tailIsProse = (items) => words(last(items)) > Math.max(8, 3 * Math.max(...items.slice(0, -1).map(words)));
10472
+ var headIsProse = (items) => words(first(items)) > Math.max(...items.slice(1).map(words));
10473
+ var check6 = ({ file, runs }) => runs.filter(isFlat).map((run) => ({
10474
+ file,
10475
+ line: run.line,
10476
+ rule: RULE.INTERPUNCT_RUN,
10477
+ message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`
10478
+ }));
10479
+ var fix5 = ({ doc, runs }) => {
10462
10480
  const { src } = doc;
10463
10481
  const edits = [];
10464
- const runs = interpunctRuns(doc).filter((r) => r.lines === 1);
10465
- for (const view of doc.paragraphs) {
10466
- if (!runs.some((r) => r.range[0] === view.startOffset))
10482
+ for (const view of paragraphsOf(doc, runs.filter(isFlat))) {
10483
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
10484
+ const colons = colonsIn(src, view);
10485
+ if (separators === null || colons === null)
10467
10486
  continue;
10468
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
10469
- const colons = matchesInAll(src, view.directTexts, /:/);
10470
- if (seps === null || colons === null || seps.length !== (view.prose.match(/·/g) ?? []).length || seps.some(([s]) => escaped(src, s)))
10471
- continue;
10472
- const first = seps[0];
10473
- const last = seps[seps.length - 1];
10474
- const span = sentenceSpan(src, view, first[0], last[1]);
10475
- const colon = colons.filter((c) => within(c, span) && c[1] <= first[0]).pop();
10487
+ const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
10488
+ const colon = colonsBefore(colons, span, first(separators)[0]).pop();
10476
10489
  if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset))
10477
10490
  continue;
10478
- const cuts = [colon ?? [span[0], span[0]], ...seps];
10479
- const items = cuts.map((cut, i) => {
10480
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : span[1];
10481
- return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
10482
- });
10491
+ const cuts = [colon ?? [span[0], span[0]], ...separators];
10492
+ const items = itemsBetween(src, cuts, span[1]);
10483
10493
  if (tailIsProse(items) || !colon && headIsProse(items))
10484
10494
  continue;
10485
10495
  const edit = promote({
@@ -10500,12 +10510,7 @@ var pg006 = {
10500
10510
  id: RULE.INTERPUNCT_RUN,
10501
10511
  category: "list",
10502
10512
  summary: "interpunct-joined inline list (2+ separators)",
10503
- check: ({ file, runs }) => runs.filter((r) => r.lines < 2).map((r) => ({
10504
- file,
10505
- line: r.line,
10506
- rule: RULE.INTERPUNCT_RUN,
10507
- message: `interpunct-joined inline list (${r.separators + 1} items); promote to a bullet list`
10508
- })),
10513
+ check: check6,
10509
10514
  fix: fix5
10510
10515
  };
10511
10516
 
@@ -10517,62 +10522,74 @@ var ENUM_FAMILIES = [
10517
10522
  ];
10518
10523
  var ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"];
10519
10524
  var LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
10520
- var sequenceFor = (first) => {
10521
- if (first === "a")
10525
+ var sequenceFor = (label) => {
10526
+ if (label === "a")
10522
10527
  return LETTERS;
10523
- if (first === "1")
10528
+ if (label === "1")
10524
10529
  return Array.from({ length: 99 }, (_, i) => String(i + 1));
10525
- if (first === "i")
10530
+ if (label === "i")
10526
10531
  return ROMAN;
10527
10532
  return null;
10528
10533
  };
10529
10534
  var MARKER = /\(([a-z]|[ivx]+|\d{1,2})\)(?=\s)/g;
10530
10535
  var markersIn = (src, texts) => {
10531
- const out = [];
10532
- for (const t of texts) {
10533
- const slice = src.slice(t.start, t.end);
10534
- const opening = (s) => [...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s[(m.index ?? 0) - 1]));
10535
- const inSlice = opening(slice);
10536
- if (inSlice.length !== opening(t.value).length)
10536
+ const opening = (s) => [...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s.charAt((m.index ?? 0) - 1)));
10537
+ const markers2 = [];
10538
+ for (const text4 of texts) {
10539
+ const inSlice = opening(src.slice(text4.start, text4.end));
10540
+ if (inSlice.length !== opening(text4.value).length)
10537
10541
  return null;
10538
- for (const m of inSlice) {
10539
- const start = t.start + (m.index ?? 0);
10540
- out.push({ range: [start, start + m[0].length], label: m[1] });
10542
+ for (const match of inSlice) {
10543
+ const start = text4.start + (match.index ?? 0);
10544
+ markers2.push({ range: [start, start + match[0].length], label: match[1] ?? "" });
10541
10545
  }
10542
10546
  }
10543
- return out;
10547
+ return markers2;
10544
10548
  };
10545
- var fix6 = (doc) => {
10549
+ var familyIn = (prose) => ENUM_FAMILIES.find(([opener2, second]) => prose.includes(opener2) && prose.includes(second));
10550
+ var check7 = ({ doc, file }) => proseParagraphs(doc).flatMap((view) => {
10551
+ const family = familyIn(view.prose);
10552
+ if (!family)
10553
+ return [];
10554
+ const [opener2, second] = family;
10555
+ return [
10556
+ {
10557
+ file,
10558
+ line: view.line,
10559
+ rule: RULE.INLINE_ENUM,
10560
+ message: `enumeration ${opener2} ${second} ... inlined in prose; promote to a bullet list`
10561
+ }
10562
+ ];
10563
+ });
10564
+ var fix6 = ({ doc }) => {
10546
10565
  const { src } = doc;
10547
10566
  const edits = [];
10548
10567
  for (const view of doc.paragraphs) {
10549
10568
  const markers2 = markersIn(src, view.directTexts);
10550
10569
  if (markers2 === null || markers2.length < 2)
10551
10570
  continue;
10552
- const sequence = sequenceFor(markers2[0].label);
10553
- if (sequence === null || markers2.some((m, i) => m.label !== sequence[i]))
10554
- continue;
10555
- const first = markers2[0];
10556
- const last = markers2[markers2.length - 1];
10557
- const between = view.directTexts.map((t) => src.slice(Math.max(t.start, first.range[1]), Math.min(t.end, last.range[0])));
10558
- if (between.some((s) => /[.!?]\s/.test(s)))
10571
+ const sequence = sequenceFor(first(markers2).label);
10572
+ if (sequence === null || markers2.some((marker, i) => marker.label !== sequence[i]))
10559
10573
  continue;
10560
- const span = sentenceSpan(src, view, first.range[0], last.range[1]);
10561
- const announced = /:\s*$/.test(src.slice(span[0], first.range[0]));
10562
- const separated = /[,;]\s*$/.test(src.slice(markers2[0].range[1], markers2[1].range[0]));
10563
- if (markers2.length === 2 && !announced && !separated)
10574
+ const opener2 = first(markers2);
10575
+ const closer = last(markers2);
10576
+ const between = view.directTexts.map((text4) => src.slice(Math.max(text4.start, opener2.range[1]), Math.min(text4.end, closer.range[0])));
10577
+ if (between.some((slice) => /[.!?]\s/.test(slice)))
10564
10578
  continue;
10565
- const items = markers2.map((m, i) => {
10566
- const end = i + 1 < markers2.length ? markers2[i + 1].range[0] : span[1];
10567
- return { text: cleanItem(src.slice(m.range[1], end), { last: i + 1 === markers2.length }) };
10568
- });
10579
+ const span = sentenceSpan(src, view, opener2.range[0], closer.range[1]);
10580
+ if (markers2.length === 2) {
10581
+ const announced = /:\s*$/.test(src.slice(span[0], opener2.range[0]));
10582
+ const separated = /[,;]\s*$/.test(src.slice(opener2.range[1], closer.range[0]));
10583
+ if (!announced && !separated)
10584
+ continue;
10585
+ }
10569
10586
  const edit = promote({
10570
10587
  rule: RULE.INLINE_ENUM,
10571
10588
  src,
10572
10589
  view,
10573
10590
  span,
10574
- leadIn: cleanLeadIn(src.slice(span[0], first.range[0])),
10575
- items,
10591
+ leadIn: cleanLeadIn(src.slice(span[0], opener2.range[0])),
10592
+ items: itemsBetween(src, markers2.map((marker) => marker.range), span[1]).map((text4) => ({ text: text4 })),
10576
10593
  ordered: true
10577
10594
  });
10578
10595
  if (edit)
@@ -10580,77 +10597,65 @@ var fix6 = (doc) => {
10580
10597
  }
10581
10598
  return edits;
10582
10599
  };
10583
- var check3 = ({ doc, file }) => {
10584
- const findings = [];
10585
- for (const p of doc.paragraphs) {
10586
- if (p.inTable)
10587
- continue;
10588
- for (const [first, second] of ENUM_FAMILIES) {
10589
- if (p.prose.includes(first) && p.prose.includes(second)) {
10590
- findings.push({
10591
- file,
10592
- line: p.line,
10593
- rule: RULE.INLINE_ENUM,
10594
- message: `enumeration ${first} ${second} ... inlined in prose; promote to a bullet list`
10595
- });
10596
- break;
10597
- }
10598
- }
10599
- }
10600
- return findings;
10601
- };
10602
10600
  var pg007 = {
10603
10601
  id: RULE.INLINE_ENUM,
10604
10602
  category: "list",
10605
10603
  summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
10606
- check: check3,
10604
+ check: check7,
10607
10605
  fix: fix6
10608
10606
  };
10609
10607
 
10610
10608
  // src/rules/pg008-labelled-run.ts
10611
- var LABELLED_SEGMENT = /^(?:and\s+|or\s+)?(?:[A-Za-z][\w\s-]{0,24}:\s*)?(?:CODE|[A-Z][A-Za-z]*\d[\w/-]*)\s+\S/;
10609
+ var JOINER = "(?:and\\s+|or\\s+)?";
10610
+ var INTRO = "(?:[A-Za-z][\\w\\s-]{0,24}:\\s*)?";
10611
+ var LABEL = "(?:CODE|[A-Z][A-Za-z]*\\d[\\w/-]*)";
10612
+ var CONTENT = "\\s+\\S";
10613
+ var LABELLED_SEGMENT = new RegExp(`^${JOINER}${INTRO}${LABEL}${CONTENT}`);
10614
+ var INTRO_ONLY = /^[A-Za-z][\w\s-]{0,24}$/;
10612
10615
  var asProse = (src, view, range) => {
10613
10616
  let out = "";
10614
10617
  let at = range[0];
10615
- for (const [a, b] of view.codeRanges) {
10616
- if (b <= range[0] || a >= range[1])
10618
+ for (const [start, end] of view.codeRanges) {
10619
+ if (end <= range[0] || start >= range[1])
10617
10620
  continue;
10618
- out += `${src.slice(at, a)}CODE`;
10619
- at = b;
10621
+ out += `${src.slice(at, start)}CODE`;
10622
+ at = end;
10620
10623
  }
10621
10624
  return collapse(out + src.slice(at, range[1])).trim();
10622
10625
  };
10623
- var fix7 = (doc) => {
10626
+ var labelledSegments = (sentence) => sentence.split(/,\s+/).filter((segment) => LABELLED_SEGMENT.test(segment)).length;
10627
+ var check8 = ({ doc, file }) => proseSentences(doc).map(({ view, sentence }) => ({ view, labelled: labelledSegments(sentence) })).filter(({ labelled }) => labelled >= 3).map(({ view, labelled }) => ({
10628
+ file,
10629
+ line: view.line,
10630
+ rule: RULE.LABELLED_RUN,
10631
+ message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`
10632
+ }));
10633
+ var fix7 = ({ doc }) => {
10624
10634
  const { src } = doc;
10625
10635
  const edits = [];
10626
10636
  for (const view of doc.paragraphs) {
10627
10637
  const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
10628
- const colons = matchesInAll(src, view.directTexts, /:/);
10638
+ const colons = colonsIn(src, view);
10629
10639
  if (commas === null || colons === null)
10630
10640
  continue;
10631
10641
  for (const span of sentenceSpans(src, view)) {
10632
- const inside = commas.filter((r) => within(r, span));
10642
+ const inside = commas.filter((comma) => within(comma, span));
10633
10643
  if (inside.length < 2)
10634
10644
  continue;
10635
- const bounds = inside.map((c, i) => [i === 0 ? span[0] : inside[i - 1][1], c[0]]);
10636
- bounds.push([inside[inside.length - 1][1], span[1]]);
10637
- if (!bounds.every((b) => LABELLED_SEGMENT.test(asProse(src, view, b))))
10645
+ const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
10646
+ if (!segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment))))
10638
10647
  continue;
10639
- const firstBound = bounds[0];
10640
- const colon = colons.find((c) => within(c, firstBound));
10641
- const intro = colon && /^[A-Za-z][\w\s-]{0,24}$/.test(collapse(src.slice(span[0], colon[0])).trim());
10642
- if (intro && colon)
10643
- firstBound[0] = colon[1];
10644
- const items = bounds.map((b, i) => ({
10645
- text: cleanItem(src.slice(b[0], b[1]), { last: i + 1 === bounds.length })
10646
- }));
10648
+ const opening = first(segments);
10649
+ const colon = colons.find((c) => within(c, opening));
10650
+ const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
10651
+ const items = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
10647
10652
  const edit = promote({
10648
10653
  rule: RULE.LABELLED_RUN,
10649
10654
  src,
10650
10655
  view,
10651
10656
  span,
10652
10657
  leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
10653
- items,
10658
+ items: itemsOf(src, items).map((text4) => ({ text: text4 })),
10654
10659
  ordered: false
10655
10660
  });
10656
10661
  if (edit)
@@ -10660,66 +10665,53 @@ var fix7 = (doc) => {
10660
10665
  }
10661
10666
  return edits;
10662
10667
  };
10663
- var check4 = ({ doc, file }) => {
10664
- const findings = [];
10665
- for (const p of doc.paragraphs) {
10666
- if (p.inTable)
10667
- continue;
10668
- for (const s of sentences(p.prose)) {
10669
- const labelled = s.split(/,\s+/).filter((seg) => LABELLED_SEGMENT.test(seg)).length;
10670
- if (labelled >= 3) {
10671
- findings.push({
10672
- file,
10673
- line: p.line,
10674
- rule: RULE.LABELLED_RUN,
10675
- message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`
10676
- });
10677
- }
10678
- }
10679
- }
10680
- return findings;
10681
- };
10682
10668
  var pg008 = {
10683
10669
  id: RULE.LABELLED_RUN,
10684
10670
  category: "list",
10685
10671
  summary: "comma-joined labelled run (3+ labelled segments)",
10686
- check: check4,
10672
+ check: check8,
10687
10673
  fix: fix7
10688
10674
  };
10689
10675
 
10690
10676
  // src/rules/pg009-stacked-runs.ts
10691
- var fix8 = (doc) => {
10677
+ var linesOf = (view) => {
10678
+ const lines = [];
10679
+ let at = view.startOffset;
10680
+ for (const line of view.raw.split(`
10681
+ `)) {
10682
+ lines.push([at, at + line.length]);
10683
+ at += line.length + 1;
10684
+ }
10685
+ return lines;
10686
+ };
10687
+ var parentOf = (src, line, separators, colons) => {
10688
+ const inside = separators.filter((separator) => within(separator, line));
10689
+ if (inside.length === 0)
10690
+ return null;
10691
+ const colon = colonsBefore(colons, line, first(inside)[0])[0];
10692
+ if (!colon)
10693
+ return null;
10694
+ return {
10695
+ text: collapse(src.slice(line[0], colon[0])).trim(),
10696
+ children: itemsBetween(src, [colon, ...inside], line[1])
10697
+ };
10698
+ };
10699
+ var check9 = ({ file, runs }) => runs.filter(isStacked).map((run) => ({
10700
+ file,
10701
+ line: run.line,
10702
+ rule: RULE.STACKED_RUNS,
10703
+ message: `interpunct runs stacked on ${run.lines} lines; promote to a nested list`
10704
+ }));
10705
+ var fix8 = ({ doc, runs }) => {
10692
10706
  const { src } = doc;
10693
10707
  const edits = [];
10694
- const runs = interpunctRuns(doc).filter((r) => r.lines >= 2);
10695
- for (const view of doc.paragraphs) {
10696
- if (!runs.some((r) => r.range[0] === view.startOffset))
10697
- continue;
10698
- const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
10699
- const colons = matchesInAll(src, view.directTexts, /:/);
10700
- if (seps === null || colons === null || seps.length !== (view.prose.match(/·/g) ?? []).length || seps.some(([s]) => escaped(src, s)))
10708
+ for (const view of paragraphsOf(doc, runs.filter(isStacked))) {
10709
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
10710
+ const colons = colonsIn(src, view);
10711
+ if (separators === null || colons === null)
10701
10712
  continue;
10702
- const lines = [];
10703
- let at = view.startOffset;
10704
- for (const line of view.raw.split(`
10705
- `)) {
10706
- lines.push([at, at + line.length]);
10707
- at += line.length + 1;
10708
- }
10709
- const parents = [];
10710
- for (const line of lines) {
10711
- const lineSeps = seps.filter((s) => within(s, line));
10712
- const colon = colons.find((c) => within(c, line));
10713
- if (lineSeps.length === 0 || !colon || colon[1] > lineSeps[0][0])
10714
- break;
10715
- const label = collapse(src.slice(line[0], colon[0])).trim();
10716
- const cuts = [colon, ...lineSeps];
10717
- const children = cuts.map((cut, i) => {
10718
- const end = i + 1 < cuts.length ? cuts[i + 1][0] : line[1];
10719
- return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
10720
- });
10721
- parents.push({ text: label, children });
10722
- }
10713
+ const lines = linesOf(view);
10714
+ const parents = lines.map((line) => parentOf(src, line, separators, colons)).filter((parent) => parent !== null);
10723
10715
  if (parents.length !== lines.length)
10724
10716
  continue;
10725
10717
  const edit = promote({
@@ -10740,23 +10732,19 @@ var pg009 = {
10740
10732
  id: RULE.STACKED_RUNS,
10741
10733
  category: "list",
10742
10734
  summary: "interpunct runs stacked on several lines (nested list)",
10743
- check: ({ file, runs }) => runs.filter((r) => r.lines >= 2).map((r) => ({
10744
- file,
10745
- line: r.line,
10746
- rule: RULE.STACKED_RUNS,
10747
- message: `interpunct runs stacked on ${r.lines} lines; promote to a nested list`
10748
- })),
10735
+ check: check9,
10749
10736
  fix: fix8
10750
10737
  };
10751
10738
 
10752
10739
  // src/rules/index.ts
10753
10740
  var RULES = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
10741
+ var ruleContext = (doc) => ({ doc, runs: interpunctRuns(doc) });
10754
10742
  var FIX_ORDER = [pg009, pg007, pg008, pg006, pg003, pg005, pg004, pg001];
10755
10743
 
10756
10744
  // src/check.ts
10757
10745
  var DEFAULT_MAX_WORDS = 25;
10758
10746
  var checkModel = (doc, file, maxWords) => {
10759
- const ctx = { doc, file, maxWords, runs: interpunctRuns(doc) };
10747
+ const ctx = { ...ruleContext(doc), file, maxWords };
10760
10748
  const findings = RULES.flatMap((rule) => rule.check(ctx));
10761
10749
  for (const fence2 of doc.fences) {
10762
10750
  for (const f of checkModel(buildDocModel(fence2.value), file, maxWords)) {
@@ -10892,8 +10880,8 @@ var fixMarkdownReport = (src) => {
10892
10880
  const refusedKeys = new Set;
10893
10881
  let current = src;
10894
10882
  for (let pass = 0;pass < MAX_PASSES; pass++) {
10895
- const doc = buildDocModel(current);
10896
- const before = { src: current, tree: doc.tree };
10883
+ const ctx = ruleContext(buildDocModel(current));
10884
+ const before = { src: current, tree: ctx.doc.tree };
10897
10885
  const keyOf = (e) => `${e.rule}\x00${current.slice(e.start, e.end)}\x00${e.text}`;
10898
10886
  const attempt = (edits) => {
10899
10887
  const next = splice2(current, edits);
@@ -10907,7 +10895,7 @@ var fixMarkdownReport = (src) => {
10907
10895
  };
10908
10896
  let progressed = false;
10909
10897
  for (const rule of FIX_ORDER) {
10910
- const edits = (rule.fix?.(doc) ?? []).filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
10898
+ const edits = (rule.fix?.(ctx) ?? []).filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
10911
10899
  if (edits.length === 0)
10912
10900
  continue;
10913
10901
  const batchable = edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");