diff --git a/deno.json b/deno.json index f05a5df3..56f1b396 100644 --- a/deno.json +++ b/deno.json @@ -42,6 +42,7 @@ "./__generated__/graphql": "./src/__codegen__/graphql.ts", "graphql-request": "npm:graphql-request@^7.4.0", "lefthook": "npm:lefthook@^2.1.4", + "remark-gfm": "npm:remark-gfm@^4.0.0", "remark-parse": "npm:remark-parse@^11.0.0", "remark-stringify": "npm:remark-stringify@^11.0.0", "sanitize-filename": "npm:sanitize-filename@^1.6.4", diff --git a/deno.lock b/deno.lock index 4f68a90a..32373cac 100644 --- a/deno.lock +++ b/deno.lock @@ -41,13 +41,15 @@ "jsr:@std/testing@^1.0.17": "1.0.17", "jsr:@std/text@^1.0.17": "1.0.17", "jsr:@std/toml@^1.0.11": "1.0.11", - "npm:@graphql-codegen/cli@*": "6.2.1_graphql@16.13.2", - "npm:@graphql-codegen/cli@^6.2.1": "6.2.1_graphql@16.13.2", + "npm:@graphql-codegen/cli@*": "6.2.1_graphql@16.13.2_@types+node@24.2.0", + "npm:@graphql-codegen/cli@^6.2.1": "6.2.1_graphql@16.13.2_@types+node@24.2.0", "npm:@graphql-typed-document-node/core@^3.2.0": "3.2.0_graphql@16.13.2", "npm:@types/mdast@^4.0.4": "4.0.4", + "npm:@types/node@*": "24.2.0", "npm:graphql-request@^7.4.0": "7.4.0_graphql@16.13.2", "npm:graphql@^16.13.2": "16.13.2", "npm:lefthook@^2.1.4": "2.1.4", + "npm:remark-gfm@4": "4.0.1", "npm:remark-parse@11": "11.0.0", "npm:remark-stringify@11": "11.0.0", "npm:sanitize-filename@^1.6.4": "1.6.4", @@ -405,7 +407,7 @@ "tslib@2.6.3" ] }, - "@graphql-codegen/cli@6.2.1_graphql@16.13.2": { + "@graphql-codegen/cli@6.2.1_graphql@16.13.2_@types+node@24.2.0": { "integrity": "sha512-E1B+5nBda2l89Pci5M0HcEj2Hmx2yhORFX+1T3rmwpQjdOiulo+h9JifWxKomUpjfbmU1YkBSd47CCGLFPU10A==", "dependencies": [ "@babel/generator", @@ -621,7 +623,7 @@ "graphql" ] }, - "@graphql-tools/executor-graphql-ws@3.1.5_graphql@16.13.2": { + "@graphql-tools/executor-graphql-ws@3.1.5_graphql@16.13.2_ws@8.20.0": { "integrity": "sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==", "dependencies": [ "@graphql-tools/executor-common", @@ -634,7 +636,7 @@ "ws" ] }, - "@graphql-tools/executor-http@3.1.4_graphql@16.13.2": { + "@graphql-tools/executor-http@3.1.4_graphql@16.13.2_@types+node@24.2.0": { "integrity": "sha512-KOVSJo4WlMBgbJEIl3Fnv0DNmdZOAOKsJ9UfH4fUbxM1bDRBVHN4WM1au+JlK1sH00Uw0WRzsXXw4iquePe2tA==", "dependencies": [ "@graphql-hive/signal", @@ -649,7 +651,7 @@ "tslib@2.8.1" ] }, - "@graphql-tools/executor-legacy-ws@1.1.25_graphql@16.13.2": { + "@graphql-tools/executor-legacy-ws@1.1.25_graphql@16.13.2_ws@8.20.0": { "integrity": "sha512-6uf4AEXO0QMxJ7AWKVPqEZXgYBJaiz5vf29X0boG8QtcqWy8mqkXKWLND2Swdx0SbEx0efoGFcjuKufUcB0ASQ==", "dependencies": [ "@graphql-tools/utils", @@ -684,7 +686,7 @@ "unixify" ] }, - "@graphql-tools/github-loader@9.0.6_graphql@16.13.2": { + "@graphql-tools/github-loader@9.0.6_graphql@16.13.2_@types+node@24.2.0": { "integrity": "sha512-hhlt2MMkRcvDva/qyzqFddXzaMmRnriJ0Ts+/LcNeYnB8hcEqRMpF9RCsHYjo1mFRaiu8i4PSIpXyyFu3To7Ow==", "dependencies": [ "@graphql-tools/executor-http", @@ -708,7 +710,7 @@ "unixify" ] }, - "@graphql-tools/graphql-tag-pluck@8.3.27_graphql@16.13.2": { + "@graphql-tools/graphql-tag-pluck@8.3.27_graphql@16.13.2_@babel+core@7.29.0": { "integrity": "sha512-CJ0WVXhGYsfFngpRrAAcjRHyxSDHx4dEz2W15bkwvt9he/AWhuyXm07wuGcoLrl0q0iQp1BiRjU7D8SxWZo3JQ==", "dependencies": [ "@babel/core", @@ -783,7 +785,7 @@ "tslib@2.8.1" ] }, - "@graphql-tools/url-loader@9.0.6_graphql@16.13.2": { + "@graphql-tools/url-loader@9.0.6_graphql@16.13.2_ws@8.20.0_@types+node@24.2.0": { "integrity": "sha512-QdJI3f7ANDMYfYazRgJzzybznjOrQAOuDXweC9xmKgPZoTqNxEAsatiy69zcpTf6092taJLyrqRH6R7xUTzf4A==", "dependencies": [ "@graphql-tools/executor-graphql-ws", @@ -831,85 +833,121 @@ "@inquirer/ansi@1.0.2": { "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==" }, - "@inquirer/checkbox@4.3.2": { + "@inquirer/checkbox@4.3.2_@types+node@24.2.0": { "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dependencies": [ "@inquirer/ansi", "@inquirer/core", "@inquirer/figures", "@inquirer/type", + "@types/node", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/confirm@5.1.21": { + "@inquirer/confirm@5.1.21_@types+node@24.2.0": { "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dependencies": [ "@inquirer/core", - "@inquirer/type" + "@inquirer/type", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/core@10.3.2": { + "@inquirer/core@10.3.2_@types+node@24.2.0": { "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dependencies": [ "@inquirer/ansi", "@inquirer/figures", "@inquirer/type", + "@types/node", "cli-width", "mute-stream", "signal-exit", "wrap-ansi@6.2.0", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/editor@4.2.23": { + "@inquirer/editor@4.2.23_@types+node@24.2.0": { "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "dependencies": [ "@inquirer/core", "@inquirer/external-editor", - "@inquirer/type" + "@inquirer/type", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/expand@4.0.23": { + "@inquirer/expand@4.0.23_@types+node@24.2.0": { "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "dependencies": [ "@inquirer/core", "@inquirer/type", + "@types/node", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/external-editor@1.0.3": { + "@inquirer/external-editor@1.0.3_@types+node@24.2.0": { "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "dependencies": [ + "@types/node", "chardet", "iconv-lite" + ], + "optionalPeers": [ + "@types/node" ] }, "@inquirer/figures@1.0.15": { "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==" }, - "@inquirer/input@4.3.1": { + "@inquirer/input@4.3.1_@types+node@24.2.0": { "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "dependencies": [ "@inquirer/core", - "@inquirer/type" + "@inquirer/type", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/number@3.0.23": { + "@inquirer/number@3.0.23_@types+node@24.2.0": { "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", "dependencies": [ "@inquirer/core", - "@inquirer/type" + "@inquirer/type", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/password@4.0.23": { + "@inquirer/password@4.0.23_@types+node@24.2.0": { "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", "dependencies": [ "@inquirer/ansi", "@inquirer/core", - "@inquirer/type" + "@inquirer/type", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/prompts@7.10.1": { + "@inquirer/prompts@7.10.1_@types+node@24.2.0": { "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dependencies": [ "@inquirer/checkbox", @@ -921,38 +959,60 @@ "@inquirer/password", "@inquirer/rawlist", "@inquirer/search", - "@inquirer/select" + "@inquirer/select", + "@types/node" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/rawlist@4.1.11": { + "@inquirer/rawlist@4.1.11_@types+node@24.2.0": { "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", "dependencies": [ "@inquirer/core", "@inquirer/type", + "@types/node", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/search@3.2.2": { + "@inquirer/search@3.2.2_@types+node@24.2.0": { "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", "dependencies": [ "@inquirer/core", "@inquirer/figures", "@inquirer/type", + "@types/node", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/select@4.4.2": { + "@inquirer/select@4.4.2_@types+node@24.2.0": { "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "dependencies": [ "@inquirer/ansi", "@inquirer/core", "@inquirer/figures", "@inquirer/type", + "@types/node", "yoctocolors-cjs" + ], + "optionalPeers": [ + "@types/node" ] }, - "@inquirer/type@3.0.10": { - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==" + "@inquirer/type@3.0.10_@types+node@24.2.0": { + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dependencies": [ + "@types/node" + ], + "optionalPeers": [ + "@types/node" + ] }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", @@ -1016,8 +1076,8 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, - "@types/node@25.5.0": { - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", "dependencies": [ "undici-types" ] @@ -1144,6 +1204,9 @@ "upper-case-first" ] }, + "ccount@2.0.1": { + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" + }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -1336,6 +1399,9 @@ "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, + "escape-string-regexp@5.0.0": { + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" + }, "eventemitter3@5.0.4": { "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, @@ -1403,7 +1469,7 @@ "slash" ] }, - "graphql-config@5.1.6_graphql@16.13.2": { + "graphql-config@5.1.6_graphql@16.13.2_@types+node@24.2.0": { "integrity": "sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ==", "dependencies": [ "@graphql-tools/graphql-file-loader", @@ -1728,6 +1794,18 @@ "map-cache@0.2.2": { "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==" }, + "markdown-table@3.0.4": { + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" + }, + "mdast-util-find-and-replace@3.0.2": { + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": [ + "@types/mdast", + "escape-string-regexp", + "unist-util-is", + "unist-util-visit-parents" + ] + }, "mdast-util-from-markdown@2.0.3": { "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "dependencies": [ @@ -1745,6 +1823,65 @@ "unist-util-stringify-position" ] }, + "mdast-util-gfm-autolink-literal@2.0.1": { + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": [ + "@types/mdast", + "ccount", + "devlop", + "mdast-util-find-and-replace", + "micromark-util-character" + ] + }, + "mdast-util-gfm-footnote@2.1.0": { + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown", + "micromark-util-normalize-identifier" + ] + }, + "mdast-util-gfm-strikethrough@2.0.0": { + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": [ + "@types/mdast", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-table@2.0.0": { + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": [ + "@types/mdast", + "devlop", + "markdown-table", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-task-list-item@2.0.0": { + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm@3.1.0": { + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": [ + "mdast-util-from-markdown", + "mdast-util-gfm-autolink-literal", + "mdast-util-gfm-footnote", + "mdast-util-gfm-strikethrough", + "mdast-util-gfm-table", + "mdast-util-gfm-task-list-item", + "mdast-util-to-markdown" + ] + }, "mdast-util-phrasing@4.1.0": { "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "dependencies": [ @@ -1775,8 +1912,14 @@ "merge2@1.4.1": { "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, - "meros@1.3.2": { - "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==" + "meros@1.3.2_@types+node@24.2.0": { + "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", + "dependencies": [ + "@types/node" + ], + "optionalPeers": [ + "@types/node" + ] }, "micromark-core-commonmark@2.0.3": { "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", @@ -1799,6 +1942,78 @@ "micromark-util-types" ] }, + "micromark-extension-gfm-autolink-literal@2.1.0": { + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": [ + "micromark-util-character", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-footnote@2.1.0": { + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": [ + "devlop", + "micromark-core-commonmark", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-normalize-identifier", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-strikethrough@2.1.0": { + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": [ + "devlop", + "micromark-util-chunked", + "micromark-util-classify-character", + "micromark-util-resolve-all", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-table@2.1.1": { + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-tagfilter@2.0.0": { + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": [ + "micromark-util-types" + ] + }, + "micromark-extension-gfm-task-list-item@2.1.0": { + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm@3.0.0": { + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": [ + "micromark-extension-gfm-autolink-literal", + "micromark-extension-gfm-footnote", + "micromark-extension-gfm-strikethrough", + "micromark-extension-gfm-table", + "micromark-extension-gfm-tagfilter", + "micromark-extension-gfm-task-list-item", + "micromark-util-combine-extensions", + "micromark-util-types" + ] + }, "micromark-factory-destination@2.0.1": { "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", "dependencies": [ @@ -2074,6 +2289,17 @@ "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, + "remark-gfm@4.0.1": { + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": [ + "@types/mdast", + "mdast-util-gfm", + "micromark-extension-gfm", + "remark-parse", + "remark-stringify", + "unified" + ] + }, "remark-parse@11.0.0": { "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", "dependencies": [ @@ -2279,8 +2505,8 @@ "unc-path-regex@0.1.2": { "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==" }, - "undici-types@7.18.2": { - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", @@ -2463,6 +2689,7 @@ "npm:graphql-request@^7.4.0", "npm:graphql@^16.13.2", "npm:lefthook@^2.1.4", + "npm:remark-gfm@4", "npm:remark-parse@11", "npm:remark-stringify@11", "npm:sanitize-filename@^1.6.4", diff --git a/src/commands/document/document-view.ts b/src/commands/document/document-view.ts index 09b8f42c..bfc43868 100644 --- a/src/commands/document/document-view.ts +++ b/src/commands/document/document-view.ts @@ -5,6 +5,11 @@ import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { getOption } from "../../config.ts" +import { + downloadMarkdownImages, + replaceImageUrls, +} from "../../utils/markdown-images.ts" import { handleError, isClientError, @@ -46,7 +51,8 @@ export const viewCommand = new Command() .option("--raw", "Output raw markdown without rendering") .option("-w, --web", "Open document in browser") .option("--json", "Output full document as JSON") - .action(async ({ raw, web, json }, id) => { + .option("--no-download", "Keep remote URLs instead of downloading files") + .action(async ({ raw, web, json, download }, id) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() && !raw && !json const spinner = showSpinner ? new Spinner() : null @@ -69,16 +75,25 @@ export const viewCommand = new Command() return } - // JSON output + // JSON output preserves the raw GraphQL response; skip image rewrites. if (json) { console.log(JSON.stringify(document, null, 2)) return } + let content = document.content + const shouldDownload = download && getOption("download_images") !== false + if (shouldDownload && content) { + const urlToPath = await downloadMarkdownImages([content]) + if (urlToPath.size > 0) { + content = await replaceImageUrls(content, urlToPath) + } + } + // Raw output (for piping) if (raw || !Deno.stdout.isTerminal()) { - if (document.content) { - console.log(document.content) + if (content) { + console.log(content) } return } @@ -111,12 +126,11 @@ export const viewCommand = new Command() lines.push(`**Created:** ${formatRelativeTime(document.createdAt)}`) lines.push(`**Updated:** ${formatRelativeTime(document.updatedAt)}`) - // Content - if (document.content) { + if (content) { lines.push("") lines.push("---") lines.push("") - lines.push(document.content) + lines.push(content) } const markdown = lines.join("\n") diff --git a/src/commands/issue/issue-view.ts b/src/commands/issue/issue-view.ts index b7d16007..06974cca 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -16,15 +16,9 @@ import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" import { bold, underline } from "@std/fmt/colors" import { ensureDir } from "@std/fs" import { join } from "@std/path" -import { encodeHex } from "@std/encoding/hex" import { getOption } from "../../config.ts" import { getResolvedApiKey } from "../../utils/graphql.ts" import sanitize from "sanitize-filename" -import { unified } from "unified" -import remarkParse from "remark-parse" -import remarkStringify from "remark-stringify" -import { visit } from "unist-util-visit" -import type { Image, Link, Root } from "mdast" import { hyperlink, shouldEnableHyperlinks, @@ -32,10 +26,12 @@ import { } from "../../utils/hyperlink.ts" import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts" import { handleError, ValidationError } from "../../utils/errors.ts" +import { LINEAR_PRIVATE_UPLOAD_HOST } from "../../const.ts" import { - LINEAR_PRIVATE_UPLOAD_HOST, - LINEAR_UPLOAD_HOSTNAMES, -} from "../../const.ts" + downloadMarkdownImages, + getLinearUploadHost, + replaceImageUrls, +} from "../../utils/markdown-images.ts" export const viewCommand = new Command() .name("view") @@ -91,10 +87,15 @@ export const viewCommand = new Command() let urlToPath: Map | undefined const shouldDownload = download && getOption("download_images") !== false if (shouldDownload) { - urlToPath = await downloadIssueImages( + const sources: Array = [ issueData.description, - issueComments, - ) + ] + if (issueComments) { + for (const comment of issueComments) { + sources.push(comment.body) + } + } + urlToPath = await downloadMarkdownImages(sources) } let attachmentPaths: Map | undefined @@ -521,227 +522,6 @@ function formatResolvedThreadsSummary(hiddenCount: number): string { ". Use --show-resolved-threads to show them." } -const IMAGE_CACHE_DIR = join( - Deno.env.get("TMPDIR") || Deno.env.get("TMP") || Deno.env.get("TEMP") || - "/tmp", - "linear-cli-images", -) - -/** - * image info extracted from markdown - */ -export interface ImageInfo { - url: string - alt: string | null -} - -/** - * extract image URLs and alt text from markdown content using remark parser - */ -export function extractImageInfo( - content: string | null | undefined, -): ImageInfo[] { - if (!content) return [] - - const images: ImageInfo[] = [] - - const tree = unified().use(remarkParse).parse(content) - - visit(tree, "image", (node: Image) => { - if (node.url) { - images.push({ url: node.url, alt: node.alt || null }) - } - }) - - return images -} - -/** - * Link info extracted from markdown - */ -export interface LinkInfo { - url: string - text: string | null -} - -/** - * Extract link URLs from markdown content that point to Linear uploads - */ -export function extractLinearLinkInfo( - content: string | null | undefined, -): LinkInfo[] { - if (!content) return [] - - const links: LinkInfo[] = [] - - const tree = unified().use(remarkParse).parse(content) - - visit(tree, "link", (node: Link) => { - // Only extract links to Linear uploads - if (node.url && getLinearUploadHost(node.url)) { - // Get link text from first child if it's a text node - const textNode = node.children[0] - const text = textNode && textNode.type === "text" ? textNode.value : null - links.push({ url: node.url, text }) - } - }) - - return links -} - -/** - * replace image and link URLs in markdown with local file paths using remark - */ -export async function replaceImageUrls( - content: string, - urlToPath: Map, -): Promise { - const processor = unified() - .use(remarkParse) - .use(() => (tree: Root) => { - // Replace image URLs - visit(tree, "image", (node: Image) => { - const localPath = urlToPath.get(node.url) - if (localPath) { - node.url = localPath - } - }) - // Replace link URLs - visit(tree, "link", (node: Link) => { - const localPath = urlToPath.get(node.url) - if (localPath) { - node.url = localPath - } - }) - }) - .use(remarkStringify) - - const result = await processor.process(content) - return String(result) -} - -/** - * generate a hash from a URL for cache key purposes - */ -export async function getUrlHash(url: string): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(url) - const hashBuffer = await crypto.subtle.digest("SHA-256", data) - const hashArray = new Uint8Array(hashBuffer) - return encodeHex(hashArray).substring(0, 16) -} - -export function getLinearUploadHost(url: string): string | null { - try { - const { hostname } = new URL(url) - return LINEAR_UPLOAD_HOSTNAMES.includes(hostname) ? hostname : null - } catch { - return null - } -} - -/** - * download an image to the cache directory if not already cached - * returns the local file path - */ -async function downloadImage( - url: string, - altText: string | null, -): Promise { - const urlHash = await getUrlHash(url) - const imageDir = join(IMAGE_CACHE_DIR, urlHash) - await ensureDir(imageDir) - - const filename = altText ? sanitize(altText) : "image" - const filepath = join(imageDir, filename) - - try { - await Deno.stat(filepath) - return filepath - } catch { - /* fall through to download */ - } - - const headers: Record = {} - if (getLinearUploadHost(url) === LINEAR_PRIVATE_UPLOAD_HOST) { - const apiKey = getResolvedApiKey() - if (apiKey) { - headers["Authorization"] = apiKey - } - } - - const response = await fetch(url, { headers }) - if (!response.ok) { - throw new Error( - `Failed to download image: ${response.status} ${response.statusText}`, - ) - } - - const data = new Uint8Array(await response.arrayBuffer()) - await Deno.writeFile(filepath, data) - - return filepath -} - -/** - * Download all images and linked files from issue description and comments - * Returns a map of URL to local file path - */ -async function downloadIssueImages( - description: string | null | undefined, - comments?: Array<{ body: string }>, -): Promise> { - // Map of URL to alt text/link text (used as filename) - const filesByUrl = new Map() - - // Extract images - for (const img of extractImageInfo(description)) { - if (!filesByUrl.has(img.url)) { - filesByUrl.set(img.url, img.alt) - } - } - - // Extract links to Linear uploads - for (const link of extractLinearLinkInfo(description)) { - if (!filesByUrl.has(link.url)) { - filesByUrl.set(link.url, link.text) - } - } - - if (comments) { - for (const comment of comments) { - // Extract images from comments - for (const img of extractImageInfo(comment.body)) { - if (!filesByUrl.has(img.url)) { - filesByUrl.set(img.url, img.alt) - } - } - // Extract links to Linear uploads from comments - for (const link of extractLinearLinkInfo(comment.body)) { - if (!filesByUrl.has(link.url)) { - filesByUrl.set(link.url, link.text) - } - } - } - } - - const urlToPath = new Map() - for (const [url, alt] of filesByUrl) { - try { - const path = await downloadImage(url, alt) - urlToPath.set(url, path) - } catch (error) { - console.error( - `Failed to download ${url}: ${ - error instanceof Error ? error.message : error - }`, - ) - } - } - - return urlToPath -} - // Type for attachments and documents type AttachmentInfo = FetchedIssueDetails["attachments"][number] type DocumentInfo = FetchedIssueDetails["documents"][number] diff --git a/src/utils/markdown-images.ts b/src/utils/markdown-images.ts new file mode 100644 index 00000000..41780155 --- /dev/null +++ b/src/utils/markdown-images.ts @@ -0,0 +1,189 @@ +import { ensureDir } from "@std/fs" +import { join } from "@std/path" +import { encodeHex } from "@std/encoding/hex" +import sanitize from "sanitize-filename" +import { unified } from "unified" +import remarkParse from "remark-parse" +import remarkStringify from "remark-stringify" +import remarkGfm from "remark-gfm" +import { visit } from "unist-util-visit" +import type { Image, Link, Root } from "mdast" +import { + LINEAR_PRIVATE_UPLOAD_HOST, + LINEAR_UPLOAD_HOSTNAMES, +} from "../const.ts" +import { getResolvedApiKey } from "./graphql.ts" + +export const IMAGE_CACHE_DIR = join( + Deno.env.get("TMPDIR") || Deno.env.get("TMP") || Deno.env.get("TEMP") || + "/tmp", + "linear-cli-images", +) + +export interface ImageInfo { + url: string + alt: string | null +} + +export interface LinkInfo { + url: string + text: string | null +} + +export function extractImageInfo( + content: string | null | undefined, +): ImageInfo[] { + if (!content) return [] + + const images: ImageInfo[] = [] + const tree = unified().use(remarkParse).use(remarkGfm).parse(content) + + visit(tree, "image", (node: Image) => { + if (node.url) { + images.push({ url: node.url, alt: node.alt || null }) + } + }) + + return images +} + +export function extractLinearLinkInfo( + content: string | null | undefined, +): LinkInfo[] { + if (!content) return [] + + const links: LinkInfo[] = [] + const tree = unified().use(remarkParse).use(remarkGfm).parse(content) + + visit(tree, "link", (node: Link) => { + if (node.url && getLinearUploadHost(node.url)) { + const textNode = node.children[0] + const text = textNode && textNode.type === "text" ? textNode.value : null + links.push({ url: node.url, text }) + } + }) + + return links +} + +export async function replaceImageUrls( + content: string, + urlToPath: Map, +): Promise { + const processor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(() => (tree: Root) => { + visit(tree, "image", (node: Image) => { + const localPath = urlToPath.get(node.url) + if (localPath) { + node.url = localPath + } + }) + visit(tree, "link", (node: Link) => { + const localPath = urlToPath.get(node.url) + if (localPath) { + node.url = localPath + } + }) + }) + .use(remarkStringify, { bullet: "-" }) + + const result = await processor.process(content) + return String(result) +} + +export async function getUrlHash(url: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(url) + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + const hashArray = new Uint8Array(hashBuffer) + return encodeHex(hashArray).substring(0, 16) +} + +export function getLinearUploadHost(url: string): string | null { + try { + const { hostname } = new URL(url) + return LINEAR_UPLOAD_HOSTNAMES.includes(hostname) ? hostname : null + } catch { + return null + } +} + +async function downloadImage( + url: string, + altText: string | null, +): Promise { + const urlHash = await getUrlHash(url) + const imageDir = join(IMAGE_CACHE_DIR, urlHash) + await ensureDir(imageDir) + + const filename = altText ? sanitize(altText) : "image" + const filepath = join(imageDir, filename) + + try { + await Deno.stat(filepath) + return filepath + } catch { + /* fall through to download */ + } + + const headers: Record = {} + if (getLinearUploadHost(url) === LINEAR_PRIVATE_UPLOAD_HOST) { + const apiKey = getResolvedApiKey() + if (apiKey) { + headers["Authorization"] = apiKey + } + } + + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error( + `Failed to download image: ${response.status} ${response.statusText}`, + ) + } + + const data = new Uint8Array(await response.arrayBuffer()) + await Deno.writeFile(filepath, data) + + return filepath +} + +/** + * Download all images and Linear-upload links referenced from one or more + * markdown sources. Returns a map of original URL to local file path. + */ +export async function downloadMarkdownImages( + sources: Array, +): Promise> { + const filesByUrl = new Map() + + for (const source of sources) { + for (const img of extractImageInfo(source)) { + if (!filesByUrl.has(img.url)) { + filesByUrl.set(img.url, img.alt) + } + } + for (const link of extractLinearLinkInfo(source)) { + if (!filesByUrl.has(link.url)) { + filesByUrl.set(link.url, link.text) + } + } + } + + const urlToPath = new Map() + for (const [url, alt] of filesByUrl) { + try { + const path = await downloadImage(url, alt) + urlToPath.set(url, path) + } catch (error) { + console.error( + `Failed to download ${url}: ${ + error instanceof Error ? error.message : error + }`, + ) + } + } + + return urlToPath +} diff --git a/test/commands/document/__snapshots__/document-view.test.ts.snap b/test/commands/document/__snapshots__/document-view.test.ts.snap index 5c859afe..123ca86e 100644 --- a/test/commands/document/__snapshots__/document-view.test.ts.snap +++ b/test/commands/document/__snapshots__/document-view.test.ts.snap @@ -11,10 +11,11 @@ Description: Options: - -h, --help - Show this help. - --raw - Output raw markdown without rendering - -w, --web - Open document in browser - --json - Output full document as JSON + -h, --help - Show this help. + --raw - Output raw markdown without rendering + -w, --web - Open document in browser + --json - Output full document as JSON + --no-download - Keep remote URLs instead of downloading files " stderr: @@ -76,6 +77,16 @@ stderr: "" `; +snapshot[`Document View Command - No Download Keeps Remote URLs 1`] = ` +stdout: +"# Doc + +![screenshot](https://uploads.linear.app/abc/screenshot.png) +" +stderr: +"" +`; + snapshot[`Document View Command - Document Attached To Issue 1`] = ` stdout: "# Investigation Notes diff --git a/test/commands/document/document-view.test.ts b/test/commands/document/document-view.test.ts index f1a68dcb..a2df16f3 100644 --- a/test/commands/document/document-view.test.ts +++ b/test/commands/document/document-view.test.ts @@ -156,6 +156,56 @@ await snapshotTest({ }, }) +// With --no-download, image URLs in the markdown are passed through verbatim. +// Without --no-download, the raw output would contain a local /tmp path +// (after fetching from the Linear CDN), so this snapshot exercises the +// wiring that skips the fetch. +await snapshotTest({ + name: "Document View Command - No Download Keeps Remote URLs", + meta: import.meta, + colors: false, + args: ["d4b93e3b2695", "--raw", "--no-download"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetDocument", + variables: { id: "d4b93e3b2695" }, + response: { + data: { + document: { + id: "doc-1", + title: "Doc With Image", + slugId: "d4b93e3b2695", + content: + "# Doc\n\n![screenshot](https://uploads.linear.app/abc/screenshot.png)", + url: + "https://linear.app/test/document/doc-with-image-d4b93e3b2695", + createdAt: "2026-01-15T08:00:00Z", + updatedAt: "2026-01-18T10:30:00Z", + creator: { name: "John Doe", email: "john@example.com" }, + project: null, + issue: null, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + // NOTE: "Document Not Found" test removed - stack traces contain machine-specific paths // Test document attached to issue diff --git a/test/commands/issue/image-download.test.ts b/test/commands/issue/image-download.test.ts index bb8d301d..5ba9b270 100644 --- a/test/commands/issue/image-download.test.ts +++ b/test/commands/issue/image-download.test.ts @@ -4,7 +4,7 @@ import { extractLinearLinkInfo, getUrlHash, replaceImageUrls, -} from "../../../src/commands/issue/issue-view.ts" +} from "../../../src/utils/markdown-images.ts" import { formatPathHyperlink, hyperlink, @@ -103,6 +103,29 @@ Deno.test("replaceImageUrls - leaves unmatched URLs unchanged", async () => { assertEquals(result.includes("https://example.com/img.png"), true) }) +Deno.test("replaceImageUrls - preserves GFM task lists", async () => { + const markdown = + "- [ ] todo\n- [x] done\n\n![alt](https://example.com/img.png)\n" + const urlToPath = new Map([ + ["https://example.com/img.png", "/tmp/cached/img.png"], + ]) + const result = await replaceImageUrls(markdown, urlToPath) + assertEquals(result.includes("- [ ] todo"), true) + assertEquals(result.includes("- [x] done"), true) + assertEquals(result.includes("/tmp/cached/img.png"), true) +}) + +Deno.test("replaceImageUrls - preserves GFM tables", async () => { + const markdown = + "| a | b |\n| - | - |\n| 1 | 2 |\n\n![alt](https://example.com/img.png)\n" + const urlToPath = new Map([ + ["https://example.com/img.png", "/tmp/cached/img.png"], + ]) + const result = await replaceImageUrls(markdown, urlToPath) + assertEquals(result.includes("| a | b |"), true) + assertEquals(result.includes("/tmp/cached/img.png"), true) +}) + Deno.test("replaceImageUrls - handles empty map", async () => { const markdown = "![alt](https://example.com/img.png)" const urlToPath = new Map()