From b54f0d5fc77d889b530cec6e174dfe1d002e585d Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Tue, 21 Apr 2026 15:43:07 -0700 Subject: [PATCH] feat(document): download attached images in document view Document view's rendered/raw output now downloads inline images and Linear-upload links to the same /tmp cache used by issue view, so terminal renderers and downstream tools see local file paths instead of remote URLs that require auth. Image helpers (extractImageInfo, extractLinearLinkInfo, replaceImageUrls, getUrlHash, getLinearUploadHost, downloadMarkdownImages) move from src/commands/issue/issue-view.ts to src/utils/markdown-images.ts so both commands share one implementation. downloadIssueImages becomes downloadMarkdownImages, which takes an array of markdown sources rather than an issue-specific (description, comments) tuple. Adds --no-download to document view (mirroring issue view) and reorders the command so the download step runs after the --json early return but before --raw, matching issue view's behavior where piped output also gets local paths. Also adds remark-gfm to the parse/stringify pipeline used by replaceImageUrls so GFM constructs (task lists, tables, strikethrough) survive the URL rewrite. Without it, remark-stringify would re-escape `- [ ] todo` as `* \[ ] todo` whenever an image is rewritten, mangling documents and issue descriptions that lean on GFM syntax. --- deno.json | 1 + deno.lock | 301 +++++++++++++++--- src/commands/document/document-view.ts | 28 +- src/commands/issue/issue-view.ts | 246 +------------- src/utils/markdown-images.ts | 189 +++++++++++ .../__snapshots__/document-view.test.ts.snap | 19 +- test/commands/document/document-view.test.ts | 50 +++ test/commands/issue/image-download.test.ts | 25 +- 8 files changed, 577 insertions(+), 282 deletions(-) create mode 100644 src/utils/markdown-images.ts 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()