asciidoclint 0.5.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/LICENSE +21 -0
- package/README.md +258 -0
- package/assets/README.md +12 -0
- package/assets/icon.svg +198 -0
- package/assets/logo.svg +203 -0
- package/dist/api/fixes.d.ts +6 -0
- package/dist/api/fixes.js +61 -0
- package/dist/api/lint.d.ts +2 -0
- package/dist/api/lint.js +191 -0
- package/dist/api/rules.d.ts +33 -0
- package/dist/api/rules.js +115 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +86 -0
- package/dist/cli/init-rule.d.ts +7 -0
- package/dist/cli/init-rule.js +74 -0
- package/dist/cli/install-skill.d.ts +10 -0
- package/dist/cli/install-skill.js +37 -0
- package/dist/formatters/json.d.ts +2 -0
- package/dist/formatters/json.js +30 -0
- package/dist/formatters/pretty.d.ts +2 -0
- package/dist/formatters/pretty.js +41 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/parsers/asciidoctor.d.ts +4 -0
- package/dist/parsers/asciidoctor.js +444 -0
- package/dist/parsers/tolerant.d.ts +4 -0
- package/dist/parsers/tolerant.js +528 -0
- package/dist/rules/AD001.d.ts +2 -0
- package/dist/rules/AD001.js +28 -0
- package/dist/rules/AD002.d.ts +2 -0
- package/dist/rules/AD002.js +30 -0
- package/dist/rules/AD003.d.ts +2 -0
- package/dist/rules/AD003.js +28 -0
- package/dist/rules/AD004.d.ts +2 -0
- package/dist/rules/AD004.js +58 -0
- package/dist/rules/AD005.d.ts +2 -0
- package/dist/rules/AD005.js +31 -0
- package/dist/rules/AD006.d.ts +2 -0
- package/dist/rules/AD006.js +53 -0
- package/dist/rules/AD007.d.ts +2 -0
- package/dist/rules/AD007.js +39 -0
- package/dist/rules/AD008.d.ts +2 -0
- package/dist/rules/AD008.js +88 -0
- package/dist/rules/AD010.d.ts +2 -0
- package/dist/rules/AD010.js +39 -0
- package/dist/rules/AD011.d.ts +2 -0
- package/dist/rules/AD011.js +31 -0
- package/dist/rules/AD012.d.ts +2 -0
- package/dist/rules/AD012.js +28 -0
- package/dist/rules/AD013.d.ts +2 -0
- package/dist/rules/AD013.js +43 -0
- package/dist/rules/AD016.d.ts +2 -0
- package/dist/rules/AD016.js +83 -0
- package/dist/rules/AD017.d.ts +2 -0
- package/dist/rules/AD017.js +53 -0
- package/dist/rules/AD019.d.ts +2 -0
- package/dist/rules/AD019.js +58 -0
- package/dist/rules/AD020.d.ts +2 -0
- package/dist/rules/AD020.js +40 -0
- package/dist/rules/AD022.d.ts +2 -0
- package/dist/rules/AD022.js +55 -0
- package/dist/rules/AD023.d.ts +2 -0
- package/dist/rules/AD023.js +59 -0
- package/dist/rules/AD024.d.ts +2 -0
- package/dist/rules/AD024.js +30 -0
- package/dist/rules/AD025.d.ts +2 -0
- package/dist/rules/AD025.js +32 -0
- package/dist/rules/AD026.d.ts +2 -0
- package/dist/rules/AD026.js +26 -0
- package/dist/rules/AD027.d.ts +2 -0
- package/dist/rules/AD027.js +31 -0
- package/dist/rules/AD028.d.ts +2 -0
- package/dist/rules/AD028.js +113 -0
- package/dist/rules/AD029.d.ts +2 -0
- package/dist/rules/AD029.js +46 -0
- package/dist/rules/AD030.d.ts +2 -0
- package/dist/rules/AD030.js +33 -0
- package/dist/rules/AD031.d.ts +2 -0
- package/dist/rules/AD031.js +66 -0
- package/dist/rules/AD032.d.ts +2 -0
- package/dist/rules/AD032.js +81 -0
- package/dist/rules/AD034.d.ts +2 -0
- package/dist/rules/AD034.js +50 -0
- package/dist/rules/AD035.d.ts +2 -0
- package/dist/rules/AD035.js +77 -0
- package/dist/rules/AD036.d.ts +2 -0
- package/dist/rules/AD036.js +34 -0
- package/dist/rules/AD037.d.ts +2 -0
- package/dist/rules/AD037.js +34 -0
- package/dist/rules/AD039.d.ts +2 -0
- package/dist/rules/AD039.js +58 -0
- package/dist/rules/AD040.d.ts +2 -0
- package/dist/rules/AD040.js +56 -0
- package/dist/rules/AD041.d.ts +2 -0
- package/dist/rules/AD041.js +66 -0
- package/dist/rules/AD042.d.ts +2 -0
- package/dist/rules/AD042.js +62 -0
- package/dist/rules/AD043.d.ts +2 -0
- package/dist/rules/AD043.js +30 -0
- package/dist/rules/AD044.d.ts +2 -0
- package/dist/rules/AD044.js +54 -0
- package/dist/rules/AD045.d.ts +2 -0
- package/dist/rules/AD045.js +66 -0
- package/dist/rules/builtin.d.ts +3 -0
- package/dist/rules/builtin.js +81 -0
- package/dist/rules/helpers.d.ts +2 -0
- package/dist/rules/helpers.js +11 -0
- package/dist/rules/registry.d.ts +3 -0
- package/dist/rules/registry.js +34 -0
- package/dist/rules/utils.d.ts +42 -0
- package/dist/rules/utils.js +274 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +70 -0
- package/skills/asciidoclint/SKILL.md +84 -0
- package/skills/asciidoclint/references/ai-fix-policy.md +11 -0
- package/skills/asciidoclint/references/result-schema.md +22 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { isAsciiDocAnchorLine, isAsciiDocAttributeEntryLine, isAsciiDocBlockAttributeLine, isBlockDelimiter, isLineComment, isLineInProtectedBlock } from "./utils.js";
|
|
3
|
+
export const AD019 = {
|
|
4
|
+
id: "AD019",
|
|
5
|
+
alias: "content-after-include",
|
|
6
|
+
description: "Text should not be attached directly after include directives",
|
|
7
|
+
tags: ["core", "structure", "includes"],
|
|
8
|
+
parser: "text",
|
|
9
|
+
docs: {
|
|
10
|
+
summary: "A paragraph immediately after an include directive can accidentally attach to included content.",
|
|
11
|
+
rationale: "A section boundary or blank-separated block after an include keeps merged documents predictable.",
|
|
12
|
+
badExamples: [{ code: "include::chapter.adoc[]\nThis paragraph attaches unexpectedly." }],
|
|
13
|
+
goodExamples: [{ code: "include::chapter.adoc[]\n\n== Next Section\n\nThis paragraph is separate." }],
|
|
14
|
+
fixability: "no",
|
|
15
|
+
fixHelper: "Insert a blank line after the include directive or ensure the included file ends with a blank line.",
|
|
16
|
+
},
|
|
17
|
+
function: ({ document }, onError) => {
|
|
18
|
+
for (const file of document.files) {
|
|
19
|
+
for (const [index, line] of file.lines.entries()) {
|
|
20
|
+
if (!/^include::[^\[]+\[[^\]]*]\s*$/.test(line.trim()) || isLineInProtectedBlock(document, file.file, index + 1)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const include = document.includes.find((record) => record.range.start.file === file.file && record.range.start.line === index + 1);
|
|
24
|
+
if (include && (include.attributes.tag !== undefined || include.attributes.tags !== undefined || include.attributes.lines !== undefined)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (include?.resolvedTarget && includedFileEndsWithBlankLine(include.resolvedTarget)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = file.lines[index + 1] ?? "";
|
|
31
|
+
if (next.trim() === ""
|
|
32
|
+
|| /^include::[^\[]+\[[^\]]*]\s*$/.test(next.trim())
|
|
33
|
+
|| isBlockDelimiter(next.trim())
|
|
34
|
+
|| isLineComment(next)
|
|
35
|
+
|| isAsciiDocAnchorLine(next)
|
|
36
|
+
|| isAsciiDocAttributeEntryLine(next)
|
|
37
|
+
|| isAsciiDocBlockAttributeLine(next)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
onError({
|
|
41
|
+
severity: "warning",
|
|
42
|
+
message: "Content immediately after include should start a clear block or section",
|
|
43
|
+
range: { start: { file: file.file, line: index + 2, column: 1 } },
|
|
44
|
+
fixHelper: "Insert a blank line after the include directive or ensure the included file ends with a blank line.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
function includedFileEndsWithBlankLine(file) {
|
|
51
|
+
try {
|
|
52
|
+
const content = fs.readFileSync(file, "utf8");
|
|
53
|
+
return /\r?\n\s*\r?\n\s*$/.test(content);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const AD020 = {
|
|
2
|
+
id: "AD020",
|
|
3
|
+
alias: "appendix-section-level",
|
|
4
|
+
description: "Appendices should be section-level blocks in article documents",
|
|
5
|
+
tags: ["core", "structure"],
|
|
6
|
+
parser: "document",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "An appendix in an article document should use a section heading, not a second document title.",
|
|
9
|
+
rationale: "Asciidoctor documents article appendices as level-1 sections. A [appendix] block followed by = in an article triggers Asciidoctor's level-0 section error.",
|
|
10
|
+
badExamples: [{ code: "[appendix]\n= API Reference" }],
|
|
11
|
+
goodExamples: [
|
|
12
|
+
{ code: "[appendix]\n== API Reference\n\n=== Child" },
|
|
13
|
+
{ code: ":doctype: book\n\n= Book\n\n[appendix]\n= API Reference\n\n=== Child" },
|
|
14
|
+
],
|
|
15
|
+
fixability: "no",
|
|
16
|
+
fixHelper: "In article documents, change the appendix heading to ==. Keep = only for a book appendix that is intentionally part-like.",
|
|
17
|
+
},
|
|
18
|
+
function: ({ document }, onError) => {
|
|
19
|
+
if (document.attributes.doctype === "book") {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const file of document.files) {
|
|
23
|
+
for (const [index, line] of file.lines.entries()) {
|
|
24
|
+
if (line.trim() !== "[appendix]") {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const title = file.lines[index + 1] ?? "";
|
|
28
|
+
if (!/^=\s+\S/.test(title)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
onError({
|
|
32
|
+
severity: "warning",
|
|
33
|
+
message: "Appendix should use a section-level heading",
|
|
34
|
+
range: { start: { file: file.file, line: index + 2, column: 1 } },
|
|
35
|
+
fixHelper: "Use == for an article appendix heading, with === or deeper headings for child sections. Keep = only for a book appendix that is intentionally part-like.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const AD022 = {
|
|
2
|
+
id: "AD022",
|
|
3
|
+
alias: "circular-include",
|
|
4
|
+
description: "Include trees must not contain cycles",
|
|
5
|
+
tags: ["core", "includes", "structure"],
|
|
6
|
+
parser: "project",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "Include trees should not be circular.",
|
|
9
|
+
rationale: "Asciidoctor stops circular include expansion only after the include-depth limit is exceeded; reporting the actual cycle gives authors a direct repair target.",
|
|
10
|
+
fixability: "no",
|
|
11
|
+
fixHelper: "Remove one include from the reported cycle, or replace one edge with an xref or a smaller shared partial that does not include its parent.",
|
|
12
|
+
badExamples: [{ code: "include::a.adoc[]\n// a.adoc includes this file again" }],
|
|
13
|
+
goodExamples: [{ code: "include::chapter.adoc[]\ninclude::appendix.adoc[]" }],
|
|
14
|
+
},
|
|
15
|
+
function: ({ file, document }, onError) => {
|
|
16
|
+
const reportedCycles = new Set();
|
|
17
|
+
const explored = new Set();
|
|
18
|
+
const graph = new Map();
|
|
19
|
+
for (const include of document.includes) {
|
|
20
|
+
if (include.status !== "resolved" || !include.resolvedTarget) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const source = include.range.start.file;
|
|
24
|
+
const targets = graph.get(source) ?? [];
|
|
25
|
+
targets.push({ target: include.resolvedTarget, range: include.range });
|
|
26
|
+
graph.set(source, targets);
|
|
27
|
+
}
|
|
28
|
+
const root = document.file || file;
|
|
29
|
+
visit(root, [root]);
|
|
30
|
+
function visit(current, stack) {
|
|
31
|
+
if (explored.has(current)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const edge of graph.get(current) ?? []) {
|
|
35
|
+
const cycleStart = stack.indexOf(edge.target);
|
|
36
|
+
if (cycleStart !== -1) {
|
|
37
|
+
const cycle = [...stack.slice(cycleStart), edge.target].join(" -> ");
|
|
38
|
+
if (!reportedCycles.has(cycle)) {
|
|
39
|
+
reportedCycles.add(cycle);
|
|
40
|
+
onError({
|
|
41
|
+
severity: "error",
|
|
42
|
+
message: "Circular include detected",
|
|
43
|
+
detail: cycle,
|
|
44
|
+
range: edge.range,
|
|
45
|
+
fixHelper: "Remove one include from the reported cycle, or replace one edge with an xref or a shared partial.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
visit(edge.target, [...stack, edge.target]);
|
|
51
|
+
}
|
|
52
|
+
explored.add(current);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { parseAsciiDocAnchor } from "./utils.js";
|
|
2
|
+
export const AD023 = {
|
|
3
|
+
id: "AD023",
|
|
4
|
+
alias: "empty-section",
|
|
5
|
+
description: "Sections should contain body content or child sections",
|
|
6
|
+
tags: ["core", "structure", "references"],
|
|
7
|
+
parser: "document",
|
|
8
|
+
docs: {
|
|
9
|
+
summary: "Sections should not be empty.",
|
|
10
|
+
rationale: "Asciidoctor accepts empty sections and creates section IDs for them, but visible empty outline nodes are usually conversion residue or unfinished structure.",
|
|
11
|
+
fixability: "no",
|
|
12
|
+
fixHelper: "Add body content, add a child section that belongs under this heading, or remove the empty section if it is only conversion/bookmark residue.",
|
|
13
|
+
badExamples: [{ code: "== Empty\n\n== Next" }],
|
|
14
|
+
goodExamples: [
|
|
15
|
+
{ code: "== Overview\n\nContent." },
|
|
16
|
+
{ code: "== Container\n\n=== Child\n\nContent." },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
function: ({ document }, onError) => {
|
|
20
|
+
for (const section of document.sections) {
|
|
21
|
+
if (section.level === 0 || section.children.length > 0 || hasSectionBodyContent(document, section)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
onError({
|
|
25
|
+
severity: "info",
|
|
26
|
+
message: "Section has no body content or child sections",
|
|
27
|
+
range: section.titleRange,
|
|
28
|
+
fixHelper: "Add body content, add a child section, or remove the empty section if it is only conversion/bookmark residue.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
function hasSectionBodyContent(document, section) {
|
|
34
|
+
const file = document.files.find((candidate) => candidate.file === section.titleRange.start.file);
|
|
35
|
+
if (!file) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const startLine = section.titleRange.start.line + 1;
|
|
39
|
+
const endLine = nextSiblingOrAncestorLine(document, section) ?? file.lines.length + 1;
|
|
40
|
+
for (let lineNumber = startLine; lineNumber < endLine; lineNumber += 1) {
|
|
41
|
+
const line = file.lines[lineNumber - 1] ?? "";
|
|
42
|
+
if (isBodyContentLine(line)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function nextSiblingOrAncestorLine(document, section) {
|
|
49
|
+
return document.sections
|
|
50
|
+
.filter((candidate) => candidate.titleRange.start.file === section.titleRange.start.file)
|
|
51
|
+
.filter((candidate) => candidate.titleRange.start.line > section.titleRange.start.line)
|
|
52
|
+
.filter((candidate) => candidate.level <= section.level)
|
|
53
|
+
.sort((left, right) => left.titleRange.start.line - right.titleRange.start.line)[0]
|
|
54
|
+
?.titleRange.start.line;
|
|
55
|
+
}
|
|
56
|
+
function isBodyContentLine(line) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
return trimmed !== "" && !trimmed.startsWith("//") && !parseAsciiDocAnchor(line);
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const AD024 = {
|
|
2
|
+
id: "AD024",
|
|
3
|
+
alias: "missing-include",
|
|
4
|
+
description: "Include targets should exist after attribute substitution",
|
|
5
|
+
tags: ["dependencies", "include"],
|
|
6
|
+
parser: "dependency",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "include:: targets should resolve to an existing file.",
|
|
9
|
+
rationale: "Asciidoctor reports missing required includes as parser diagnostics and renders an unresolved directive. AD024 reports the same dependency failure through the normalized dependency graph so it is configurable and available even when users focus on dependency rules.",
|
|
10
|
+
fixability: "no",
|
|
11
|
+
fixHelper: "Create the included file, fix the include target or attribute value, or mark the include optional when it is intentionally absent.",
|
|
12
|
+
badExamples: [{ code: "include::{chapter-file}[]" }],
|
|
13
|
+
goodExamples: [
|
|
14
|
+
{ code: ":chapter-file: chapter.adoc\ninclude::{chapter-file}[]" },
|
|
15
|
+
{ code: "include::optional-chapter.adoc[opts=optional]" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
function: ({ dependencies }, onError) => {
|
|
19
|
+
for (const record of dependencies.records) {
|
|
20
|
+
if (record.type === "include" && record.status === "missing") {
|
|
21
|
+
onError({
|
|
22
|
+
severity: "error",
|
|
23
|
+
message: `Missing include target: ${record.target}`,
|
|
24
|
+
range: record.range,
|
|
25
|
+
fixHelper: "Create the included file, fix the include path or attribute value, or add opts=optional if the missing include is intentional.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const AD025 = {
|
|
2
|
+
id: "AD025",
|
|
3
|
+
alias: "missing-image",
|
|
4
|
+
description: "Image targets should exist after attribute substitution",
|
|
5
|
+
tags: ["dependencies", "image"],
|
|
6
|
+
parser: "dependency",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "image:: targets should resolve to an existing file.",
|
|
9
|
+
rationale: "Asciidoctor can emit HTML that points at a missing image path, but publishable output is incomplete and PDF/data-uri style conversions need the asset at conversion time.",
|
|
10
|
+
fixability: "no",
|
|
11
|
+
fixHelper: "Create the image file, fix the image target or imagesdir value, or use a URL target when the image is intentionally remote.",
|
|
12
|
+
badExamples: [{ code: "image::missing.png[]" }],
|
|
13
|
+
goodExamples: [
|
|
14
|
+
{ code: "image::diagram.png[Architecture diagram]" },
|
|
15
|
+
{ code: ":imagesdir: images\nimage::diagram.png[Architecture diagram]" },
|
|
16
|
+
{ code: "See image:icon.svg[Status icon]." },
|
|
17
|
+
{ code: "image::https://example.com/diagram.png[Remote diagram]" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
function: ({ dependencies }, onError) => {
|
|
21
|
+
for (const record of dependencies.records) {
|
|
22
|
+
if (record.type === "image" && record.status === "missing") {
|
|
23
|
+
onError({
|
|
24
|
+
severity: "error",
|
|
25
|
+
message: `Missing image target: ${record.target}`,
|
|
26
|
+
range: record.range,
|
|
27
|
+
fixHelper: "Create the image file, fix the image target or imagesdir value, or use a URL target when the image is intentionally remote.",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const AD026 = {
|
|
2
|
+
id: "AD026",
|
|
3
|
+
alias: "missing-xref",
|
|
4
|
+
description: "Cross-reference targets should resolve to an anchor or file",
|
|
5
|
+
tags: ["dependencies", "xref"],
|
|
6
|
+
parser: "dependency",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "xref and shorthand cross references should resolve to a known local anchor or file.",
|
|
9
|
+
badExamples: [{ code: "xref:missing-section[]\n\nSee <<missing-section>>." }],
|
|
10
|
+
goodExamples: [{ code: "[[overview]]\n== Overview\n\nxref:overview[]\n\nSee <<Overview>>." }],
|
|
11
|
+
fixability: "no",
|
|
12
|
+
fixHelper: "Create the referenced anchor/file or fix the xref target.",
|
|
13
|
+
},
|
|
14
|
+
function: ({ dependencies }, onError) => {
|
|
15
|
+
for (const record of dependencies.records) {
|
|
16
|
+
if (record.type === "xref" && record.status === "missing") {
|
|
17
|
+
onError({
|
|
18
|
+
severity: "error",
|
|
19
|
+
message: `Missing xref target: ${record.target}`,
|
|
20
|
+
range: record.range,
|
|
21
|
+
fixHelper: "Create the referenced anchor/file or fix the xref target.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const AD027 = {
|
|
2
|
+
id: "AD027",
|
|
3
|
+
alias: "missing-local-link",
|
|
4
|
+
description: "Local link targets should resolve to existing files",
|
|
5
|
+
tags: ["dependencies", "links"],
|
|
6
|
+
parser: "dependency",
|
|
7
|
+
docs: {
|
|
8
|
+
summary: "Local link: targets should resolve to existing files.",
|
|
9
|
+
rationale: "Asciidoctor renders relative link: targets without checking the target file. A missing local target becomes a broken link in the published document.",
|
|
10
|
+
fixability: "no",
|
|
11
|
+
fixHelper: "Create the linked file, fix the local link target, or change the target to an xref or URL when that is the intended link type.",
|
|
12
|
+
badExamples: [{ code: "link:missing.pdf[Download]" }],
|
|
13
|
+
goodExamples: [
|
|
14
|
+
{ code: "link:datasheet.pdf[Download]" },
|
|
15
|
+
{ code: "link:tools.html#editors[Editors]" },
|
|
16
|
+
{ code: "https://example.com/downloads/report.pdf[Download]" },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
function: ({ dependencies }, onError) => {
|
|
20
|
+
for (const record of dependencies.records) {
|
|
21
|
+
if (record.type === "attachment" && record.status === "missing") {
|
|
22
|
+
onError({
|
|
23
|
+
severity: "error",
|
|
24
|
+
message: `Missing local link target: ${record.target}`,
|
|
25
|
+
range: record.range,
|
|
26
|
+
fixHelper: "Create the linked file, fix the local link target, or use xref:/URL syntax if this is not a local file link.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isLineInProtectedBlock } from "./utils.js";
|
|
3
|
+
const inlineImagePattern = /(^|[^\w:])image:(?!:)([^\s\[]+)\[((?:\\]|[^\]])*)]/g;
|
|
4
|
+
export const AD028 = {
|
|
5
|
+
id: "AD028",
|
|
6
|
+
alias: "image-alt-text",
|
|
7
|
+
description: "Images should not explicitly set empty alt text",
|
|
8
|
+
tags: ["accessibility", "image"],
|
|
9
|
+
parser: "document",
|
|
10
|
+
docs: {
|
|
11
|
+
summary: "Image macros should not explicitly set empty alt text.",
|
|
12
|
+
rationale: "Asciidoctor derives fallback alt text from the image target when the attribute list is empty. An explicit empty alt attribute renders an empty alt value, which weakens accessible output and fallback text.",
|
|
13
|
+
fixability: "no",
|
|
14
|
+
fixHelper: "Replace the empty alt attribute with concise text that identifies the image purpose, or remove the explicit empty alt when the derived target name is acceptable.",
|
|
15
|
+
badExamples: [{ code: "image::diagram.png[alt=\"\"]\n\nClick image:play.png[\"\"] to start." }],
|
|
16
|
+
goodExamples: [{ code: "image::diagram.png[Architecture diagram]\n\nClick image:play.png[Play] to start." }],
|
|
17
|
+
},
|
|
18
|
+
function: ({ document }, onError) => {
|
|
19
|
+
for (const block of document.blocks.filter((candidate) => candidate.type === "image")) {
|
|
20
|
+
if (typeof block.attributes.alt !== "string" || block.attributes.alt.trim() === "") {
|
|
21
|
+
const target = String(block.attributes.target ?? "image");
|
|
22
|
+
onError({
|
|
23
|
+
severity: "warning",
|
|
24
|
+
message: `Image is missing alt text: ${path.basename(target)}`,
|
|
25
|
+
range: block.range,
|
|
26
|
+
fixHelper: "Add meaningful text as the first image macro attribute, such as image::target.png[Architecture diagram].",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const file of document.files) {
|
|
31
|
+
for (const [index, line] of file.lines.entries()) {
|
|
32
|
+
if (isLineInProtectedBlock(document, file.file, index + 1)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const match of line.matchAll(inlineImagePattern)) {
|
|
36
|
+
const attributes = match[3] ?? "";
|
|
37
|
+
if (!hasExplicitEmptyAlt(attributes)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const target = match[2] ?? "image";
|
|
41
|
+
const prefixLength = match[1]?.length ?? 0;
|
|
42
|
+
onError({
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message: `Image is missing alt text: ${path.basename(target)}`,
|
|
45
|
+
range: {
|
|
46
|
+
start: {
|
|
47
|
+
file: file.file,
|
|
48
|
+
line: index + 1,
|
|
49
|
+
column: (match.index ?? 0) + prefixLength + 1,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
fixHelper: "Add meaningful text as the first inline image macro attribute, such as image:target.png[Status icon].",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
function hasExplicitEmptyAlt(attributes) {
|
|
60
|
+
for (const [index, entry] of splitAttributeEntries(attributes).entries()) {
|
|
61
|
+
const named = entry.match(/^([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.*)$/);
|
|
62
|
+
if (named) {
|
|
63
|
+
const name = named[1] ?? "";
|
|
64
|
+
const value = named[2] ?? "";
|
|
65
|
+
if (name.toLowerCase() === "alt" && unquote(value).trim() === "") {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (index === 0 && /^(['"])(?:\s*)\1$/.test(entry.trim())) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function splitAttributeEntries(attributes) {
|
|
77
|
+
const entries = [];
|
|
78
|
+
let current = "";
|
|
79
|
+
let quote;
|
|
80
|
+
for (let index = 0; index < attributes.length; index += 1) {
|
|
81
|
+
const character = attributes[index] ?? "";
|
|
82
|
+
if (character === "\\" && index + 1 < attributes.length) {
|
|
83
|
+
current += character + (attributes[index + 1] ?? "");
|
|
84
|
+
index += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if ((character === "\"" || character === "'") && quote === undefined) {
|
|
88
|
+
quote = character;
|
|
89
|
+
current += character;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (character === quote) {
|
|
93
|
+
quote = undefined;
|
|
94
|
+
current += character;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (character === "," && quote === undefined) {
|
|
98
|
+
entries.push(current.trim());
|
|
99
|
+
current = "";
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
current += character;
|
|
103
|
+
}
|
|
104
|
+
entries.push(current.trim());
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
function unquote(value) {
|
|
108
|
+
const trimmed = value.trim();
|
|
109
|
+
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
110
|
+
return trimmed.slice(1, -1);
|
|
111
|
+
}
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isLineInProtectedBlock, markdownResidueIssue } from "./utils.js";
|
|
2
|
+
export const AD029 = {
|
|
3
|
+
id: "AD029",
|
|
4
|
+
alias: "markdown-link-image-residue",
|
|
5
|
+
description: "Markdown link and image residue should not render as text",
|
|
6
|
+
tags: ["conversion", "link", "image"],
|
|
7
|
+
parser: "text",
|
|
8
|
+
docs: {
|
|
9
|
+
summary: "Flag Markdown link, image, and reversed-link patterns that Asciidoctor renders as paragraph text instead of links or images.",
|
|
10
|
+
rationale: "Asciidoctor accepts some Markdown-compatible syntax, including Markdown-style headings and fenced code blocks. This rule only flags known conversion residue that does not become the intended AsciiDoc structure.",
|
|
11
|
+
fixability: "unsafe",
|
|
12
|
+
fixHelper: "Rewrite the residue using the intended AsciiDoc macro: link:target[text], xref:target[text], or image::target[Alt text].",
|
|
13
|
+
badExamples: [{ code: "See [Architecture](architecture.adoc).\n\n\n\n(https://example.com)[Example]" }],
|
|
14
|
+
goodExamples: [{ code: "See xref:architecture.adoc[Architecture].\n\nimage::image.png[Alt]\n\nlink:https://example.com[Example]" }],
|
|
15
|
+
},
|
|
16
|
+
function: ({ document }, onError) => {
|
|
17
|
+
for (const file of document.files) {
|
|
18
|
+
for (const [index, line] of file.lines.entries()) {
|
|
19
|
+
if (isLineInProtectedBlock(document, file.file, index + 1)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const issue = markdownResidueIssue(line);
|
|
23
|
+
if (!issue) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
onError({
|
|
27
|
+
severity: issue.severity,
|
|
28
|
+
message: issue.message,
|
|
29
|
+
range: { start: { file: file.file, line: index + 1, column: issue.column } },
|
|
30
|
+
fixHelper: issue.fixHelper,
|
|
31
|
+
fix: issue.replacement ? {
|
|
32
|
+
applicability: "unsafe",
|
|
33
|
+
edits: [{
|
|
34
|
+
file: file.file,
|
|
35
|
+
range: {
|
|
36
|
+
start: { file: file.file, line: index + 1, column: issue.column },
|
|
37
|
+
end: { file: file.file, line: index + 1, column: issue.endColumn },
|
|
38
|
+
},
|
|
39
|
+
replacement: issue.replacement,
|
|
40
|
+
}],
|
|
41
|
+
} : undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isLineInProtectedBlock, isLineInTableBlock } from "./utils.js";
|
|
2
|
+
export const AD030 = {
|
|
3
|
+
id: "AD030",
|
|
4
|
+
alias: "markdown-table-residue",
|
|
5
|
+
description: "Markdown pipe table residue should not render as text",
|
|
6
|
+
tags: ["conversion", "table"],
|
|
7
|
+
parser: "text",
|
|
8
|
+
docs: {
|
|
9
|
+
summary: "Flag Markdown pipe table separator lines that Asciidoctor renders as paragraph text.",
|
|
10
|
+
rationale: "Asciidoctor documents Markdown-compatible headings, fenced code blocks, blockquotes, and thematic breaks, but not Markdown pipe tables. Converted Markdown tables often appear table-like in source while rendering as plain paragraphs.",
|
|
11
|
+
fixability: "no",
|
|
12
|
+
fixHelper: "Rewrite the Markdown pipe table as an AsciiDoc table delimited by |=== and verify column structure manually.",
|
|
13
|
+
badExamples: [{ code: "| A | B |\n|---|---|" }],
|
|
14
|
+
goodExamples: [{ code: "|===\n| A | B\n|===" }],
|
|
15
|
+
},
|
|
16
|
+
function: ({ document }, onError) => {
|
|
17
|
+
for (const file of document.files) {
|
|
18
|
+
for (const [index, line] of file.lines.entries()) {
|
|
19
|
+
if (!/^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
|
|
20
|
+
|| isLineInProtectedBlock(document, file.file, index + 1)
|
|
21
|
+
|| isLineInTableBlock(document, file.file, index + 1)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
onError({
|
|
25
|
+
severity: "warning",
|
|
26
|
+
message: "Markdown pipe table separator renders as text in AsciiDoc",
|
|
27
|
+
range: { start: { file: file.file, line: index + 1, column: 1 } },
|
|
28
|
+
fixHelper: "Rewrite this as an AsciiDoc table delimited by |===.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isLineInProtectedBlock } from "./utils.js";
|
|
2
|
+
const outerLinkPattern = /(?:link|xref):[^\s\[]+\[|https?:\/\/[^\s\[]+\[/g;
|
|
3
|
+
const nestedLinkPattern = /(?:link|xref):[^\s\[]+\[|<<[^>]+>>/;
|
|
4
|
+
export const AD031 = {
|
|
5
|
+
id: "AD031",
|
|
6
|
+
alias: "no-nested-link-text",
|
|
7
|
+
description: "Link text should not contain nested links or cross references",
|
|
8
|
+
tags: ["links", "conversion"],
|
|
9
|
+
parser: "text",
|
|
10
|
+
docs: {
|
|
11
|
+
summary: "Visible link text should not contain another AsciiDoc link, URL macro, or shorthand cross reference.",
|
|
12
|
+
rationale: "Nested link markup can render invalid nested anchors, malformed labels, or escaped reference text. This is often conversion residue from HTML or Markdown sources.",
|
|
13
|
+
fixability: "no",
|
|
14
|
+
fixHelper: "Replace the nested link markup with plain visible text, or split the sentence into separate links.",
|
|
15
|
+
badExamples: [{ code: "link:https://example.com[link:https://nested.example[Nested]]\n\nxref:target.adoc[<<target,Target>>]" }],
|
|
16
|
+
goodExamples: [{ code: "link:https://example.com[Example]" }],
|
|
17
|
+
},
|
|
18
|
+
function: ({ document }, onError) => {
|
|
19
|
+
for (const file of document.files) {
|
|
20
|
+
for (const [index, line] of file.lines.entries()) {
|
|
21
|
+
if (isLineInProtectedBlock(document, file.file, index + 1)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (const match of line.matchAll(outerLinkPattern)) {
|
|
25
|
+
const attrStart = (match.index ?? 0) + match[0].length;
|
|
26
|
+
const attrText = linkAttributeText(line, attrStart);
|
|
27
|
+
if (!attrText || !nestedLinkPattern.test(attrText)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
onError({
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: "Link text should not contain nested links or cross references",
|
|
33
|
+
range: { start: { file: file.file, line: index + 1, column: (match.index ?? 0) + 1 } },
|
|
34
|
+
fixHelper: "Replace nested link markup with plain visible text, or split this into separate links.",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
function linkAttributeText(line, start) {
|
|
42
|
+
let depth = 1;
|
|
43
|
+
let escaped = false;
|
|
44
|
+
for (let index = start; index < line.length; index += 1) {
|
|
45
|
+
const character = line[index] ?? "";
|
|
46
|
+
if (escaped) {
|
|
47
|
+
escaped = false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (character === "\\") {
|
|
51
|
+
escaped = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === "[") {
|
|
55
|
+
depth += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (character === "]") {
|
|
59
|
+
depth -= 1;
|
|
60
|
+
if (depth === 0) {
|
|
61
|
+
return line.slice(start, index);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|