feat(integration): add image variants section and standardize code snippets
This commit is contained in:
1 parent
6f9ae6207a
commit
e6746f2be7
28 files changed
+2256
-422
No files matched your search
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface CodeSnippetTab {
|
||||
k: string
|
||||
label: string
|
||||
code: string
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user