Skip to content

Commit e9bd40b

Browse files
committed
Fix relative link detection across display parts
This feels entirely too much like one of those pieces of code that is going to have to change another 20 times before it's actually correct, but this is better than it used to be... Resolves #2606
1 parent e300453 commit e9bd40b

File tree

5 files changed

+125
-29
lines changed

5 files changed

+125
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Added `@author` to the default list of recognized tags, #2603.
1010
- Anchor links are no longer incorrectly checked for relative paths, #2604.
1111
- Fixed an issue where line numbers reported in error messages could be incorrect, #2605.
12+
- Fixed relative link detection for markdown links containing code in their label, #2606.
1213

1314
### Thanks!
1415

src/lib/converter/comments/parser.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
TranslationProxy,
1818
} from "../../internationalization/internationalization";
1919
import { FileRegistry } from "../../models/FileRegistry";
20-
import { textContent } from "./textParser";
20+
import { textContent, TextParserReentryState } from "./textParser";
2121

2222
interface LookaheadGenerator<T> {
2323
done(): boolean;
@@ -137,13 +137,15 @@ export function parseCommentString(
137137
},
138138
};
139139

140+
const reentry = new TextParserReentryState();
140141
const content: CommentDisplayPart[] = [];
141142
const lexer = makeLookaheadGenerator(tokens);
142143

