diff --git a/package.json b/package.json index bdd4dc9fa..0633cbba8 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,9 @@ "magic-string": "catalog:", "markdownlint-cli2": "catalog:", "mdast-util-from-markdown": "catalog:", + "mdast-util-gfm": "catalog:", "micromark": "catalog:", + "micromark-extension-gfm": "catalog:", "micromatch": "catalog:", "mock-fs": "catalog:", "nanotar": "catalog:", @@ -159,6 +161,7 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "package-builder": "workspace:1.0.0", + "parse5": "catalog:", "playwright-core": "catalog:", "portless": "catalog:", "postject": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce0f48e69..3e571e52f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,9 +418,15 @@ catalogs: mdast-util-from-markdown: specifier: 2.0.3 version: 2.0.3 + mdast-util-gfm: + specifier: 3.1.0 + version: 3.1.0 micromark: specifier: 4.0.2 version: 4.0.2 + micromark-extension-gfm: + specifier: 3.0.0 + version: 3.0.0 micromatch: specifier: 4.0.8 version: 4.0.8 @@ -454,6 +460,9 @@ catalogs: oxlint-tsgolint: specifier: 7.0.2001 version: 7.0.2001 + parse5: + specifier: 8.0.1 + version: 8.0.1 playwright-core: specifier: 1.62.0 version: 1.62.0 @@ -826,9 +835,15 @@ importers: mdast-util-from-markdown: specifier: 'catalog:' version: 2.0.3(supports-color@7.2.0) + mdast-util-gfm: + specifier: 'catalog:' + version: 3.1.0(supports-color@7.2.0) micromark: specifier: 'catalog:' version: 4.0.2(supports-color@7.2.0) + micromark-extension-gfm: + specifier: 'catalog:' + version: 3.0.0 micromatch: specifier: 'catalog:' version: 4.0.8 @@ -840,7 +855,7 @@ importers: version: 0.2.1 neosanitize: specifier: 'catalog:' - version: 0.3.0 + version: 0.3.0(parse5@8.0.1) nock: specifier: 'catalog:' version: 14.0.16 @@ -868,6 +883,9 @@ importers: package-builder: specifier: workspace:1.0.0 version: link:packages/package-builder + parse5: + specifier: 'catalog:' + version: 8.0.1 playwright-core: specifier: 'catalog:' version: 1.62.0 @@ -5518,6 +5536,9 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -5829,6 +5850,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 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'} @@ -6370,6 +6395,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -6411,6 +6439,9 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + markdownlint-cli2-formatter-default@0.0.6: resolution: {integrity: sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==} peerDependencies: @@ -6429,9 +6460,36 @@ packages: resolution: {integrity: sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==} engines: {node: '>=6'} + 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==} @@ -6476,9 +6534,21 @@ packages: 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-extension-math@3.1.0: resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} @@ -6854,6 +6924,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -7450,9 +7523,18 @@ packages: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} engines: {node: '>=20'} + 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==} @@ -7709,6 +7791,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + ignoredOptionalDependencies: - esbuild @@ -9577,6 +9662,8 @@ snapshots: caniuse-lite@1.0.30001806: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk-table@1.0.2: @@ -9867,6 +9954,8 @@ snapshots: entities@4.5.0: {} + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -10359,6 +10448,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -10431,6 +10522,8 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-table@3.0.4: {} + markdownlint-cli2-formatter-default@0.0.6(markdownlint-cli2@0.23.2(supports-color@7.2.0)): dependencies: markdownlint-cli2: 0.23.2(supports-color@7.2.0) @@ -10467,6 +10560,13 @@ snapshots: dependencies: '@arr/every': 1.0.1 + 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(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 @@ -10484,6 +10584,80 @@ snapshots: 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(supports-color@7.2.0): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0(supports-color@7.2.0): + dependencies: + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.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 @@ -10564,6 +10738,15 @@ snapshots: 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 @@ -10572,6 +10755,29 @@ snapshots: 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-extension-math@3.1.0: dependencies: '@types/katex': 0.16.8 @@ -10801,7 +11007,9 @@ snapshots: neo-async@2.6.2: {} - neosanitize@0.3.0: {} + neosanitize@0.3.0(parse5@8.0.1): + optionalDependencies: + parse5: 8.0.1 nock@14.0.16: dependencies: @@ -11062,6 +11270,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -11690,10 +11902,25 @@ snapshots: unicorn-magic@0.4.0: {} + 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: {} @@ -11895,3 +12122,5 @@ snapshots: zod@4.1.13: {} zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fe5226738..cb2cff8c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -150,7 +150,9 @@ catalog: magic-string: 1.1.0 'markdownlint-cli2': 0.23.2 'mdast-util-from-markdown': 2.0.3 + 'mdast-util-gfm': 3.1.0 'micromark': 4.0.2 + 'micromark-extension-gfm': 3.0.0 micromatch: 4.0.8 'minimatch': 10.2.6 mock-fs: 5.5.0 @@ -167,6 +169,7 @@ catalog: oxlint: 1.77.0 'oxlint-tsgolint': 7.0.2001 packageurl-js: npm:@socketregistry/packageurl-js@^1.5.0 + 'parse5': 8.0.1 path-parse: npm:@socketregistry/path-parse@^1.0.8 'playwright-core': 1.62.0 'portless': 0.15.4 diff --git a/scripts/fleet/_shared/markdown-ast.mts b/scripts/fleet/_shared/markdown-ast.mts new file mode 100644 index 000000000..6fa1aa71a --- /dev/null +++ b/scripts/fleet/_shared/markdown-ast.mts @@ -0,0 +1,61 @@ +/** + * @file One owner for "parse this markdown the way GitHub renders it". The + * README pin and the changelog flows all need structure answers — where a + * heading is, whether a block carries bullets, which refs are images — and + * answering those with line scans or string patterns misreads content as + * structure: a `## ` or `- ` inside a fenced code block is text, not a + * heading or an entry. Parsing to a position-tracked mdast tree (GFM, so + * tables/footnotes/strikethrough parse as their real constructs) gives every + * consumer byte- and line-accurate positions to edit against, with no + * serializer round-trip — untouched bytes stay byte-identical. + */ + +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' + +import type { Nodes, Root } from 'mdast' + +/** Parse markdown to a position-tracked mdast tree with the GFM extensions. */ +export function parseMarkdownGfm(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. The changelog flows treat `## ` headings as + * section boundaries; reading them from the tree means a `## ` line inside a + * fenced code block is never mistaken for one. + */ +export function h2LineIndexes(source: string): number[] { + const indexes: number[] = [] + for (const node of parseMarkdownGfm(source).children) { + if ( + node.type === 'heading' && + node.depth === 2 && + node.position?.start.line !== undefined + ) { + indexes.push(node.position.start.line - 1) + } + } + return indexes +} + +/** + * True when the tree under `node` contains a real list item. The changelog + * flows use this as "does this section carry at least one entry" — a `- ` + * lookalike inside a code fence parses as code, not a listItem, and does not + * count. + */ +export function hasListItem(node: Nodes): boolean { + if (node.type === 'listItem') { + return true + } + if ('children' in node) { + return node.children.some(hasListItem) + } + return false +} diff --git a/scripts/fleet/lib/changelog-render.mts b/scripts/fleet/lib/changelog-render.mts index ee3339466..2c8565dbb 100644 --- a/scripts/fleet/lib/changelog-render.mts +++ b/scripts/fleet/lib/changelog-render.mts @@ -6,6 +6,8 @@ * contract; `changelog.mts` imports these internally. */ +import { h2LineIndexes } from '../_shared/markdown-ast.mts' + import type { ConventionalCommit } from './changelog.mts' // User-visible commit types → the Keep a Changelog section each lands under. @@ -71,18 +73,20 @@ export function unreleasedRange( // generated, and `[unreleased]` / `[UNRELEASED]` mean the same section. An // exact match silently skipped those and promoted nothing, so the accrued // entries stayed behind while the release cut an empty section. + // + // Headings come from the parsed tree, not a line scan, so a `## ` line + // inside a fenced code block is content and can neither be the heading nor + // truncate the block. const wanted = unreleasedHeading.trim().toLowerCase() - const start = lines.findIndex(l => l.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 } } diff --git a/scripts/fleet/lib/changelog.mts b/scripts/fleet/lib/changelog.mts index ac612e404..f745a926d 100644 --- a/scripts/fleet/lib/changelog.mts +++ b/scripts/fleet/lib/changelog.mts @@ -19,6 +19,7 @@ import { parseVersion } from '@socketsecurity/lib-stable/versions/parse' import { maxVersion } from '@socketsecurity/lib-stable/versions/range' +import { h2LineIndexes, hasListItem, parseMarkdownGfm } from '../_shared/markdown-ast.mts' import { renderBullet, renderSectionMap, @@ -312,7 +313,9 @@ export const UNRELEASED_HEADING = '## [Unreleased]' * supplies an explicit empty-changelog entry. */ export function sectionHasEntries(section: string): boolean { - return section.split('\n').some(line => /^\s*-\s/u.test(line)) + // Parsed, not pattern-matched: a `- ` lookalike inside a fenced code block + // is code, not an entry, and must not satisfy the empty-changelog guard. + return parseMarkdownGfm(section).children.some(hasListItem) } /** @@ -418,7 +421,9 @@ export function mergeUnreleased( let after: string[] let existingBody = '' if (!range) { - const firstVersion = lines.findIndex(l => l.startsWith('## ')) + // First real `## ` heading from the parsed tree — a `## ` line inside a + // fenced code block is content, not an insertion point. + const firstVersion = h2LineIndexes(changelog)[0] ?? -1 if (firstVersion === -1) { before = lines after = [] diff --git a/scripts/fleet/publish-infra/pin-readme.mts b/scripts/fleet/publish-infra/pin-readme.mts index e7a2b89c3..ae7d3f518 100644 --- a/scripts/fleet/publish-infra/pin-readme.mts +++ b/scripts/fleet/publish-infra/pin-readme.mts @@ -30,21 +30,148 @@ import { readFileSync } from 'node:fs' import path from 'node:path' +import { parseFragment } from 'parse5' + import { runCapture } from './shared.mts' import { parseGitHubSlug, rawBaseUrl } from '../_shared/github-raw-url.mts' +import { parseMarkdownGfm } from '../_shared/markdown-ast.mts' import { writeThroughMirrorLock } from '../_shared/mirror-lock.mts' +import type { Definition, Image, Link, Nodes } from 'mdast' + +// The relative-ref sentinel: only urls/attribute values with this exact +// leading path are pinned; absolute refs never start with it, which is also +// what makes the rewrite idempotent. +const RELATIVE_PREFIX = 'assets/' +const PINNED_ATTRS = new Set(['src', 'srcset']) + +interface Parse5AttrLocation { + endOffset: number + startOffset: number +} + +interface Parse5Node { + attrs?: Array<{ name: string; value: string }> + childNodes?: Parse5Node[] + content?: Parse5Node + sourceCodeLocation?: { + attrs?: Record + } | null +} + +/** Visit every element (attrs-bearing node) in a parse5 tree, templates included. */ +function walkParse5(node: Parse5Node, visit: (element: Parse5Node) => void): void { + 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) + } + } +} + /** - * Rewrite the README's RELATIVE `assets/…` refs (both `` - * and markdown `](assets/…)`) to absolute `${baseUrl}assets/…`. Absolute refs - * (the socket.dev badge, any https link) are untouched — only the leading - * `assets/` sentinel is matched. Idempotent: an already-absolute ref has no - * leading `assets/` to match. Pure. + * 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. + */ +function markdownUrlOffset( + readme: string, + node: Definition | Image | Link, +): number | undefined { + const start = node.position?.start?.offset + const end = node.position?.end?.offset + if (start === undefined || end === undefined) { + return undefined + } + const urlAt = readme.slice(start, end).lastIndexOf(node.url) + return urlAt === -1 ? undefined : start + urlAt +} + +/** + * Byte offsets of relative `src`/`srcset` attribute values inside an mdast + * `html` node's source slice, from parse5's per-attribute source locations. + */ +function htmlAttrOffsets(html: string, nodeStart: number): number[] { + const offsets: number[] = [] + const fragment = parseFragment(html, { sourceCodeLocationInfo: true }) + walkParse5(fragment as Parse5Node, element => { + const attrLocations = element.sourceCodeLocation?.attrs + if (!attrLocations || !element.attrs) { + 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 + } + offsets.push(nodeStart + location.startOffset + valueAt) + } + }) + return offsets +} + +/** + * Rewrite the README's RELATIVE `assets/…` refs (markdown images/links/ + * definitions and raw-HTML `src`/`srcset` attributes) to absolute + * `${baseUrl}assets/…`. Absolute refs (the socket.dev badge, any https link) + * are untouched, and the rewrite is idempotent — an already-absolute ref has + * no leading `assets/` to pin. Parsed, not pattern-matched: the README goes + * through a position-tracked GFM mdast parse and each edit lands on a + * parser-reported byte offset, so an `assets/` lookalike inside a fenced code + * block or inline code span is content and stays as written; raw HTML arrives + * as mdast `html` nodes whose source slices go through parse5 with source + * locations on, so only real attribute values are touched. No serializer + * round-trip — untouched bytes stay byte-identical. Pure. */ export function pinReadmeAssets(readme: string, baseUrl: string): string { - return readme - .replaceAll('src="assets/', `src="${baseUrl}assets/`) - .replaceAll('](assets/', `](${baseUrl}assets/`) + const insertAt: number[] = [] + const visit = (node: Nodes): void => { + if ( + (node.type === 'image' || + node.type === 'link' || + node.type === 'definition') && + node.url.startsWith(RELATIVE_PREFIX) + ) { + const offset = markdownUrlOffset(readme, node) + if (offset !== undefined) { + insertAt.push(offset) + } + } else if (node.type === 'html') { + const start = node.position?.start?.offset + const end = node.position?.end?.offset + if (start !== undefined && end !== undefined) { + insertAt.push(...htmlAttrOffsets(readme.slice(start, end), start)) + } + } + if ('children' in node) { + for (const child of node.children) { + visit(child) + } + } + } + visit(parseMarkdownGfm(readme)) + let pinned = readme + for (const offset of [...new Set(insertAt)].sort((a, b) => b - a)) { + pinned = pinned.slice(0, offset) + baseUrl + pinned.slice(offset) + } + return pinned } // A full git commit sha — the only thing we'll pin a raw URL to besides the diff --git a/test/fleet/changelog-markdown-structure.test.mts b/test/fleet/changelog-markdown-structure.test.mts new file mode 100644 index 000000000..6e5016bfb --- /dev/null +++ b/test/fleet/changelog-markdown-structure.test.mts @@ -0,0 +1,111 @@ +/** + * @file Structure-vs-content coverage for the changelog flows + * (`scripts/fleet/lib/changelog.mts`). The `[Unreleased]` range, the + * insertion point, and the has-entries check read the parsed GFM mdast tree, + * so a `## ` or `- ` line inside a fenced code block is content — it can + * neither truncate a promoted block nor satisfy the empty-changelog guard. + */ +import { describe, expect, it } from 'vitest' + +import { + mergeUnreleased, + promoteUnreleased, + sectionHasEntries, +} from '../../scripts/fleet/lib/changelog.mts' + +const preamble = [ + '# Changelog', + '', + 'All notable changes to this project will be documented in this file.', + '', +].join('\n') +const versionHeading = + '## [1.2.3](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.3) - 2026-08-06' + +describe('promoteUnreleased', () => { + 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.2.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.2) - 2026-08-01', + '', + '### Changed', + '', + '- Updated the Coana CLI.', + '', + ].join('\n') + const promoted = promoteUnreleased(changelog, versionHeading) + expect(promoted).toBeDefined() + 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('does not promote when the only bullet lookalike sits in a code fence', () => { + const changelog = [ + preamble, + '## [Unreleased]', + '', + '```sh', + '- not a bullet, just shell output', + '```', + '', + '## [1.2.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.2) - 2026-08-01', + '', + '### Changed', + '', + '- Updated the Coana CLI.', + '', + ].join('\n') + expect(promoteUnreleased(changelog, versionHeading)).toBeUndefined() + }) +}) + +describe('sectionHasEntries', () => { + it('counts a real bullet', () => { + expect(sectionHasEntries('### Changed\n\n- a real entry')).toBe(true) + }) + + it('does not count a bullet lookalike inside a fence', () => { + expect(sectionHasEntries('```sh\n- fenced output\n```')).toBe(false) + }) +}) + +describe('mergeUnreleased', () => { + it('creates the block above the first real heading, not a fenced ## line', () => { + const changelog = [ + preamble, + 'Usage example:', + '', + '```md', + '## [0.0.0](https://example.com) - 2019-01-01', + '```', + '', + '## [1.2.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.2.2) - 2026-08-01', + '', + '### Changed', + '', + '- Updated the Coana CLI.', + '', + ].join('\n') + const merged = mergeUnreleased(changelog, '### Fixed\n\n- a new fix') + const unreleasedAt = merged.indexOf('## [Unreleased]') + const fencedAt = merged.indexOf('## [0.0.0]') + const firstVersionAt = merged.indexOf('## [1.2.2]') + expect(unreleasedAt).toBeGreaterThan(-1) + // The fenced ## line stays where it was, before the inserted block. + expect(fencedAt).toBeLessThan(unreleasedAt) + expect(unreleasedAt).toBeLessThan(firstVersionAt) + }) +}) diff --git a/test/fleet/pin-readme.test.mts b/test/fleet/pin-readme.test.mts new file mode 100644 index 000000000..805d2e48b --- /dev/null +++ b/test/fleet/pin-readme.test.mts @@ -0,0 +1,134 @@ +/** + * @file Behavior coverage for the publish-time README asset pin + * (`scripts/fleet/publish-infra/pin-readme.mts`). `pinReadmeAssets` parses + * the README to a position-tracked GFM mdast tree and derives every edit + * from parser-reported byte offsets (raw HTML goes through parse5 with + * source locations), so a relative `assets/` ref is pinned wherever it is a + * real ref — markdown image/link/definition, `src`/`srcset` attribute — + * and never where it is content (fenced code blocks, inline code spans). + */ +import { describe, expect, it } from 'vitest' + +import { pinReadmeAssets } from '../../scripts/fleet/publish-infra/pin-readme.mts' + +const base = + 'https://raw.githubusercontent.com/SocketDev/socket-cli/0123456789abcdef0123456789abcdef01234567/' + +describe('pinReadmeAssets', () => { + it('pins a relative img src', () => { + expect(pinReadmeAssets('logo\n', base)).toBe( + `logo\n`, + ) + }) + + it('pins a relative srcset', () => { + expect( + pinReadmeAssets( + '\n', + base, + ), + ).toBe( + `\n`, + ) + }) + + it('pins a markdown image ref', () => { + expect(pinReadmeAssets('![banner](assets/banner.png)\n', base)).toBe( + `![banner](${base}assets/banner.png)\n`, + ) + }) + + it('pins a markdown link ref', () => { + expect(pinReadmeAssets('[download](assets/file.pdf)\n', base)).toBe( + `[download](${base}assets/file.pdf)\n`, + ) + }) + + it('pins a reference-style definition', () => { + expect( + pinReadmeAssets('![banner][ref]\n\n[ref]: assets/banner.png\n', base), + ).toBe(`![banner][ref]\n\n[ref]: ${base}assets/banner.png\n`) + }) + + it('pins refs inside blockquotes and list items', () => { + expect( + pinReadmeAssets( + '> ![quoted](assets/quoted.png)\n\n- [download](assets/file.pdf)\n', + base, + ), + ).toBe( + `> ![quoted](${base}assets/quoted.png)\n\n- [download](${base}assets/file.pdf)\n`, + ) + }) + + it('pins refs inside GFM tables and footnotes', () => { + const pinned = pinReadmeAssets( + '| Logo | Name |\n' + + '| --- | --- |\n' + + '| ![logo](assets/logo.png) | Socket |\n' + + '\n' + + 'See the screenshot.[^shot]\n' + + '\n' + + '[^shot]: ![shot](assets/shot.png)\n', + base, + ) + expect(pinned).toContain(`| ![logo](${base}assets/logo.png) | Socket |`) + expect(pinned).toContain(`[^shot]: ![shot](${base}assets/shot.png)`) + }) + + it('leaves absolute refs untouched and returns the input byte-identical', () => { + const readme = + '\n' + + '![ext](https://example.com/assets/banner.png)\n' + expect(pinReadmeAssets(readme, base)).toBe(readme) + }) + + it('is idempotent — pinning a pinned README changes nothing', () => { + const readme = + '\n' + + '\n' + + '![banner](assets/banner.png)\n' + const once = pinReadmeAssets(readme, base) + expect(pinReadmeAssets(once, base)).toBe(once) + }) + + it('leaves assets/ refs inside fenced code blocks alone', () => { + const readme = + '```md\n![example](assets/example.png)\n\n```\n' + expect(pinReadmeAssets(readme, base)).toBe(readme) + }) + + it('leaves assets/ refs inside inline code spans alone', () => { + const readme = 'Point refs like `](assets/x.png)` at the release sha.\n' + expect(pinReadmeAssets(readme, base)).toBe(readme) + }) + + it('pins real refs while leaving code-block lookalikes alone', () => { + expect( + pinReadmeAssets( + '![banner](assets/banner.png)\n\n```html\n\n```\n', + base, + ), + ).toBe( + `![banner](${base}assets/banner.png)\n\n` + + '```html\n\n```\n', + ) + }) + + it('pins every ref form in one pass', () => { + const pinned = pinReadmeAssets( + '\n' + + ' \n' + + ' Socket CLI\n' + + '\n' + + '\n' + + 'See ![the flow](assets/flow.png) for details.\n', + base, + ) + 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)`) + }) +})