lens-content-processor 0.43.0 → 0.44.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.
@@ -0,0 +1,265 @@
1
+ import { parseTimestamp } from "../bundler/video.js";
2
+ import { isValidHeight } from "../parser/widget.js";
3
+ // ::video{attr}[[path|alias]] or ::video[[path|alias]]{attr}, anchored to a
4
+ // full (trimmed) line. Alias is accepted for wikilink-tooling compatibility
5
+ // but unused — the display title comes from the transcript frontmatter.
6
+ const VIDEO_IMPORT_LINE_RE = /^::video(?:\{([^}]*)\})?\[\[([^\]|]+)(?:\|[^\]]+)?\]\](?:\{([^}]*)\})?$/;
7
+ // Anything that looks like an attempted ::video import (used to flag
8
+ // malformed/misplaced usages without false-positives on prose that merely
9
+ // mentions "::video").
10
+ const VIDEO_IMPORT_CANDIDATE_RE = /::video[[{]/;
11
+ // ![[path]] or ![[path|modifier]], anchored to a full (trimmed) line.
12
+ const WIDGET_EMBED_LINE_RE = /^!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]$/;
13
+ // Every ![[...]] on a line; the target decides whether it is a widget embed.
14
+ const WIDGET_EMBED_CANDIDATE_RE = /!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g;
15
+ // A file directly under a widgets/ folder, written relative to the article
16
+ // (`../widgets/name`) or to the vault root (`widgets/name`), no fragment.
17
+ const WIDGET_TARGET_RE = /^(?:\.\.?\/)*widgets\/[^/#|]+$/;
18
+ // Inline code is not prose: an import mentioned in backticks is neither an
19
+ // import nor a mistake.
20
+ const INLINE_CODE_RE = /`[^`]*`/g;
21
+ // CommonMark permits up to three leading spaces before a fenced code block.
22
+ const CODE_FENCE = /^ {0,3}(?:```|~~~)/;
23
+ const CONTAINER_OPEN = /^(:{3,})([\w-]+)/;
24
+ const CONTAINER_CLOSE = /^(:{3,})\s*(?:\{[^}]*\})?\s*$/;
25
+ /** Parse `from="..." to="..."` attribute text. */
26
+ function parseAttrs(attr) {
27
+ if (attr === undefined || attr.trim() === "")
28
+ return {};
29
+ const tokens = [...attr.matchAll(/([\w-]+)\s*=\s*"([^"]*)"/g)];
30
+ const consumed = tokens.map((m) => m[0]).join(" ");
31
+ if (consumed.replace(/\s+/g, " ").trim() !== attr.replace(/\s+/g, " ").trim()) {
32
+ return {
33
+ reason: `invalid attributes '{${attr}}'`,
34
+ suggestion: 'Use key="value" attributes: from and/or to',
35
+ };
36
+ }
37
+ const result = {};
38
+ for (const token of tokens) {
39
+ const key = token[1];
40
+ const value = token[2];
41
+ if (key !== "from" && key !== "to") {
42
+ return {
43
+ reason: `unrecognized attribute '${key}'`,
44
+ suggestion: "Only from and to attributes are supported",
45
+ };
46
+ }
47
+ if (result[key] !== undefined) {
48
+ return {
49
+ reason: `duplicate attribute '${key}'`,
50
+ suggestion: `Keep only one ${key} attribute`,
51
+ };
52
+ }
53
+ if (parseTimestamp(value) === null) {
54
+ return {
55
+ reason: `invalid timestamp in '${key}="${value}"'`,
56
+ suggestion: "Use M:SS (e.g. 1:30) or H:MM:SS (e.g. 1:30:00)",
57
+ };
58
+ }
59
+ result[key] = value;
60
+ }
61
+ return result;
62
+ }
63
+ /**
64
+ * Classify a single line of article body text as a ::video import.
65
+ * - "import": a valid own-line ::video import
66
+ * - "invalid": an attempted import with broken syntax
67
+ * - "none": not a video import at all
68
+ */
69
+ export function parseVideoImportLine(line) {
70
+ const trimmed = line.trim();
71
+ if (!VIDEO_IMPORT_CANDIDATE_RE.test(trimmed.replace(INLINE_CODE_RE, ""))) {
72
+ return { kind: "none" };
73
+ }
74
+ const invalid = (reason, suggestion) => ({
75
+ kind: "invalid",
76
+ kindName: "video",
77
+ reason,
78
+ suggestion,
79
+ });
80
+ const match = trimmed.match(VIDEO_IMPORT_LINE_RE);
81
+ if (!match) {
82
+ if (trimmed.startsWith("::video")) {
83
+ return invalid("malformed ::video import", 'Use ::video[[../video_transcripts/name]] with optional {from="1:30" to="4:10"}, alone on its own line');
84
+ }
85
+ // ::video appears mid-line — an import buried in prose.
86
+ return invalid("::video import must be alone on its own line", "Move the ::video[[...]] import to its own line");
87
+ }
88
+ const preAttr = match[1];
89
+ const target = match[2].trim();
90
+ const postAttr = match[3];
91
+ if (preAttr !== undefined && postAttr !== undefined) {
92
+ return invalid("attributes given both before and after the wikilink", "Use a single {from=... to=...} block after the ]]");
93
+ }
94
+ const attrs = parseAttrs(preAttr ?? postAttr);
95
+ if ("reason" in attrs)
96
+ return invalid(attrs.reason, attrs.suggestion);
97
+ if (target === "") {
98
+ return invalid("empty wikilink target", "Reference a transcript: ::video[[../video_transcripts/name]]");
99
+ }
100
+ return { kind: "import", import: { kind: "video", target, ...attrs } };
101
+ }
102
+ /** True when a wikilink target names a file directly under a widgets/ folder. */
103
+ export function isWidgetEmbedTarget(target) {
104
+ return WIDGET_TARGET_RE.test(target.trim());
105
+ }
106
+ /** True when a line mentions a widget embed anywhere outside inline code. */
107
+ function hasWidgetEmbedCandidate(line) {
108
+ return [
109
+ ...line.replace(INLINE_CODE_RE, "").matchAll(WIDGET_EMBED_CANDIDATE_RE),
110
+ ].some((m) => isWidgetEmbedTarget(m[1]));
111
+ }
112
+ /**
113
+ * Classify a single line of article body text as a widget embed.
114
+ * - "import": a valid own-line ![[../widgets/name]] embed
115
+ * - "invalid": a widget embed with broken syntax or buried in prose
116
+ * - "none": not a widget embed (other ![[...]] embeds included)
117
+ */
118
+ export function parseWidgetEmbedLine(line) {
119
+ const trimmed = line.trim();
120
+ if (!hasWidgetEmbedCandidate(trimmed))
121
+ return { kind: "none" };
122
+ const invalid = (reason, suggestion) => ({
123
+ kind: "invalid",
124
+ kindName: "widget",
125
+ reason,
126
+ suggestion,
127
+ });
128
+ const match = trimmed.match(WIDGET_EMBED_LINE_RE);
129
+ if (!match || !isWidgetEmbedTarget(match[1])) {
130
+ return invalid("widget embed must be alone on its own line", "Move the ![[../widgets/name]] embed to its own line");
131
+ }
132
+ const target = match[1].trim();
133
+ const modifier = match[2]?.trim();
134
+ if (modifier === undefined || modifier === "") {
135
+ return { kind: "import", import: { kind: "widget", target } };
136
+ }
137
+ const heightMatch = modifier.match(/^height\s*=\s*(\S+)$/);
138
+ if (!heightMatch || !isValidHeight(heightMatch[1])) {
139
+ return invalid(`invalid modifier '|${modifier}'`, 'The only modifier is height, e.g. ![[../widgets/name|height=480px]] ("auto" or a CSS length)');
140
+ }
141
+ return {
142
+ kind: "import",
143
+ import: { kind: "widget", target, height: heightMatch[1] },
144
+ };
145
+ }
146
+ /** Classify a line as any of the inline article imports. */
147
+ export function parseArticleImportLine(line) {
148
+ const video = parseVideoImportLine(line);
149
+ return video.kind === "none" ? parseWidgetEmbedLine(line) : video;
150
+ }
151
+ /**
152
+ * Walk an article body line by line, tracking fenced code and container
153
+ * directives, so every consumer of import lines skips the same regions.
154
+ */
155
+ export function scanArticleLines(body) {
156
+ const out = [];
157
+ let inCodeBlock = false;
158
+ // Names of open container directives, innermost last.
159
+ const containerStack = [];
160
+ for (const line of body.split("\n")) {
161
+ if (CODE_FENCE.test(line)) {
162
+ inCodeBlock = !inCodeBlock;
163
+ out.push({ line, prose: false });
164
+ continue;
165
+ }
166
+ if (inCodeBlock) {
167
+ out.push({ line, prose: false });
168
+ continue;
169
+ }
170
+ const openMatch = line.match(CONTAINER_OPEN);
171
+ if (openMatch) {
172
+ containerStack.push(openMatch[2]);
173
+ out.push({ line, prose: false });
174
+ continue;
175
+ }
176
+ if (CONTAINER_CLOSE.test(line)) {
177
+ containerStack.pop();
178
+ out.push({ line, prose: false });
179
+ continue;
180
+ }
181
+ const hiddenIn = containerStack.find((name) => name === "hide" || name === "collapse");
182
+ out.push(hiddenIn ? { line, prose: true, hiddenIn } : { line, prose: true });
183
+ }
184
+ return out;
185
+ }
186
+ const IMPORT_LABEL = {
187
+ video: { code: "video-import", label: "::video import" },
188
+ widget: { code: "widget-embed", label: "widget embed" },
189
+ };
190
+ /**
191
+ * Parse-time validation of inline imports in an article body.
192
+ * Resolution errors (missing file, bad range) are reported later by the
193
+ * flattener, which has the file map.
194
+ */
195
+ export function validateArticleImports(body, file, bodyStartLine) {
196
+ const errors = [];
197
+ const scanned = scanArticleLines(body);
198
+ for (let i = 0; i < scanned.length; i++) {
199
+ const { line, prose, hiddenIn } = scanned[i];
200
+ if (!prose)
201
+ continue;
202
+ const absLine = bodyStartLine + i;
203
+ const parsed = parseArticleImportLine(line);
204
+ if (parsed.kind === "none")
205
+ continue;
206
+ if (parsed.kind === "invalid") {
207
+ const { code, label } = IMPORT_LABEL[parsed.kindName];
208
+ const notOwnLine = parsed.reason.includes("own line");
209
+ errors.push({
210
+ file,
211
+ line: absLine,
212
+ code: `article.${code}-${notOwnLine ? "not-own-line" : "invalid"}`,
213
+ message: `Invalid ${label}: ${parsed.reason}`,
214
+ suggestion: parsed.suggestion,
215
+ severity: "error",
216
+ });
217
+ continue;
218
+ }
219
+ // Imports inside hide/collapse would silently vanish from rendered output
220
+ // (the collapsed string strips them), so they are rejected outright.
221
+ if (hiddenIn) {
222
+ const { code, label } = IMPORT_LABEL[parsed.import.kind];
223
+ errors.push({
224
+ file,
225
+ line: absLine,
226
+ code: `article.${code}-in-hide`,
227
+ message: `${label} inside a ':::${hiddenIn}' container`,
228
+ suggestion: `Move the import outside the hidden block — hidden text is collapsed and the ${parsed.import.kind} would never render`,
229
+ severity: "error",
230
+ });
231
+ }
232
+ }
233
+ return errors;
234
+ }
235
+ /**
236
+ * Lens text segments do not resolve inline article imports — catch attempts
237
+ * there with a pointer to the supported mechanisms.
238
+ */
239
+ export function validateNoArticleImportsInLensText(content, file, line) {
240
+ const errors = [];
241
+ const lines = content.split("\n");
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const prose = lines[i].replace(INLINE_CODE_RE, "");
244
+ if (VIDEO_IMPORT_CANDIDATE_RE.test(prose)) {
245
+ errors.push({
246
+ file,
247
+ line: line + i,
248
+ message: "::video imports are only supported in article bodies",
249
+ suggestion: "Use a '#### Video' segment in the lens, or move the import into the article file",
250
+ severity: "error",
251
+ });
252
+ }
253
+ else if (hasWidgetEmbedCandidate(prose)) {
254
+ errors.push({
255
+ file,
256
+ line: line + i,
257
+ message: "Widget embeds (![[../widgets/...]]) are only supported in article bodies",
258
+ suggestion: "Use a '#### Widget' segment in the lens, or move the embed into the article file",
259
+ severity: "error",
260
+ });
261
+ }
262
+ }
263
+ return errors;
264
+ }
265
+ //# sourceMappingURL=article-imports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"article-imports.js","sourceRoot":"","sources":["../../src/validator/article-imports.ts"],"names":[],"mappings":"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"}
@@ -5,9 +5,14 @@ import { stripAuthoringMarkupForValidation } from "../authoring-markup.js";
5
5
  import { parseFrontmatter } from "../parser/frontmatter.js";
