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