fix(agents): decode full HTML entities and media assets

This commit is contained in:
4gray committed 2026-09-20 23:03:17 +02:00
1 parent a73538994a
commit 0ac3421594
3 files changed
+54 -9

No files matched your search

+3 -1
View File
@@ -58,7 +58,9 @@ Rendered HTML anchor hrefs and image sources use the same local-reference checks
as Markdown links, including decoded attributes and fragment validation.
Image references check file existence without interpreting image fragments as
Markdown headings; document links keep anchor checks even when sharing a target.
HTML video, audio and source `src` assets use the same existence checks as images.
HTML video, audio, source and track `src` assets and video posters use the same
existence checks as images. Entity decoding uses full HTML text/attribute rules,
including references whose semicolon may be omitted.
Inline guidance imports are rejected after punctuation as well as whitespace.
Declared scoped dependencies, scope wildcards and matching TypeScript path aliases
are recognized as package/alias mentions. Traversal and document-file imports are
+15 -8
View File
@@ -45,11 +45,15 @@ function inlineText(tokens) {
.join('');
}
function decodeEntities(text) {
return text.replace(
/&(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]+);/giu,
(entity) => parseFragment(entity).childNodes[0]?.value ?? entity
);
function decodeEntities(text, attribute = false) {
if (attribute) {
const html = `<a href="${text.replace(/"/gu, '&quot;')}"></a>`;
return parseFragment(html).childNodes[0].attrs[0].value;
}
// RCDATA decodes the full HTML character-reference grammar without
// interpreting literal tags. The prefix preserves an initial newline.
const html = `<textarea>x${text.replace(/</gu, '&lt;')}</textarea>`;
return parseFragment(html).childNodes[0].childNodes[0].value.slice(1);
}
function htmlNavigation(html, inspect = () => {}) {
@@ -66,8 +70,11 @@ function htmlNavigation(html, inspect = () => {}) {
anchors.push(attribute.value);
if (
(node.tagName === 'a' && attribute.name === 'href') ||
(['img', 'video', 'audio', 'source'].includes(node.tagName) &&
attribute.name === 'src')
(['img', 'video', 'audio', 'source', 'track'].includes(
node.tagName
) &&
attribute.name === 'src') ||
(node.tagName === 'video' && attribute.name === 'poster')
)
references.push({
target: attribute.value,
@@ -216,7 +223,7 @@ export function guidanceReferences(markdown, includeLiterals) {
markdownLexer.walkTokens(tokens, (token) => {
if (token.type === 'def')
mark(token, {
target: decodeEntities(token.href),
target: decodeEntities(token.href, true),
definition: true,
});
else if (token.type === 'unresolved-reference')
@@ -955,3 +955,39 @@ for (const html of [
);
});
}
for (const heading of ['A&copy B', 'A&#169 B', 'A&#xA9 B']) {
test(`semicolonless heading entities decode: ${heading}`, async (t) => {
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md': '[Heading](docs/example.md#a-b)',
'docs/example.md': '# ' + heading,
}),
[]
);
});
}
test('semicolonless definition entities match rendered destinations', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md': '[ref]: docs/a&copy.md',
'docs/a©.md': '',
}),
[]
);
});
for (const html of [
'<video poster="docs/asset.png"></video>',
'<video><track src="docs/asset.png"></video>',
]) {
test(`remaining media assets are checked: ${html}`, async (t) => {
assert.match(
(await diagnostics(t, { 'AGENTS.md': html })).join('\n'),
/does not exist/
);
assert.deepEqual(
await diagnostics(t, { 'AGENTS.md': html, 'docs/asset.png': '' }),
[]
);
});
}