@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/README.md +1 -0
- 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/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/
|
|
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
|
|
10018
|
-
|
|
10100
|
+
for (const view of proseParagraphs(doc)) {
|
|
10101
|
+
const separators = countOf(view.prose, GLYPH);
|
|
10102
|
+
if (separators < 2)
|
|
10019
10103
|
continue;
|
|
10020
|
-
const
|
|
10021
|
-
|
|
10022
|
-
|
|
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
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
|
|
10050
|
-
|
|
10051
|
-
|
|
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
|
-
|
|
10055
|
-
|
|
10056
|
-
|
|
10057
|
-
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
10061
|
-
|
|
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
|
|
10146
|
+
return wraps;
|
|
10068
10147
|
};
|
|
10069
|
-
var
|
|
10070
|
-
const
|
|
10071
|
-
const
|
|
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
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10088
|
-
|
|
10089
|
-
|
|
10090
|
-
|
|
10091
|
-
|
|
10092
|
-
|
|
10093
|
-
|
|
10094
|
-
|
|
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 = (
|
|
10206
|
+
var excerpt = (sentence) => sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence;
|
|
10111
10207
|
var lengthMessage = (sentence, maxWords) => {
|
|
10112
|
-
const
|
|
10208
|
+
const count = words(sentence);
|
|
10113
10209
|
const clauses = countClauses(sentence);
|
|
10114
|
-
const target = Math.max(2, clauses, Math.ceil(
|
|
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 ${
|
|
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:
|
|
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 (
|
|
10237
|
+
if (last2)
|
|
10204
10238
|
s = s.replace(CONJUNCTION_HEAD, "").trim();
|
|
10205
10239
|
}
|
|
10206
|
-
if (
|
|
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
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
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
|
|
10286
|
-
const colons =
|
|
10287
|
-
if (
|
|
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 =
|
|
10314
|
+
const inside = semicolons.filter((semicolon) => within(semicolon, span));
|
|
10291
10315
|
if (inside.length < 2)
|
|
10292
10316
|
continue;
|
|
10293
|
-
const colon = colons
|
|
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:
|
|
10340
|
+
check: check3,
|
|
10322
10341
|
fix: fix2
|
|
10323
10342
|
};
|
|
10324
10343
|
|
|
10325
10344
|
// src/rules/pg004-em-dash.ts
|
|
10326
|
-
var
|
|
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
|
|
10329
|
-
const { src } = doc;
|
|
10348
|
+
var blocksOf = (doc) => {
|
|
10330
10349
|
const byBlock = new Map;
|
|
10331
|
-
for (const
|
|
10332
|
-
if (
|
|
10350
|
+
for (const { block, start, end, value: value2 } of doc.texts) {
|
|
10351
|
+
if (block === undefined || start === undefined || end === undefined)
|
|
10333
10352
|
continue;
|
|
10334
|
-
|
|
10335
|
-
|
|
10336
|
-
|
|
10337
|
-
|
|
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:
|
|
10364
|
+
entry.dashes.push(...found.map((range) => ({ range, text: text4 })));
|
|
10342
10365
|
}
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10357
|
-
|
|
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
|
|
10382
|
+
return [...bySentence].map(([sentence, group]) => ({
|
|
10383
|
+
dashes: group,
|
|
10384
|
+
hasColon: colons.some((colon) => sentenceOf(colon) === sentence)
|
|
10385
|
+
}));
|
|
10369
10386
|
};
|
|
10370
|
-
var
|
|
10371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10379
|
-
if (
|
|
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:
|
|
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
|
|
10416
|
-
|
|
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
|
|
10419
|
-
if (
|
|
10446
|
+
for (const { start, end, value: value2 } of textsContaining(doc, INTERPUNCT)) {
|
|
10447
|
+
if (start === undefined || end === undefined)
|
|
10420
10448
|
continue;
|
|
10421
|
-
if (inRanges(
|
|
10449
|
+
if (inRanges(start, reported))
|
|
10422
10450
|
continue;
|
|
10423
|
-
const text4 = { value:
|
|
10424
|
-
const glyphs = matchesIn(doc.src, text4,
|
|
10425
|
-
const
|
|
10426
|
-
if (glyphs === null ||
|
|
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 [
|
|
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:
|
|
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
|
|
10452
|
-
var
|
|
10453
|
-
|
|
10454
|
-
|
|
10455
|
-
|
|
10456
|
-
|
|
10457
|
-
|
|
10458
|
-
|
|
10459
|
-
|
|
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
|
|
10465
|
-
|
|
10466
|
-
|
|
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
|
|
10469
|
-
const
|
|
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]], ...
|
|
10479
|
-
const items =
|
|
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:
|
|
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 = (
|
|
10521
|
-
if (
|
|
10525
|
+
var sequenceFor = (label) => {
|
|
10526
|
+
if (label === "a")
|
|
10522
10527
|
return LETTERS;
|
|
10523
|
-
if (
|
|
10528
|
+
if (label === "1")
|
|
10524
10529
|
return Array.from({ length: 99 }, (_, i) => String(i + 1));
|
|
10525
|
-
if (
|
|
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
|
|
10532
|
-
|
|
10533
|
-
|
|
10534
|
-
const
|
|
10535
|
-
|
|
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
|
|
10539
|
-
const start =
|
|
10540
|
-
|
|
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
|
|
10547
|
+
return markers2;
|
|
10544
10548
|
};
|
|
10545
|
-
var
|
|
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
|
|
10553
|
-
if (sequence === null || markers2.some((
|
|
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
|
|
10561
|
-
const
|
|
10562
|
-
const
|
|
10563
|
-
if (
|
|
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
|
|
10566
|
-
|
|
10567
|
-
|
|
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],
|
|
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:
|
|
10604
|
+
check: check7,
|
|
10607
10605
|
fix: fix6
|
|
10608
10606
|
};
|
|
10609
10607
|
|
|
10610
10608
|
// src/rules/pg008-labelled-run.ts
|
|
10611
|
-
var
|
|
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 [
|
|
10616
|
-
if (
|
|
10618
|
+
for (const [start, end] of view.codeRanges) {
|
|
10619
|
+
if (end <= range[0] || start >= range[1])
|
|
10617
10620
|
continue;
|
|
10618
|
-
out += `${src.slice(at,
|
|
10619
|
-
at =
|
|
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
|
|
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 =
|
|
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((
|
|
10642
|
+
const inside = commas.filter((comma) => within(comma, span));
|
|
10633
10643
|
if (inside.length < 2)
|
|
10634
10644
|
continue;
|
|
10635
|
-
const
|
|
10636
|
-
|
|
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
|
|
10640
|
-
const colon = colons.find((c) => within(c,
|
|
10641
|
-
const intro = colon &&
|
|
10642
|
-
|
|
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:
|
|
10672
|
+
check: check8,
|
|
10687
10673
|
fix: fix7
|
|
10688
10674
|
};
|
|
10689
10675
|
|
|
10690
10676
|
// src/rules/pg009-stacked-runs.ts
|
|
10691
|
-
var
|
|
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
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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?.(
|
|
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");
|