diff --git a/.github/scripts/pin-readme-assets.mjs b/.github/scripts/pin-readme-assets.mjs index 2899560c8..786ba9515 100644 --- a/.github/scripts/pin-readme-assets.mjs +++ b/.github/scripts/pin-readme-assets.mjs @@ -3,18 +3,150 @@ // repo on GitHub. Rewrite them to the release tag's raw-GitHub URL before the // packs, so every variant ships absolute, immutable asset URLs. The CI // workspace is ephemeral, so no restore pass is needed. Mirrors -// socket-wheelhouse's publish-infra pin-readme pass (src, srcset, and -// markdown ref forms; absolute refs are untouched and the rewrite is -// idempotent — an already-absolute ref has no leading `assets/` to match). +// socket-wheelhouse's publish-infra pin-readme pass; absolute refs are +// untouched and the rewrite is idempotent — an already-absolute ref has no +// leading `assets/` to pin. +// +// The README is parsed to a position-tracked mdast tree and edits land on the +// exact byte ranges the parser reports, never via a scan of the raw markdown: +// `assets/` inside a code fence or an inline code span is content, not a ref, +// and stays as written. Raw HTML arrives as mdast `html` nodes; each node's +// source slice goes through parse5 with source locations on, and only +// `src`/`srcset` attribute values that start with `assets/` are pinned. import { readFileSync, writeFileSync } from 'node:fs' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import { parseFragment } from 'parse5' + +const RELATIVE_PREFIX = 'assets/' +const PINNED_ATTRS = new Set(['src', 'srcset']) + const { version } = JSON.parse(readFileSync('package.json', 'utf8')) const base = `https://raw.githubusercontent.com/SocketDev/socket-cli/v${version}/` const readme = readFileSync('README.md', 'utf8') -const pinned = readme - .replaceAll('src="assets/', `src="${base}assets/`) - .replaceAll('srcset="assets/', `srcset="${base}assets/`) - .replaceAll('](assets/', `](${base}assets/`) + +/** + * Byte offsets in the README where `base` gets inserted, each sitting + * immediately before a relative ref's leading `assets/`. + * @type {number[]} + */ +const insertAt = [] + +/** @param {import('mdast').Nodes} node */ +function walkMdast(node) { + if ( + node.type === 'image' || + node.type === 'link' || + node.type === 'definition' + ) { + collectMarkdownUrl(node) + } else if (node.type === 'html') { + collectHtmlAttrs(node) + } + if ('children' in node) { + for (const child of node.children) { + walkMdast(child) + } + } +} + +/** + * An image, link, or definition node carries its destination in `url` and its + * own span in `position`. The destination is the last occurrence of that url + * inside the span (label text precedes it), so the insertion point derives + * from the node position rather than a scan of the document. + * @param {import('mdast').Image | import('mdast').Link | import('mdast').Definition} node + */ +function collectMarkdownUrl(node) { + if (!node.url.startsWith(RELATIVE_PREFIX)) { + return + } + const start = node.position?.start?.offset + const end = node.position?.end?.offset + if (start === undefined || end === undefined) { + return + } + const urlAt = readme.slice(start, end).lastIndexOf(node.url) + if (urlAt === -1) { + return + } + insertAt.push(start + urlAt) +} + +/** + * Parse the html node's source slice with locations on and pin `src`/`srcset` + * values that start with `assets/`, using parse5's attribute byte ranges. + * @param {import('mdast').Html} node + */ +function collectHtmlAttrs(node) { + const nodeStart = node.position?.start?.offset + const nodeEnd = node.position?.end?.offset + if (nodeStart === undefined || nodeEnd === undefined) { + return + } + const html = readme.slice(nodeStart, nodeEnd) + const fragment = parseFragment(html, { sourceCodeLocationInfo: true }) + walkParse5(fragment, element => { + const attrLocations = element.sourceCodeLocation?.attrs + if (!attrLocations) { + return + } + for (const attr of element.attrs) { + if ( + !PINNED_ATTRS.has(attr.name) || + !attr.value.startsWith(RELATIVE_PREFIX) + ) { + continue + } + const location = attrLocations[attr.name] + if (!location) { + continue + } + const attrText = html.slice(location.startOffset, location.endOffset) + const valueAt = attrText.indexOf(attr.value, attr.name.length) + if (valueAt === -1) { + continue + } + insertAt.push(nodeStart + location.startOffset + valueAt) + } + }) +} + +/** + * Visit every element in a parse5 tree, including template contents. + * @param {object} node + * @param {(element: { attrs: Array<{ name: string, value: string }>, sourceCodeLocation?: { attrs?: Record } }) => void} visit + */ +function walkParse5(node, visit) { + if (Array.isArray(node.attrs)) { + visit(node) + } + if (node.content) { + walkParse5(node.content, visit) + } + if (Array.isArray(node.childNodes)) { + for (const child of node.childNodes) { + walkParse5(child, visit) + } + } +} + +// Parse with the GFM extensions so the tree matches how GitHub and npm +// actually render the README (tables, footnotes, strikethrough, task lists). +walkMdast( + fromMarkdown(readme, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }), +) + +let pinned = readme +for (const offset of [...new Set(insertAt)].sort((a, b) => b - a)) { + pinned = pinned.slice(0, offset) + base + pinned.slice(offset) +} + if (pinned === readme) { console.log('pin-readme-assets: no relative assets/ refs to pin') } else { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46a2204c9..dd64727cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,7 +421,8 @@ jobs: strategy: fail-fast: true matrix: - node-version: [20, 22, 24] + # Node 20 reached end-of-life 2026-03-24; test supported majors only. + node-version: [22, 24] os: [ubuntu-latest] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d66870448..ddf8a611c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -16,7 +16,8 @@ jobs: strategy: fail-fast: true matrix: - node-version: [20, 22, 24] + # Node 20 reached end-of-life 2026-03-24; test supported majors only. + node-version: [22, 24] os: [ubuntu-latest] # os: [ubuntu-latest, windows-latest] - Windows tests disbaled (see project https://linear.app/socketdev/project/autofixes-windows-support-fc2f2a45f759) steps: diff --git a/package.json b/package.json index c20af76e5..1f6b6f46e 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "@types/blessed": "0.1.25", "@types/cmd-shim": "5.0.2", "@types/js-yaml": "4.0.9", + "@types/mdast": "4.0.4", "@types/micromatch": "4.0.9", "@types/mock-fs": "4.13.4", "@types/node": "24.3.1", @@ -169,7 +170,10 @@ "knip": "5.63.1", "lint-staged": "16.1.6", "magic-string": "0.30.19", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-gfm": "3.1.0", "meow": "13.2.0", + "micromark-extension-gfm": "3.0.0", "micromatch": "4.0.8", "mock-fs": "5.5.0", "nock": "14.0.10", @@ -177,6 +181,7 @@ "npm-run-all2": "8.0.4", "open": "10.2.0", "oxlint": "1.15.0", + "parse5": "8.0.1", "pony-cause": "2.1.11", "postject": "1.0.0-alpha.6", "rollup": "4.50.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d009a3b4a..ad7b5f56f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 '@types/micromatch': specifier: 4.0.9 version: 4.0.9 @@ -335,9 +338,18 @@ importers: magic-string: specifier: 0.30.19 version: 0.30.19 + mdast-util-from-markdown: + specifier: 2.0.3 + version: 2.0.3 + mdast-util-gfm: + specifier: 3.1.0 + version: 3.1.0 meow: specifier: 13.2.0 version: 13.2.0(patch_hash=00fba6d3f9a0591670dcc98f872839fd1669152891f292799bfd7fdda4d9ce36) + micromark-extension-gfm: + specifier: 3.0.0 + version: 3.0.0 micromatch: specifier: 4.0.8 version: 4.0.8 @@ -359,6 +371,9 @@ importers: oxlint: specifier: 1.15.0 version: 1.15.0 + parse5: + specifier: 8.0.1 + version: 8.0.1 pony-cause: specifier: 2.1.11 version: 2.1.11 @@ -1935,6 +1950,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/micromatch@4.0.9': resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} @@ -1986,6 +2004,9 @@ packages: '@types/ssri@7.1.5': resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/which@3.0.4': resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} @@ -2490,6 +2511,9 @@ packages: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} hasBin: true + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2513,6 +2537,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + charm@0.1.2: resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} @@ -2688,6 +2715,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@10.0.0: resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} engines: {node: '>=20'} @@ -2743,6 +2773,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2755,6 +2789,9 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2836,6 +2873,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2876,6 +2917,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -3575,6 +3620,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3617,6 +3665,9 @@ packages: map-canvas@0.1.5: resolution: {integrity: sha512-f7M3sOuL9+up0NCOZbb1rQpWDLZwR/ftCiNbyscjl9LUUEwrRaoumH4sz6swgs58lF21DQ0hsYOCw5C6Zz7hbg==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked-terminal@5.2.0: resolution: {integrity: sha512-Piv6yNwAQXGFjZSaiNljyNFw7jKDdGrw70FSbtxEyldLsyeuV5ZHm/1wW++kWbrOF1VPnUgYOhB2oLL0ZpnekA==} engines: {node: '>=14.13.1 || >=16.0.0'} @@ -3628,6 +3679,39 @@ packages: engines: {node: '>= 12'} hasBin: true + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3650,6 +3734,90 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4073,6 +4241,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4767,6 +4938,18 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -5054,6 +5237,9 @@ packages: zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@ampproject/remapping@2.3.0': @@ -6593,7 +6779,6 @@ snapshots: '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 - optional: true '@types/deep-eql@4.0.2': {} @@ -6605,6 +6790,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/micromatch@4.0.9': dependencies: '@types/braces': 3.0.5 @@ -6613,8 +6802,7 @@ snapshots: dependencies: '@types/node': 24.3.1 - '@types/ms@2.1.0': - optional: true + '@types/ms@2.1.0': {} '@types/node-fetch@2.6.13': dependencies: @@ -6673,6 +6861,8 @@ snapshots: dependencies: '@types/node': 24.3.1 + '@types/unist@3.0.3': {} + '@types/which@3.0.4': {} '@types/yargs-parser@21.0.3': {} @@ -7220,6 +7410,8 @@ snapshots: ansicolors: 0.3.2 redeyed: 2.1.1 + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -7254,6 +7446,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + charm@0.1.2: {} check-error@2.1.1: {} @@ -7430,6 +7624,10 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decompress-response@10.0.0: dependencies: mimic-response: 4.0.0 @@ -7479,6 +7677,8 @@ snapshots: depd@2.0.0: optional: true + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: @@ -7489,6 +7689,10 @@ snapshots: meow: 13.2.0(patch_hash=00fba6d3f9a0591670dcc98f872839fd1669152891f292799bfd7fdda4d9ce36) noop-stream: 1.0.0 + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7573,6 +7777,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -7625,6 +7831,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@9.35.0(jiti@2.6.1)): dependencies: eslint: 9.35.0(jiti@2.6.1) @@ -8373,6 +8581,8 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + longest-streak@3.1.0: {} + loupe@3.2.1: {} lowercase-keys@3.0.0: {} @@ -8453,6 +8663,8 @@ snapshots: drawille-canvas-blessed-contrib: 0.1.3(patch_hash=baf1e92576f78c2c86283e7a3182ddd59d52cd7e86ad9fe21d1c4ccc2274bcf3) xml2js: 0.6.2 + markdown-table@3.0.4: {} + marked-terminal@5.2.0(marked@4.3.0): dependencies: ansi-escapes: 6.2.1 @@ -8465,6 +8677,108 @@ snapshots: marked@4.3.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + media-typer@1.1.0: optional: true @@ -8480,6 +8794,197 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8983,6 +9488,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: optional: true @@ -9713,6 +10222,25 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universal-user-agent@7.0.3: {} unpipe@1.0.0: @@ -10014,3 +10542,5 @@ snapshots: zod@3.25.76: {} zod@4.1.8: {} + + zwitch@2.0.4: {} diff --git a/scripts/release/changelog.mts b/scripts/release/changelog.mts index 1f89bdf98..a45f889a3 100644 --- a/scripts/release/changelog.mts +++ b/scripts/release/changelog.mts @@ -12,10 +12,56 @@ * silently replaced by a raw commit subject. */ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' + import type { ConventionalCommit } from './version.mts' +import type { Nodes, Root } from 'mdast' export const UNRELEASED_HEADING = '## [Unreleased]' +/** + * Parse markdown the way GitHub renders it (GFM) into a position-tracked + * mdast tree. Structure questions — where a heading is, whether a block has + * bullets — are answered from this tree, never by scanning raw lines: a `## ` + * or `- ` inside a fenced code block is content, not structure. + */ +function parseMarkdown(source: string): Root { + return fromMarkdown(source, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }) +} + +/** + * 0-based line indexes of the document's level-2 (`## `) headings, from + * parser-reported positions. + */ +function h2LineIndexes(source: string): number[] { + const indexes: number[] = [] + for (const node of parseMarkdown(source).children) { + if ( + node.type === 'heading' && + node.depth === 2 && + node.position?.start.line !== undefined + ) { + indexes.push(node.position.start.line - 1) + } + } + return indexes +} + +function hasListItem(node: Nodes): boolean { + if (node.type === 'listItem') { + return true + } + if ('children' in node) { + return node.children.some(hasListItem) + } + return false +} + // User-visible commit types → the Keep a Changelog section each lands under. A // type absent from this map is internal churn and never reaches the changelog, // matching the repo's "exclude internal changes" rule. @@ -148,11 +194,12 @@ export function generateChangelogSection( } /** - * True when a rendered section carries at least one `- ` bullet, as opposed to - * a bare heading with nothing under it. + * True when a rendered section carries at least one real list item, as + * opposed to a bare heading with nothing under it. Parsed, not pattern + * matched: a `- ` line inside a fenced code block is not an entry. */ export function sectionHasEntries(section: string): boolean { - return section.split('\n').some(line => /^\s*-\s/u.test(line)) + return parseMarkdown(section).children.some(hasListItem) } /** @@ -166,17 +213,15 @@ export function unreleasedRange( lines: readonly string[], ): { end: number; start: number } | undefined { const wanted = UNRELEASED_HEADING.toLowerCase() - const start = lines.findIndex(line => line.trim().toLowerCase() === wanted) - if (start === -1) { + const headings = h2LineIndexes(lines.join('\n')) + const at = headings.findIndex( + index => lines[index]!.trim().toLowerCase() === wanted, + ) + if (at === -1) { return undefined } - let end = lines.length - for (let i = start + 1, { length } = lines; i < length; i += 1) { - if (lines[i]!.startsWith('## ')) { - end = i - break - } - } + const start = headings[at]! + const end = at + 1 < headings.length ? headings[at + 1]! : lines.length return { end, start } } @@ -220,7 +265,7 @@ export function promoteChangelog( source = 'unreleased' } } - const insertAt = remaining.findIndex(line => line.startsWith('## ')) + const insertAt = h2LineIndexes(remaining.join('\n'))[0] ?? -1 const head = insertAt === -1 ? remaining : remaining.slice(0, insertAt) const tail = insertAt === -1 ? [] : remaining.slice(insertAt) const changelog = `${[...head, section, '', ...tail].join('\n').trimEnd()}\n` diff --git a/test/pin-readme-assets.test.mts b/test/pin-readme-assets.test.mts new file mode 100644 index 000000000..ebc78435e --- /dev/null +++ b/test/pin-readme-assets.test.mts @@ -0,0 +1,231 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'vitest' + +const rootPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const scriptPath = path.join( + rootPath, + '.github', + 'scripts', + 'pin-readme-assets.mjs', +) + +const tempDirs: string[] = [] + +function stageWorkspace(options: { + readme: string + version?: string | undefined +}): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'pin-readme-assets-')) + tempDirs.push(dir) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'socket', version: options.version ?? '1.2.3' }), + ) + writeFileSync(path.join(dir, 'README.md'), options.readme) + return dir +} + +function runPin(cwd: string): { status: number | null; stdout: string } { + const result = spawnSync(process.execPath, [scriptPath], { + cwd, + encoding: 'utf8', + }) + return { status: result.status, stdout: result.stdout } +} + +function readmeIn(dir: string): string { + return readFileSync(path.join(dir, 'README.md'), 'utf8') +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }) + } +}) + +// Registry pages (npmjs.com) render the README from the published tarball, +// where relative assets/ refs 404 — they only resolve when browsing the repo +// on GitHub. The publish workflow runs this script once before packing so +// every variant ships absolute raw-GitHub URLs pinned to the release tag. +describe('pin-readme-assets', () => { + const base = 'https://raw.githubusercontent.com/SocketDev/socket-cli/v1.2.3/' + + it('pins a relative img src to the release tag raw URL', () => { + const dir = stageWorkspace({ + readme: 'logo\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `logo\n`, + ) + }) + + it('pins a relative srcset ref', () => { + const dir = stageWorkspace({ + readme: + '\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe( + `\n`, + ) + }) + + it('pins a markdown image ref', () => { + const dir = stageWorkspace({ + readme: '![banner](assets/banner.png)\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe(`![banner](${base}assets/banner.png)\n`) + }) + + it('reads the tag from package.json version', () => { + const dir = stageWorkspace({ + readme: '![banner](assets/banner.png)\n', + version: '9.9.9', + }) + runPin(dir) + expect(readmeIn(dir)).toContain( + 'https://raw.githubusercontent.com/SocketDev/socket-cli/v9.9.9/assets/banner.png', + ) + }) + + it('leaves absolute refs untouched', () => { + const readme = + '\n' + + '![ext](https://example.com/assets/banner.png)\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('is idempotent — a second run changes nothing', () => { + const dir = stageWorkspace({ + readme: + '\n' + + '\n' + + '![banner](assets/banner.png)\n', + }) + runPin(dir) + const afterFirst = readmeIn(dir) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(afterFirst) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('pins a reference-style definition', () => { + const dir = stageWorkspace({ + readme: '![banner][banner-ref]\n\n[banner-ref]: assets/banner.png\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `![banner][banner-ref]\n\n[banner-ref]: ${base}assets/banner.png\n`, + ) + }) + + it('pins refs inside blockquotes and list items', () => { + const dir = stageWorkspace({ + readme: + '> ![quoted](assets/quoted.png)\n' + + '\n' + + '- [download](assets/file.pdf)\n', + }) + runPin(dir) + expect(readmeIn(dir)).toBe( + `> ![quoted](${base}assets/quoted.png)\n` + + '\n' + + `- [download](${base}assets/file.pdf)\n`, + ) + }) + + it('leaves assets/ refs inside fenced code blocks alone', () => { + const readme = + '```md\n' + + '![example](assets/example.png)\n' + + '\n' + + '```\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('leaves assets/ refs inside inline code spans alone', () => { + const readme = 'Point refs like `](assets/x.png)` at the release tag.\n' + const dir = stageWorkspace({ readme }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe(readme) + expect(stdout).toContain('no relative assets/ refs to pin') + }) + + it('pins real refs while leaving code-block lookalikes alone', () => { + const dir = stageWorkspace({ + readme: + '![banner](assets/banner.png)\n' + + '\n' + + '```html\n' + + '\n' + + '```\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + expect(readmeIn(dir)).toBe( + `![banner](${base}assets/banner.png)\n` + + '\n' + + '```html\n' + + '\n' + + '```\n', + ) + }) + + it('pins refs inside GFM tables and footnotes', () => { + const dir = stageWorkspace({ + readme: + '| Logo | Name |\n' + + '| --- | --- |\n' + + '| ![logo](assets/logo.png) | Socket |\n' + + '\n' + + 'See the screenshot.[^shot]\n' + + '\n' + + '[^shot]: ![shot](assets/shot.png)\n', + }) + const { status } = runPin(dir) + expect(status).toBe(0) + const pinned = readmeIn(dir) + expect(pinned).toContain(`| ![logo](${base}assets/logo.png) | Socket |`) + expect(pinned).toContain(`[^shot]: ![shot](${base}assets/shot.png)`) + }) + + it('pins every ref form in one pass and reports the base', () => { + const dir = stageWorkspace({ + readme: + '\n' + + ' \n' + + ' Socket CLI\n' + + '\n' + + '\n' + + 'See ![the flow](assets/flow.png) for details.\n', + }) + const { status, stdout } = runPin(dir) + expect(status).toBe(0) + expect(stdout).toContain(`pinned relative assets/ refs to ${base}`) + const pinned = readmeIn(dir) + expect(pinned).not.toContain('"assets/') + expect(pinned).not.toContain('](assets/') + expect(pinned).toContain(`src="${base}assets/light.png"`) + expect(pinned).toContain(`srcset="${base}assets/dark.png"`) + expect(pinned).toContain(`](${base}assets/flow.png)`) + }) +}) diff --git a/test/release-version.test.mts b/test/release-version.test.mts index 6b9b824ee..92290643a 100644 --- a/test/release-version.test.mts +++ b/test/release-version.test.mts @@ -327,4 +327,52 @@ describe('promoteChangelog', () => { expect(promoted.source).toBe('derived') expect(promoted.changelog).not.toContain('[Unreleased]') }) + + it('promotes a block whose code fence contains a ## line intact', () => { + const changelog = [ + preamble, + '## [Unreleased]', + '', + '### Changed', + '- The changelog format now looks like:', + '', + '```md', + '## [9.9.9](https://example.com) - 2020-01-01', + '```', + '', + '## [1.1.153](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.153) - 2026-08-04', + '', + '### Changed', + '- Updated the Coana CLI.', + '', + ].join('\n') + const promoted = promoteChangelog({ changelog, derivedSection, heading }) + expect(promoted.source).toBe('unreleased') + expect(promoted.section).toContain( + '```md\n## [9.9.9](https://example.com) - 2020-01-01\n```', + ) + expect(promoted.changelog).not.toContain('[Unreleased]') + expect(promoted.changelog).toContain('- Updated the Coana CLI.') + }) + + it('treats a bullet lookalike inside a code fence as no entries', () => { + const changelog = [ + preamble, + '## [Unreleased]', + '', + '```sh', + '- not a bullet, just shell output', + '```', + '', + '## [1.1.153](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.153) - 2026-08-04', + '', + '### Changed', + '- Updated the Coana CLI.', + '', + ].join('\n') + const promoted = promoteChangelog({ changelog, derivedSection, heading }) + expect(promoted.source).toBe('derived') + expect(promoted.section).toBe(derivedSection) + expect(promoted.changelog).not.toContain('[Unreleased]') + }) }) diff --git a/test/script-run-main.test.mts b/test/script-run-main.test.mts index 9d92b56b0..06a5edc4d 100644 --- a/test/script-run-main.test.mts +++ b/test/script-run-main.test.mts @@ -204,7 +204,17 @@ describe('entry scripts', () => { 'scripts/release/promote.mts', ] - it.each(entries)( + // Bare `node .mts` needs native type stripping, which landed in + // Node 22.6 (`process.features.typescript` reports 'strip' or 'transform' + // there and is undefined before it). These entries are maintainer tooling + // that targets the repo's pinned dev Node; the older CI matrix lanes cover + // the built product, not this tooling, so the spawn assertions skip where + // the runtime cannot execute .mts at all. + // The experimental flag is the capability being probed; undefined is false. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const canRunMts = Boolean(process.features.typescript) + + it.skipIf(!canRunMts).each(entries)( '%s answers --describe without touching the tree', entryPath => { const before = spawnSync('git', ['status', '--porcelain'], { @@ -219,8 +229,10 @@ describe('entry scripts', () => { cwd: rootPath, encoding: 'utf8', }).stdout - expect(result.status).toBe(0) - expect(result.stdout.trim().length).toBeGreaterThan(0) + // Surface the spawned stderr on failure so an environmental break + // (missing runtime capability, bad PATH) explains itself. + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim().length, result.stderr).toBeGreaterThan(0) expect(result.stdout.trim().split('\n')).toHaveLength(1) expect(after).toBe(before) },