Skip to content

Commit 59db5b2

Browse files
committed
Handle multiple URLs in srcset
1 parent d3899e0 commit 59db5b2

File tree

4 files changed

+214
-38
lines changed

4 files changed

+214
-38
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ title: Changelog
1010

1111
### Bug Fixes
1212

13-
- Relative links in `<source src=x>` and `<source srcset=x>` elements will now be discovered by TypeDoc, #2975.
13+
- Relative links in `<img srcset>` will now be discovered by TypeDoc, #2975.
14+
- Relative links in `<source src>` and `<source srcset>` elements will now be discovered by TypeDoc, #2975.
1415

1516
### Thanks!
1617

index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!doctype html>
2+
<body>
3+
<picture>
4+
<img width="343" src=" .github/wagtailb.svg " alt="Wagtail">
5+
</picture>
6+
</body>

src/lib/converter/comments/textParser.ts

Lines changed: 122 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@ export function textContent(
136136
continue;
137137
}
138138

139-
const tagLink = checkTagLink(data);
140-
if (tagLink) {
141-
addRef(tagLink);
139+
const tagLinks = checkTagLink(data);
140+
if (tagLinks.length) {
141+
for (const tagLink of tagLinks) {
142+
addRef(tagLink);
143+
}
142144
continue;
143145
}
144146

@@ -299,61 +301,150 @@ function checkReference(data: TextParserData): RelativeLink | undefined {
299301
/**
300302
* Looks for `<a href="./relative">`, `<img src="./relative">`, and `<source srcset="./relative">`
301303
*/
302-
function checkTagLink(data: TextParserData): RelativeLink | undefined {
304+
function checkTagLink(data: TextParserData): RelativeLink[] {
303305
const { pos, token } = data;
304306

305307
if (token.text.startsWith("<img ", pos)) {
306308
data.pos += 4;
307-
return checkAttribute(data, "src");
309+
return checkAttributes(data, {
310+
src: checkAttributeDirectPath,
311+
srcset: checkAttributeSrcSet,
312+
});
313+
}
314+
315+
if (token.text.startsWith("<link ", pos)) {
316+
data.pos += 4;
317+
return checkAttributes(data, {
318+
imagesrcset: checkAttributeSrcSet,
319+
});
308320
}
309321

310322
if (token.text.startsWith("<a ", pos)) {
311323
data.pos += 3;
312-
return checkAttribute(data, "href");
324+
return checkAttributes(data, { href: checkAttributeDirectPath });
313325
}
314326

315327
if (token.text.startsWith("<source ", pos)) {
316328
data.pos += 8;
317-
const saveData = { ...data };
318-
const attr = checkAttribute(data, "srcset");
319-
if (!attr) {
320-
Object.assign(data, saveData);
321-
return checkAttribute(data, "src");
322-
}
323-
return attr;
329+
return checkAttributes(data, {
330+
src: checkAttributeDirectPath,
331+
srcset: checkAttributeSrcSet,
332+
});
324333
}
334+
335+
return [];
325336
}
326337

327-
function checkAttribute(
338+
function checkAttributes(
328339
data: TextParserData,
329-
attr: string,
330-
): RelativeLink | undefined {
340+
attributes: Record<
341+
string,
342+
(data: TextParserData, text: string, pos: number, end: number) => RelativeLink[]
343+
>,
344+
): RelativeLink[] {
345+
const links: RelativeLink[] = [];
331346
const parser = new HtmlAttributeParser(data.token.text, data.pos);
332347
while (parser.state !== ParserState.END) {
333348
if (
334349
parser.state === ParserState.BeforeAttributeValue &&
335-
parser.currentAttributeName === attr
350+
attributes.hasOwnProperty(parser.currentAttributeName)
336351
) {
337352
parser.step();
338353

339-
if (isRelativePath(parser.currentAttributeValue)) {
340-
data.pos = parser.pos;
341-
const { target, anchor } = data.files.register(
342-
data.sourcePath,
343-
parser.currentAttributeValue as NormalizedPath,
344-
) || { target: undefined, anchor: undefined };
345-
return {
346-
pos: parser.currentAttributeValueStart,
347-
end: parser.currentAttributeValueEnd,
348-
target,
349-
targetAnchor: anchor,
350-
};
351-
}
352-
return;
354+
links.push(...attributes[parser.currentAttributeName](
355+
data,
356+
parser.currentAttributeValue,
357+
parser.currentAttributeValueStart,
358+
parser.currentAttributeValueEnd,
359+
));
353360
}
354361

355362
parser.step();
356363
}
364+
365+
return links;
366+
}
367+
368+
function checkAttributeDirectPath(
369+
data: TextParserData,
370+
text: string,
371+
pos: number,
372+
end: number,
373+
): RelativeLink[] {
374+
if (isRelativePath(text.trim())) {
375+
const { target, anchor } = data.files.register(
376+
data.sourcePath,
377+
text.trim() as NormalizedPath,
378+
) || { target: undefined, anchor: undefined };
379+
return [{
380+
pos,
381+
end,
382+
target,
383+
targetAnchor: anchor,
384+
}];
385+
}
386+
387+
return [];
388+
}
389+
390+
// See https://html.spec.whatwg.org/multipage/images.html#srcset-attribute
391+
function checkAttributeSrcSet(data: TextParserData, text: string, pos: number, _end: number): RelativeLink[] {
392+
const result: RelativeLink[] = [];
393+
394+
let textPos = 0;
395+
parseImageCandidate();
396+
while (textPos < text.length && text[textPos] == ",") {
397+
++textPos;
398+
parseImageCandidate();
399+
}
400+
401+
return result;
402+
403+
function parseImageCandidate() {
404+
// 1. Zero or more ASCII whitespace
405+
while (textPos < text.length && /[\t\r\f\n ]/.test(text[textPos])) ++textPos;
406+
// 2. A valid non-empty URL that does not start or end with a comma
407+
// TypeDoc: We don't exactly match this, PR welcome! For now, just permit anything
408+
// that's not whitespace or a comma
409+
const url = text.slice(textPos).match(/^[^\t\r\f\n ,]+/);
410+
411+
if (url && isRelativePath(url[0])) {
412+
const { target, anchor } = data.files.register(
413+
data.sourcePath,
414+
url[0] as NormalizedPath,
415+
) || { target: undefined, anchor: undefined };
416+
result.push({
417+
pos: pos + textPos,
418+
end: pos + textPos + url[0].length,
419+
target,
420+
targetAnchor: anchor,
421+
});
422+
}
423+
textPos += url ? url[0].length : 0;
424+
425+
// 3. Zero or more ASCII whitespace
426+
while (textPos < text.length && /[\t\r\f\n ]/.test(text[textPos])) ++textPos;
427+
428+
// 4. Zero or one of the following:
429+
{
430+
// A width descriptor, consisting of: ASCII whitespace, a valid non-negative integer giving
431+
// a number greater than zero representing the width descriptor value, and a U+0077 LATIN
432+
// SMALL LETTER W character.
433+
const w = text.slice(textPos).match(/^\+?\d+\s*w/);
434+
textPos += w ? w[0].length : 0;
435+
436+
// A pixel density descriptor, consisting of: ASCII whitespace, a valid floating-point number
437+
// giving a number greater than zero representing the pixel density descriptor value, and a
438+
// U+0078 LATIN SMALL LETTER X character.
439+
if (!w) {
440+
const x = text.slice(textPos).match(/^\+?\d+(\.\d+)?([eE][+-]\d+)?\s*x/);
441+
textPos += x ? x[0].length : 0;
442+
}
443+
}
444+
445+
// 5. Zero or more ASCII whitespace
446+
while (textPos < text.length && /[\t\r\f\n ]/.test(text[textPos])) ++textPos;
447+
}
357448
}
358449

359450
function isRelativePath(link: string) {

src/test/comments.test.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,7 +1639,8 @@ describe("Comment Parser", () => {
16391639
it("Recognizes HTML picture source srcset links", () => {
16401640
const comment = getComment(`/**
16411641
* <source media="(prefers-color-scheme: light)" srcset="./test.png" >
1642-
* <source media="(prefers-color-scheme: dark)" srcset="./test space.png"/>
1642+
* <source srcset="./test2.png 100w, ./test3.png 2x" >
1643+
* <source media="(prefers-color-scheme: dark)" srcset="./test%20space.png, ./test5.png"/>
16431644
* <source srcset="https://example.com/favicon.ico">
16441645
*/`);
16451646

@@ -1653,13 +1654,40 @@ describe("Comment Parser", () => {
16531654
target: 1 as FileId,
16541655
targetAnchor: undefined,
16551656
},
1656-
{ kind: "text", text: '" >\n<source media="(prefers-color-scheme: dark)" srcset="' },
1657+
{ kind: "text", text: '" >\n<source srcset="' },
16571658
{
16581659
kind: "relative-link",
1659-
text: "./test space.png",
1660+
text: "./test2.png",
16601661
target: 2 as FileId,
16611662
targetAnchor: undefined,
16621663
},
1664+
{ kind: "text", text: " 100w, " },
1665+
{
1666+
kind: "relative-link",
1667+
text: "./test3.png",
1668+
target: 3 as FileId,
1669+
targetAnchor: undefined,
1670+
},
1671+
{
1672+
kind: "text",
1673+
text: ' 2x" >\n<source media="(prefers-color-scheme: dark)" srcset="',
1674+
},
1675+
{
1676+
kind: "relative-link",
1677+
text: "./test%20space.png",
1678+
target: 4 as FileId,
1679+
targetAnchor: undefined,
1680+
},
1681+
{
1682+
kind: "text",
1683+
text: ", ",
1684+
},
1685+
{
1686+
kind: "relative-link",
1687+
text: "./test5.png",
1688+
target: 5 as FileId,
1689+
targetAnchor: undefined,
1690+
},
16631691
{
16641692
kind: "text",
16651693
text: '"/>\n<source srcset="https://example.com/favicon.ico">',
@@ -1668,11 +1696,31 @@ describe("Comment Parser", () => {
16681696
);
16691697
});
16701698

1671-
it("Recognizes HTML picture source src links", () => {
1699+
it("Recognizes <link imagesrcset> links", () => {
1700+
const comment = getComment(`/**
1701+
* <link imagesrcset="./test.png 100w" >
1702+
*/`);
1703+
1704+
equal(
1705+
comment.summary,
1706+
[
1707+
{ kind: "text", text: '<link imagesrcset="' },
1708+
{
1709+
kind: "relative-link",
1710+
text: "./test.png",
1711+
target: 1 as FileId,
1712+
targetAnchor: undefined,
1713+
},
1714+
{ kind: "text", text: ' 100w" >' },
1715+
] satisfies CommentDisplayPart[],
1716+
);
1717+
});
1718+
1719+
it("Recognizes HTML audio and video src links", () => {
16721720
const comment = getComment(`/**
16731721
* <source src="./test.wav" >
16741722
* <source media="(prefers-color-scheme: dark)" src="./test_dark.mp4"/>
1675-
* <source src="https://example.com/favicon.ico">
1723+
* <source src="https://example.com/test.wav">
16761724
*/`);
16771725

16781726
equal(
@@ -1694,7 +1742,37 @@ describe("Comment Parser", () => {
16941742
},
16951743
{
16961744
kind: "text",
1697-
text: '"/>\n<source src="https://example.com/favicon.ico">',
1745+
text: '"/>\n<source src="https://example.com/test.wav">',
1746+
},
1747+
] satisfies CommentDisplayPart[],
1748+
);
1749+
});
1750+
1751+
it("Recognizes img tag with both src and srcset", () => {
1752+
const comment = getComment(`/**
1753+
* <img src="./test.png" srcset="./test2.png">
1754+
*/`);
1755+
1756+
equal(
1757+
comment.summary,
1758+
[
1759+
{ kind: "text", text: '<img src="' },
1760+
{
1761+
kind: "relative-link",
1762+
text: "./test.png",
1763+
target: 1 as FileId,
1764+
targetAnchor: undefined,
1765+
},
1766+
{ kind: "text", text: '" srcset="' },
1767+
{
1768+
kind: "relative-link",
1769+
text: "./test2.png",
1770+
target: 2 as FileId,
1771+
targetAnchor: undefined,
1772+
},
1773+
{
1774+
kind: "text",
1775+
text: '">',
16981776
},
16991777
] satisfies CommentDisplayPart[],
17001778
);

0 commit comments

Comments
 (0)