lens-content-processor 0.28.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-markup.d.ts +11 -0
- package/dist/authoring-markup.js +52 -0
- package/dist/authoring-markup.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +7 -0
- package/dist/content-schema.js.map +1 -1
- package/dist/dedupe-errors.js +7 -1
- package/dist/dedupe-errors.js.map +1 -1
- package/dist/flattener/index.js +50 -16
- package/dist/flattener/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/article.js +34 -32
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +0 -10
- package/dist/parser/course.js +16 -14
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +1 -0
- package/dist/parser/learning-outcome.js +28 -38
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +1 -6
- package/dist/parser/lens.js +64 -17
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.js +24 -17
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +2 -1
- package/dist/parser/sections.js +5 -3
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/survey.js +16 -10
- package/dist/parser/survey.js.map +1 -1
- package/dist/source-location.d.ts +2 -0
- package/dist/source-location.js +12 -0
- package/dist/source-location.js.map +1 -0
- package/dist/validator/article-review-provenance.d.ts +5 -0
- package/dist/validator/article-review-provenance.js +57 -0
- package/dist/validator/article-review-provenance.js.map +1 -0
- package/dist/validator/article-structure.d.ts +8 -0
- package/dist/validator/article-structure.js +674 -0
- package/dist/validator/article-structure.js.map +1 -0
- package/dist/validator/directives.js +42 -0
- package/dist/validator/directives.js.map +1 -1
- package/dist/validator/emphasis.js +126 -2
- package/dist/validator/emphasis.js.map +1 -1
- package/dist/validator/html-tags.js +87 -2
- package/dist/validator/html-tags.js.map +1 -1
- package/dist/validator/math.js +66 -9
- package/dist/validator/math.js.map +1 -1
- package/dist/validator/output-integrity.js +7 -1
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/same-lens-links.d.ts +4 -0
- package/dist/validator/same-lens-links.js +297 -0
- package/dist/validator/same-lens-links.js.map +1 -0
- package/dist/validator/suppressions.d.ts +42 -0
- package/dist/validator/suppressions.js +97 -0
- package/dist/validator/suppressions.js.map +1 -0
- package/dist/validator/uuid.d.ts +1 -0
- package/dist/validator/uuid.js +2 -0
- package/dist/validator/uuid.js.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { toString } from "mdast-util-to-string";
|
|
2
|
+
import remarkDirective from "remark-directive";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkMath from "remark-math";
|
|
5
|
+
import remarkParse from "remark-parse";
|
|
6
|
+
import { unified } from "unified";
|
|
7
|
+
import { visit } from "unist-util-visit";
|
|
8
|
+
import { stripAuthoringMarkupForValidation } from "../authoring-markup.js";
|
|
9
|
+
import { parseFrontmatter } from "../parser/frontmatter.js";
|
|
10
|
+
import { applyNextLineValidatorSuppressions, formatValidatorSuppression, VALIDATOR_SUPPRESSIONS, validatorSuppressionsForCode, } from "./suppressions.js";
|
|
11
|
+
const lowResolutionSuppression = VALIDATOR_SUPPRESSIONS.imageLowResolutionAiSearchExhausted;
|
|
12
|
+
const lowResolutionSuppressionGuidance = validatorSuppressionsForCode(lowResolutionSuppression.code).map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`).join(" ");
|
|
13
|
+
const repeatedBlockSuppressionGuidance = validatorSuppressionsForCode("article.block-repeated-nearby").map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`).join(" ");
|
|
14
|
+
const externalSelfFragmentSuppression = VALIDATOR_SUPPRESSIONS.externalSelfFragmentTargetsSourceOnlyContent;
|
|
15
|
+
function issue(file, bodyStartLine, code, severity, message, line, suggestion) {
|
|
16
|
+
return {
|
|
17
|
+
file,
|
|
18
|
+
line: bodyStartLine + line - 1,
|
|
19
|
+
code,
|
|
20
|
+
severity,
|
|
21
|
+
message,
|
|
22
|
+
...(suggestion ? { suggestion } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function renderedHeadingId(value) {
|
|
26
|
+
return value
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
29
|
+
.replace(/\s+/g, "-")
|
|
30
|
+
.replace(/-+/g, "-")
|
|
31
|
+
.slice(0, 50);
|
|
32
|
+
}
|
|
33
|
+
function lineAt(source, offset) {
|
|
34
|
+
return source.slice(0, offset).split("\n").length;
|
|
35
|
+
}
|
|
36
|
+
/** Mask fenced/inline code and math without changing offsets or line numbers. */
|
|
37
|
+
function proseMask(source) {
|
|
38
|
+
const chars = source.split("");
|
|
39
|
+
const mask = (start, end) => {
|
|
40
|
+
for (let i = start; i < end; i += 1)
|
|
41
|
+
if (chars[i] !== "\n")
|
|
42
|
+
chars[i] = " ";
|
|
43
|
+
};
|
|
44
|
+
const ranges = [];
|
|
45
|
+
const tree = unified()
|
|
46
|
+
.use(remarkParse)
|
|
47
|
+
.use(remarkGfm)
|
|
48
|
+
.use(remarkDirective)
|
|
49
|
+
.use(remarkMath, { singleDollarTextMath: false })
|
|
50
|
+
.parse(source);
|
|
51
|
+
visit(tree, (node) => {
|
|
52
|
+
if (["code", "inlineCode", "math", "inlineMath"].includes(node.type)) {
|
|
53
|
+
const positioned = node;
|
|
54
|
+
const start = positioned.position?.start.offset;
|
|
55
|
+
const end = positioned.position?.end.offset;
|
|
56
|
+
if (start !== undefined && end !== undefined)
|
|
57
|
+
ranges.push([start, end]);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
for (const [start, end] of ranges)
|
|
61
|
+
mask(start, end);
|
|
62
|
+
// The frontend deliberately recognises conservative single-dollar inline
|
|
63
|
+
// math after remark-math. Mirror that pass so valid TeX is not examined as
|
|
64
|
+
// prose while currency remains ordinary text.
|
|
65
|
+
const inlineMath = /(?<!\$)\$(?!\$)([^\s$](?:[^$\n]*?[^\s$])?)\$(?![\d$])/g;
|
|
66
|
+
const astMasked = chars.join("");
|
|
67
|
+
for (const match of astMasked.matchAll(inlineMath)) {
|
|
68
|
+
const start = match.index ?? 0;
|
|
69
|
+
mask(start, start + match[0].length);
|
|
70
|
+
}
|
|
71
|
+
return chars.join("");
|
|
72
|
+
}
|
|
73
|
+
/** Mask selected AST nodes in an already offset-preserving source string. */
|
|
74
|
+
function maskNodes(source, tree, nodeTypes) {
|
|
75
|
+
const chars = source.split("");
|
|
76
|
+
visit(tree, (node) => {
|
|
77
|
+
if (!nodeTypes.has(node.type))
|
|
78
|
+
return;
|
|
79
|
+
const positioned = node;
|
|
80
|
+
const start = positioned.position?.start.offset;
|
|
81
|
+
const end = positioned.position?.end.offset;
|
|
82
|
+
if (start === undefined || end === undefined)
|
|
83
|
+
return;
|
|
84
|
+
for (let offset = start; offset < end; offset += 1) {
|
|
85
|
+
if (chars[offset] !== "\n")
|
|
86
|
+
chars[offset] = " ";
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return chars.join("");
|
|
90
|
+
}
|
|
91
|
+
function escapedAt(source, index) {
|
|
92
|
+
let slashes = 0;
|
|
93
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
|
|
94
|
+
slashes += 1;
|
|
95
|
+
}
|
|
96
|
+
return slashes % 2 === 1;
|
|
97
|
+
}
|
|
98
|
+
function sameSourceFragment(targetUrl, sourceUrl) {
|
|
99
|
+
if (typeof sourceUrl !== "string" || !sourceUrl.trim())
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
const target = new URL(targetUrl);
|
|
103
|
+
const source = new URL(sourceUrl);
|
|
104
|
+
const normalizedPath = (url) => url.pathname.replace(/\/+$/, "") || "/";
|
|
105
|
+
if (target.origin !== source.origin ||
|
|
106
|
+
normalizedPath(target) !== normalizedPath(source) ||
|
|
107
|
+
target.search !== source.search ||
|
|
108
|
+
!target.hash) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const fragment = decodeURIComponent(target.hash.slice(1));
|
|
112
|
+
return fragment || undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Vike page directories plus stable content routes accepted by the SPA. Keep
|
|
119
|
+
// this explicit: treating every root-relative URL as local would hide the
|
|
120
|
+
// source-site-relative import failures this rule exists to catch.
|
|
121
|
+
const PLATFORM_LOCAL_ROUTE = /^\/(?:about|admin|ai-doc|availability|coach|coefficient-giving-proposal|content|courses?|enroll|facilitator|funnel-prototype|if-anyone-builds-it-everyone-dies|lenses?|meetings|modules?|navigate|navigators|onboarding|ops|overview|privacy|progress|promptlab|referrals|reschedule|settings|skill-tree|subscribe|terms|theory-of-change|tts-test|validate)(?:\/|$)/i;
|
|
122
|
+
function isUnsupportedRootRelativeLink(url) {
|
|
123
|
+
return /^\/(?!\/)/.test(url) && url !== "/" && !PLATFORM_LOCAL_ROUTE.test(url);
|
|
124
|
+
}
|
|
125
|
+
const FLATTENED_MATH_TOKEN = /(?<!\\)\b(?:pi|mu|theta|phi|lambda|gamma|tilde|times|infinity|RR|sum|dot)(?=_|\b)|->/g;
|
|
126
|
+
const TYPED_FOOTNOTE_ID = /^(?:cite|note)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
127
|
+
function hasSuspiciousFlattenedMath(tex, displayMode) {
|
|
128
|
+
const inspectable = tex.replace(/\\(?:text|textrm|textsf|texttt|mathrm|mathbf|operatorname)\{[^{}]*\}/g, "");
|
|
129
|
+
// These converter spellings are unambiguous even in short inline math.
|
|
130
|
+
if (/[_^]\s*\(/.test(inspectable) || /(?<!\\)\bdot\.c\b/.test(inspectable)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
// Display math is almost never the right place for prose quotes or an
|
|
134
|
+
// unescaped conditional/probability separator. Keep these checks out of
|
|
135
|
+
// inline math, where absolute values and short quoted labels are plausible.
|
|
136
|
+
if (displayMode &&
|
|
137
|
+
(/["“”][^"“”\n]+["“”]/.test(inspectable) || /\s\|\s/.test(inspectable))) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const tokens = [...inspectable.matchAll(FLATTENED_MATH_TOKEN)];
|
|
141
|
+
if (tokens.length >= 2)
|
|
142
|
+
return true;
|
|
143
|
+
if (tokens.length === 0)
|
|
144
|
+
return false;
|
|
145
|
+
if (displayMode &&
|
|
146
|
+
/(?<!\\)\b(?:pi|mu|theta|phi|lambda|gamma)\b/.test(inspectable)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return /->|\b(?:tilde|times|infinity|RR)\b|\bsum_|\bdot\.|[:=]/.test(inspectable);
|
|
150
|
+
}
|
|
151
|
+
export function collectArticleImages(body, bodyStartLine) {
|
|
152
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
|
|
153
|
+
const definitions = new Map();
|
|
154
|
+
const images = [];
|
|
155
|
+
visit(tree, "definition", (node) => { definitions.set(node.identifier.toLowerCase(), node.url); });
|
|
156
|
+
visit(tree, (node) => {
|
|
157
|
+
if (node.type === "image")
|
|
158
|
+
images.push({ url: node.url, line: bodyStartLine + (node.position?.start.line ?? 1) - 1 });
|
|
159
|
+
if (node.type === "imageReference") {
|
|
160
|
+
const url = definitions.get(node.identifier.toLowerCase());
|
|
161
|
+
if (url)
|
|
162
|
+
images.push({ url, line: bodyStartLine + (node.position?.start.line ?? 1) - 1 });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return images;
|
|
166
|
+
}
|
|
167
|
+
export function validateWikilinkImages(body, file, bodyStartLine) {
|
|
168
|
+
const source = proseMask(body);
|
|
169
|
+
return [...source.matchAll(/!\[\[([^\n\]]+)\]\]/g)].map((match) => issue(file, bodyStartLine, "article.wikilink-image-unsupported", "error", `Wiki-link image not supported: ![[${match[1]}]]`, lineAt(source, match.index ?? 0), "Use standard markdown image syntax: "));
|
|
170
|
+
}
|
|
171
|
+
function scanFences(body, file, bodyStartLine) {
|
|
172
|
+
const errors = [];
|
|
173
|
+
let open;
|
|
174
|
+
body.split("\n").forEach((line, index) => {
|
|
175
|
+
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
176
|
+
if (!match)
|
|
177
|
+
return;
|
|
178
|
+
const char = match[1][0];
|
|
179
|
+
if (!open) {
|
|
180
|
+
open = { char, length: match[1].length, line: index + 1 };
|
|
181
|
+
}
|
|
182
|
+
else if (open.char === char && match[1].length >= open.length && match[2].trim() === "") {
|
|
183
|
+
open = undefined;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
if (open) {
|
|
187
|
+
errors.push(issue(file, bodyStartLine, "article.unclosed-fence", "error", `Unclosed ${open.char === "`" ? "backtick" : "tilde"} code fence`, open.line, "Add a compatible closing fence."));
|
|
188
|
+
}
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
function splitTableRow(line) {
|
|
192
|
+
const cells = [];
|
|
193
|
+
let current = "";
|
|
194
|
+
let escaped = false;
|
|
195
|
+
let codeTicks = 0;
|
|
196
|
+
for (const char of line.trim().replace(/^\|/, "").replace(/\|$/, "")) {
|
|
197
|
+
if (escaped) {
|
|
198
|
+
current += char;
|
|
199
|
+
escaped = false;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (char === "\\") {
|
|
203
|
+
current += char;
|
|
204
|
+
escaped = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === "`")
|
|
208
|
+
codeTicks ^= 1;
|
|
209
|
+
if (char === "|" && !codeTicks) {
|
|
210
|
+
cells.push(current.trim());
|
|
211
|
+
current = "";
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
current += char;
|
|
215
|
+
}
|
|
216
|
+
cells.push(current.trim());
|
|
217
|
+
return cells;
|
|
218
|
+
}
|
|
219
|
+
function scanTables(body, file, bodyStartLine) {
|
|
220
|
+
const errors = [];
|
|
221
|
+
const lines = body.split("\n");
|
|
222
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
223
|
+
if (!/^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i]))
|
|
224
|
+
continue;
|
|
225
|
+
const expected = splitTableRow(lines[i - 1]).length;
|
|
226
|
+
if (splitTableRow(lines[i]).length !== expected) {
|
|
227
|
+
errors.push(issue(file, bodyStartLine, "article.table-malformed", "error", "Table delimiter does not match its header", i + 1));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (let row = i + 1; row < lines.length && lines[row].includes("|") && lines[row].trim(); row += 1) {
|
|
231
|
+
if (splitTableRow(lines[row]).length !== expected) {
|
|
232
|
+
errors.push(issue(file, bodyStartLine, "article.table-malformed", "error", `Table row has ${splitTableRow(lines[row]).length} cells; expected ${expected}`, row + 1, "Add or remove cells so every row matches the header."));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return errors;
|
|
237
|
+
}
|
|
238
|
+
function normalizedBlock(node, body) {
|
|
239
|
+
const start = node.position?.start.offset;
|
|
240
|
+
const end = node.position?.end.offset;
|
|
241
|
+
if (start === undefined || end === undefined)
|
|
242
|
+
return "";
|
|
243
|
+
const normalized = body.slice(start, end).replace(/\s+/g, " ").trim();
|
|
244
|
+
// TeX identifiers are case-sensitive: N_1 and n_1 can denote different
|
|
245
|
+
// variables in otherwise parallel propositions. Preserve case for math so
|
|
246
|
+
// the duplicate check still catches exact copies without conflating them.
|
|
247
|
+
return /^\$\$[\s\S]*\$\$$/.test(normalized) ? normalized : normalized.toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
function isSectionBoilerplate(value) {
|
|
250
|
+
return /^applicability to different subgoals in our verification framework:$/i.test(value);
|
|
251
|
+
}
|
|
252
|
+
function isEmojiJoiner(source, index) {
|
|
253
|
+
if (source[index] !== "\u200d")
|
|
254
|
+
return false;
|
|
255
|
+
const before = Array.from(source.slice(0, index));
|
|
256
|
+
// Emoji modifiers and variation selectors extend the preceding pictograph.
|
|
257
|
+
while (before.length && /^(?:\p{Emoji_Modifier}|[\uFE00-\uFE0F\u{E0100}-\u{E01EF}])$/u.test(before.at(-1) ?? ""))
|
|
258
|
+
before.pop();
|
|
259
|
+
const previous = before.at(-1) ?? "";
|
|
260
|
+
const next = Array.from(source.slice(index + 1))[0] ?? "";
|
|
261
|
+
return /^\p{Extended_Pictographic}$/u.test(previous) && /^\p{Extended_Pictographic}$/u.test(next);
|
|
262
|
+
}
|
|
263
|
+
function isAttributionParagraph(node) {
|
|
264
|
+
if (node.type !== "paragraph")
|
|
265
|
+
return false;
|
|
266
|
+
const text = toString(node).trim();
|
|
267
|
+
return (text.length <= 240 &&
|
|
268
|
+
/^\(?\s*(?:joint work|co-?authored|written) with\b/i.test(text));
|
|
269
|
+
}
|
|
270
|
+
const LEGITIMATE_REPEATED_ANNOTATIONS = new Set([
|
|
271
|
+
"_note_: included here for completeness. this isn't, strictly speaking, a new cause area since allfed is now working on it.",
|
|
272
|
+
]);
|
|
273
|
+
function isLegitimateRepeatedAnnotation(value) {
|
|
274
|
+
return LEGITIMATE_REPEATED_ANNOTATIONS.has(value);
|
|
275
|
+
}
|
|
276
|
+
/** Structural validation for a complete article draft. Parsing happens once. */
|
|
277
|
+
export function validateArticleStructure(content, file) {
|
|
278
|
+
const fm = parseFrontmatter(content.replace(/\r\n?/g, "\n"), file);
|
|
279
|
+
if (fm.error)
|
|
280
|
+
return [];
|
|
281
|
+
const rawBody = fm.body;
|
|
282
|
+
const body = stripAuthoringMarkupForValidation(fm.body);
|
|
283
|
+
const bodyStartLine = fm.bodyStartLine;
|
|
284
|
+
const sourceUrl = fm.frontmatter.source_url;
|
|
285
|
+
const errors = [...scanFences(body, file, bodyStartLine)];
|
|
286
|
+
const tree = unified()
|
|
287
|
+
.use(remarkParse)
|
|
288
|
+
.use(remarkGfm)
|
|
289
|
+
.use(remarkDirective)
|
|
290
|
+
.use(remarkMath, { singleDollarTextMath: false })
|
|
291
|
+
.parse(body);
|
|
292
|
+
const prose = proseMask(body);
|
|
293
|
+
const inlineMathScanSource = maskNodes(body, tree, new Set(["code", "inlineCode", "math", "image", "imageReference"]));
|
|
294
|
+
const definitions = new Map();
|
|
295
|
+
const referencedFootnotes = new Map();
|
|
296
|
+
const definedFootnotes = new Map();
|
|
297
|
+
const headingIds = new Set();
|
|
298
|
+
const imageOccurrences = new Map();
|
|
299
|
+
const externalSelfFragments = new Set();
|
|
300
|
+
const blocks = [];
|
|
301
|
+
// Lens uses ordinary GFM footnotes with semantic identifiers. The prefix
|
|
302
|
+
// drives distinct citation/note rendering; kebab-case suffixes stay stable
|
|
303
|
+
// when definitions are reordered or new references are inserted.
|
|
304
|
+
const invalidFootnoteIds = new Set();
|
|
305
|
+
for (const match of prose.matchAll(/\[\^([^\]\n]+)\](?::)?/g)) {
|
|
306
|
+
const id = match[1];
|
|
307
|
+
if (TYPED_FOOTNOTE_ID.test(id) || invalidFootnoteIds.has(id))
|
|
308
|
+
continue;
|
|
309
|
+
invalidFootnoteIds.add(id);
|
|
310
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-id-invalid", "error", `Footnote identifier '${id}' is not a typed kebab-case identifier`, lineAt(prose, match.index ?? 0), "Use [^note-<id>] for explanatory footnotes and [^cite-<id>] for citations; Lens renders the two differently. <id> may be any unique lowercase kebab-case identifier within this file. Rename every reference and its matching definition together."));
|
|
311
|
+
}
|
|
312
|
+
visit(tree, "definition", (node) => {
|
|
313
|
+
definitions.set(node.identifier.toLowerCase(), node.url);
|
|
314
|
+
});
|
|
315
|
+
// ArticleSectionWrapper pre-registers H1-H3 IDs for the table of contents.
|
|
316
|
+
// Duplicate lower-level headings fall back to their unsuffixed slug unless
|
|
317
|
+
// their exact text is also registered. Mirror that behavior here rather
|
|
318
|
+
// than applying GitHub-style suffixes to every heading.
|
|
319
|
+
const registeredHeadingIds = new Map();
|
|
320
|
+
const registeredBaseCounts = new Map();
|
|
321
|
+
visit(tree, "heading", (node) => {
|
|
322
|
+
if (node.depth > 3)
|
|
323
|
+
return;
|
|
324
|
+
const text = toString(node);
|
|
325
|
+
const base = renderedHeadingId(text);
|
|
326
|
+
const count = registeredBaseCounts.get(base) ?? 0;
|
|
327
|
+
const ids = registeredHeadingIds.get(text) ?? [];
|
|
328
|
+
ids.push(count === 0 ? base : `${base}-${count}`);
|
|
329
|
+
registeredHeadingIds.set(text, ids);
|
|
330
|
+
registeredBaseCounts.set(base, count + 1);
|
|
331
|
+
});
|
|
332
|
+
const renderedTextCounts = new Map();
|
|
333
|
+
visit(tree, "heading", (node) => {
|
|
334
|
+
const text = toString(node);
|
|
335
|
+
const registered = registeredHeadingIds.get(text);
|
|
336
|
+
if (!registered?.length) {
|
|
337
|
+
headingIds.add(renderedHeadingId(text));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const count = renderedTextCounts.get(text) ?? 0;
|
|
341
|
+
headingIds.add(registered[count] ?? registered[registered.length - 1]);
|
|
342
|
+
renderedTextCounts.set(text, count + 1);
|
|
343
|
+
});
|
|
344
|
+
visit(tree, "footnoteReference", (node) => {
|
|
345
|
+
referencedFootnotes.set(node.identifier.toLowerCase(), node.position?.start.line ?? 1);
|
|
346
|
+
});
|
|
347
|
+
visit(tree, "footnoteDefinition", (node) => {
|
|
348
|
+
const id = node.identifier.toLowerCase();
|
|
349
|
+
definedFootnotes.set(id, [...(definedFootnotes.get(id) ?? []), node.position?.start.line ?? 1]);
|
|
350
|
+
if (id.startsWith("cite-") &&
|
|
351
|
+
/^https?:\/\/\S+$/i.test(toString(node).trim())) {
|
|
352
|
+
errors.push(issue(file, bodyStartLine, "article.citation-definition-url-only", "warning", `Citation '${id}' contains only a URL`, node.position?.start.line ?? 1, "Include the available author, title, year, or publication details while preserving the source link."));
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
visit(tree, (node, index, parent) => {
|
|
356
|
+
const positioned = node;
|
|
357
|
+
const line = positioned.position?.start.line ?? 1;
|
|
358
|
+
// Raw HTML is handled by validateHtmlTags, which shares the renderer's
|
|
359
|
+
// allow-list for sup/sub/audio/SVG rather than rejecting every html node.
|
|
360
|
+
if (node.type === "link" || node.type === "image") {
|
|
361
|
+
const target = node;
|
|
362
|
+
const isImage = node.type === "image";
|
|
363
|
+
if (!target.url.trim()) {
|
|
364
|
+
errors.push(issue(file, bodyStartLine, isImage ? "article.image-destination-empty" : "article.link-destination-empty", "error", `${isImage ? "Image" : "Link"} destination is empty`, line));
|
|
365
|
+
}
|
|
366
|
+
if (!isImage && isUnsupportedRootRelativeLink(target.url.trim())) {
|
|
367
|
+
errors.push(issue(file, bodyStartLine, "article.root-relative-source-link", "error", `Root-relative link '${target.url}' resolves against Lens instead of the source website`, line, "Resolve the destination against source_url and use the resulting absolute URL."));
|
|
368
|
+
}
|
|
369
|
+
const linkHasImage = !isImage &&
|
|
370
|
+
target.children.some((child) => child.type === "image" || child.type === "imageReference");
|
|
371
|
+
const imageOnlyChild = !isImage &&
|
|
372
|
+
target.children.length === 1 &&
|
|
373
|
+
(target.children[0].type === "image" ||
|
|
374
|
+
target.children[0].type === "imageReference")
|
|
375
|
+
? target.children[0]
|
|
376
|
+
: undefined;
|
|
377
|
+
if (imageOnlyChild) {
|
|
378
|
+
const imageUrl = imageOnlyChild.type === "image"
|
|
379
|
+
? imageOnlyChild.url
|
|
380
|
+
: imageOnlyChild.type === "imageReference"
|
|
381
|
+
? definitions.get(imageOnlyChild.identifier.toLowerCase())
|
|
382
|
+
: undefined;
|
|
383
|
+
if (imageUrl && imageUrl.trim() === target.url.trim()) {
|
|
384
|
+
errors.push(issue(file, bodyStartLine, "article.image-link-wrapper-redundant", "warning", "Image is wrapped in a redundant link to the same URL", line, "Remove the outer link and keep the standard Markdown image."));
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
errors.push(issue(file, bodyStartLine, "article.image-link-target-discarded", "error", `Image-only link destination '${target.url}' is discarded by the Lens renderer`, line, "Remove the outer link and add a labeled text link, or use the destination as the image URL when it is the intended full-resolution image."));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!isImage && !linkHasImage && !toString(node).trim()) {
|
|
391
|
+
errors.push(issue(file, bodyStartLine, "article.link-label-empty", "error", "Link has no visible label", line));
|
|
392
|
+
}
|
|
393
|
+
if (!isImage) {
|
|
394
|
+
const sourceFragment = sameSourceFragment(target.url, sourceUrl);
|
|
395
|
+
if (sourceFragment &&
|
|
396
|
+
!externalSelfFragments.has(sourceFragment)) {
|
|
397
|
+
externalSelfFragments.add(sourceFragment);
|
|
398
|
+
errors.push(issue(file, bodyStartLine, "article.external-self-fragment", "warning", `Link '#${sourceFragment}' points back to this article's source page`, line, `Use a native Markdown footnote for a note, or a local #fragment link for content imported into this article. If this deliberately targets source-only content that was not imported, place this exemption immediately above the link: ${formatValidatorSuppression(externalSelfFragmentSuppression)}`));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const start = positioned.position?.start.offset;
|
|
402
|
+
const end = positioned.position?.end.offset;
|
|
403
|
+
if (start !== undefined && end !== undefined && !isImage) {
|
|
404
|
+
const before = body[start - 1] ?? "";
|
|
405
|
+
const after = body[end] ?? "";
|
|
406
|
+
const label = toString(node);
|
|
407
|
+
const siblings = parent && "children" in parent ? parent.children : [];
|
|
408
|
+
const previous = index === undefined || index === null ? undefined : siblings[index - 1];
|
|
409
|
+
const next = index === undefined || index === null ? undefined : siblings[index + 1];
|
|
410
|
+
const adjacentTypes = new Set([
|
|
411
|
+
"link",
|
|
412
|
+
"linkReference",
|
|
413
|
+
"image",
|
|
414
|
+
"imageReference",
|
|
415
|
+
"footnoteReference",
|
|
416
|
+
]);
|
|
417
|
+
const structuredBefore = previous !== undefined &&
|
|
418
|
+
adjacentTypes.has(previous.type) &&
|
|
419
|
+
previous.position?.end.offset === start;
|
|
420
|
+
const structuredAfter = next !== undefined &&
|
|
421
|
+
adjacentTypes.has(next.type) &&
|
|
422
|
+
next.position?.start.offset === end;
|
|
423
|
+
const touchesWord = /[\p{L}\p{N}]/u.test(before) || /[\p{L}\p{N}]/u.test(after);
|
|
424
|
+
const bracketWrapped = before === "[" && after === "]";
|
|
425
|
+
const touchesUnescapedBracket = !bracketWrapped &&
|
|
426
|
+
((/\[|\]/.test(before) &&
|
|
427
|
+
!structuredBefore &&
|
|
428
|
+
!escapedAt(body, start - 1)) ||
|
|
429
|
+
(/\[|\]/.test(after) &&
|
|
430
|
+
!structuredAfter &&
|
|
431
|
+
!escapedAt(body, end)));
|
|
432
|
+
if (touchesUnescapedBracket) {
|
|
433
|
+
errors.push(issue(file, bodyStartLine, "article.inline-boundary-malformed", "error", "Markdown link touches a stray bracket", line, "Remove the unmatched bracket or complete the intended bracketed citation."));
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const citationLabel = /^\[?(?:\d+(?:[.,–-]\d+)*%?|[†‡*]+)\]?$/u.test(label.trim());
|
|
437
|
+
// Preserve source-page citation/backreference badges when an import
|
|
438
|
+
// intentionally attaches them to prose. A single-letter label is
|
|
439
|
+
// otherwise ambiguous (for example, `[a](url)word`), so only treat
|
|
440
|
+
// it as a marker when its destination is a known citation anchor.
|
|
441
|
+
const sourceCitationMarker = /#(?:cite_(?:note|ref)-|ftnt\d+)/i.test(target.url) &&
|
|
442
|
+
/^\[?(?:[a-z]|[†‡*§¶]+)\]?$/iu.test(label.trim());
|
|
443
|
+
const suppliesVisualSpace = /^\s|\s$/u.test(label);
|
|
444
|
+
if (touchesWord &&
|
|
445
|
+
!citationLabel &&
|
|
446
|
+
!sourceCitationMarker &&
|
|
447
|
+
!suppliesVisualSpace) {
|
|
448
|
+
errors.push(issue(file, bodyStartLine, "article.inline-boundary-malformed", "warning", "Markdown link touches an adjacent word", line, "Check whether the joined text is intentional; otherwise add whitespace or punctuation."));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (isImage) {
|
|
453
|
+
const image = target;
|
|
454
|
+
const alt = image.alt?.trim() ?? "";
|
|
455
|
+
if (alt && /^(?:image|figure|photo)(?:\s*\d+)?[.:]?$/i.test(alt))
|
|
456
|
+
errors.push(issue(file, bodyStartLine, "article.image-alt-generic", "warning", `Image alt text '${alt}' is generic`, line, "Remove the alt text by leaving the image brackets empty: ."));
|
|
457
|
+
if (/^\/attachments\//i.test(image.url))
|
|
458
|
+
errors.push(issue(file, bodyStartLine, "article.attachment-url-unservable", "error", "Image uses the unsupported /attachments/ route", line));
|
|
459
|
+
if (/(?:-\d{2,3}x\d{2,3}(?=\.[a-z]+(?:\?|$))|(?:^|\/)\d{2,3}x\d{2,3}(?=\/|$)|[?&,/]w[_=]\d{2,3}(?:[&,/]|$))/i.test(image.url)) {
|
|
460
|
+
errors.push(issue(file, bodyStartLine, lowResolutionSuppression.code, "error", "Image URL requests a low-resolution derivative", line, `Use the highest-resolution image from the original webpage. If none can be found, place one of these audited exemptions immediately above the image: ${lowResolutionSuppressionGuidance}`));
|
|
461
|
+
}
|
|
462
|
+
const seen = imageOccurrences.get(image.url) ?? [];
|
|
463
|
+
if (seen.some((previous) => line - previous <= 30))
|
|
464
|
+
errors.push(issue(file, bodyStartLine, "article.image-repeated-nearby", "warning", "The same image URL is repeated nearby", line));
|
|
465
|
+
seen.push(line);
|
|
466
|
+
imageOccurrences.set(image.url, seen);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (node.type === "linkReference" || node.type === "imageReference") {
|
|
470
|
+
const id = node.identifier.toLowerCase();
|
|
471
|
+
if (!definitions.has(id))
|
|
472
|
+
errors.push(issue(file, bodyStartLine, node.type === "imageReference" ? "article.image-destination-empty" : "article.reference-target-undefined", "error", `Reference target '${id}' is not defined`, line));
|
|
473
|
+
const referencedUrl = definitions.get(id);
|
|
474
|
+
if (node.type === "linkReference" &&
|
|
475
|
+
referencedUrl !== undefined &&
|
|
476
|
+
isUnsupportedRootRelativeLink(referencedUrl)) {
|
|
477
|
+
errors.push(issue(file, bodyStartLine, "article.root-relative-source-link", "error", `Root-relative link '${referencedUrl}' resolves against Lens instead of the source website`, line, "Resolve the destination against source_url and use the resulting absolute URL."));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const mathStart = positioned.position?.start.offset;
|
|
481
|
+
const mathEnd = positioned.position?.end.offset;
|
|
482
|
+
const rawMath = mathStart === undefined || mathEnd === undefined
|
|
483
|
+
? ""
|
|
484
|
+
: body.slice(mathStart, mathEnd);
|
|
485
|
+
const wellDelimitedDisplay = node.type !== "math" ||
|
|
486
|
+
(rawMath.split("\n")[0]?.trim() === "$$" &&
|
|
487
|
+
rawMath.split("\n").at(-1)?.trim() === "$$" &&
|
|
488
|
+
!rawMath.slice(2, -2).includes("$$") &&
|
|
489
|
+
!/\n[ \t]*\n/.test(rawMath));
|
|
490
|
+
if ((node.type === "math" || node.type === "inlineMath") &&
|
|
491
|
+
wellDelimitedDisplay &&
|
|
492
|
+
hasSuspiciousFlattenedMath(String(node.value ?? ""), node.type === "math")) {
|
|
493
|
+
errors.push(issue(file, bodyStartLine, "article.math-token-flattened", "warning", "Math contains tokens that look flattened by a converter", line, "Compare the expression with the source and restore the intended TeX commands."));
|
|
494
|
+
}
|
|
495
|
+
// Undefined references degrade to plain text in mdast. Inspect text nodes
|
|
496
|
+
// rather than the raw source so URL query parameters are never mistaken
|
|
497
|
+
// for reference-style Markdown.
|
|
498
|
+
if (node.type === "text") {
|
|
499
|
+
const baseLine = node.position?.start.line ?? 1;
|
|
500
|
+
for (const match of node.value.matchAll(/(?<!!)\[([^\]\n]+)\]\[([^\]\n]*)\]/g)) {
|
|
501
|
+
if (match[1].startsWith("^"))
|
|
502
|
+
continue;
|
|
503
|
+
const id = (match[2] || match[1]).trim().toLowerCase();
|
|
504
|
+
if (id && !definitions.has(id))
|
|
505
|
+
errors.push(issue(file, bodyStartLine, "article.reference-target-undefined", "error", `Reference target '${id}' is not defined`, baseLine + lineAt(node.value, match.index ?? 0) - 1));
|
|
506
|
+
}
|
|
507
|
+
for (const match of node.value.matchAll(/!\[([^\]\n]*)\]\[([^\]\n]*)\]/g)) {
|
|
508
|
+
const id = (match[2] || match[1]).trim().toLowerCase();
|
|
509
|
+
if (id && !definitions.has(id))
|
|
510
|
+
errors.push(issue(file, bodyStartLine, "article.image-destination-empty", "error", `Image reference target '${id}' is not defined`, baseLine + lineAt(node.value, match.index ?? 0) - 1));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (["paragraph", "table", "image"].includes(node.type) && parent?.type === "root") {
|
|
514
|
+
const value = normalizedBlock(positioned, body);
|
|
515
|
+
if (!/^>/.test(value))
|
|
516
|
+
blocks.push({
|
|
517
|
+
node: positioned,
|
|
518
|
+
value,
|
|
519
|
+
line,
|
|
520
|
+
substantial: value.length >= 80 || node.type === "table" || node.type === "image",
|
|
521
|
+
attribution: isAttributionParagraph(positioned),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
// The renderer's conservative single-dollar pass is intentionally outside
|
|
526
|
+
// remark-math, so inspect those expressions separately for flattened TeX.
|
|
527
|
+
for (const match of inlineMathScanSource.matchAll(/(?<!\$)\$(?!\$)([^\s$](?:[^$\n]*?[^\s$])?)\$(?![\d$])/g)) {
|
|
528
|
+
if (!hasSuspiciousFlattenedMath(match[1], false))
|
|
529
|
+
continue;
|
|
530
|
+
errors.push(issue(file, bodyStartLine, "article.math-token-flattened", "warning", "Math contains tokens that look flattened by a converter", lineAt(inlineMathScanSource, match.index ?? 0), "Compare the expression with the source and restore the intended TeX commands."));
|
|
531
|
+
}
|
|
532
|
+
for (const [id, line] of referencedFootnotes)
|
|
533
|
+
if (!definedFootnotes.has(id))
|
|
534
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-missing-definition", "error", `Footnote '${id}' is referenced but never defined`, line));
|
|
535
|
+
for (const [id, lines] of definedFootnotes) {
|
|
536
|
+
if (lines.length > 1)
|
|
537
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-duplicate-definition", "error", `Footnote '${id}' is defined more than once`, lines[1]));
|
|
538
|
+
if (!referencedFootnotes.has(id))
|
|
539
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-unused-definition", "warning", `Footnote '${id}' is defined but never referenced`, lines[0]));
|
|
540
|
+
}
|
|
541
|
+
const rawFootnoteDefinitions = new Map();
|
|
542
|
+
for (const match of prose.matchAll(/^\[\^([^\]]+)\]:/gm)) {
|
|
543
|
+
const id = match[1].toLowerCase();
|
|
544
|
+
rawFootnoteDefinitions.set(id, [...(rawFootnoteDefinitions.get(id) ?? []), lineAt(prose, match.index ?? 0)]);
|
|
545
|
+
}
|
|
546
|
+
for (const [id, lines] of rawFootnoteDefinitions)
|
|
547
|
+
if (lines.length > 1 && (definedFootnotes.get(id)?.length ?? 0) < 2)
|
|
548
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-duplicate-definition", "error", `Footnote '${id}' is defined more than once`, lines[1]));
|
|
549
|
+
for (const match of prose.matchAll(/\[\^([^\]\n]+)\](?!:)/g)) {
|
|
550
|
+
const id = match[1].toLowerCase();
|
|
551
|
+
if (!rawFootnoteDefinitions.has(id))
|
|
552
|
+
errors.push(issue(file, bodyStartLine, "article.footnote-missing-definition", "error", `Footnote '${id}' is referenced but never defined`, lineAt(prose, match.index ?? 0)));
|
|
553
|
+
}
|
|
554
|
+
for (let i = 1; i < blocks.length; i += 1) {
|
|
555
|
+
if (!blocks[i].substantial || blocks[i].value !== blocks[i - 1].value)
|
|
556
|
+
continue;
|
|
557
|
+
errors.push(issue(file, bodyStartLine, "article.adjacent-block-duplicate", "error", "A substantial content block is repeated immediately", blocks[i].line));
|
|
558
|
+
}
|
|
559
|
+
for (let i = 0; i < blocks.length; i += 1)
|
|
560
|
+
for (let j = i + 2; j < Math.min(blocks.length, i + 8); j += 1) {
|
|
561
|
+
if (blocks[i].substantial &&
|
|
562
|
+
blocks[i].value === blocks[j].value &&
|
|
563
|
+
!(blocks[i].attribution && blocks[j].attribution) &&
|
|
564
|
+
!isLegitimateRepeatedAnnotation(blocks[i].value)) {
|
|
565
|
+
const repeatedUnderBoilerplate = i > 0 &&
|
|
566
|
+
j > 0 &&
|
|
567
|
+
blocks[i - 1].value === blocks[j - 1].value &&
|
|
568
|
+
isSectionBoilerplate(blocks[i - 1].value);
|
|
569
|
+
if (repeatedUnderBoilerplate)
|
|
570
|
+
continue;
|
|
571
|
+
errors.push(issue(file, bodyStartLine, "article.block-repeated-nearby", "warning", "A substantial content block is repeated nearby", blocks[j].line, repeatedBlockSuppressionGuidance));
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
for (let i = 0; i + 1 < blocks.length; i += 1)
|
|
576
|
+
for (let j = i + 3; j + 1 < Math.min(blocks.length, i + 9); j += 1) {
|
|
577
|
+
const first = `${blocks[i].value}\n${blocks[i + 1].value}`;
|
|
578
|
+
const second = `${blocks[j].value}\n${blocks[j + 1].value}`;
|
|
579
|
+
if (first.length >= 80 && first === second) {
|
|
580
|
+
if (isSectionBoilerplate(blocks[i].value))
|
|
581
|
+
continue;
|
|
582
|
+
errors.push(issue(file, bodyStartLine, "article.block-repeated-nearby", "warning", "A short sequence of content blocks is repeated nearby", blocks[j].line, repeatedBlockSuppressionGuidance));
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Source checks for constructs CommonMark intentionally degrades to text.
|
|
587
|
+
for (const match of prose.matchAll(/^[ \t]*(?:optional|hide|feedback|source|from|to)::/gim))
|
|
588
|
+
errors.push(issue(file, bodyStartLine, "article.body-property-unsupported", "error", `Lens-only property '${match[0].trim()}' is not supported in an article body`, lineAt(prose, match.index ?? 0)));
|
|
589
|
+
const texProse = maskNodes(prose, tree, new Set(["image", "imageReference"]));
|
|
590
|
+
for (const match of texProse.matchAll(/(?<!\$)\\(?:frac|dfrac|tfrac|boldsymbol|mathbb|mathrm|mathbf|operatorname|begin|end|alpha|beta|gamma|delta|epsilon|theta|lambda|mu|pi|rho|sigma|tau|phi|psi|omega|approx|sim|leq|geq|neq|to|mapsto|infty|cdot|times|sum|prod)\b/g))
|
|
591
|
+
errors.push(issue(file, bodyStartLine, "article.tex-outside-math", "error", `TeX command '${match[0]}' appears outside math`, lineAt(texProse, match.index ?? 0), "Wrap the expression in valid math delimiters."));
|
|
592
|
+
for (const match of prose.matchAll(/\u00ad/g))
|
|
593
|
+
errors.push(issue(file, bodyStartLine, "article.soft-hyphen", "warning", "Soft hyphen in prose damages search and copy/paste", lineAt(prose, match.index ?? 0)));
|
|
594
|
+
const pdfLineWrapArtifacts = [
|
|
595
|
+
...prose.matchAll(/(?<=\p{L})-[ \t]*\n[ \t]*(?=\p{Ll})/gu),
|
|
596
|
+
];
|
|
597
|
+
if (pdfLineWrapArtifacts.length > 0) {
|
|
598
|
+
const first = pdfLineWrapArtifacts[0];
|
|
599
|
+
errors.push(issue(file, bodyStartLine, "article.pdf-line-wrap-artifact", "error", `Article contains ${pdfLineWrapArtifacts.length} PDF-style word break${pdfLineWrapArtifacts.length === 1 ? "" : "s"} across source lines`, lineAt(prose, first.index ?? 0), "Reconstruct the affected paragraphs from the source, joining wrapped lines and preserving a hyphen only when it belongs in the original word."));
|
|
600
|
+
}
|
|
601
|
+
for (const match of prose.matchAll(/[\u200b-\u200d\ufeff]/g)) {
|
|
602
|
+
const index = match.index ?? 0;
|
|
603
|
+
if (match[0] === "\u200d" && isEmojiJoiner(prose, index))
|
|
604
|
+
continue;
|
|
605
|
+
errors.push(issue(file, bodyStartLine, "article.zero-width-character", "warning", "Zero-width character in prose", lineAt(prose, index)));
|
|
606
|
+
}
|
|
607
|
+
for (const match of prose.matchAll(/!\[[^\]]*\]\(\s*\)|\[[^\]]+\]\(\s*\)/g)) {
|
|
608
|
+
const raw = match[0];
|
|
609
|
+
const code = raw.startsWith("![") ? "article.image-destination-empty" : "article.link-destination-empty";
|
|
610
|
+
errors.push(issue(file, bodyStartLine, code, "error", "Incomplete Markdown link or image", lineAt(prose, match.index ?? 0)));
|
|
611
|
+
}
|
|
612
|
+
for (const match of prose.matchAll(/\]\(#([^\s)]+)\)/g)) {
|
|
613
|
+
let fragment;
|
|
614
|
+
try {
|
|
615
|
+
fragment = decodeURIComponent(match[1]).toLowerCase();
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
fragment = match[1].toLowerCase();
|
|
619
|
+
}
|
|
620
|
+
if (!headingIds.has(fragment))
|
|
621
|
+
errors.push(issue(file, bodyStartLine, "article.fragment-target-missing", "error", `Fragment target '#${match[1]}' does not resolve to a rendered heading`, lineAt(prose, match.index ?? 0)));
|
|
622
|
+
}
|
|
623
|
+
for (const match of prose.matchAll(/^#{1,6}\s+(Image|Figure|Photo)\s*$/gim)) {
|
|
624
|
+
const line = lineAt(prose, match.index ?? 0);
|
|
625
|
+
const nearby = body.split("\n").slice(line, line + 4).join("\n");
|
|
626
|
+
errors.push(issue(file, bodyStartLine, /!\[/.test(nearby) ? "article.placeholder-image-heading" : "article.placeholder-heading", /!\[/.test(nearby) ? "error" : "warning", `Placeholder heading '${match[1]}' lacks descriptive context`, line));
|
|
627
|
+
}
|
|
628
|
+
for (const match of prose.matchAll(/(?:Posted in:\s*(?:,\s*)+|Export citation|Download citation|Share\s+Save\s+Follow)/gi))
|
|
629
|
+
errors.push(issue(file, bodyStartLine, "article.page-chrome-residue", "warning", "Source-page interface residue remains in article text", lineAt(prose, match.index ?? 0)));
|
|
630
|
+
const converterResiduePatterns = [
|
|
631
|
+
{
|
|
632
|
+
re: /^[ \t]*(?:††)?longtable[ \t]*$\n(?:[ \t]*\n)*(?=[ \t]*(?:#{1,6}\s+|Abstract\b))/gim,
|
|
633
|
+
message: "Standalone LaTeX environment name remains in converted prose",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
re: /_\(button:\s*Give feedback\)_/gi,
|
|
637
|
+
message: "Serialized source-page button remains in article text",
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
re: /^[ \t]*(?:>[ \t]*)*(?:\[\^[^\]\n]+\]:[ \t]+)?(?:[-+*][ \t]+)?\d+\.[ \t]+\d+\.[ \t]+\S/gm,
|
|
641
|
+
message: "Duplicated numbered-list marker remains after conversion",
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
re: /^[ \t]*(?:>[ \t]*)*(?:\[\^[^\]\n]+\]:[ \t]+)?[-+*][ \t]+[•◦▪‣][ \t]+\S/gm,
|
|
645
|
+
message: "Duplicated bullet marker remains after conversion",
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
re: /marginparsep has been altered[\s\S]{0,1200}?The page layout violates the ICML style[\s\S]{0,1200}?(?:remove|avoid)[^.\n]{0,160}(?:package|layout)/gi,
|
|
649
|
+
message: "Document-submission layout diagnostics remain in article text",
|
|
650
|
+
},
|
|
651
|
+
];
|
|
652
|
+
for (const { re, message } of converterResiduePatterns) {
|
|
653
|
+
for (const match of prose.matchAll(re)) {
|
|
654
|
+
errors.push(issue(file, bodyStartLine, "article.page-chrome-residue", "warning", message, lineAt(prose, match.index ?? 0), "Remove the converter or source-page residue while preserving the article content."));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
for (const match of body.matchAll(/\b([A-Za-z][A-Za-z0-9]*)\s+(?:over|divided by)\s+([A-Za-z][A-Za-z0-9]*).{0,100}?\${1,2}\s*\\frac\{\1\}\{\2\}/gis))
|
|
658
|
+
errors.push(issue(file, bodyStartLine, "article.math-expression-duplicated", "warning", "Flattened math is followed by an equivalent TeX expression", lineAt(body, match.index ?? 0)));
|
|
659
|
+
// Only warn when Markdown actually parsed a list-looking line as indented
|
|
660
|
+
// code. Valid nested lists and footnote continuations are intentional.
|
|
661
|
+
visit(tree, "code", (node) => {
|
|
662
|
+
if (!node.position || node.lang)
|
|
663
|
+
return;
|
|
664
|
+
const lines = body.split("\n");
|
|
665
|
+
for (let line = node.position.start.line; line <= node.position.end.line; line += 1) {
|
|
666
|
+
if (/^ {4,}(?:[-+*]|\d+[.)])\s+\S/.test(lines[line - 1] ?? "")) {
|
|
667
|
+
errors.push(issue(file, bodyStartLine, "article.list-indentation-suspicious", "warning", "List indentation makes this item render as a code block", line, "Reduce the indentation or attach it to the intended parent list."));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
errors.push(...scanTables(prose, file, bodyStartLine));
|
|
672
|
+
return applyNextLineValidatorSuppressions(proseMask(rawBody), bodyStartLine, errors, file);
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=article-structure.js.map
|