Skip to content

Commit 07d8315

Browse files
committed
Merge branch 'fix/source-srcset' into dev
2 parents dcb0bd6 + 59db5b2 commit 07d8315

4 files changed

Lines changed: 281 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@ title: Changelog
1010
- Permit `-` within tag names to support `typescript-json-schema`'s `@TJS-type` tag, #2972.
1111
- Exposed `Context.createSymbolId` for use by plugins.
1212

13+
### Bug Fixes
14+
15+
- Relative links in `<img srcset>` will now be discovered by TypeDoc, #2975.
16+
- Relative links in `<source src>` and `<source srcset>` elements will now be discovered by TypeDoc, #2975.
17+
1318
### Thanks!
1419

1520
- @jonathanhefner
21+
- @laymonage
1622

1723
## v0.28.7 (2025-06-30)
1824

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: 127 additions & 25 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

@@ -297,52 +299,152 @@ function checkReference(data: TextParserData): RelativeLink | undefined {
297299
}
298300

299301
/**
300-
* Looks for `<a href="./relative">` and `<img src="./relative">`
302+
* 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 });
325+
}
326+
327+
if (token.text.startsWith("<source ", pos)) {
328+
data.pos += 8;
329+
return checkAttributes(data, {
330+
src: checkAttributeDirectPath,
331+
srcset: checkAttributeSrcSet,
332+
});
313333
}
334+
335+
return [];
314336
}
315337

316-
function checkAttribute(
338+
function checkAttributes(
317339
data: TextParserData,
318-
attr: string,
319-
): RelativeLink | undefined {
340+
attributes: Record<
341+
string,
342+
(data: TextParserData, text: string, pos: number, end: number) => RelativeLink[]
343+
>,
344+
): RelativeLink[] {
345+
const links: RelativeLink[] = [];
320346
const parser = new HtmlAttributeParser(data.token.text, data.pos);
321347
while (parser.state !== ParserState.END) {
322348
if (
323349
parser.state === ParserState.BeforeAttributeValue &&
324-
parser.currentAttributeName === attr
350+
attributes.hasOwnProperty(parser.currentAttributeName)
325351
) {
326352
parser.step();
327353

328-
if (isRelativePath(parser.currentAttributeValue)) {
329-
data.pos = parser.pos;
330-
const { target, anchor } = data.files.register(
331-
data.sourcePath,
332-
parser.currentAttributeValue as NormalizedPath,
333-
) || { target: undefined, anchor: undefined };
334-
return {
335-
pos: parser.currentAttributeValueStart,
336-
end: parser.currentAttributeValueEnd,
337-
target,
338-
targetAnchor: anchor,
339-
};
340-
}
341-
return;
354+
links.push(...attributes[parser.currentAttributeName](
355+
data,
356+
parser.currentAttributeValue,
357+
parser.currentAttributeValueStart,
358+
parser.currentAttributeValueEnd,
359+
));
342360
}
343361

344362
parser.step();
345363
}
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+
}
346448
}
347449

348450
function isRelativePath(link: string) {

src/test/comments.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,148 @@ describe("Comment Parser", () => {
16301630
);
16311631
});
16321632

1633+
it("Recognizes HTML picture source srcset links", () => {
1634+
const comment = getComment(`/**
1635+
* <source media="(prefers-color-scheme: light)" srcset="./test.png" >
1636+
* <source srcset="./test2.png 100w, ./test3.png 2x" >
1637+
* <source media="(prefers-color-scheme: dark)" srcset="./test%20space.png, ./test5.png"/>
1638+
* <source srcset="https://example.com/favicon.ico">
1639+
*/`);
1640+
1641+
equal(
1642+
comment.summary,
1643+
[
1644+
{ kind: "text", text: '<source media="(prefers-color-scheme: light)" srcset="' },
1645+
{
1646+
kind: "relative-link",
1647+
text: "./test.png",
1648+
target: 1 as FileId,
1649+
targetAnchor: undefined,
1650+
},
1651+
{ kind: "text", text: '" >\n<source srcset="' },
1652+
{
1653+
kind: "relative-link",
1654+
text: "./test2.png",
1655+
target: 2 as FileId,
1656+
targetAnchor: undefined,
1657+
},
1658+
{ kind: "text", text: " 100w, " },
1659+
{
1660+
kind: "relative-link",
1661+
text: "./test3.png",
1662+
target: 3 as FileId,
1663+
targetAnchor: undefined,
1664+
},
1665+
{
1666+
kind: "text",
1667+
text: ' 2x" >\n<source media="(prefers-color-scheme: dark)" srcset="',
1668+
},
1669+
{
1670+
kind: "relative-link",
1671+
text: "./test%20space.png",
1672+
target: 4 as FileId,
1673+
targetAnchor: undefined,
1674+
},
1675+
{
1676+
kind: "text",
1677+
text: ", ",
1678+
},
1679+
{
1680+
kind: "relative-link",
1681+
text: "./test5.png",
1682+
target: 5 as FileId,
1683+
targetAnchor: undefined,
1684+
},
1685+
{
1686+
kind: "text",
1687+
text: '"/>\n<source srcset="https://example.com/favicon.ico">',
1688+
},
1689+
] satisfies CommentDisplayPart[],
1690+
);
1691+
});
1692+
1693+
it("Recognizes <link imagesrcset> links", () => {
1694+
const comment = getComment(`/**
1695+
* <link imagesrcset="./test.png 100w" >
1696+
*/`);
1697+
1698+
equal(
1699+
comment.summary,
1700+
[
1701+
{ kind: "text", text: '<link imagesrcset="' },
1702+
{
1703+
kind: "relative-link",
1704+
text: "./test.png",
1705+
target: 1 as FileId,
1706+
targetAnchor: undefined,
1707+
},
1708+
{ kind: "text", text: ' 100w" >' },
1709+
] satisfies CommentDisplayPart[],
1710+
);
1711+
});
1712+
1713+
it("Recognizes HTML audio and video src links", () => {
1714+
const comment = getComment(`/**
1715+
* <source src="./test.wav" >
1716+
* <source media="(prefers-color-scheme: dark)" src="./test_dark.mp4"/>
1717+
* <source src="https://example.com/test.wav">
1718+
*/`);
1719+
1720+
equal(
1721+
comment.summary,
1722+
[
1723+
{ kind: "text", text: '<source src="' },
1724+
{
1725+
kind: "relative-link",
1726+
text: "./test.wav",
1727+
target: 1 as FileId,
1728+
targetAnchor: undefined,
1729+
},
1730+
{ kind: "text", text: '" >\n<source media="(prefers-color-scheme: dark)" src="' },
1731+
{
1732+
kind: "relative-link",
1733+
text: "./test_dark.mp4",
1734+
target: 2 as FileId,
1735+
targetAnchor: undefined,
1736+
},
1737+
{
1738+
kind: "text",
1739+
text: '"/>\n<source src="https://example.com/test.wav">',
1740+
},
1741+
] satisfies CommentDisplayPart[],
1742+
);
1743+
});
1744+
1745+
it("Recognizes img tag with both src and srcset", () => {
1746+
const comment = getComment(`/**
1747+
* <img src="./test.png" srcset="./test2.png">
1748+
*/`);
1749+
1750+
equal(
1751+
comment.summary,
1752+
[
1753+
{ kind: "text", text: '<img src="' },
1754+
{
1755+
kind: "relative-link",
1756+
text: "./test.png",
1757+
target: 1 as FileId,
1758+
targetAnchor: undefined,
1759+
},
1760+
{ kind: "text", text: '" srcset="' },
1761+
{
1762+
kind: "relative-link",
1763+
text: "./test2.png",
1764+
target: 2 as FileId,
1765+
targetAnchor: undefined,
1766+
},
1767+
{
1768+
kind: "text",
1769+
text: '">',
1770+
},
1771+
] satisfies CommentDisplayPart[],
1772+
);
1773+
});
1774+
16331775
it("Recognizes HTML anchor links", () => {
16341776
const comment = getComment(`/**
16351777
* <a data-foo="./path.txt" href="./test.png" >

0 commit comments

Comments
 (0)