mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(agents): parse prose and rendered HTML anchors
This commit is contained in:
1 parent
1206f0d255
commit
bdf7017a97
7 files changed
+96
-35
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { Marked, Tokenizer } from 'marked';
|
||||
import { parseFragment } from 'parse5';
|
||||
|
||||
// Lex only: no Markdown is rendered and no HTML is sanitized or re-emitted.
|
||||
const markdownLexer = new Marked({
|
||||
@@ -34,14 +35,50 @@ function inlineText(tokens) {
|
||||
.map((token) => {
|
||||
if (token.type === 'html') return '';
|
||||
if (token.tokens) return inlineText(token.tokens);
|
||||
return token.text ?? '';
|
||||
return token.type === 'text'
|
||||
? decodeEntities(token.text ?? '')
|
||||
: (token.text ?? '');
|
||||
})
|
||||
.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 htmlAnchors(html) {
|
||||
const found = [];
|
||||
function visit(node) {
|
||||
if (['script', 'style', 'template'].includes(node.tagName)) return;
|
||||
for (const attribute of node.attrs ?? []) {
|
||||
if (
|
||||
attribute.name === 'id' ||
|
||||
(node.tagName === 'a' && attribute.name === 'name')
|
||||
)
|
||||
found.push(attribute.value);
|
||||
}
|
||||
for (const child of node.childNodes ?? []) visit(child);
|
||||
}
|
||||
visit(parseFragment(html));
|
||||
return found;
|
||||
}
|
||||
|
||||
export function guidanceProse(markdown) {
|
||||
function prose(token) {
|
||||
if (['code', 'codespan', 'html'].includes(token.type)) return '';
|
||||
if (token.items) return token.items.map(prose).join('\n');
|
||||
if (token.tokens) return token.tokens.map(prose).join('');
|
||||
return token.text ?? '';
|
||||
}
|
||||
return markdownLexer.lexer(markdown).map(prose).join('\n');
|
||||
}
|
||||
|
||||
export function guidanceAnchors(markdown) {
|
||||
const found = new Set();
|
||||
const explicit = new Set();
|
||||
const html = [];
|
||||
markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => {
|
||||
if (token.type === 'heading') {
|
||||
const slug = inlineText(token.tokens)
|
||||
@@ -53,14 +90,9 @@ export function guidanceAnchors(markdown) {
|
||||
while (found.has(unique)) unique = `${slug}-${++suffix}`;
|
||||
found.add(unique);
|
||||
}
|
||||
if (token.type === 'html') {
|
||||
for (const match of token.raw.matchAll(
|
||||
/<(?:a|[a-z][\w-]*)\b[^>]*\b(?:id|name)=["']([^"']+)["']/giu
|
||||
))
|
||||
explicit.add(match[1]);
|
||||
}
|
||||
if (token.type === 'html') html.push(token.raw);
|
||||
});
|
||||
return new Set([...found, ...explicit]);
|
||||
return new Set([...found, ...htmlAnchors(html.join('\n'))]);
|
||||
}
|
||||
|
||||
function isLiteralRepositoryPath(token) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"{projectRoot}/*.mjs",
|
||||
"{projectRoot}/project.json",
|
||||
{
|
||||
"externalDependencies": ["marked"]
|
||||
"externalDependencies": ["marked", "parse5"]
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
guidanceAnchors as anchors,
|
||||
guidanceProse,
|
||||
guidanceReferences as references,
|
||||
} from './agent-guidance-markdown.mjs';
|
||||
|
||||
@@ -21,27 +22,6 @@ function within(root, path) {
|
||||
);
|
||||
}
|
||||
|
||||
// Deliberately scoped to authored guidance, not a general Markdown crawler.
|
||||
function withoutFences(markdown) {
|
||||
let fence;
|
||||
return markdown
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
const match = /^\s{0,3}(`{3,}|~{3,})/u.exec(line);
|
||||
if (match) {
|
||||
if (!fence) fence = match[1];
|
||||
else if (
|
||||
match[1][0] === fence[0] &&
|
||||
match[1].length >= fence.length
|
||||
)
|
||||
fence = undefined;
|
||||
return '';
|
||||
}
|
||||
return fence ? '' : line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function validateReference(
|
||||
rootDir,
|
||||
source,
|
||||
@@ -109,7 +89,7 @@ export async function validateAgentGuidance({ rootDir }) {
|
||||
diagnostics.push(
|
||||
`${source}: at most ${maxBytes} UTF-8 bytes allowed (received ${bytes})`
|
||||
);
|
||||
const unfenced = withoutFences(markdown);
|
||||
const unfenced = guidanceProse(markdown);
|
||||
const imports = [...unfenced.matchAll(/^\s*@([^\s]+)\s*$/gmu)].map(
|
||||
(match) => match[1]
|
||||
);
|
||||
|
||||
@@ -454,3 +454,47 @@ for (const [encoded, filename] of [
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const source of ['AGENTS.md', 'CLAUDE.md']) {
|
||||
test(`${source}: indented decorators are not imports`, async (t) => {
|
||||
assert.deepEqual(
|
||||
await diagnostics(t, {
|
||||
[source]:
|
||||
(source === 'CLAUDE.md' ? '@AGENTS.md\n\n' : '') +
|
||||
' @Injectable()\n @docs/missing.md\n',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
}
|
||||
for (const body of [
|
||||
'<!-- <a id="old"></a> -->',
|
||||
`<script>const s = '<a id="old"></a>';</script>`,
|
||||
'<template><a id="old"></a></template>',
|
||||
]) {
|
||||
test(`non-rendered HTML does not define anchors: ${body}`, async (t) => {
|
||||
assert.match(
|
||||
(
|
||||
await diagnostics(t, {
|
||||
'AGENTS.md': '[Old](docs/example.md#old)',
|
||||
'docs/example.md': body,
|
||||
})
|
||||
).join('\n'),
|
||||
/missing anchor/
|
||||
);
|
||||
});
|
||||
}
|
||||
for (const [heading, anchor] of [
|
||||
['A & B', 'a--b'],
|
||||
['Café A B', 'café-a-b'],
|
||||
]) {
|
||||
test(`heading entities produce rendered anchors: ${heading}`, async (t) => {
|
||||
assert.deepEqual(
|
||||
await diagnostics(t, {
|
||||
'AGENTS.md': `[Heading](docs/example.md#${anchor})`,
|
||||
'docs/example.md': '# ' + heading,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user