143144
let atNewLine = false;
144145
while (!lexer.done()) {
145146
let consume = true;
146147
const next = lexer.peek();
148+
reentry.checkState(next);
147149

148150
switch (next.kind) {
149151
case TokenSyntaxKind.TypeAnnotation:
@@ -162,6 +164,7 @@ export function parseCommentString(
162164
content,
163165
files,
164166
atNewLine,
167+
reentry,
165168
);
166169
break;
167170

@@ -576,9 +579,11 @@ function blockContent(
576579
): CommentDisplayPart[] {
577580
const content: CommentDisplayPart[] = [];
578581
let atNewLine = true as boolean;
582+
const reentry = new TextParserReentryState();
579583

580584
loop: while (!lexer.done()) {
581585
const next = lexer.peek();
586+
reentry.checkState(next);
582587
let consume = true;
583588

584589
switch (next.kind) {
@@ -595,6 +600,7 @@ function blockContent(
595600
/*out*/ content,
596601
files,
597602
atNewLine,
603+
reentry,
598604
);
599605
break;
600606

src/lib/converter/comments/textParser.ts

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ interface RelativeLink {
3434
target: number | undefined;
3535
}
3636

37+
/**
38+
* This is incredibly unfortunate. The comment lexer owns the responsibility
39+
* for splitting up text into text/code, this is totally fine for HTML links
40+
* but for markdown links, ``[`code`](./link)`` is valid, so we need to keep
41+
* track of state across calls to {@link textContent}.
42+
*/
43+
export class TextParserReentryState {
44+
withinLinkLabel = false;
45+
private lastPartWasNewline = false;
46+
47+
checkState(token: Token) {
48+
switch (token.kind) {
49+
case TokenSyntaxKind.Code:
50+
if (/\n\s*\n/.test(token.text)) {
51+
this.withinLinkLabel = false;
52+
}
53+
break;
54+
case TokenSyntaxKind.NewLine:
55+
if (this.lastPartWasNewline) {
56+
this.withinLinkLabel = false;
57+
}
58+
break;
59+
}
60+
61+
this.lastPartWasNewline = token.kind === TokenSyntaxKind.NewLine;
62+
}
63+
}
64+
3765
/**
3866
* Look for relative links within a piece of text and add them to the {@link FileRegistry}
3967
* so that they can be correctly resolved during rendering.
@@ -46,6 +74,7 @@ export function textContent(
4674
outContent: CommentDisplayPart[],
4775
files: FileRegistry,
4876
atNewLine: boolean,
77+
reentry: TextParserReentryState,
4978
) {
5079
let lastPartEnd = 0;
5180
const data: TextParserData = {
@@ -86,7 +115,7 @@ export function textContent(
86115
}
87116

88117
while (data.pos < token.text.length) {
89-
const link = checkMarkdownLink(data);
118+
const link = checkMarkdownLink(data, reentry);
90119
if (link) {
91120
addRef(link);
92121
continue;
@@ -123,37 +152,54 @@ export function textContent(
123152
* Reference: https://github.com/markdown-it/markdown-it/blob/14.1.0/lib/rules_inline/image.mjs
124153
*
125154
*/
126-
function checkMarkdownLink(data: TextParserData): RelativeLink | undefined {
155+
function checkMarkdownLink(
156+
data: TextParserData,
157+
reentry: TextParserReentryState,
158+
): RelativeLink | undefined {
127159
const { token, sourcePath, files } = data;
128160

129-
if (token.text[data.pos] === "[") {
130-
const labelEnd = findLabelEnd(token.text, data.pos + 1);
131-
if (
132-
labelEnd !== -1 &&
133-
token.text[labelEnd] === "]" &&
134-
token.text[labelEnd + 1] === "("
135-
) {
136-
const link = MdHelpers.parseLinkDestination(
137-
token.text,
138-
labelEnd + 2,
139-
token.text.length,
140-
);
161+
let searchStart: number;
162+
if (reentry.withinLinkLabel) {
163+
searchStart = data.pos;
164+
reentry.withinLinkLabel = false;
165+
} else if (token.text[data.pos] === "[") {
166+
searchStart = data.pos + 1;
167+
} else {
168+
return;
169+
}
141170

142-
if (link.ok) {
143-
// Only make a relative-link display part if it's actually a relative link.
144-
// Discard protocol:// links, unix style absolute paths, and windows style absolute paths.
145-
if (isRelativeLink(link.str)) {
146-
return {
147-
pos: labelEnd + 2,
148-
end: link.pos,
149-
target: files.register(sourcePath, link.str),
150-
};
151-
}
171+
const labelEnd = findLabelEnd(token.text, searchStart);
172+
if (labelEnd === -1) {
173+
// This markdown link might be split across multiple display parts
174+
// [ `text` ](link)
175+
// ^^ text
176+
// ^^^^^^ code
177+
// ^^^^^^^^ text
178+
reentry.withinLinkLabel = true;
179+
return;
180+
}
181+
182+
if (token.text[labelEnd] === "]" && token.text[labelEnd + 1] === "(") {
183+
const link = MdHelpers.parseLinkDestination(
184+
token.text,
185+
labelEnd + 2,
186+
token.text.length,
187+
);
152188

153-
// This was a link, skip ahead to ensure we don't happen to parse
154-
// something else as a link within the link.
155-
data.pos = link.pos - 1;
189+
if (link.ok) {
190+
// Only make a relative-link display part if it's actually a relative link.
191+
// Discard protocol:// links, unix style absolute paths, and windows style absolute paths.
192+
if (isRelativeLink(link.str)) {
193+
return {
194+
pos: labelEnd + 2,
195+
end: link.pos,
196+
target: files.register(sourcePath, link.str),
197+
};
156198
}
199+
200+
// This was a link, skip ahead to ensure we don't happen to parse
201+
// something else as a link within the link.
202+
data.pos = link.pos - 1;
157203
}
158204
}
159205
}
@@ -265,6 +311,7 @@ function findLabelEnd(text: string, pos: number) {
265311
switch (text[pos]) {
266312
case "\n":
267313
case "]":
314+
case "[":
268315
return pos;
269316
}
270317
++pos;

src/lib/models/FileRegistry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Reflection } from "./reflections";
88
export class FileRegistry {
99
protected nextId = 1;
1010

11-
// The combination of thest two make up the registry
11+
// The combination of these two make up the registry
1212
protected mediaToReflection = new Map<number, Reflection>();
1313
protected mediaToPath = new Map<number, string>();
1414

src/test/comments.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,48 @@ describe("Comment Parser", () => {
13711371
] satisfies CommentDisplayPart[]);
13721372
});
13731373

1374+
it("#2606 Recognizes markdown links which contain inline code in the label", () => {
1375+
const comment = getComment(`/**
1376+
* [\`text\`](./relative.md)
1377+
* [\`text\`
1378+
* more](./relative.md)
1379+
* [\`text\`
1380+
*
1381+
* more](./relative.md)
1382+
*/`);
1383+
1384+
equal(comment.summary, [
1385+
// Simple case with code
1386+
{ kind: "text", text: "[" },
1387+
{ kind: "code", text: "`text`" },
1388+
{ kind: "text", text: "](" },
1389+
{ kind: "relative-link", text: "./relative.md", target: 1 },
1390+
// Labels can also include single newlines
1391+
{ kind: "text", text: ")\n[" },
1392+
{ kind: "code", text: "`text`" },
1393+
{ kind: "text", text: "\nmore](" },
1394+
{ kind: "relative-link", text: "./relative.md", target: 1 },
1395+
// But not double!
1396+
{ kind: "text", text: ")\n[" },
1397+
{ kind: "code", text: "`text`" },
1398+
{ kind: "text", text: "\n\nmore](./relative.md)" },
1399+
] satisfies CommentDisplayPart[]);
1400+
});
1401+
1402+
it("Recognizes markdown links which contain inline code in the label", () => {
1403+
const comment = getComment(`/**
1404+
* [\`text\`](./relative.md)
1405+
*/`);
1406+
1407+
equal(comment.summary, [
1408+
{ kind: "text", text: "[" },
1409+
{ kind: "code", text: "`text`" },
1410+
{ kind: "text", text: "](" },
1411+
{ kind: "relative-link", text: "./relative.md", target: 1 },
1412+
{ kind: "text", text: ")" },
1413+
] satisfies CommentDisplayPart[]);
1414+
});
1415+
13741416
it("Recognizes markdown reference definition blocks", () => {
13751417
const comment = getComment(`/**
13761418
* [1]: ./example.md

0 commit comments

Comments
 (0)