diff --git a/scripts/fetch-github-projects.mjs b/scripts/fetch-github-projects.mjs index fb0281b..9022eb7 100644 --- a/scripts/fetch-github-projects.mjs +++ b/scripts/fetch-github-projects.mjs @@ -53,6 +53,12 @@ export function truncate(text, max) { return t.length <= max ? t : t.slice(0, max - 1).trimEnd() + '…'; } +// An image or link with an inline `(url)` or a reference `[label]` target, optionally wrapped in a link. +// Alt text, labels and URLs exclude brackets, and URLs allow only one level of parentheses, +// so every match attempt stops at the next bracket and matching stays linear. +const TARGET = String.raw`(?:\((?:[^()[\]\s]|\([^()[\]\s]*\))*\)|\[[^[\]]*\])`; +const BADGE = new RegExp(String.raw`\[?!?\[[^[\]]*\]${TARGET}(?:\]${TARGET})?`, 'g'); + /** Pull the first real paragraph out of a README, skipping title/badges/code. */ export function readmeExcerpt(md) { const lines = String(md).split('\n'); @@ -69,8 +75,8 @@ export function readmeExcerpt(md) { if (fence) continue; if (!line) { if (para.length) break; else continue; } if (line.startsWith('#')) continue; // heading - // line of images or linked badges; alt and URL exclude brackets so matching stays linear - if (!line.replace(/\[?!?\[[^[\]]*\]\([^()\s]*\)(?:\]\([^()\s]*\))?/g, '').trim()) continue; + if (!line.replace(BADGE, '').trim()) continue; // line of images or (linked) badges + if (/^\[[^[\]]+\]:\s/.test(line)) continue; // link reference definition if (/^[-=]{3,}$/.test(line)) continue; // setext underline / hr para.push(line); } diff --git a/scripts/fetch-github-projects.test.mjs b/scripts/fetch-github-projects.test.mjs index 2b84a8d..322dcbb 100644 --- a/scripts/fetch-github-projects.test.mjs +++ b/scripts/fetch-github-projects.test.mjs @@ -53,10 +53,22 @@ test('the excerpt skips linked badges, tilde fences and plain link lines', () => assert.equal(readmeExcerpt(`# T\n\n${badges}\n\n~~~\ncode\n~~~\n\n[Docs](https://x/docs)\n\nReal text.`), 'Real text.'); assert.equal(readmeExcerpt('See [the docs](https://x/docs) for more.'), 'See [the docs](https://x/docs) for more.'); assert.equal(readmeExcerpt('~~~md\n```js\nx\n~~~\n\nReal text.'), 'Real text.'); - // CodeQL's backtracking input: took seconds with the old badge pattern - const start = performance.now(); - readmeExcerpt(`[]()${'[[]()'.repeat(28)}x`); - assert.ok(performance.now() - start < 100); +}); + +test('the excerpt skips reference-style badges and URLs with parentheses', () => { + const refs = '[![CI][ci-badge]][ci] ![Cov][cov]\n\n[ci-badge]: https://x/ci.svg'; + assert.equal(readmeExcerpt(`${refs}\n\nReal text.`), 'Real text.'); + assert.equal(readmeExcerpt('[![W](https://x/w_(a).svg)](https://x/w_(a))\n\nReal text.'), 'Real text.'); + assert.equal(readmeExcerpt('See [a][b] for more.'), 'See [a][b] for more.'); +}); + +test('badge matching stays fast on crafted lines', () => { + // the first is CodeQL's backtracking input, which took seconds with an earlier pattern + for (const line of [`[]()${'[[]()'.repeat(28)}x`, '[a]('.repeat(5000), '[a]['.repeat(5000), `[a](${'(b'.repeat(5000)}`]) { + const start = performance.now(); + readmeExcerpt(line); + assert.ok(performance.now() - start < 100, line.slice(0, 8)); + } }); test('a repo link is found in a file with Windows line endings', () => {