6
6
  import { applyNextLineValidatorSuppressions, formatValidatorSuppression, VALIDATOR_SUPPRESSIONS, validatorSuppressionsForCode, } from "./suppressions.js";
7
7
  import { validateArticleSamePageLinks } from "./same-lens-links.js";
8
+ import { isWidgetEmbedTarget } from "./article-imports.js";
8
9
  const lowResolutionSuppression = VALIDATOR_SUPPRESSIONS.imageLowResolutionAiSearchExhausted;
9
- const lowResolutionSuppressionGuidance = validatorSuppressionsForCode(lowResolutionSuppression.code).map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`).join(" ");
10
- const repeatedBlockSuppressionGuidance = validatorSuppressionsForCode("article.block-repeated-nearby").map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`).join(" ");
10
+ const lowResolutionSuppressionGuidance = validatorSuppressionsForCode(lowResolutionSuppression.code)
11
+ .map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`)
12
+ .join(" ");
13
+ const repeatedBlockSuppressionGuidance = validatorSuppressionsForCode("article.block-repeated-nearby")
14
+ .map((definition) => `${definition.explanation} Use: ${formatValidatorSuppression(definition)}`)
15
+ .join(" ");
11
16
  const externalSelfFragmentSuppression = VALIDATOR_SUPPRESSIONS.externalSelfFragmentTargetsSourceOnlyContent;
12
17
  function issue(file, bodyStartLine, code, severity, message, line, suggestion) {
13
18
  return {
@@ -104,7 +109,7 @@ function sameSourceFragment(targetUrl, sourceUrl) {
104
109
  // source-site-relative import failures this rule exists to catch.
105
110
  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;
106
111
  function isUnsupportedRootRelativeLink(url) {
107
- return /^\/(?!\/)/.test(url) && url !== "/" && !PLATFORM_LOCAL_ROUTE.test(url);
112
+ return (/^\/(?!\/)/.test(url) && url !== "/" && !PLATFORM_LOCAL_ROUTE.test(url));
108
113
  }
109
114
  const FLATTENED_MATH_TOKEN = /(?<!\\)\b(?:pi|mu|theta|phi|lambda|gamma|tilde|times|infinity|RR|sum|dot)(?=_|\b)|->/g;
110
115
  const TYPED_FOOTNOTE_ID = /^(?:cite|note)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -136,21 +141,33 @@ export function collectArticleImages(body, bodyStartLine) {
136
141
  const tree = parseMarkdown(body, "gfm");
137
142
  const definitions = new Map();
138
143
  const images = [];
139
- visit(tree, "definition", (node) => { definitions.set(node.identifier.toLowerCase(), node.url); });
144
+ visit(tree, "definition", (node) => {
145
+ definitions.set(node.identifier.toLowerCase(), node.url);
146
+ });
140
147
  visit(tree, (node) => {
141
148
  if (node.type === "image")
142
- images.push({ url: node.url, line: bodyStartLine + (node.position?.start.line ?? 1) - 1 });
149
+ images.push({
150
+ url: node.url,
151
+ line: bodyStartLine + (node.position?.start.line ?? 1) - 1,
152
+ });
143
153
  if (node.type === "imageReference") {
144
154
  const url = definitions.get(node.identifier.toLowerCase());
145
155
  if (url)
146
- images.push({ url, line: bodyStartLine + (node.position?.start.line ?? 1) - 1 });
156
+ images.push({
157
+ url,
158
+ line: bodyStartLine + (node.position?.start.line ?? 1) - 1,
159
+ });
147
160
  }
148
161
  });
149
162
  return images;
150
163
  }
151
164
  export function validateWikilinkImages(body, file, bodyStartLine) {
152
165
  const source = proseMask(body);
153
- 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: ![alt](url)"));
166
+ // Widget embeds (![[../widgets/name]]) are inline imports, checked by
167
+ // validator/article-imports.ts.
168
+ return [...source.matchAll(/!\[\[([^\n\]]+)\]\]/g)]
169
+ .filter((match) => !isWidgetEmbedTarget(match[1].split("|")[0]))
170
+ .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: ![alt](url)"));
154
171
  }
155
172
  function scanFences(body, file, bodyStartLine) {
156
173
  const errors = [];
@@ -163,7 +180,9 @@ function scanFences(body, file, bodyStartLine) {
163
180
  if (!open) {
164
181
  open = { char, length: match[1].length, line: index + 1 };
165
182
  }
166
- else if (open.char === char && match[1].length >= open.length && match[2].trim() === "") {
183
+ else if (open.char === char &&
184
+ match[1].length >= open.length &&
185
+ match[2].trim() === "") {
167
186
  open = undefined;
168
187
  }
169
188
  });
@@ -228,7 +247,9 @@ function normalizedBlock(node, body) {
228
247
  // TeX identifiers are case-sensitive: N_1 and n_1 can denote different
229
248
  // variables in otherwise parallel propositions. Preserve case for math so
230
249
  // the duplicate check still catches exact copies without conflating them.
231
- return /^\$\$[\s\S]*\$\$$/.test(normalized) ? normalized : normalized.toLowerCase();
250
+ return /^\$\$[\s\S]*\$\$$/.test(normalized)
251
+ ? normalized
252
+ : normalized.toLowerCase();
232
253
  }
233
254
  function isSectionBoilerplate(value) {
234
255
  return /^applicability to different subgoals in our verification framework:$/i.test(value);
@@ -238,11 +259,13 @@ function isEmojiJoiner(source, index) {
238
259
  return false;
239
260
  const before = Array.from(source.slice(0, index));
240
261
  // Emoji modifiers and variation selectors extend the preceding pictograph.
241
- while (before.length && /^(?:\p{Emoji_Modifier}|[\uFE00-\uFE0F\u{E0100}-\u{E01EF}])$/u.test(before.at(-1) ?? ""))
262
+ while (before.length &&
263
+ /^(?:\p{Emoji_Modifier}|[\uFE00-\uFE0F\u{E0100}-\u{E01EF}])$/u.test(before.at(-1) ?? ""))
242
264
  before.pop();
243
265
  const previous = before.at(-1) ?? "";
244
266
  const next = Array.from(source.slice(index + 1))[0] ?? "";
245
- return /^\p{Extended_Pictographic}$/u.test(previous) && /^\p{Extended_Pictographic}$/u.test(next);
267
+ return (/^\p{Extended_Pictographic}$/u.test(previous) &&
268
+ /^\p{Extended_Pictographic}$/u.test(next));
246
269
  }
247
270
  function isAttributionParagraph(node) {
248
271
  if (node.type !== "paragraph")
@@ -298,7 +321,10 @@ export function validateArticleStructure(content, file) {
298
321
  });
299
322
  visit(tree, "footnoteDefinition", (node) => {
300
323
  const id = node.identifier.toLowerCase();
301
- definedFootnotes.set(id, [...(definedFootnotes.get(id) ?? []), node.position?.start.line ?? 1]);
324
+ definedFootnotes.set(id, [
325
+ ...(definedFootnotes.get(id) ?? []),
326
+ node.position?.start.line ?? 1,
327
+ ]);
302
328
  if (id.startsWith("cite-") &&
303
329
  /^https?:\/\/\S+$/i.test(toString(node).trim())) {
304
330
  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."));
@@ -313,7 +339,9 @@ export function validateArticleStructure(content, file) {
313
339
  const target = node;
314
340
  const isImage = node.type === "image";
315
341
  if (!target.url.trim()) {
316
- errors.push(issue(file, bodyStartLine, isImage ? "article.image-destination-empty" : "article.link-destination-empty", "error", `${isImage ? "Image" : "Link"} destination is empty`, line));
342
+ errors.push(issue(file, bodyStartLine, isImage
343
+ ? "article.image-destination-empty"
344
+ : "article.link-destination-empty", "error", `${isImage ? "Image" : "Link"} destination is empty`, line));
317
345
  }
318
346
  if (!isImage && isUnsupportedRootRelativeLink(target.url.trim())) {
319
347
  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."));
@@ -344,8 +372,7 @@ export function validateArticleStructure(content, file) {
344
372
  }
345
373
  if (!isImage) {
346
374
  const sourceFragment = sameSourceFragment(target.url, sourceUrl);
347
- if (sourceFragment &&
348
- !externalSelfFragments.has(sourceFragment)) {
375
+ if (sourceFragment && !externalSelfFragments.has(sourceFragment)) {
349
376
  externalSelfFragments.add(sourceFragment);
350
377
  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 add a ^block-id to imported content and link to [[#^block-id|label]]. If this deliberately targets source-only content that was not imported, place this exemption immediately above the link: ${formatValidatorSuppression(externalSelfFragmentSuppression)}`));
