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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/assets/README.md +12 -0
  4. package/assets/icon.svg +198 -0
  5. package/assets/logo.svg +203 -0
  6. package/dist/api/fixes.d.ts +6 -0
  7. package/dist/api/fixes.js +61 -0
  8. package/dist/api/lint.d.ts +2 -0
  9. package/dist/api/lint.js +191 -0
  10. package/dist/api/rules.d.ts +33 -0
  11. package/dist/api/rules.js +115 -0
  12. package/dist/cli/index.d.ts +2 -0
  13. package/dist/cli/index.js +86 -0
  14. package/dist/cli/init-rule.d.ts +7 -0
  15. package/dist/cli/init-rule.js +74 -0
  16. package/dist/cli/install-skill.d.ts +10 -0
  17. package/dist/cli/install-skill.js +37 -0
  18. package/dist/formatters/json.d.ts +2 -0
  19. package/dist/formatters/json.js +30 -0
  20. package/dist/formatters/pretty.d.ts +2 -0
  21. package/dist/formatters/pretty.js +41 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.js +3 -0
  24. package/dist/parsers/asciidoctor.d.ts +4 -0
  25. package/dist/parsers/asciidoctor.js +444 -0
  26. package/dist/parsers/tolerant.d.ts +4 -0
  27. package/dist/parsers/tolerant.js +528 -0
  28. package/dist/rules/AD001.d.ts +2 -0
  29. package/dist/rules/AD001.js +28 -0
  30. package/dist/rules/AD002.d.ts +2 -0
  31. package/dist/rules/AD002.js +30 -0
  32. package/dist/rules/AD003.d.ts +2 -0
  33. package/dist/rules/AD003.js +28 -0
  34. package/dist/rules/AD004.d.ts +2 -0
  35. package/dist/rules/AD004.js +58 -0
  36. package/dist/rules/AD005.d.ts +2 -0
  37. package/dist/rules/AD005.js +31 -0
  38. package/dist/rules/AD006.d.ts +2 -0
  39. package/dist/rules/AD006.js +53 -0
  40. package/dist/rules/AD007.d.ts +2 -0
  41. package/dist/rules/AD007.js +39 -0
  42. package/dist/rules/AD008.d.ts +2 -0
  43. package/dist/rules/AD008.js +88 -0
  44. package/dist/rules/AD010.d.ts +2 -0
  45. package/dist/rules/AD010.js +39 -0
  46. package/dist/rules/AD011.d.ts +2 -0
  47. package/dist/rules/AD011.js +31 -0
  48. package/dist/rules/AD012.d.ts +2 -0
  49. package/dist/rules/AD012.js +28 -0
  50. package/dist/rules/AD013.d.ts +2 -0
  51. package/dist/rules/AD013.js +43 -0
  52. package/dist/rules/AD016.d.ts +2 -0
  53. package/dist/rules/AD016.js +83 -0
  54. package/dist/rules/AD017.d.ts +2 -0
  55. package/dist/rules/AD017.js +53 -0
  56. package/dist/rules/AD019.d.ts +2 -0
  57. package/dist/rules/AD019.js +58 -0
  58. package/dist/rules/AD020.d.ts +2 -0
  59. package/dist/rules/AD020.js +40 -0
  60. package/dist/rules/AD022.d.ts +2 -0
  61. package/dist/rules/AD022.js +55 -0
  62. package/dist/rules/AD023.d.ts +2 -0
  63. package/dist/rules/AD023.js +59 -0
  64. package/dist/rules/AD024.d.ts +2 -0
  65. package/dist/rules/AD024.js +30 -0
  66. package/dist/rules/AD025.d.ts +2 -0
  67. package/dist/rules/AD025.js +32 -0
  68. package/dist/rules/AD026.d.ts +2 -0
  69. package/dist/rules/AD026.js +26 -0
  70. package/dist/rules/AD027.d.ts +2 -0
  71. package/dist/rules/AD027.js +31 -0
  72. package/dist/rules/AD028.d.ts +2 -0
  73. package/dist/rules/AD028.js +113 -0
  74. package/dist/rules/AD029.d.ts +2 -0
  75. package/dist/rules/AD029.js +46 -0
  76. package/dist/rules/AD030.d.ts +2 -0
  77. package/dist/rules/AD030.js +33 -0
  78. package/dist/rules/AD031.d.ts +2 -0
  79. package/dist/rules/AD031.js +66 -0
  80. package/dist/rules/AD032.d.ts +2 -0
  81. package/dist/rules/AD032.js +81 -0
  82. package/dist/rules/AD034.d.ts +2 -0
  83. package/dist/rules/AD034.js +50 -0
  84. package/dist/rules/AD035.d.ts +2 -0
  85. package/dist/rules/AD035.js +77 -0
  86. package/dist/rules/AD036.d.ts +2 -0
  87. package/dist/rules/AD036.js +34 -0
  88. package/dist/rules/AD037.d.ts +2 -0
  89. package/dist/rules/AD037.js +34 -0
  90. package/dist/rules/AD039.d.ts +2 -0
  91. package/dist/rules/AD039.js +58 -0
  92. package/dist/rules/AD040.d.ts +2 -0
  93. package/dist/rules/AD040.js +56 -0
  94. package/dist/rules/AD041.d.ts +2 -0
  95. package/dist/rules/AD041.js +66 -0
  96. package/dist/rules/AD042.d.ts +2 -0
  97. package/dist/rules/AD042.js +62 -0
  98. package/dist/rules/AD043.d.ts +2 -0
  99. package/dist/rules/AD043.js +30 -0
  100. package/dist/rules/AD044.d.ts +2 -0
  101. package/dist/rules/AD044.js +54 -0
  102. package/dist/rules/AD045.d.ts +2 -0
  103. package/dist/rules/AD045.js +66 -0
  104. package/dist/rules/builtin.d.ts +3 -0
  105. package/dist/rules/builtin.js +81 -0
  106. package/dist/rules/helpers.d.ts +2 -0
  107. package/dist/rules/helpers.js +11 -0
  108. package/dist/rules/registry.d.ts +3 -0
  109. package/dist/rules/registry.js +34 -0
  110. package/dist/rules/utils.d.ts +42 -0
  111. package/dist/rules/utils.js +274 -0
  112. package/dist/types.d.ts +166 -0
  113. package/dist/types.js +1 -0
  114. package/dist/version.d.ts +2 -0
  115. package/dist/version.js +4 -0
  116. package/package.json +70 -0
  117. package/skills/asciidoclint/SKILL.md +84 -0
  118. package/skills/asciidoclint/references/ai-fix-policy.md +11 -0
  119. package/skills/asciidoclint/references/result-schema.md +22 -0
