feat(integration): add image variants section and standardize code snippets

This commit is contained in:
Florian Sylvain committed 2026-10-12 23:27:41 +02:00
1 parent 6f9ae6207a
commit e6746f2be7
28 files changed
+2256 -422

No files matched your search

-91
View File
@@ -16,7 +16,6 @@
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-router": "^4.6.3",
@@ -307,22 +306,6 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
"@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
"@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
"@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
"@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
@@ -453,16 +436,10 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/type-utils": "8.50.0", "@typescript-eslint/utils": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg=="],
@@ -485,8 +462,6 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@unhead/bundler": ["@unhead/bundler@3.4.1", "", { "dependencies": { "magic-string": "^1.3.1", "oxc-walker": "^1.1.1", "unplugin": "^3.3.0" }, "peerDependencies": { "@unhead/cli": "^3.4.1", "@vitejs/devtools-kit": "^0.4.1", "esbuild": ">=0.17.0", "lightningcss": ">=1.20.0", "oxc-parser": ">=0.98.0", "rolldown": ">=1.0.0", "unhead": "^3.4.1", "vite": ">=6.4.2", "webpack": ">=5.0.0" }, "optionalPeers": ["@unhead/cli", "@vitejs/devtools-kit", "esbuild", "lightningcss", "oxc-parser", "rolldown", "vite", "webpack"] }, "sha512-1RLNvuZYCt6K+oIK7v0mKlbmFbTuNZRgAMrf+GdmiO5xfml2pl/ViZmBrCOxoBtLxCboDZ70hs+F9+z2YWHLuQ=="],
"@unhead/vue": ["@unhead/vue@3.4.1", "", { "dependencies": { "@unhead/bundler": "3.4.1", "hookable": "^6.1.1", "unhead": "3.4.1", "unplugin": "^3.3.0" }, "peerDependencies": { "vite": ">=6.4.2", "vue": ">=3.5.18", "webpack": ">=5.0.0" }, "optionalPeers": ["vite", "webpack"] }, "sha512-FQLTdsX1+m+a7Z5SzkQde8TR/4QK6p0+NQ6BuO9zbtc5xps2fRy7W+wQ90qn177C2X88aZAPzw29mg6T4VFSdA=="],
@@ -603,16 +578,10 @@
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
@@ -625,8 +594,6 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -673,14 +640,10 @@
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -831,18 +794,12 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -963,8 +920,6 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
@@ -973,16 +928,6 @@
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -1045,10 +990,6 @@
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -1103,8 +1044,6 @@
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.0", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"prosemirror-changeset": ["prosemirror-changeset@2.4.3", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ=="],
"prosemirror-commands": ["prosemirror-commands@1.7.2", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw=="],
@@ -1147,12 +1086,6 @@
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
"reka-ui": ["reka-ui@2.10.4", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", "defu": "^6.1.5", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.4.0" } }, "sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw=="],
@@ -1187,22 +1120,16 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -1245,8 +1172,6 @@
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1275,16 +1200,6 @@
"unimport": ["unimport@6.5.0", "", { "dependencies": { "acorn": "^8.18.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.2.1", "magic-string": "^1.2.3", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.7", "pkg-types": "^2.3.3", "scule": "^1.3.0", "strip-literal": "^4.0.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "oxc-parser": "*", "rolldown": "^1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-t0KLJLRbebz3f7NrQe+vy2ObeugVeM1XqI2oeYnauddmHSglYj3U6bxOJ65IbXmOFkcAGFJK2OyGH7aqHPM6Ug=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unplugin": ["unplugin@3.4.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w=="],
"unplugin-auto-import": ["unplugin-auto-import@21.1.0", "", { "dependencies": { "local-pkg": "^1.2.1", "magic-string": "^1.1.0", "picomatch": "^4.0.5", "unimport": "^6.4.0", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@nuxt/kit": "^4.0.0", "@vueuse/core": "*" }, "optionalPeers": ["@nuxt/kit", "@vueuse/core"] }, "sha512-EzrSqWIBulEqCuP7idADXH+tVKYrbwKlR5+r/lOWik0o+Ksny1kitmtryHGNSv7puzzORlcIwT/x2JStiszlHA=="],
@@ -1307,10 +1222,6 @@
"verkit": ["verkit@0.3.2", "", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vite-dev-rpc": ["vite-dev-rpc@1.1.0", "", { "dependencies": { "birpc": "^2.4.0", "vite-hot-client": "^2.1.0" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" } }, "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A=="],
@@ -1377,8 +1288,6 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
-1
View File
@@ -27,7 +27,6 @@
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
+6 -14
View File
@@ -389,18 +389,10 @@ body {
}
}
.shiki-block :where(pre.shiki) {
margin: 0;
padding: 0;
background-color: transparent !important;
}
.shiki-block :where(pre.shiki),
.shiki-block :where(pre.shiki span) {
color: var(--shiki-light);
}
.dark .shiki-block :where(pre.shiki),
.dark .shiki-block :where(pre.shiki span) {
color: var(--shiki-dark);
.image-placeholder {
background: repeating-linear-gradient(
135deg,
var(--ui-bg-elevated) 0 8px,
var(--ui-bg-accented) 8px 16px
);
}
+5 -25
View File
@@ -2,7 +2,7 @@
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import CodeSnippet from '@/components/CodeSnippet.vue'
const props = defineProps<{
name: string
@@ -13,14 +13,12 @@ const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ done: [] }>()
const { copied, copy } = useCopyToClipboard()
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const snippet = ref<{ focusCopy: () => void } | null>(null)
const description = computed(() =>
t('"{0}" is ready. Copy the key and store it somewhere safe.', [props.name]),
)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const keyTabs = computed(() => [{ k: 'key', label: t('API key'), code: props.apiKey }])
watch(
open,
@@ -30,7 +28,7 @@ watch(
}
await nextTick()
await nextTick()
copyButton.value?.$el?.focus()
snippet.value?.focusCopy()
},
{ immediate: true },
)
@@ -78,25 +76,7 @@ function keepOpenOnOutsideInteraction(event: Event) {
:title="t('You won\'t be able to see this key again.')"
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
/>
<div class="flex flex-col border border-accented bg-elevated md:flex-row">
<span
class="min-w-0 flex-1 px-3 py-2.5 font-mono text-code/5 break-all text-highlighted"
>{{ apiKey }}</span
>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-11 justify-center gap-1.5 rounded-none border-t border-accented px-3.5 font-medium md:border-t-0 md:border-l"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copy(apiKey)"
>
{{ copyLabel }}
</UButton>
</div>
<CodeSnippet ref="snippet" :tabs="keyTabs" />
<span class="text-muted">{{
t('Closing this window discards the key from the screen. Copy it first.')
}}</span>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
items: string[]
label: string
display?: string
warning?: boolean
}>()
const model = defineModel<string>({ required: true })
const options = computed(() => props.items.map((item) => ({ label: item, value: item })))
const borderClass = computed(() => (props.warning ? 'border-warning' : 'border-accented'))
const shown = computed(() => props.display ?? model.value)
</script>
<template>
<USelectMenu
:model-value="model"
:items="options"
value-key="value"
label-key="label"
:search-input="false"
:aria-label="label"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: `h-11 rounded-none border ${borderClass} bg-default px-2.5 text-sm ring-0 data-[state=open]:border-primary data-[state=open]:ring-1 data-[state=open]:ring-primary lg:h-8`,
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-(--reka-combobox-trigger-width)',
item: 'min-h-11 text-sm lg:min-h-8',
itemLeadingIcon: 'hidden',
itemTrailingIcon: 'hidden',
}"
@update:model-value="model = $event as string"
>
<span class="truncate text-highlighted">{{ shown }}</span>
</USelectMenu>
</template>
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
const props = defineProps<{
code: string
lang: string
label?: string
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
watchEffect(async () => {
const highlighter = await useShikiHighlighter()
highlightedHtml.value = highlightToHtml(highlighter, props.code, props.lang)
})
function onCopy() {
copy(props.code)
}
</script>
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-1 pl-3">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
<UButton
:icon="buttonIcon"
color="neutral"
variant="ghost"
size="xs"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-2 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
{{ buttonLabel }}
</UButton>
</div>
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { CodeSnippetTab } from '@/lib/codeSnippet'
const props = defineProps<{
tabs: CodeSnippetTab[]
}>()
const active = defineModel<string>('active')
const { copied, copy } = useCopyToClipboard()
const tabList = ref<HTMLElement | null>(null)
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const current = computed(() => props.tabs.find((tab) => tab.k === active.value) ?? props.tabs[0])
const single = computed(() => props.tabs.length === 1)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const TAB_BASE =
'flex h-8 cursor-pointer items-center px-2.5 text-sm whitespace-nowrap focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary'
const TAB_SELECTED = 'font-medium text-highlighted shadow-[inset_0_-2px_0_var(--ui-primary)]'
const TAB_IDLE = 'font-normal text-toned'
function isSelected(tab: CodeSnippetTab) {
return tab.k === current.value?.k
}
function select(tab: CodeSnippetTab) {
active.value = tab.k
}
function targetIndex(key: string, from: number) {
const last = props.tabs.length - 1
const targets: Record<string, number> = {
ArrowRight: from === last ? 0 : from + 1,
ArrowLeft: from === 0 ? last : from - 1,
Home: 0,
End: last,
}
return targets[key]
}
function onTabKeydown(event: KeyboardEvent, index: number) {
const target = targetIndex(event.key, index)
if (target === undefined) {
return
}
event.preventDefault()
select(props.tabs[target]!)
const buttons = tabList.value?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[target]?.focus()
}
function onCopy() {
copy(current.value?.code ?? '')
}
function focusCopy() {
copyButton.value?.$el?.focus()
}
defineExpose({ focusCopy })
</script>
<template>
<div class="flex min-w-0 flex-col border border-default bg-elevated text-default">
<div class="flex min-h-11 items-center gap-2 border-b border-default pr-1 pl-2">
<div
ref="tabList"
role="tablist"
:aria-label="t('Language')"
class="flex min-w-0 flex-1 gap-0.5 overflow-x-auto"
>
<template v-if="single">
<span class="flex h-8 items-center px-2.5 text-sm font-normal text-muted">{{
current?.label
}}</span>
</template>
<template v-else>
<button
v-for="(tab, index) in tabs"
:key="tab.k"
type="button"
role="tab"
:aria-selected="isSelected(tab)"
:tabindex="isSelected(tab) ? 0 : -1"
:class="[TAB_BASE, isSelected(tab) ? TAB_SELECTED : TAB_IDLE]"
@click="select(tab)"
@keydown="onTabKeydown($event, index)"
>
{{ tab.label }}
</button>
</template>
</div>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
:aria-label="t('Copy code')"
class="h-8 min-w-11 justify-center gap-1.5 rounded-none px-2.5 text-sm font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
<span aria-live="polite">{{ copyLabel }}</span>
</UButton>
</div>
<pre
tabindex="0"
class="m-0 overflow-x-auto px-4 py-3 font-mono text-code whitespace-pre text-highlighted"
>{{ current?.code }}</pre
>
</div>
</template>
+8 -57
View File
@@ -3,13 +3,8 @@ import { t, translate } from '@/i18n'
import { computed, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeBlock from '@/components/CodeBlock.vue'
import {
RATE_LIMIT_SENTENCE,
NO_CONTENT_RESPONSE,
endpointExamples,
type ApiEndpoint,
} from '@/data/apiCatalogue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import { RATE_LIMIT_SENTENCE, endpointExamples, type ApiEndpoint } from '@/data/apiCatalogue'
import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
const props = defineProps<{
@@ -30,23 +25,12 @@ const bodyTitle = computed(() => {
}
return props.endpoint.bodyKind === 'json' ? t('Request body · JSON') : t('Request body')
})
const responseLabel = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'HTTP' : 'JSON',
)
const responseLang = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'text' : 'json',
)
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{
get label() {
return t('Response')
},
value: 'response',
},
]
const snippetTabs = computed(() => [
{ k: 'fetch', label: 'fetch', code: examples.value.fetch },
{ k: 'curl', label: 'curl', code: examples.value.curl },
{ k: 'response', label: t('Response'), code: examples.value.response },
])
</script>
<template>
@@ -119,40 +103,7 @@ const tabItems = [
<span v-else class="text-muted">{{ t('No request body.') }}</span>
</div>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned max-lg:h-11',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<CodeBlock
v-else-if="item.value === 'curl'"
label="Shell"
lang="shell"
:code="examples.curl"
/>
<CodeBlock
v-else
:label="responseLabel"
:lang="responseLang"
:code="examples.response"
/>
</template>
</UTabs>
<CodeSnippet v-model:active="activeTab" :tabs="snippetTabs" />
<div v-if="endpoint.rateLimited" class="flex gap-2.5 text-toned">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
+412
View File
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import BuilderSelect from '@/components/BuilderSelect.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import { SECTION_TITLE } from '@/lib/apiReference'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import {
SAMPLE_IMAGE,
SETTINGS_VARIANTS_PATH,
imageResponseRows,
imageSnippets,
unknownVariantBody,
variantUrl,
} from '@/lib/imageSnippets'
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants'
const props = defineProps<{
origin: string
}>()
const MAX_ROWS_BEFORE_SCROLL = 6
const DEFAULT_RATIO = '4 / 3'
const store = useImageVariantsStore()
const canManage = useCanManageVariants()
const chosen = ref<string | null>(null)
const tab = ref<string>()
const urlTab = ref<string>()
const enabled = computed(() => store.items.filter((item) => item.enabled))
const enabledNames = computed(() => enabled.value.map((item) => item.name))
const hasDisabled = computed(() => store.items.some((item) => !item.enabled))
const view = computed(() => {
if (!canManage.value || store.loadState === 'forbidden') {
return 'plain'
}
if (store.loadState === 'loading') {
return 'loading'
}
if (store.loadState === 'error') {
return 'error'
}
return enabled.value.length === 0 ? 'none' : 'many'
})
const showSnippets = computed(() => view.value !== 'loading')
const showBuilder = computed(() => view.value === 'many')
const showResponses = computed(() => view.value !== 'loading' && view.value !== 'error')
const snippetNames = computed(() => (view.value === 'many' ? enabledNames.value : []))
const snippets = computed(() => imageSnippets(props.origin, snippetNames.value))
const activeTab = computed(() => tab.value ?? (view.value === 'many' ? 'html' : 'curl'))
const selected = computed(() => chosen.value ?? enabledNames.value[0] ?? '')
const removed = computed(() => showBuilder.value && !enabledNames.value.includes(selected.value))
const selectedVariant = computed(() => enabled.value.find((item) => item.name === selected.value))
const variantDisplay = computed(() =>
removed.value ? t('{0} (removed)', [selected.value]) : selected.value,
)
const urlTabs = computed(() => [
{ k: 'url', label: t('URL'), code: variantUrl(props.origin, selected.value) },
])
const previewLabel = computed(() => {
const variant = selectedVariant.value
if (!variant) {
return t('No preview: unknown variant')
}
return [
variant.name,
formatDimensions(variant.width, variant.height),
formatLabel(variant.format),
].join(' · ')
})
const previewRatio = computed(() => {
const variant = selectedVariant.value
if (!variant || variant.width === null || variant.height === null) {
return DEFAULT_RATIO
}
return `${variant.width} / ${variant.height}`
})
const countText = computed(() =>
enabled.value.length === 1
? t('1 enabled variant')
: t('{0} enabled variants', [enabled.value.length]),
)
const rows = computed(() =>
enabled.value.map((item) => ({
name: item.name,
dimensions: formatDimensions(item.width, item.height),
format: formatLabel(item.format),
url: variantUrl('', item.name),
})),
)
const scrollBody = computed(() => rows.value.length > MAX_ROWS_BEFORE_SCROLL)
const responseRows = computed(() => imageResponseRows())
const removedTitle = computed(() => t('The variant "{0}" no longer exists', [selected.value]))
const removedDescription = computed(() =>
t(
'There is no fallback to the original. Requests with ?v={0} return status 400 with the body `{1}`. Remove the parameter to get the original image, or pick another variant.',
[selected.value, unknownVariantBody(selected.value)],
),
)
function loadList() {
return store.load()
}
function reloadOnFocus() {
if (!canManage.value) {
return
}
if (store.loadState === 'ready') {
return store.reloadQuietly()
}
return store.load()
}
onMounted(() => {
if (canManage.value) {
store.load()
}
window.addEventListener('focus', reloadOnFocus)
})
onBeforeUnmount(() => window.removeEventListener('focus', reloadOnFocus))
const SUB_HEADING = 'text-base/5.5 font-semibold text-highlighted'
const FIELD_LABEL = 'font-medium text-highlighted'
const TABLE_BOX = 'border border-default bg-default overflow-x-auto'
const VARIANT_COLUMNS = 'grid grid-cols-[96px_112px_72px_minmax(0,1fr)] items-center gap-3 px-3'
const RESPONSE_COLUMNS = 'grid grid-cols-[minmax(0,1.3fr)_56px_minmax(0,1fr)] gap-3 px-3'
const EMPTY_UI = {
root: 'gap-3 border border-default bg-default px-6 py-8',
header: 'max-w-none gap-3',
body: 'w-full max-w-none items-center gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-105 text-pretty text-muted',
actions: 'w-full flex-col items-center justify-center gap-3',
}
</script>
<template>
<section
data-anchor="images"
aria-labelledby="h-images"
class="flex max-w-220 scroll-mt-16 flex-col gap-5 lg:scroll-mt-8 lg:gap-6"
>
<div class="flex flex-col gap-2">
<h2 id="h-images" :class="SECTION_TITLE">
{{ t('Images and variants') }}
</h2>
<p class="max-w-180 text-pretty text-toned">
<CodeText
:text="
t(
'Every image in the media library can be requested as a named variant. Add the `?v=<variant-name>` parameter to the image URL. The original file is never changed, and a variant is generated the first time it is requested. Variants are managed in Settings, Media, Variants.',
)
"
/>
</p>
</div>
<section
v-if="view === 'loading'"
:aria-label="t('Loading image variants')"
aria-busy="true"
class="flex flex-col gap-3"
>
<USkeleton class="h-35 rounded-none bg-accented" />
<USkeleton class="h-50 rounded-none bg-accented" />
<USkeleton class="h-30 rounded-none bg-accented" />
</section>
<UAlert
v-if="view === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load image variants')"
:description="
t(
'The snippets below still work. The variant list and the URL builder need a connection.',
)
"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 gap-1.5 rounded-none border border-accented bg-default px-3 text-sm font-medium text-highlighted ring-0 lg:h-8"
@click="loadList"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div v-if="showSnippets" class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('Request an image') }}</h3>
<CodeSnippet :active="activeTab" :tabs="snippets" @update:active="tab = $event" />
</div>
<UEmpty
v-if="view === 'none'"
variant="naked"
icon="i-lucide-layers"
:title="t('No image variants yet')"
:description="
t(
'Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
"
:ui="EMPTY_UI"
>
<template #actions>
<UButton :to="SETTINGS_VARIANTS_PATH" class="h-11 px-4 lg:h-8">
{{ t('Go to image variants') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-if="view === 'plain'"
variant="naked"
icon="i-lucide-layers"
:title="t('No image variants yet')"
:description="
t(
'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
"
:ui="EMPTY_UI"
/>
<template v-if="showBuilder">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-baseline gap-3">
<h3 :class="SUB_HEADING">{{ t('Variants') }}</h3>
<span class="text-muted">{{ countText }}</span>
</div>
<div :class="TABLE_BOX">
<div role="table" :aria-label="t('Variants')" class="min-w-130">
<div role="rowgroup">
<div
role="row"
:class="[VARIANT_COLUMNS, 'h-9 bg-elevated font-medium text-muted']"
>
<span role="columnheader">{{ t('Name') }}</span>
<span role="columnheader">{{ t('Dimensions') }}</span>
<span role="columnheader">{{ t('Format') }}</span>
<span role="columnheader">{{ t('Example URL') }}</span>
</div>
</div>
<div
role="rowgroup"
class="overflow-y-auto"
:class="scrollBody ? 'max-h-66' : ''"
>
<div
v-for="row in rows"
:key="row.name"
role="row"
:class="[
VARIANT_COLUMNS,
'min-h-10 border-t border-default',
row.name === selected ? 'bg-elevated' : 'bg-default',
]"
>
<span
role="cell"
class="font-mono text-xs/5 font-medium text-highlighted"
>{{ row.name }}</span
>
<span role="cell" class="text-toned">{{ row.dimensions }}</span>
<span role="cell" class="text-toned">{{ row.format }}</span>
<span role="cell" class="truncate font-mono text-xs/5 text-toned">{{
row.url
}}</span>
</div>
</div>
</div>
</div>
<span v-if="hasDisabled" class="text-muted">{{
t(
'Disabled variants are not served. A request for one returns 400 "Unknown variant".',
)
}}</span>
</div>
<div class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('URL builder') }}</h3>
<div
class="grid items-start gap-4 border border-default bg-default p-4 lg:grid-cols-2 lg:p-5"
>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Image') }}</span>
<BuilderSelect
:model-value="SAMPLE_IMAGE"
:items="[SAMPLE_IMAGE]"
:label="t('Image')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Variant') }}</span>
<BuilderSelect
:model-value="selected"
:items="enabledNames"
:label="t('Variant')"
:display="variantDisplay"
:warning="removed"
@update:model-value="chosen = $event"
/>
</div>
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('URL') }}</span>
<CodeSnippet v-model:active="urlTab" :tabs="urlTabs" />
</div>
<UAlert
v-if="removed"
role="alert"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:title="removedTitle"
:ui="{
root: 'gap-2.5 px-3.5 py-3',
description: 'text-toned text-pretty',
}"
>
<template #description>
<CodeText :text="removedDescription" />
</template>
</UAlert>
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Preview') }}</span>
<div
role="img"
:aria-label="previewLabel"
class="image-placeholder flex max-h-60 w-full items-center justify-center border border-default text-toned"
:class="removed ? 'opacity-50' : ''"
:style="{ aspectRatio: previewRatio }"
>
<span
class="border border-default bg-default px-2 py-1 font-mono text-xs font-medium"
>{{ previewLabel }}</span
>
</div>
</div>
</div>
</div>
</template>
<div v-if="showResponses" class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('Responses') }}</h3>
<div :class="TABLE_BOX">
<div role="table" :aria-label="t('Responses')" class="min-w-140">
<div role="rowgroup" class="sr-only">
<div role="row">
<span role="columnheader">{{ t('Request') }}</span>
<span role="columnheader">{{ t('Status') }}</span>
<span role="columnheader">{{ t('Response') }}</span>
</div>
</div>
<div role="rowgroup">
<div
v-for="(row, index) in responseRows"
:key="row.request"
role="row"
:class="[
RESPONSE_COLUMNS,
'min-h-10 items-center py-1.5',
index > 0 ? 'border-t border-default' : '',
]"
>
<span role="cell" class="font-mono text-xs/5 text-highlighted">{{
row.request
}}</span>
<span
role="cell"
class="font-mono text-xs/5 font-medium text-highlighted"
>{{ row.status }}</span
>
<span role="cell" class="flex min-w-0 flex-col gap-0.5 text-toned">
{{ row.response }}
<span
v-if="row.body"
class="font-mono text-xs/4.5 wrap-anywhere text-muted"
>{{ row.body }}</span
>
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
+2 -1
View File
@@ -51,7 +51,7 @@ function onJump(id: string) {
</nav>
<div
class="sticky -top-5 z-5 order-first -mx-4 -mt-5 mb-5 border-b border-default bg-default px-4 py-2 lg:hidden"
class="sticky -top-4 z-5 order-first -mx-4 -mt-4 mb-4 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
@@ -64,6 +64,7 @@ function onJump(id: string) {
color="neutral"
variant="outline"
class="w-full"
:content="{ sideOffset: 4 }"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
trailingIcon: 'size-4 text-muted',
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UButton from '@nuxt/ui/components/Button.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import type { CodeSnippetTab } from '@/lib/codeSnippet'
const THREE_TABS: CodeSnippetTab[] = [
{ k: 'curl', label: 'curl', code: 'curl -O "https://cms.renew.example/a"' },
{ k: 'html', label: 'HTML', code: '<img src="a">' },
{ k: 'js', label: 'JavaScript', code: 'const res = await fetch("a")' },
]
let wrapper: VueWrapper | null = null
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
function mountSnippet(tabs: CodeSnippetTab[] = THREE_TABS) {
wrapper = mount(CodeSnippet, {
props: { tabs },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return wrapper
}
function copyButton(target: VueWrapper) {
return target.get('button[aria-label="Copy code"]')
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
})
describe('CodeSnippet', () => {
it('renders three tabs, the first selected with an underline, and a Copy code button', () => {
const target = mountSnippet()
const list = target.get('[role="tablist"]')
expect(list.attributes('aria-label')).toBe('Language')
const tabs = list.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['curl', 'HTML', 'JavaScript'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[0]!.classes().join(' ')).toContain('shadow-')
expect(tabs[1]!.attributes('aria-selected')).toBe('false')
expect(tabs[1]!.classes().join(' ')).not.toContain('shadow-')
expect(copyButton(target).text()).toBe('Copy')
})
it('switches the pre on a tab click and copies the active tab', async () => {
const writeText = stubClipboard()
const target = mountSnippet()
expect(target.get('pre').text()).toBe(THREE_TABS[0]!.code)
await target.findAll('[role="tab"]')[2]!.trigger('click')
expect(target.get('pre').text()).toBe(THREE_TABS[2]!.code)
await copyButton(target).trigger('click')
expect(writeText).toHaveBeenCalledWith(THREE_TABS[2]!.code)
})
it('moves between tabs with the arrow keys', async () => {
const target = mountSnippet()
await target.findAll('[role="tab"]')[0]!.trigger('keydown', { key: 'ArrowRight' })
expect(target.get('pre').text()).toBe(THREE_TABS[1]!.code)
await target.findAll('[role="tab"]')[1]!.trigger('keydown', { key: 'ArrowLeft' })
expect(target.get('pre').text()).toBe(THREE_TABS[0]!.code)
})
it('renders a muted label and no tab for a single tab', () => {
const target = mountSnippet([
{ k: 'base', label: 'Base URL', code: 'https://cms.renew.example' },
])
expect(target.findAll('[role="tab"]')).toHaveLength(0)
expect(target.text()).toContain('Base URL')
expect(target.get('pre').text()).toBe('https://cms.renew.example')
})
it('copies, reads Copied in a polite region for 2 s, then reverts', async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const target = mountSnippet()
await copyButton(target).trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(THREE_TABS[0]!.code)
const region = target.get('[aria-live="polite"]')
expect(region.text()).toBe('Copied')
expect(target.findComponent(UButton).props('icon')).toBe('i-lucide-check')
await vi.advanceTimersByTimeAsync(1999)
expect(region.text()).toBe('Copied')
await vi.advanceTimersByTimeAsync(1)
expect(region.text()).toBe('Copy')
expect(target.findComponent(UButton).props('icon')).toBe('i-lucide-copy')
})
it('copies two instances independently', async () => {
stubClipboard()
const first = mountSnippet()
const second = mount(CodeSnippet, {
props: { tabs: THREE_TABS },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await copyButton(first).trigger('click')
await flushPromises()
expect(copyButton(first).text()).toBe('Copied')
expect(copyButton(second).text()).toBe('Copy')
second.unmount()
})
it('draws a focusable pre without syntax markup', () => {
const target = mountSnippet()
const pre = target.get('pre')
expect(pre.attributes('tabindex')).toBe('0')
expect(pre.find('span').exists()).toBe(false)
})
})
@@ -1,21 +0,0 @@
import { createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
export function useShikiHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['javascript', 'shellscript', 'json'],
})
}
return highlighterPromise
}
export function highlightToHtml(highlighter: Highlighter, code: string, lang: string) {
return highlighter.codeToHtml(code, {
lang,
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false,
})
}
+32
View File
@@ -1557,6 +1557,38 @@ export const messages = {
'Removed {0} files ({1}) not requested for {2} days':
'{0} fichiers supprimés ({1}) non demandés depuis {2} jours',
'{0} failed': '{0} en échec',
'Copy code': 'Copier le code',
'Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.':
'Tant que vous n’ajoutez pas de variante, toute requête avec le paramètre ?v= renvoie le statut 400 unknown_variant. Les requêtes sans ce paramètre renvoient l’image d’origine.',
'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.':
'Demandez à un propriétaire ou à un administrateur d’en ajouter une. D’ici là, toute requête avec le paramètre ?v= renvoie le statut 400 unknown_variant. Les requêtes sans ce paramètre renvoient l’image d’origine.',
'Source image too large': 'Image source trop volumineuse',
'Unable to read the file': 'Impossible de lire le fichier',
'Images and variants': 'Images et variantes',
'Every image in the media library can be requested as a named variant. Add the `?v=<variant-name>` parameter to the image URL. The original file is never changed, and a variant is generated the first time it is requested. Variants are managed in Settings, Media, Variants.':
'Chaque image de la médiathèque peut être demandée sous la forme d’une variante nommée. Ajoutez le paramètre `?v=<nom-de-la-variante>` à l’URL de l’image. Le fichier d’origine n’est jamais modifié, et une variante est générée la première fois qu’elle est demandée. Les variantes se gèrent dans Réglages, Médias, Variantes.',
'Request an image': 'Demander une image',
'Loading image variants': 'Chargement des variantes d’image',
"Couldn't load image variants": 'Impossible de charger les variantes d’image',
'The snippets below still work. The variant list and the URL builder need a connection.':
'Les extraits ci-dessous fonctionnent toujours. La liste des variantes et le générateur d’URL ont besoin d’une connexion.',
'No image variants yet': 'Aucune variante d’image pour le moment',
'Go to image variants': 'Aller aux variantes d’image',
'1 enabled variant': '1 variante activée',
'{0} enabled variants': '{0} variantes activées',
'Example URL': 'URL d’exemple',
'Disabled variants are not served. A request for one returns 400 "Unknown variant".':
'Les variantes désactivées ne sont pas servies. Une requête pour l’une d’elles renvoie 400 « Unknown variant ».',
'URL builder': 'Générateur d’URL',
'{0} (removed)': '{0} (supprimée)',
'The variant "{0}" no longer exists': 'La variante « {0} » n’existe plus',
'There is no fallback to the original. Requests with ?v={0} return status 400 with the body `{1}`. Remove the parameter to get the original image, or pick another variant.':
'Il n’y a pas de repli sur l’original. Les requêtes avec ?v={0} renvoient le statut 400 avec le corps `{1}`. Retirez le paramètre pour obtenir l’image d’origine, ou choisissez une autre variante.',
'No preview: unknown variant': 'Pas d’aperçu : variante inconnue',
Responses: 'Réponses',
Request: 'Requête',
'The variant image. Generated on first request.':
'L’image de la variante. Générée à la première requête.',
} as const
export type MessageKey = keyof typeof messages
+2
View File
@@ -1,5 +1,7 @@
import type { HttpMethod } from '@/data/apiCatalogue'
export const SECTION_TITLE = 'text-[1.125rem]/6.5 font-semibold text-highlighted'
const CHIP_BASE = 'h-5 gap-1 rounded-none px-1.5 py-0 text-xs whitespace-nowrap ring-0 border'
export const CHIP_CLASS = {
+5
View File
@@ -0,0 +1,5 @@
export interface CodeSnippetTab {
k: string
label: string
code: string
}
+91
View File
@@ -0,0 +1,91 @@
import { t } from '@/i18n'
import type { CodeSnippetTab } from '@/lib/codeSnippet'
export const SAMPLE_IMAGE = 'north-wall.jpg'
export const VARIANT_PLACEHOLDER = '<variant-name>'
export const SETTINGS_VARIANTS_PATH = '/settings/media/variants'
const SAMPLE_WIDTHS = [400, 800, 1200]
export interface ImageResponseRow {
request: string
status: string
response: string
body: string
}
export function variantUrl(origin: string, name: string) {
return `${origin}/media/${SAMPLE_IMAGE}?v=${name}`
}
export function unknownVariantBody(name: string) {
return `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "${name}" }`
}
function unsupportedParameterBody(name: string) {
return `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "${name}" }`
}
export function imageSnippets(origin: string, names: readonly string[]): CodeSnippetTab[] {
const first = names[0] ?? VARIANT_PLACEHOLDER
const second = names[1] ?? first
const third = names[2] ?? first
const url = (name: string) => variantUrl(origin, name)
const srcset = [first, second, third]
.map((name, index) => `${url(name)} ${SAMPLE_WIDTHS[index]}w`)
.join(',\n ')
return [
{ k: 'curl', label: 'curl', code: `curl -O "${url(first)}"` },
{
k: 'html',
label: 'HTML',
code: `<img\n src="${url(second)}"\n srcset="${srcset}"\n sizes="(min-width: 800px) 800px, 100vw"\n alt="North wall">`,
},
{
k: 'js',
label: 'JavaScript',
code: `const res = await fetch("${url(first)}")\nif (!res.ok) throw new Error(await res.text())\nconst blob = await res.blob()`,
},
]
}
export function imageResponseRows(): ImageResponseRow[] {
return [
{
request: '/media/photo.png?v=thumb',
status: '200',
response: t('The variant image. Generated on first request.'),
body: '',
},
{
request: '/media/photo.png?v=unknown',
status: '400',
response: t('Unknown variant'),
body: unknownVariantBody('unknown'),
},
{
request: '/media/photo.png?width=400',
status: '400',
response: t('Unsupported parameter'),
body: unsupportedParameterBody('width'),
},
{
request: '/media/missing.png?v=thumb',
status: '404',
response: t('Not found'),
body: '404 page not found',
},
{
request: '/media/huge.png?v=thumb',
status: '413',
response: t('Source image too large'),
body: 'The source image is too large to generate this variant.',
},
{
request: '/media/photo.png?v=thumb',
status: '500',
response: t('Unable to read the file'),
body: 'Unable to read the file',
},
]
}
+22 -76
View File
@@ -2,13 +2,13 @@
import { t, translate } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationImages from '@/components/IntegrationImages.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {
API_GROUPS,
ERROR_ROWS,
@@ -17,29 +17,26 @@ import {
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
import { SECTION_TITLE } from '@/lib/apiReference'
const SPY_OFFSET_PX = 120
const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
function copyBaseUrl() {
copy(origin.value)
}
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const baseUrlTabs = computed(() => [{ k: 'base', label: t('Base URL'), code: origin.value }])
const errorSampleTabs = computed(() => [
{ k: 'error', label: t('Error response · text/plain'), code: ERROR_SAMPLE },
])
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
{ id: 'images', label: t('Images and variants') },
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
@@ -48,14 +45,8 @@ const sections = computed(() => [
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
{
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
{ k: 'curl', label: 'curl', code: examples.curl },
{ k: 'fetch', label: 'fetch', code: examples.fetch },
]
})
@@ -141,10 +132,12 @@ function updateActive() {
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
let currentTop = Number.NEGATIVE_INFINITY
for (const section of sections.value) {
const target = sectionElement(section.id)
if (target && target.getBoundingClientRect().top <= limit) {
const top = sectionElement(section.id)?.getBoundingClientRect().top
if (top !== undefined && top <= limit && top > currentTop) {
current = section.id
currentTop = top
}
}
activeSection.value = current
@@ -159,15 +152,14 @@ onMounted(() => {
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</script>
<template>
<DashboardShell :mobile-title="t('Integration')">
<DashboardShell compact-padding :mobile-title="t('Integration')">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
@@ -186,7 +178,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
@@ -204,24 +196,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
</div>
<CodeSnippet :tabs="baseUrlTabs" />
</div>
<section
@@ -272,27 +247,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
)
"
/></span>
<UTabs
v-model="authTab"
:items="authItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
:label="(item as (typeof authItems)[number]).title"
:lang="(item as (typeof authItems)[number]).lang"
:code="(item as (typeof authItems)[number]).code"
/>
</template>
</UTabs>
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
<span class="text-pretty text-muted"
><CodeText
:text="
@@ -343,6 +298,8 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</div>
</section>
<IntegrationImages :origin="origin" />
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
@@ -399,18 +356,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</div>
</template>
</UTable>
<div class="min-w-0 border border-default bg-elevated">
<div
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
>
{{ t('Error response · text/plain') }}
</div>
<pre
tabindex="0"
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
>{{ ERROR_SAMPLE }}</pre
>
</div>
<CodeSnippet :tabs="errorSampleTabs" />
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
+18 -13
View File
@@ -690,7 +690,9 @@ describe('Create key: success reveals the key once', () => {
const warning = modal.querySelector('[role="alert"]')!
expect(warning.textContent).toContain("You won't be able to see this key again.")
expect(modal.textContent).toContain(FULL_KEY)
expect(modal.querySelector('.font-mono')?.textContent?.trim()).toBe(FULL_KEY)
expect(modal.querySelector('pre')?.textContent?.trim()).toBe(FULL_KEY)
expect(modal.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('API key')
expect(modal.querySelectorAll('[role="tab"]')).toHaveLength(0)
expect(modal.textContent).toContain(
'Closing this window discards the key from the screen. Copy it first.',
)
@@ -759,7 +761,7 @@ describe('One-time reveal: copied', () => {
expect(copied).toBeTruthy()
expect(copied.className).toContain('text-success')
expect(iconOf(copied)).toBe('i-lucide-check')
expect(copied.getAttribute('aria-live')).toBe('polite')
expect(copied.getAttribute('aria-label')).toBe('Copy code')
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe(
'Copied',
)
@@ -942,18 +944,21 @@ describe('Mobile Create key button, not drawn on board', () => {
{ name: 'list', list: () => Promise.resolve(ownerFixture()) },
{ name: 'loading', list: () => deferred<ApiKey[]>().promise },
{ name: 'error', list: () => Promise.reject(new ApiError(500)) },
])('is pinned in a sticky footer, full width and 48 px in $name with no header button', async ({ list }) => {
await mountPage({ list }, { desktop: false })
])(
'is pinned in a sticky footer, full width and 48 px in $name with no header button',
async ({ list }) => {
await mountPage({ list }, { desktop: false })
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).toContain('sticky')
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('w-full')
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
})
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).toContain('sticky')
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('w-full')
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
},
)
it('is not drawn in the Empty state', async () => {
await mountPage({ list: () => Promise.resolve([]) }, { desktop: false })
@@ -0,0 +1,630 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import { deferred, setViewport, stubBrowser, variant } from './variantsTestKit'
import IntegrationView from '../IntegrationView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ORIGIN = 'https://cms.renew.example'
const LIST_PATH = '/v1/image-variants'
const BASE = `${ORIGIN}/media/north-wall.jpg`
const SECTION_ORDER = ['settings', 'images', 'session', 'errors']
const BODY_UNKNOWN =
'{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'
const BODY_UNSUPPORTED =
'{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }'
const BODY_HERO =
'{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }'
const CURL = `curl -O "${BASE}?v=thumb"`
const HTML = [
'<img',
` src="${BASE}?v=card"`,
` srcset="${BASE}?v=thumb 400w,`,
` ${BASE}?v=card 800w,`,
` ${BASE}?v=hero 1200w"`,
' sizes="(min-width: 800px) 800px, 100vw"',
' alt="North wall">',
].join('\n')
const JAVASCRIPT = [
`const res = await fetch("${BASE}?v=thumb")`,
'if (!res.ok) throw new Error(await res.text())',
'const blob = await res.blob()',
].join('\n')
type Row = [string, number, number | null, ImageVariant['format']]
const BOARD_ROWS: Row[] = [
['thumb', 400, 300, 'webp'],
['card', 800, null, 'webp'],
['hero', 1200, 630, 'webp'],
['square', 600, 600, 'jpeg'],
['og', 1200, 630, 'jpeg'],
['banner', 1600, 400, 'webp'],
['teaser', 640, 360, 'webp'],
['sidebar', 320, null, 'webp'],
['gallery', 1000, 750, 'webp'],
['avatar', 96, 96, 'webp'],
['wide', 1920, 800, 'webp'],
['mini', 120, 90, 'webp'],
]
function boardVariants() {
return BOARD_ROWS.map(([name, width, height, format]) =>
variant({ name, width, height, format, quality: 82 }),
)
}
function withPrint() {
return [...boardVariants(), variant({ name: 'print', enabled: false })]
}
type ListHandler = () => Promise<unknown>
interface MountOptions {
role?: string | null
list?: ListHandler
desktop?: boolean
}
let wrapper: VueWrapper | null = null
let requestMock: ReturnType<typeof vi.fn>
let listHandler: ListHandler
function listing(items: ImageVariant[]): ListHandler {
return () => Promise.resolve({ items })
}
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
async function mountPage(options: MountOptions = {}) {
const { role = 'owner', list = listing(withPrint()), desktop = true } = options
setViewport(desktop)
stubBrowser()
Element.prototype.scrollIntoView = vi.fn()
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
listHandler = list
setActivePinia(createPinia())
const session = useSessionStore()
const sessionRole = (role ?? undefined) as 'owner' | 'admin' | undefined
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole,
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
if (path === LIST_PATH && !init?.method) {
return listHandler()
}
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role }),
request: requestMock,
})
await router.push('/integration')
const Host = defineComponent({ render: () => h(UApp, null, () => h(IntegrationView)) })
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function listCalls() {
return requestMock.mock.calls.filter(([path]) => path === LIST_PATH)
}
function requestedPaths() {
return requestMock.mock.calls.map(([path]) => path)
}
function images() {
return document.body.querySelector<HTMLElement>('[data-anchor="images"]')!
}
function headingBlock(title: string) {
const heading = Array.from(images().querySelectorAll('h3')).find(
(item) => item.textContent?.trim() === title,
)
return heading?.closest('div.flex.flex-col') as HTMLElement | undefined
}
function normalized(element: Element | null | undefined) {
return element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''
}
function tabs(root: ParentNode) {
return Array.from(root.querySelectorAll<HTMLElement>('[role="tab"]'))
}
function tabNamed(root: ParentNode, label: string) {
return tabs(root).find((tab) => tab.textContent?.trim() === label)!
}
function copyButton(root: ParentNode) {
return root.querySelector<HTMLElement>('button[aria-label="Copy code"]')!
}
function requestSnippet() {
return headingBlock('Request an image')!
}
function builder() {
return headingBlock('URL builder')!
}
function urlSnippet() {
return builder().querySelector<HTMLElement>('pre')!.parentElement!
}
function variantTable() {
return images().querySelector<HTMLElement>('[role="table"][aria-label="Variants"]')
}
function variantRows() {
const body = variantTable()?.querySelectorAll('[role="rowgroup"]')[1]
return Array.from(body?.querySelectorAll<HTMLElement>('[role="row"]') ?? [])
}
function cells(row: HTMLElement) {
return Array.from(row.querySelectorAll('[role="cell"]')).map((cell) => cell.textContent?.trim())
}
function responseRows() {
const table = images().querySelector('[role="table"][aria-label="Responses"]')
return Array.from(table?.querySelectorAll<HTMLElement>('[role="rowgroup"]')[1]!.children ?? [])
}
function alerts() {
return Array.from(images().querySelectorAll<HTMLElement>('[role="alert"]'))
}
function button(label: string) {
return Array.from(images().querySelectorAll('button, a')).find(
(item) => item.textContent?.trim() === label,
) as HTMLElement | undefined
}
function preview() {
return images().querySelector<HTMLElement>('div[role="img"]')!
}
function variantTrigger() {
return builder().querySelector<HTMLElement>('button[aria-label="Variant"]')!
}
async function pickVariant(name: string) {
variantTrigger().click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
(item) => item.textContent?.trim() === name,
)!
option.click()
await flushPromises()
}
async function focusWindow() {
window.dispatchEvent(new Event('focus'))
await flushPromises()
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.unstubAllGlobals()
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
})
beforeEach(() => {
vi.mocked(useApi).mockReset()
})
describe('I1. Many variants', () => {
it('draws the section in order with the snippets, the table, the builder and the responses', async () => {
await mountPage()
const anchors = Array.from(document.body.querySelectorAll('[data-anchor]')).map((item) =>
item.getAttribute('data-anchor'),
)
expect(anchors.filter((id) => SECTION_ORDER.includes(id!))).toEqual(SECTION_ORDER)
expect(images().querySelector('h2')?.textContent?.trim()).toBe('Images and variants')
expect(
Array.from(document.body.querySelectorAll('nav[aria-label="On this page"] button')).map(
(item) => item.textContent?.trim(),
),
).toEqual(expect.arrayContaining(['Images and variants']))
const labels = Array.from(
document.body.querySelectorAll('nav[aria-label="On this page"] button'),
).map((item) => item.textContent?.trim())
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Session only') - 1)
const request = requestSnippet()
expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([
'curl',
'HTML',
'JavaScript',
])
expect(
tabs(request).find((tab) => tab.getAttribute('aria-selected') === 'true')?.textContent,
).toBe('HTML')
expect(request.querySelector('pre')?.textContent).toBe(HTML)
tabNamed(request, 'curl').click()
await flushPromises()
expect(request.querySelector('pre')?.textContent).toBe(CURL)
tabNamed(request, 'JavaScript').click()
await flushPromises()
expect(request.querySelector('pre')?.textContent).toBe(JAVASCRIPT)
expect(normalized(headingBlock('Variants'))).toContain('12 enabled variants')
const headers = Array.from(variantTable()!.querySelectorAll('[role="columnheader"]')).map(
(item) => item.textContent?.trim(),
)
expect(headers).toEqual(['Name', 'Dimensions', 'Format', 'Example URL'])
const rows = variantRows()
expect(rows).toHaveLength(12)
expect(rows.map((row) => cells(row)[0])).toEqual(BOARD_ROWS.map(([name]) => name))
expect(cells(rows[0]!)).toEqual([
'thumb',
'400×300',
'WebP',
'/media/north-wall.jpg?v=thumb',
])
expect(cells(rows[1]!)[1]).toBe('800 × auto')
expect(normalized(variantTable())).not.toContain('print')
expect(variantTable()!.querySelectorAll('[role="rowgroup"]')[1]!.className).toContain(
'max-h-66',
)
expect(normalized(headingBlock('Variants'))).toContain(
'Disabled variants are not served. A request for one returns 400 "Unknown variant".',
)
const fields = normalized(builder())
expect(fields).toContain('Image')
expect(fields).toContain('north-wall.jpg')
expect(fields).toContain('Variant')
expect(variantTrigger().textContent?.trim()).toBe('thumb')
expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=thumb`)
expect(tabs(urlSnippet())).toHaveLength(0)
expect(urlSnippet().querySelector('[role="tablist"]')?.textContent?.trim()).toBe('URL')
expect(normalized(headingBlock('Responses'))).toContain('/media/photo.png?v=thumb')
})
})
describe('I2. One variant', () => {
it('uses the single variant everywhere and draws no scroll limit and no disabled note', async () => {
await mountPage({ list: listing([variant({ name: 'thumb', width: 400, height: 300 })]) })
expect(normalized(headingBlock('Variants'))).toContain('1 enabled variant')
expect(normalized(headingBlock('Variants'))).not.toContain('1 enabled variants')
expect(variantRows()).toHaveLength(1)
expect(variantTable()!.querySelectorAll('[role="rowgroup"]')[1]!.className).not.toContain(
'max-h-66',
)
const request = requestSnippet()
expect(request.querySelector('pre')?.textContent).toBe(
HTML.replaceAll('?v=card', '?v=thumb').replaceAll('?v=hero', '?v=thumb'),
)
expect(normalized(images())).not.toContain('Disabled variants are not served')
})
})
describe('I3. No variants, admin', () => {
it.each([
['an empty list', listing([])],
['only disabled variants', listing([variant({ name: 'print', enabled: false })])],
])('shows the empty block with the link for %s', async (_name, list) => {
await mountPage({ role: 'admin', list })
const text = normalized(images())
expect(text).toContain('No image variants yet')
expect(text).toContain(
'Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
const link = button('Go to image variants') as HTMLAnchorElement
expect(link.getAttribute('href')).toBe('/settings/media/variants')
const request = requestSnippet()
tabNamed(request, 'curl').click()
await flushPromises()
expect(request.querySelector('pre')?.textContent).toBe(`curl -O "${BASE}?v=<variant-name>"`)
expect(variantTable()).toBeNull()
expect(headingBlock('URL builder')).toBeUndefined()
expect(normalized(headingBlock('Responses'))).toContain('/media/photo.png?v=thumb')
})
})
describe('I4. No variants, not an admin', () => {
it.each([['member'], [null]])(
'sends no request and shows plain information for role %s',
async (role) => {
await mountPage({ role })
await focusWindow()
expect(requestedPaths()).toEqual([])
const text = normalized(images())
expect(text).toContain('No image variants yet')
expect(text).toContain(
'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
expect(button('Go to image variants')).toBeUndefined()
expect(images().querySelectorAll('a')).toHaveLength(0)
expect(headingBlock('URL builder')).toBeUndefined()
expect(requestSnippet().querySelector('pre')?.textContent).toContain('<variant-name>')
},
)
it('shows the same state when an owner gets a 403', async () => {
await mountPage({ list: () => Promise.reject(new ApiError(403)) })
expect(normalized(images())).toContain(
'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
expect(images().querySelectorAll('a')).toHaveLength(0)
expect(headingBlock('URL builder')).toBeUndefined()
})
})
describe('I5. Variant removed', () => {
function assertRemoved() {
expect(variantTrigger().textContent?.trim()).toBe('hero (removed)')
expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=hero`)
const warning = alerts().find((item) => item.textContent?.includes('no longer exists'))!
expect(warning.textContent).toContain('The variant "hero" no longer exists')
expect(normalized(warning)).toContain(
`There is no fallback to the original. Requests with ?v=hero return status 400 with the body ${BODY_HERO}. Remove the parameter to get the original image, or pick another variant.`,
)
const box = preview()
expect(box.getAttribute('aria-label')).toBe('No preview: unknown variant')
expect(box.textContent?.trim()).toBe('No preview: unknown variant')
expect(box.getAttribute('style')).toContain('4 / 3')
expect(box.className).toContain('opacity-50')
}
it('shows the warning after a window focus reload drops the selected variant', async () => {
await mountPage()
await pickVariant('hero')
expect(listCalls()).toHaveLength(1)
listHandler = listing(boardVariants().filter((item) => item.name !== 'hero'))
await focusWindow()
expect(listCalls()).toHaveLength(2)
assertRemoved()
})
it('shows the warning after Try again when the first load failed and the list lacks the selection', async () => {
await mountPage({ list: () => Promise.reject(new ApiError(500)) })
listHandler = listing(boardVariants())
button('Try again')!.click()
await flushPromises()
await pickVariant('hero')
listHandler = listing(boardVariants().filter((item) => item.name !== 'hero'))
await focusWindow()
assertRemoved()
})
})
describe('I6. Loading', () => {
it('draws three skeletons in a busy region and nothing else of the section', async () => {
const pending = deferred<unknown>()
await mountPage({ list: () => pending.promise })
const busy = images().querySelector('[aria-busy="true"]')!
expect(busy.children).toHaveLength(3)
expect(images().querySelector('h2')?.textContent?.trim()).toBe('Images and variants')
expect(images().querySelector('h3')).toBeNull()
expect(variantTable()).toBeNull()
expect(images().querySelector('pre')).toBeNull()
pending.resolve({ items: [] })
})
})
describe('I7. Error loading', () => {
it.each([
['status 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the alert, the placeholder snippet and retries for %s', async (_name, failing) => {
await mountPage({ list: failing })
const [alert] = alerts()
expect(alert!.textContent).toContain("Couldn't load image variants")
expect(alert!.textContent).toContain(
'The snippets below still work. The variant list and the URL builder need a connection.',
)
expect(requestSnippet().querySelector('pre')?.textContent).toContain('<variant-name>')
expect(variantTable()).toBeNull()
expect(headingBlock('URL builder')).toBeUndefined()
expect(headingBlock('Responses')).toBeUndefined()
listHandler = listing(boardVariants())
button('Try again')!.click()
await flushPromises()
expect(listCalls()).toHaveLength(2)
expect(alerts()).toHaveLength(0)
expect(variantRows()).toHaveLength(12)
})
})
describe('I8. Snippet copied', () => {
it('copies the active tab, reads Copied politely for 2 s and leaves the builder alone', async () => {
const writeText = stubClipboard()
await mountPage()
vi.useFakeTimers()
copyButton(requestSnippet()).click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith(HTML)
const live = requestSnippet().querySelector('[aria-live="polite"]')!
expect(live.textContent).toBe('Copied')
expect(copyButton(urlSnippet()).textContent?.trim()).toBe('Copy')
await vi.advanceTimersByTimeAsync(1999)
expect(live.textContent).toBe('Copied')
await vi.advanceTimersByTimeAsync(1)
expect(live.textContent).toBe('Copy')
})
})
describe('The URL builder', () => {
it('builds the URL and the placeholder preview for the picked variant', async () => {
await mountPage()
await pickVariant('square')
expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=square`)
const box = preview()
expect(box.getAttribute('role')).toBe('img')
expect(box.getAttribute('aria-label')).toBe('square · 600×600 · JPEG')
expect(box.textContent?.trim()).toBe('square · 600×600 · JPEG')
expect(box.getAttribute('style')).toContain('600 / 600')
expect(box.className).toContain('max-h-60')
expect(images().querySelectorAll('img')).toHaveLength(0)
expect(requestedPaths()).toEqual([LIST_PATH])
})
})
describe('The preview ratio', () => {
it('falls back to 4 / 3 when a dimension is null', async () => {
await mountPage()
await pickVariant('card')
expect(preview().getAttribute('style')).toContain('4 / 3')
expect(preview().getAttribute('aria-label')).toBe('card · 800 × auto · WebP')
})
})
describe('I9. URL builder copied', () => {
it('copies the exact URL and leaves the request snippet alone', async () => {
const writeText = stubClipboard()
await mountPage()
vi.useFakeTimers()
copyButton(urlSnippet()).click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith(`${BASE}?v=thumb`)
expect(urlSnippet().querySelector('[aria-live="polite"]')?.textContent).toBe('Copied')
expect(copyButton(requestSnippet()).textContent?.trim()).toBe('Copy')
await vi.advanceTimersByTimeAsync(2000)
expect(urlSnippet().querySelector('[aria-live="polite"]')?.textContent).toBe('Copy')
})
})
describe('Reload triggers', () => {
it('sends one call on mount, one per focus and one per Try again', async () => {
await mountPage()
expect(listCalls()).toHaveLength(1)
await focusWindow()
await focusWindow()
expect(listCalls()).toHaveLength(3)
expect(requestedPaths()).toEqual([LIST_PATH, LIST_PATH, LIST_PATH])
})
it('keeps the active tab and the selection when the variant is still enabled', async () => {
await mountPage()
tabNamed(requestSnippet(), 'JavaScript').click()
await flushPromises()
await pickVariant('square')
await focusWindow()
const request = requestSnippet()
expect(
tabs(request)
.find((tab) => tab.getAttribute('aria-selected') === 'true')
?.textContent?.trim(),
).toBe('JavaScript')
expect(variantTrigger().textContent?.trim()).toBe('square')
expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=square`)
})
it('stops listening to focus once the page is gone', async () => {
await mountPage()
wrapper!.unmount()
wrapper = null
await focusWindow()
expect(listCalls()).toHaveLength(1)
})
})
describe('The Responses table', () => {
it('lists the six rows with the exact bodies and no 409 or 422', async () => {
await mountPage()
const headers = Array.from(
images().querySelectorAll('[aria-label="Responses"] [role="columnheader"]'),
).map((item) => item.textContent?.trim())
expect(headers).toEqual(['Request', 'Status', 'Response'])
const rows = responseRows().map((row) =>
Array.from(row.children)
.map((cell) => normalized(cell))
.join(' '),
)
expect(rows).toEqual([
'/media/photo.png?v=thumb 200 The variant image. Generated on first request.',
`/media/photo.png?v=unknown 400 Unknown variant ${BODY_UNKNOWN}`,
`/media/photo.png?width=400 400 Unsupported parameter ${BODY_UNSUPPORTED}`,
'/media/missing.png?v=thumb 404 Not found 404 page not found',
'/media/huge.png?v=thumb 413 Source image too large The source image is too large to generate this variant.',
'/media/photo.png?v=thumb 500 Unable to read the file Unable to read the file',
])
const statuses = responseRows().map((row) => row.querySelectorAll('span')[1]!.textContent)
for (const absent of ['409', '422']) {
expect(statuses).not.toContain(absent)
}
expect(images().querySelector('[aria-label="Responses"]')!.className).toContain('min-w-140')
expect(variantTable()!.className).toContain('min-w-130')
})
})
describe('The error sample label', () => {
it('keeps "Error response · text/plain" and labels nothing in the images section text/plain', async () => {
await mountPage()
const errors = document.body.querySelector('[data-anchor="errors"] [role="tablist"]')!
expect(errors.textContent?.trim()).toBe('Error response · text/plain')
expect(normalized(images())).not.toContain('text/plain')
})
})
describe('Calls made by the page', () => {
it('sends only the list call for an owner and for an admin', async () => {
await mountPage({ role: 'admin' })
expect(requestedPaths()).toEqual([LIST_PATH])
})
it('sends nothing for any other role', async () => {
await mountPage({ role: 'member' })
expect(requestedPaths()).toEqual([])
})
})
+88 -20
View File
@@ -22,6 +22,7 @@ const SECTION_LABELS = [
'Media',
'Users and invites',
'Settings',
'Images and variants',
'Session only',
'Errors',
'Notes',
@@ -97,10 +98,7 @@ async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
button?.click()
await flushPromises()
}
@@ -182,7 +180,7 @@ describe('Default, every card collapsed', () => {
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request while rendering or expanding cards', async () => {
it('makes no /v1 request for a session without the owner or admin role', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
@@ -210,12 +208,12 @@ describe('Group scrolled, one card expanded', () => {
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(text).toContain('JavaScript')
expect(
created
.findAll('button')
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
).toBe(true)
expect(created.findAll('[role="tablist"]')).toHaveLength(1)
expect(created.get('pre').text()).toBe(
endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch,
)
expect(text).not.toContain('Shell')
expect(created.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(text).toContain('Errors')
const statuses = created
@@ -273,17 +271,19 @@ describe('Copied, the Base URL copy button', () => {
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
expect(wrapper.find('[aria-live="polite"]').text()).toContain('Copied')
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copied')
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copy')
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
describe("Copied, a CodeSnippet's Copy button is independent of the Base URL's", () => {
it("copies only that card's active tab and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
@@ -291,7 +291,7 @@ describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", (
const button = created
.findAll('button')
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
.find((item) => item.attributes('aria-label') === 'Copy code')!
await button.trigger('click')
await flushPromises()
@@ -299,10 +299,13 @@ describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", (
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
expect(findButton(wrapper, 'Copy')).toBeTruthy()
expect(copied[0]!.attributes('aria-label')).toBe('Copy code')
expect(copied[0]!.element).toBe(button.element)
expect(findButton(wrapper, 'Copy')!.attributes('aria-label')).toBe('Copy code')
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
@@ -591,3 +594,68 @@ describe('Notes', () => {
)
})
})
describe('CodeSnippet migration', () => {
it('draws the Base URL as a CodeSnippet with the single tab "Base URL"', async () => {
const wrapper = await mountIntegration()
const label = wrapper.findAll('span').find((item) => item.text() === 'Base URL')!
const snippet = label.element.parentElement!
expect(snippet.querySelectorAll('[role="tab"]')).toHaveLength(0)
expect(snippet.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('Base URL')
expect(snippet.querySelector('pre')?.textContent).toBe(STUB_ORIGIN)
})
it('draws the Authentication example with curl and fetch tabs and no Shell or JavaScript label', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
expect(auth.findAll('[role="tablist"]')).toHaveLength(1)
expect(auth.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['curl', 'fetch'])
expect(auth.get('[role="tab"][aria-selected="true"]').text()).toBe('curl')
expect(auth.get('pre').text()).toBe(authExamples(STUB_ORIGIN).curl)
expect(auth.text()).not.toContain('Shell')
expect(auth.text()).not.toContain('JavaScript')
})
it('draws the Errors sample as a CodeSnippet named "Error response · text/plain"', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.get('[role="tablist"]').text()).toBe('Error response · text/plain')
expect(errors.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(errors.get('pre').text().split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
it('keeps independent Copy buttons across the page', async () => {
const writeText = stubClipboard()
const wrapper = await mountIntegration()
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(wrapper.findAll('button').filter((item) => item.text() === 'Copied')).toHaveLength(1)
})
it('removes CodeBlock and every import of it, and keeps CodeText', () => {
const sources = import.meta.glob<string>('/src/**/*.{vue,ts}', {
query: '?raw',
import: 'default',
eager: true,
})
const paths = Object.keys(sources)
expect(paths).not.toContain('/src/components/CodeBlock.vue')
expect(paths).toContain('/src/components/CodeText.vue')
const importers = paths.filter(
(path) => !path.endsWith('.spec.ts') && sources[path]!.includes('CodeBlock'),
)
expect(importers).toEqual([])
})
})
+29 -1
View File
@@ -32,6 +32,24 @@ const VISUAL_REFERENCE =
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = {
integration: [
{
board: officeBoard,
group: 'Integration: images',
states: [
'I1 · Many variants',
'I2 · One variant',
'I3 · No variants, admin',
'I4 · No variants, not an admin',
'I5 · Variant removed',
'I6 · Loading',
'I7 · Error loading',
'I8 · Snippet copied',
'I9 · URL builder copied',
],
alias: {},
},
],
articles: [
{
board: officeBoard,
@@ -390,12 +408,22 @@ function groupSectionRows(markdown: string, group: string) {
return [...section.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) => match[1]!.trim())
}
const referenceGroups: Record<string, string> = {
'integration-api-keys': 'API keys',
}
function referenceWithoutGroup(name: string, markdown: string) {
const group = referenceGroups[name]
const board = /Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`/.exec(markdown)
return group && board ? [board[0], board[1]!, `"${group}"`] : null
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = VISUAL_REFERENCE.exec(markdown)
const reference = VISUAL_REFERENCE.exec(markdown) ?? referenceWithoutGroup(name, markdown)
if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
+168 -14
View File
@@ -972,18 +972,118 @@ function inviteWith(email: string, response?: ApiMock) {
const integrationOrigin = 'https://cms.renew.example'
async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
await page.getByRole('button', { name: 'Copy code', exact: true }).first().click()
}
const integrationImagesOffsets = { desktop: -9, mobile: -40 }
type ImageVariantRow = [string, number, number | null, string]
const integrationVariantRows: ImageVariantRow[] = [
['thumb', 400, 300, 'webp'],
['card', 800, null, 'webp'],
['hero', 1200, 630, 'webp'],
['square', 600, 600, 'jpeg'],
['og', 1200, 630, 'jpeg'],
['banner', 1600, 400, 'webp'],
['teaser', 640, 360, 'webp'],
['sidebar', 320, null, 'webp'],
['gallery', 1000, 750, 'webp'],
['avatar', 96, 96, 'webp'],
['wide', 1920, 800, 'webp'],
['mini', 120, 90, 'webp'],
]
function integrationVariant(row: ImageVariantRow, enabled = true) {
const [name, width, height, format] = row
return {
name,
width,
height,
fit: 'cover',
format,
quality: 82,
generate_on_upload: false,
enabled,
generated_files: 0,
cache_size: 0,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
function integrationVariantsList(rows: ImageVariantRow[], withDisabled = false): ApiMock {
const items = rows.map((row) => integrationVariant(row))
if (withDisabled) {
items.push(integrationVariant(['print', 0, null, 'png'], false))
}
return { status: 200, body: { items } }
}
const integrationRemovedRows = integrationVariantRows.filter((row) => row[0] !== 'hero').slice(0, 5)
const integrationManyVariants = integrationVariantsList(integrationVariantRows, true)
function afterImagesLoaded(steps: VisualState['steps']): VisualState['steps'] {
if (!steps) {
return undefined
}
return async (page) => {
await page.locator('[data-anchor="images"] h3').first().waitFor()
await steps(page)
}
}
function integrationState(overrides: Partial<VisualState> = {}): VisualState {
return {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/image-variants': integrationManyVariants,
},
...overrides,
steps: afterImagesLoaded(overrides.steps),
}
}
function integrationImagesState(
list: ApiMock | null,
overrides: Partial<VisualState> & { session?: ApiMock } = {},
): VisualState {
const { session = adminSession, ...rest } = overrides
return {
path: '/integration',
origin: integrationOrigin,
api: {
'GET /v1/auth/me': session,
...(list ? { 'GET /v1/image-variants': list } : {}),
},
steps: (page) => scrollToAnchor(page, 'images', integrationImagesOffsets),
...rest,
}
}
const memberSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'member' },
}
async function pickBuilderVariant(page: Page, name: string) {
await page.locator('button[aria-label="Variant"]').click()
await page.getByRole('option', { name, exact: true }).click()
}
async function copyUnder(page: Page, heading: string) {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await page
.locator('[data-anchor="images"] h3', { hasText: heading })
.locator('xpath=..')
.getByRole('button', { name: 'Copy code' })
.click()
await page.mouse.move(0, 0)
}
async function scrollToAnchor(
page: Page,
anchor: string,
@@ -2378,12 +2478,9 @@ const apiKeysStates: Record<string, VisualState> = {
await revealApiKey(page)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy', exact: true })
.getByRole('button', { name: 'Copy code', exact: true })
.click()
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copied', exact: true })
.waitFor()
await page.getByRole('dialog').getByText('Copied', { exact: true }).waitFor()
await page.mouse.move(0, 0)
},
},
@@ -4447,13 +4544,16 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'3a. Copied: base URL': integrationState({ steps: clickBaseUrlCopy }),
'3b. Copied: CodeBlock': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'auth')
await page.getByRole('button', { name: 'Copy Shell example' }).click()
await scrollToAnchor(page, 'auth', { desktop: 29, mobile: 63 })
await page
.locator('[data-anchor="auth"]')
.getByRole('button', { name: 'Copy code' })
.click()
await page.mouse.move(0, 0)
},
}),
'4. Authentication': integrationState({
steps: (page) => scrollToAnchor(page, 'auth'),
steps: (page) => scrollToAnchor(page, 'auth', { desktop: 29, mobile: 63 }),
}),
'5. Session only': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: 55 }),
@@ -4464,16 +4564,70 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
}),
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 48, mobile: 47 }),
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
exception: {
maxPercent: 12.5,
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board scroll position varies between captures',
maxPercent: { desktop: 12.5, mobile: 19 },
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
approvedBy: 'Florian Sylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
'I2 · One variant': integrationImagesState(
integrationVariantsList(integrationVariantRows.slice(0, 1)),
{
exception: {
maxPercent: { desktop: 16, mobile: 10 },
reason: 'The board draws the striped preview offset by 32 px',
approvedBy: 'Florian Sylvain',
},
},
),
'I3 · No variants, admin': integrationImagesState({ status: 200, body: { items: [] } }),
'I4 · No variants, not an admin': integrationImagesState(null, { session: memberSession }),
'I5 · Variant removed': integrationImagesState(
{
status: 200,
bodyFor: (_url, call) => ({
items: (call === 1
? integrationVariantRows.slice(0, 5)
: integrationRemovedRows
).map((row) => integrationVariant(row)),
}),
},
{
steps: async (page) => {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await pickBuilderVariant(page, 'hero')
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await page.getByText('hero (removed)').waitFor()
await scrollToAnchor(page, 'images', integrationImagesOffsets)
},
},
),
'I6 · Loading': integrationImagesState({ status: 200, hang: true }),
'I7 · Error loading': integrationImagesState({ status: 500 }),
'I8 · Snippet copied': integrationImagesState(integrationManyVariants, {
exception: {
maxPercent: { desktop: 12, mobile: 10 },
reason: 'The board draws the striped preview offset by 32 px',
approvedBy: 'Florian Sylvain',
},
steps: async (page) => {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await page.getByRole('tab', { name: 'JavaScript', exact: true }).click()
await copyUnder(page, 'Request an image')
},
}),
'I9 · URL builder copied': integrationImagesState(integrationManyVariants, {
steps: async (page) => {
await pickBuilderVariant(page, 'card')
await copyUnder(page, 'URL builder')
await scrollToAnchor(page, 'images', integrationImagesOffsets)
},
}),
'7. Navigation': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: 14 })
await scrollToAnchor(page, 'media', { desktop: -5, mobile: -3 })
if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}