351
378
  }
@@ -357,8 +384,12 @@ export function validateArticleStructure(content, file) {
357
384
  const after = body[end] ?? "";
358
385
  const label = toString(node);
359
386
  const siblings = parent && "children" in parent ? parent.children : [];
360
- const previous = index === undefined || index === null ? undefined : siblings[index - 1];
361
- const next = index === undefined || index === null ? undefined : siblings[index + 1];
387
+ const previous = index === undefined || index === null
388
+ ? undefined
389
+ : siblings[index - 1];
390
+ const next = index === undefined || index === null
391
+ ? undefined
392
+ : siblings[index + 1];
362
393
  const adjacentTypes = new Set([
363
394
  "link",
364
395
  "linkReference",
@@ -378,9 +409,7 @@ export function validateArticleStructure(content, file) {
378
409
  ((/\[|\]/.test(before) &&
379
410
  !structuredBefore &&
380
411
  !escapedAt(body, start - 1)) ||
381
- (/\[|\]/.test(after) &&
382
- !structuredAfter &&
383
- !escapedAt(body, end)));
412
+ (/\[|\]/.test(after) && !structuredAfter && !escapedAt(body, end)));
384
413
  if (touchesUnescapedBracket) {
385
414
  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."));
386
415
  }
@@ -421,7 +450,9 @@ export function validateArticleStructure(content, file) {
421
450
  if (node.type === "linkReference" || node.type === "imageReference") {
422
451
  const id = node.identifier.toLowerCase();
423
452
  if (!definitions.has(id))
424
- errors.push(issue(file, bodyStartLine, node.type === "imageReference" ? "article.image-destination-empty" : "article.reference-target-undefined", "error", `Reference target '${id}' is not defined`, line));
453
+ errors.push(issue(file, bodyStartLine, node.type === "imageReference"
454
+ ? "article.image-destination-empty"
455
+ : "article.reference-target-undefined", "error", `Reference target '${id}' is not defined`, line));
425
456
  const referencedUrl = definitions.get(id);
426
457
  if (node.type === "linkReference" &&
427
458
  referencedUrl !== undefined &&
@@ -462,14 +493,17 @@ export function validateArticleStructure(content, file) {
462
493
  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));
463
494
  }
464
495
  }
465
- if (["paragraph", "table", "image"].includes(node.type) && parent?.type === "root") {
496
+ if (["paragraph", "table", "image"].includes(node.type) &&
497
+ parent?.type === "root") {
466
498
  const value = normalizedBlock(positioned, body);
467
499
  if (!/^>/.test(value))
468
500
  blocks.push({
469
501
  node: positioned,
470
502
  value,
471
503
  line,
472
- substantial: value.length >= 80 || node.type === "table" || node.type === "image",
504
+ substantial: value.length >= 80 ||
505
+ node.type === "table" ||
506
+ node.type === "image",
473
507
  attribution: isAttributionParagraph(positioned),
474
508
  });
475
509
  }
@@ -493,7 +527,10 @@ export function validateArticleStructure(content, file) {
493
527
  const rawFootnoteDefinitions = new Map();
494
528
  for (const match of prose.matchAll(/^\[\^([^\]]+)\]:/gm)) {
495
529
  const id = match[1].toLowerCase();
496
- rawFootnoteDefinitions.set(id, [...(rawFootnoteDefinitions.get(id) ?? []), lineAt(prose, match.index ?? 0)]);
530
+ rawFootnoteDefinitions.set(id, [
531
+ ...(rawFootnoteDefinitions.get(id) ?? []),
532
+ lineAt(prose, match.index ?? 0),
533
+ ]);
497
534
  }
498
535
  for (const [id, lines] of rawFootnoteDefinitions)
499
536
  if (lines.length > 1 && (definedFootnotes.get(id)?.length ?? 0) < 2)
@@ -558,13 +595,20 @@ export function validateArticleStructure(content, file) {
558
595
  }
559
596
  for (const match of prose.matchAll(/!\[[^\]]*\]\(\s*\)|\[[^\]]+\]\(\s*\)/g)) {
560
597
  const raw = match[0];
561
- const code = raw.startsWith("![") ? "article.image-destination-empty" : "article.link-destination-empty";
598
+ const code = raw.startsWith("![")
599
+ ? "article.image-destination-empty"
600
+ : "article.link-destination-empty";
562
601
  errors.push(issue(file, bodyStartLine, code, "error", "Incomplete Markdown link or image", lineAt(prose, match.index ?? 0)));
563
602
  }
564
603
  for (const match of prose.matchAll(/^#{1,6}\s+(Image|Figure|Photo)\s*$/gim)) {
565
604
  const line = lineAt(prose, match.index ?? 0);
566
- const nearby = body.split("\n").slice(line, line + 4).join("\n");
567
- 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));
605
+ const nearby = body
606
+ .split("\n")
607
+ .slice(line, line + 4)
608
+ .join("\n");
609
+ errors.push(issue(file, bodyStartLine, /!\[/.test(nearby)
610
+ ? "article.placeholder-image-heading"
611
+ : "article.placeholder-heading", /!\[/.test(nearby) ? "error" : "warning", `Placeholder heading '${match[1]}' lacks descriptive context`, line));
568
612
  }
569
613
  for (const match of prose.matchAll(/(?:Posted in:\s*(?:,\s*)+|Export citation|Download citation|Share\s+Save\s+Follow)/gi))
570
614
  errors.push(issue(file, bodyStartLine, "article.page-chrome-residue", "warning", "Source-page interface residue remains in article text", lineAt(prose, match.index ?? 0)));