@@ -0,0 +1,81 @@
1
+ import { blockDelimiterType, isAsciiDocAnchorLine, isAsciiDocBlockAttributeLine, isAsciiDocSectionTitleLine, isAsciiDocTitleLine, isBlockDelimiter, isLineInProtectedBlock, } from "./utils.js";
2
+ export const AD032 = {
3
+ id: "AD032",
4
+ alias: "blank-before-block",
5
+ description: "Structural block delimiters should be preceded by a blank line",
6
+ tags: ["cleanup", "blocks"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Delimited blocks should be separated from preceding text so Asciidoctor can parse the intended block boundary.",
10
+ fixability: "safe",
11
+ badExamples: [{ code: "Text\n====\nexample\n====" }],
12
+ goodExamples: [{ code: "Text\n\n====\nexample\n====" }],
13
+ },
14
+ function: ({ document }, onError) => {
15
+ for (const file of document.files) {
16
+ const openDelimiters = [];
17
+ for (let index = 1; index < file.lines.length; index += 1) {
18
+ const line = file.lines[index] ?? "";
19
+ const previous = file.lines[index - 1] ?? "";
20
+ const delimiter = line.trim();
21
+ if (!isBlockDelimiter(delimiter)) {
22
+ continue;
23
+ }
24
+ const delimiterType = blockDelimiterType(delimiter);
25
+ if (isLineInProtectedBlock(document, file.file, index + 1)) {
26
+ continue;
27
+ }
28
+ if (delimiterType === "table" && isKnownTableClosingDelimiter(document, file.file, index + 1)) {
29
+ continue;
30
+ }
31
+ if (delimiterType === "table" && isTableCellLine(previous, delimiter)) {
32
+ continue;
33
+ }
34
+ const isClosing = openDelimiters[openDelimiters.length - 1] === delimiter;
35
+ if (isClosing) {
36
+ openDelimiters.pop();
37
+ continue;
38
+ }
39
+ openDelimiters.push(delimiter);
40
+ if (previous.trim() !== ""
41
+ && !isBlockDelimiter(previous.trim())
42
+ && previous.trim() !== "+"
43
+ && !isAsciiDocBlockAttributeLine(previous)
44
+ && !isAsciiDocTitleLine(previous)
45
+ && !isAsciiDocSectionTitleLine(previous)
46
+ && !isAsciiDocAnchorLine(previous)) {
47
+ const lineNumber = index + 1;
48
+ const severity = canBeParsedAsSetextSectionDelimiter(delimiter) ? "error" : "warning";
49
+ onError({
50
+ severity,
51
+ message: "Block delimiter should be preceded by a blank line",
52
+ range: { start: { file: file.file, line: lineNumber, column: 1 } },
53
+ fix: {
54
+ applicability: "safe",
55
+ edits: [{
56
+ file: file.file,
57
+ range: {
58
+ start: { file: file.file, line: lineNumber, column: 1 },
59
+ end: { file: file.file, line: lineNumber, column: 1 },
60
+ },
61
+ replacement: "\n",
62
+ }],
63
+ },
64
+ });
65
+ }
66
+ }
67
+ }
68
+ },
69
+ };
70
+ function isTableCellLine(line, delimiter = "|===") {
71
+ const trimmed = line.trim();
72
+ return trimmed.startsWith(delimiter[0] ?? "|") && trimmed !== delimiter;
73
+ }
74
+ function isKnownTableClosingDelimiter(document, file, line) {
75
+ return document.blocks.some((block) => (block.type === "table"
76
+ && block.range.end?.file === file
77
+ && block.range.end.line === line));
78
+ }
79
+ function canBeParsedAsSetextSectionDelimiter(delimiter) {
80
+ return /^={4,}$/.test(delimiter) || /^-{4,}$/.test(delimiter) || /^\+{4,}$/.test(delimiter);
81
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD034: Rule;
@@ -0,0 +1,50 @@
1
+ import { isLineInProtectedBlock, isLineInTableBlock } from "./utils.js";
2
+ export const AD034 = {
3
+ id: "AD034",
4
+ alias: "no-hard-tabs",
5
+ description: "Lines should not contain hard tabs",
6
+ tags: ["cleanup", "whitespace"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Use spaces instead of hard tab characters.",
10
+ rationale: "Hard tabs in prose and structural markup render inconsistently across editors, terminals, and generated output.",
11
+ fixability: "safe",
12
+ fixHelper: "Replace hard tabs in prose or structural markup with spaces. Keep tabs inside TSV tables and protected/verbatim content when they are intentional data.",
13
+ badExamples: [{ code: "*\tTabbed list item" }],
14
+ goodExamples: [{ code: "* Tabbed list item" }],
15
+ },
16
+ function: ({ document }, onError) => {
17
+ for (const file of document.files) {
18
+ for (const [index, line] of file.lines.entries()) {
19
+ const lineNumber = index + 1;
20
+ if (!line.includes("\t")
21
+ || isLineInProtectedBlock(document, file.file, lineNumber)
22
+ || isLineInTableBlock(document, file.file, lineNumber)) {
23
+ continue;
24
+ }
25
+ for (const match of line.matchAll(/\t/g)) {
26
+ const column = (match.index ?? 0) + 1;
27
+ onError({
28
+ severity: "warning",
29
+ message: "Line contains a hard tab",
30
+ range: {
31
+ start: { file: file.file, line: lineNumber, column },
32
+ end: { file: file.file, line: lineNumber, column: column + 1 },
33
+ },
34
+ fix: {
35
+ applicability: "safe",
36
+ edits: [{
37
+ file: file.file,
38
+ range: {
39
+ start: { file: file.file, line: lineNumber, column },
40
+ end: { file: file.file, line: lineNumber, column: column + 1 },
41
+ },
42
+ replacement: " ",
43
+ }],
44
+ },
45
+ });
46
+ }
47
+ }
48
+ }
49
+ },
50
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD035: Rule;
@@ -0,0 +1,77 @@
1
+ import { blockDelimiterType, isAsciiDocAnchorLine, isAsciiDocBlockAttributeLine, isAsciiDocTitleLine, isBlockDelimiter, isLineComment, isLineInProtectedBlock, isLineInTableBlock, isListMarkerLine } from "./utils.js";
2
+ export const AD035 = {
3
+ id: "AD035",
4
+ alias: "blank-after-block",
5
+ description: "Structural block delimiters should be followed by a blank line",
6
+ tags: ["cleanup", "blocks"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Delimited blocks are easier to parse and read when separated from following paragraph text.",
10
+ fixability: "safe",
11
+ badExamples: [{ code: "====\nexample\n====\nText" }],
12
+ goodExamples: [{ code: "====\nexample\n====\n\nText" }],
13
+ },
14
+ function: ({ document }, onError) => {
15
+ for (const file of document.files) {
16
+ const openDelimiters = [];
17
+ for (let index = 0; index < file.lines.length - 1; index += 1) {
18
+ const line = file.lines[index] ?? "";
19
+ const next = file.lines[index + 1] ?? "";
20
+ const delimiter = line.trim();
21
+ if (!isBlockDelimiter(delimiter)) {
22
+ continue;
23
+ }
24
+ const delimiterType = blockDelimiterType(delimiter);
25
+ if (isLineInProtectedBlock(document, file.file, index + 1) || isLineInTableBlock(document, file.file, index + 1)) {
26
+ continue;
27
+ }
28
+ const isClosing = openDelimiters[openDelimiters.length - 1] === delimiter;
29
+ if (!isClosing) {
30
+ openDelimiters.push(delimiter);
31
+ continue;
32
+ }
33
+ openDelimiters.pop();
34
+ if (delimiterType === "table" && isTableCellLine(next, delimiter)) {
35
+ continue;
36
+ }
37
+ if (next.trim() === ""
38
+ || next.trim() === "+"
39
+ || next.trim() === "{nbsp}"
40
+ || /^=+\s+\S/.test(next.trim())
41
+ || isBlockDelimiter(next.trim())
42
+ || isListMarkerLine(next)
43
+ || isLineComment(next)
44
+ || isConditionalDirective(next)
45
+ || isAsciiDocAnchorLine(next)
46
+ || isAsciiDocTitleLine(next)
47
+ || isAsciiDocBlockAttributeLine(next)) {
48
+ continue;
49
+ }
50
+ const lineNumber = index + 1;
51
+ onError({
52
+ severity: "warning",
53
+ message: "Block delimiter should be followed by a blank line",
54
+ range: { start: { file: file.file, line: lineNumber, column: line.length + 1 } },
55
+ fix: {
56
+ applicability: "safe",
57
+ edits: [{
58
+ file: file.file,
59
+ range: {
60
+ start: { file: file.file, line: lineNumber + 1, column: 1 },
61
+ end: { file: file.file, line: lineNumber + 1, column: 1 },
62
+ },
63
+ replacement: "\n",
64
+ }],
65
+ },
66
+ });
67
+ }
68
+ }
69
+ },
70
+ };
71
+ function isTableCellLine(line, delimiter = "|===") {
72
+ const trimmed = line.trim();
73
+ return trimmed.startsWith(delimiter[0] ?? "|") && trimmed !== delimiter;
74
+ }
75
+ function isConditionalDirective(line) {
76
+ return /^(?:ifdef|ifndef|ifeval|endif)::/.test(line.trim());
77
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD036: Rule;
@@ -0,0 +1,34 @@
1
+ import { isLineInProtectedBlock, isLineInTableBlock, isListMarkerResidueLine } from "./utils.js";
2
+ export const AD036 = {
3
+ id: "AD036",
4
+ alias: "list-marker-residue",
5
+ description: "Standalone list marker residue should be removed or completed",
6
+ tags: ["cleanup", "lists"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "A line containing only a list marker renders as visible text instead of a list item.",
10
+ rationale: "AsciiDoc list items require principal text after the marker. Marker-only lines are usually conversion residue or unfinished content.",
11
+ fixability: "no",
12
+ fixHelper: "Remove the marker residue, add the missing list item text, or use {empty} plus a list continuation when the list item intentionally has only attached block content.",
13
+ badExamples: [{ code: "*\nText" }],
14
+ goodExamples: [{ code: "* Text" }],
15
+ },
16
+ function: ({ document }, onError) => {
17
+ for (const file of document.files) {
18
+ for (const [index, line] of file.lines.entries()) {
19
+ const lineNumber = index + 1;
20
+ if (!isListMarkerResidueLine(line)
21
+ || isLineInProtectedBlock(document, file.file, lineNumber)
22
+ || isLineInTableBlock(document, file.file, lineNumber)) {
23
+ continue;
24
+ }
25
+ onError({
26
+ severity: "warning",
27
+ message: "Standalone list marker renders as text",
28
+ range: { start: { file: file.file, line: lineNumber, column: line.search(/\S/) + 1 } },
29
+ fixHelper: "Remove the marker residue, add the missing list item text, or use {empty} plus a list continuation for a list item that intentionally has only attached block content.",
30
+ });
31
+ }
32
+ }
33
+ },
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD037: Rule;
@@ -0,0 +1,34 @@
1
+ import { isLineInProtectedBlock, isLineInTableBlock, isUnderlineResidueLine } from "./utils.js";
2
+ export const AD037 = {
3
+ id: "AD037",
4
+ alias: "underline-residue",
5
+ description: "Standalone underline residue should be removed or converted",
6
+ tags: ["cleanup", "conversion"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "A standalone three-underscore line is usually leftover underline styling from converted documents.",
10
+ rationale: "AsciiDoc uses four or more underscores for quote block delimiters, but exactly three underscores are not document structure and render as visible residue.",
11
+ fixability: "no",
12
+ fixHelper: "Remove the three-underscore residue, or convert the intended underlined text to AsciiDoc emphasis such as _text_. Keep four or more underscores only when they are matching quote block delimiters.",
13
+ badExamples: [{ code: "Important\n___" }],
14
+ goodExamples: [{ code: "_Important_" }],
15
+ },
16
+ function: ({ document }, onError) => {
17
+ for (const file of document.files) {
18
+ for (const [index, line] of file.lines.entries()) {
19
+ const lineNumber = index + 1;
20
+ if (!isUnderlineResidueLine(line)
21
+ || isLineInProtectedBlock(document, file.file, lineNumber)
22
+ || isLineInTableBlock(document, file.file, lineNumber)) {
23
+ continue;
24
+ }
25
+ onError({
26
+ severity: "warning",
27
+ message: "Standalone three-underscore residue should be removed",
28
+ range: { start: { file: file.file, line: lineNumber, column: line.search(/\S/) + 1 } },
29
+ fixHelper: "Remove the three-underscore residue or convert the intended underlined text to AsciiDoc emphasis.",
30
+ });
31
+ }
32
+ }
33
+ },
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD039: Rule;
@@ -0,0 +1,58 @@
1
+ import { isLineComment, isLineInCommentParagraph, isLineInProtectedBlock, isLineInTableBlock } from "./utils.js";
2
+ export const AD039 = {
3
+ id: "AD039",
4
+ alias: "punctuation-passthrough-residue",
5
+ description: "Safe punctuation passthrough residue should be removed",
6
+ tags: ["cleanup", "conversion"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Inline passthrough wrappers around safe punctuation are usually conversion residue.",
10
+ rationale: "AsciiDoc supports inline passthroughs, but wrappers around _, [, ], <, >, and | are common conversion residue when they appear in ordinary source text.",
11
+ fixability: "safe",
12
+ fixHelper: "Replace the passthrough-wrapped punctuation with the literal character when AD039 reports it.",
13
+ badExamples: [{ code: "SE++_++KSLT and SE++[++0-4++]++" }],
14
+ goodExamples: [{ code: "SE_KSLT and SE[0-4]" }],
15
+ },
16
+ function: ({ document }, onError) => {
17
+ for (const file of document.files) {
18
+ for (const [index, line] of file.lines.entries()) {
19
+ const lineNumber = index + 1;
20
+ if (isLineComment(line) || isLineInProtectedBlock(document, file.file, lineNumber) || isLineInCommentParagraph(file.lines, index)) {
21
+ continue;
22
+ }
23
+ for (const match of line.matchAll(/\+\+([_[\]<>|])\+\+/g)) {
24
+ const replacement = match[1] ?? "";
25
+ if ((replacement === "|" && isLineInTableBlock(document, file.file, lineNumber))
26
+ || (["[", "]"].includes(replacement) && isStandaloneBracketPassthroughLine(line))) {
27
+ continue;
28
+ }
29
+ const column = (match.index ?? 0) + 1;
30
+ const endColumn = column + match[0].length;
31
+ onError({
32
+ severity: "warning",
33
+ message: "Punctuation passthrough conversion residue should be removed",
34
+ range: {
35
+ start: { file: file.file, line: lineNumber, column },
36
+ end: { file: file.file, line: lineNumber, column: endColumn },
37
+ },
38
+ fixHelper: `Replace ${match[0]} with ${replacement}.`,
39
+ fix: {
40
+ applicability: "safe",
41
+ edits: [{
42
+ file: file.file,
43
+ range: {
44
+ start: { file: file.file, line: lineNumber, column },
45
+ end: { file: file.file, line: lineNumber, column: endColumn },
46
+ },
47
+ replacement,
48
+ }],
49
+ },
50
+ });
51
+ }
52
+ }
53
+ }
54
+ },
55
+ };
56
+ function isStandaloneBracketPassthroughLine(line) {
57
+ return /^\s*\+\+\[\+\+[^+\n]*\+\+\]\+\+\s*$/.test(line);
58
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD040: Rule;
@@ -0,0 +1,56 @@
1
+ import { isLineComment, isLineInCommentParagraph, isLineInProtectedBlock } from "./utils.js";
2
+ export const AD040 = {
3
+ id: "AD040",
4
+ alias: "html-link-text-residue",
5
+ description: "Link text should not contain raw HTML or XML residue",
6
+ tags: ["cleanup", "links", "conversion"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Visible link text should not contain raw HTML or XML fragments.",
10
+ rationale: "Raw markup inside link text is escaped by Asciidoctor and becomes visible label text, which is usually conversion residue.",
11
+ fixability: "no",
12
+ fixHelper: "Keep only the intended human-readable link text and remove raw markup fragments from the label.",
13
+ badExamples: [{ code: "link:https://example.com[++<span>Example</span>++]" }],
14
+ goodExamples: [{ code: "link: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
+ const lineNumber = index + 1;
20
+ if (isLineComment(line)
21
+ || isLineInProtectedBlock(document, file.file, lineNumber)
22
+ || isLineInCommentParagraph(file.lines, index)) {
23
+ continue;
24
+ }
25
+ for (const match of linkLabelMarkupResidueMatches(line)) {
26
+ onError({
27
+ severity: "warning",
28
+ message: "Link text should not contain raw HTML or XML residue",
29
+ range: {
30
+ start: { file: file.file, line: lineNumber, column: match.column },
31
+ end: { file: file.file, line: lineNumber, column: match.endColumn },
32
+ },
33
+ fixHelper: "Keep only the intended human-readable link text and remove raw markup fragments from the label.",
34
+ });
35
+ }
36
+ }
37
+ }
38
+ },
39
+ };
40
+ function linkLabelMarkupResidueMatches(line) {
41
+ const matches = [];
42
+ const macroPattern = /\b(?:link|xref):[^\s[]+\[((?:[^\]\\]|\\.|\][^\s])*?)]/g;
43
+ for (const macro of line.matchAll(macroPattern)) {
44
+ if (macro.index === undefined) {
45
+ continue;
46
+ }
47
+ const label = macro[1] ?? "";
48
+ const markup = label.match(/\+{2,3}<\/?[A-Za-z][^>\]\n]*>\+{2,3}|<\/?[A-Za-z][^>\]\n]*>/);
49
+ if (!markup || markup.index === undefined) {
50
+ continue;
51
+ }
52
+ const column = macro.index + macro[0].indexOf(label) + markup.index + 1;
53
+ matches.push({ column, endColumn: column + markup[0].length });
54
+ }
55
+ return matches;
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD041: Rule;
@@ -0,0 +1,66 @@
1
+ import { isLineComment, isLineInCommentParagraph, isLineInProtectedBlock, isLineInTableBlock, listMarkerContent } from "./utils.js";
2
+ const spacedFormattingPatterns = [
3
+ /(^|[^\w])([*_])\s+[^\d\s][^\n]*?\s+\2(?=\s|$|[.,;:!?])/,
4
+ /(^|[^\w])([*_])\S[^\n]*?\s+\2(?=\s|$|[.,;:!?])/,
5
+ /`\+\s+\S[^`]*?\s+\+`/,
6
+ ];
7
+ const literalMonospacePatternIndex = 2;
8
+ export const AD041 = {
9
+ id: "AD041",
10
+ alias: "no-space-in-inline-formatting",
11
+ description: "Inline formatting markers should not contain inner spaces",
12
+ tags: ["cleanup", "format", "inline"],
13
+ parser: "text",
14
+ docs: {
15
+ summary: "Flag spaces just inside AsciiDoc emphasis, strong, or monospace markers.",
16
+ rationale: "AsciiDoc inline formatting is delimiter-sensitive; inner spaces often leave the intended formatting markers visible in rendered output.",
17
+ fixability: "no",
18
+ fixHelper: "Move spaces outside constrained formatting markers, or use the correct unconstrained pair when spaces must remain inside the formatted span.",
19
+ badExamples: [{ code: "This is * important * text and `+ code +` should be fixed." }],
20
+ goodExamples: [{ code: "This is *important* text and `+code+` should be fixed." }],
21
+ },
22
+ function: ({ document }, onError) => {
23
+ for (const file of document.files) {
24
+ for (const [index, line] of file.lines.entries()) {
25
+ if (isLineComment(line)
26
+ || isLineInProtectedBlock(document, file.file, index + 1)
27
+ || isLineInCommentParagraph(file.lines, index)
28
+ || isLineInTableBlock(document, file.file, index + 1)) {
29
+ continue;
30
+ }
31
+ const masked = maskRenderedInlineSpans(maskInlineMacroSpans(line));
32
+ const content = listMarkerContent(masked);
33
+ const matchEntry = spacedFormattingPatterns
34
+ .map((pattern, patternIndex) => ({ match: content.content.match(pattern), patternIndex }))
35
+ .find((entry) => entry.match?.index !== undefined);
36
+ const match = matchEntry?.match;
37
+ if (!match || match.index === undefined) {
38
+ continue;
39
+ }
40
+ if (matchEntry.patternIndex !== literalMonospacePatternIndex && isLikelyFormulaText(match[0])) {
41
+ continue;
42
+ }
43
+ onError({
44
+ severity: "warning",
45
+ message: "Inline formatting marker contains inner spaces",
46
+ range: { start: { file: file.file, line: index + 1, column: content.offset + match.index + 1 } },
47
+ fixHelper: "Move spaces outside the AsciiDoc formatting markers.",
48
+ });
49
+ }
50
+ }
51
+ },
52
+ };
53
+ function maskInlineMacroSpans(line) {
54
+ return line
55
+ .replace(/`(?!\+)[^`]*`/g, (match) => "x".repeat(match.length))
56
+ .replace(/\b[A-Za-z][A-Za-z0-9_-]*:\[[^\]]*]/g, (match) => "x".repeat(match.length));
57
+ }
58
+ function maskRenderedInlineSpans(line) {
59
+ return line
60
+ .replace(/\*_[^\n]*?_\*/g, (match) => "x".repeat(match.length))
61
+ .replace(/_\*[^\n]*?\*_/g, (match) => "x".repeat(match.length))
62
+ .replace(/\*\*[^\n]*?\*\*/g, (match) => "x".repeat(match.length));
63
+ }
64
+ function isLikelyFormulaText(text) {
65
+ return /[=+\/~^]|\b(?:mod|div)\b|\d/.test(text);
66
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD042: Rule;
@@ -0,0 +1,62 @@
1
+ import { isLineInProtectedBlock } from "./utils.js";
2
+ const xrefPattern = /(?<!\\)\bxref:([^\s\[]+)\[([^\]]*)]/g;
3
+ const adocTargetPattern = /\.a(?:scii)?doc(?:#|$)/i;
4
+ const namedAttributePattern = /^[A-Za-z_][\w.-]*\s*=/;
5
+ function firstAttribute(attrlist) {
6
+ let quote;
7
+ for (let index = 0; index < attrlist.length; index += 1) {
8
+ const char = attrlist[index];
9
+ if ((char === '"' || char === "'") && attrlist[index - 1] !== "\\") {
10
+ quote = quote === char ? undefined : quote ?? char;
11
+ }
12
+ else if (char === "," && quote === undefined) {
13
+ return attrlist.slice(0, index).trim();
14
+ }
15
+ }
16
+ return attrlist.trim();
17
+ }
18
+ function hasExplicitText(attrlist) {
19
+ const first = firstAttribute(attrlist);
20
+ return first.length > 0 && !namedAttributePattern.test(first);
21
+ }
22
+ export const AD042 = {
23
+ id: "AD042",
24
+ alias: "interdocument-xref-text",
25
+ description: "Interdocument xrefs should provide explicit link text",
26
+ tags: ["xref", "links", "accessibility"],
27
+ parser: "text",
28
+ docs: {
29
+ summary: "Flag interdocument xref macros that omit the first positional link-text attribute.",
30
+ rationale: "AsciiDoc allows intradocument xrefs to derive text from the target, but Asciidoctor documents interdocument xref text as required. When omitted, Asciidoctor.js renders a weak converted filename label.",
31
+ fixability: "no",
32
+ fixHelper: "Add human-readable text as the first xref attribute, before named attributes such as window or xrefstyle.",
33
+ badExamples: [{ code: "See xref:chapter.adoc[] and xref:chapter.adoc#overview[window=_blank]." }],
34
+ goodExamples: [
35
+ {
36
+ code: "See xref:chapter.adoc[Chapter overview] and xref:chapter.adoc#overview[Overview,window=_blank].",
37
+ },
38
+ ],
39
+ },
40
+ function: ({ document }, onError) => {
41
+ for (const file of document.files) {
42
+ for (const [index, line] of file.lines.entries()) {
43
+ if (isLineInProtectedBlock(document, file.file, index + 1)) {
44
+ continue;
45
+ }
46
+ for (const match of line.matchAll(xrefPattern)) {
47
+ const target = match[1] ?? "";
48
+ const attrlist = match[2] ?? "";
49
+ if (!adocTargetPattern.test(target) || hasExplicitText(attrlist)) {
50
+ continue;
51
+ }
52
+ onError({
53
+ severity: "warning",
54
+ message: "Interdocument xref is missing explicit link text",
55
+ range: { start: { file: file.file, line: index + 1, column: (match.index ?? 0) + 1 } },
56
+ fixHelper: `Add descriptive text to the xref for ${target}.`,
57
+ });
58
+ }
59
+ }
60
+ }
61
+ },
62
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD043: Rule;
@@ -0,0 +1,30 @@
1
+ import { isLineInProtectedBlock } from "./utils.js";
2
+ export const AD043 = {
3
+ id: "AD043",
4
+ alias: "section-title-start-left",
5
+ description: "Section title syntax should start at the beginning of the line",
6
+ tags: ["headings", "structure"],
7
+ parser: "text",
8
+ docs: {
9
+ summary: "Flag indented lines that look like AsciiDoc section titles.",
10
+ rationale: "AsciiDoc section titles are block starts aligned to the left margin. An indented section-title-looking line is valid literal content, but it is not part of the section tree.",
11
+ fixability: "no",
12
+ fixHelper: "If the line is intended to be a section title, remove the leading indentation. If it is intended as literal example text, put it in an explicit listing or literal block.",
13
+ badExamples: [{ code: "= Title\n\n == Intended Section\n\n ## Intended Markdown-Compatible Section\n\nText." }],
14
+ goodExamples: [{ code: "= Title\n\n== Intended Section\n\n## Intended Markdown-Compatible Section\n\nText." }],
15
+ },
16
+ function: ({ document }, onError) => {
17
+ for (const file of document.files) {
18
+ for (const [index, line] of file.lines.entries()) {
19
+ if (!/^[ \t]+(?:={1,6}|#{1,6})[ \t]+\S/.test(line) || isLineInProtectedBlock(document, file.file, index + 1)) {
20
+ continue;
21
+ }
22
+ onError({
23
+ severity: "warning",
24
+ message: "Indented section-title-looking line renders as literal text, not as a section",
25
+ range: { start: { file: file.file, line: index + 1, column: 1 } },
26
+ });
27
+ }
28
+ }
29
+ },
30
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../types.js";
2
+ export declare const AD044: Rule;
@@ -0,0 +1,54 @@
1
+ import { isLineInProtectedBlock } from "./utils.js";
2
+ const localAdocLinkPattern = /(^|[^\w:\\])link:(?!:)([^\s\[]+\.(?:adoc|asciidoc|asc)(?:#[^\s\[]*)?)\[[^\]]*]/gi;
3
+ export const AD044 = {
4
+ id: "AD044",
5
+ alias: "local-adoc-link",
6
+ description: "Local AsciiDoc files should be referenced with xref, not link",
7
+ tags: ["core", "links", "xref"],
8
+ parser: "text",
9
+ docs: {
10
+ summary: "Use xref: for local AsciiDoc source files so converted output targets are rewritten correctly.",
11
+ rationale: "Asciidoctor documents link: for relative non-AsciiDoc files and xref: for relative AsciiDoc files. link:chapter.adoc[] renders as chapter.adoc, while xref:chapter.adoc[] renders as the converted output target such as chapter.html.",
12
+ fixability: "unsafe",
13
+ fixHelper: "Change link:file.adoc#id[text] to xref:file.adoc#id[text] when the target is an AsciiDoc source file and does not use URL query semantics.",
14
+ badExamples: [{ code: "link:chapter.adoc#setup[Setup]" }],
15
+ goodExamples: [{ code: "xref:chapter.adoc#setup[Setup]" }],
16
+ },
17
+ function: ({ document }, onError) => {
18
+ for (const file of document.files) {
19
+ for (const [index, line] of file.lines.entries()) {
20
+ if (isLineInProtectedBlock(document, file.file, index + 1)) {
21
+ continue;
22
+ }
23
+ for (const match of line.matchAll(localAdocLinkPattern)) {
24
+ const prefixLength = match[1]?.length ?? 0;
25
+ const target = match[2] ?? "";
26
+ if (isExternalTarget(target)) {
27
+ continue;
28
+ }
29
+ const column = (match.index ?? 0) + prefixLength + 1;
30
+ onError({
31
+ severity: "warning",
32
+ message: `Local AsciiDoc link should use xref: ${target}`,
33
+ range: { start: { file: file.file, line: index + 1, column } },
34
+ fixHelper: "Use xref: for local AsciiDoc source files so the target is rewritten to the converted output path.",
35
+ fix: {
36
+ applicability: "unsafe",
37
+ edits: [{
38
+ file: file.file,
39
+ range: {
40
+ start: { file: file.file, line: index + 1, column },
41
+ end: { file: file.file, line: index + 1, column: column + "link:".length },
42
+ },
43
+ replacement: "xref:",
44
+ }],
45
+ },
46
+ });
47
+ }
48
+ }
49
+ }
50
+ },
51
+ };
52
+ function isExternalTarget(target) {
53
+ return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("mailto:");
54
+ }