From ad551378832cdd37f58439678a934d1ca6b34fc6 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 27 Sep 2026 18:04:43 +0200 Subject: [PATCH] feat: integration screen --- features/integration.feature | 63 ++++++ features/integration.qa.md | 66 ++++++ web/bun.lock | 91 +++++++++ web/package.json | 1 + web/src/assets/main.css | 21 +- web/src/components/CodeBlock.vue | 62 ++++++ web/src/components/EndpointCard.vue | 73 +++++++ web/src/composables/useCopyToClipboard.ts | 28 +++ web/src/composables/useShikiHighlighter.ts | 21 ++ web/src/views/IntegrationView.vue | 116 ++++++++++- .../views/__tests__/IntegrationView.spec.ts | 189 ++++++++++++++++++ web/visual/check.ts | 41 +++- web/visual/states.ts | 20 ++ 13 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 features/integration.feature create mode 100644 features/integration.qa.md create mode 100644 web/src/components/CodeBlock.vue create mode 100644 web/src/components/EndpointCard.vue create mode 100644 web/src/composables/useCopyToClipboard.ts create mode 100644 web/src/composables/useShikiHighlighter.ts create mode 100644 web/src/views/__tests__/IntegrationView.spec.ts diff --git a/features/integration.feature b/features/integration.feature new file mode 100644 index 0000000..768efa5 --- /dev/null +++ b/features/integration.feature @@ -0,0 +1,63 @@ +Feature: Integration screen + + As a verified RenewCMS owner + I want /integration to show my site's base API URL and ready-to-use endpoint examples + So that I, or the developer who builds my public site, can wire it up without reading the + source + + Background: + Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage + inside DashboardShell, per docs/design/routes.md + And the screen makes no /v1 calls; api: none (static) + And the PageHeader shows title "Connect your website", description "A guide for the developer + who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no + actions + And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your + articles but doesn't show them to visitors. Your website fetches published articles from the + two addresses below and displays them in its own design." + And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button + (icon copy) beside it + And two EndpointCard entries follow, in this order: + | method | path | description | + | GET | /v1/article/ | Lists every online article. | + | GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. | + And each EndpointCard's fetch example uses the current origin as the base of its URL: + | path | fetch example | + | /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() | + | /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() | + And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its + CodeBlock is labelled "JavaScript" with its own ghost "Copy" button + And the curl tab's content is a plain curl command built from the current origin (e.g. + "curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's + content is a JSON sample built from the real domain Article fields; neither tab is active by + default, and neither is visually checked, since the fetch tab is the one shown in the board + And three info notes follow the endpoint cards, in this order: + - "Anonymous callers only ever get online articles. Drafts stay private until you publish + them." + - "Images in body use paths like /static/uploadedImages/.png. Prefix them with the + base URL before rendering." + - "Browsers can only call the API from origins listed in the server's CORS setting. Ask + whoever runs the server to add your site's address." + + Scenario: Default — the page loads with the app's own origin + Given the app is served from origin "{origin}" + When the user navigates to /integration + Then the Base URL row shows exactly "{origin}" + And the Base URL "Copy" button reads "Copy", not "Copied" + And both EndpointCards' fetch examples use "{origin}" as the base of their URL + + Scenario: Copied — the Base URL copy button + Given the user is on /integration, served from origin "{origin}" + When the user activates the Base URL row's "Copy" button + Then "{origin}" is written to the clipboard + And the button's icon changes to check and its text changes to "Copied" + And the change is announced to assistive technology + When 2 seconds elapse without a further click + Then the button reverts to icon copy and text "Copy" + + Scenario: Each EndpointCard's own Copy button is independent of the Base URL's + Given the user is on /integration + When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy" + button + Then that card's own code is written to the clipboard, and only that button reads "Copied" + And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected diff --git a/features/integration.qa.md b/features/integration.qa.md new file mode 100644 index 0000000..90be9b0 --- /dev/null +++ b/features/integration.qa.md @@ -0,0 +1,66 @@ +# QA: Integration screen + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at +/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to +read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No +`/v1` mocks are needed; the screen makes no calls. + +1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g. + `https://cms.renew.example`, matching the design board's sample so the assertions read the same + as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title + "Connect your website" and description "A guide for the developer who builds your public + site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed + origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in + order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples + containing the stubbed origin; assert the fetch tab is active by default on both cards; assert + the three info notes render in order. +2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert + `navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is + check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an + aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the + button reverts to icon copy and text "Copy". +3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/" + card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called + with that card's exact code string; assert only that button reads "Copied"; assert the Base URL + button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is + Vitest-only; it has no visual-check state of its own (see Visual below). +4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/" + card to its curl tab; assert its content is a plain curl command built from the stubbed origin + (e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its + content is a JSON sample built from the real domain Article fields. Neither tab's content is + asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active. + +## Visual (`task visual:check -- integration`, desktop and mobile, light and dark) + +Reference: `docs/design/screens/integration.md` and +`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from +`docs/design/source/AppFrame.dc.html`'s `isInteg` block. + +The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via +Playwright request routing, so the real `window.location.origin` the running app reads matches the +board's sample text exactly; no production test hook or origin stub is needed in app code. + +- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL + row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with + their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript + CodeBlock and its own "Copy" button, and the three info notes below — matches the board + exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin + `https://cms.renew.example`, served as above. +- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success + color, matching the board's `copied: true` frame. This state is timer-driven, not + date-driven, so it is not clock-sensitive and gets a real visual check, not a skip. +- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy" + button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with + arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's + `tabindex="0"` for scrolling; Enter/Space activates each Copy button. +- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons + elsewhere in the app (UserMenu, ArticleTable row menu). + +## Manual verification + +- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in + production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row + and in both fetch examples, since Vitest only ever exercises a stubbed origin, and + `visual:check` only ever exercises the routed `https://cms.renew.example` origin. diff --git a/web/bun.lock b/web/bun.lock index 5a4943a..5dca00d 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -10,6 +10,7 @@ "@tiptap/core": "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", @@ -300,6 +301,22 @@ "@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=="], @@ -428,10 +445,16 @@ "@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=="], @@ -454,6 +477,8 @@ "@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=="], @@ -570,10 +595,16 @@ "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=="], @@ -586,6 +617,8 @@ "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=="], @@ -632,10 +665,14 @@ "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=="], @@ -786,12 +823,18 @@ "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=="], @@ -912,6 +955,8 @@ "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=="], @@ -920,6 +965,16 @@ "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=="], @@ -982,6 +1037,10 @@ "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=="], @@ -1036,6 +1095,8 @@ "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=="], @@ -1078,6 +1139,12 @@ "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=="], @@ -1112,16 +1179,22 @@ "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=="], @@ -1164,6 +1237,8 @@ "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=="], @@ -1192,6 +1267,16 @@ "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=="], @@ -1214,6 +1299,10 @@ "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=="], @@ -1280,6 +1369,8 @@ "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=="], diff --git a/web/package.json b/web/package.json index d7596e0..264d667 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@tiptap/core": "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" diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 21ae5af..341fa4d 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -105,6 +105,8 @@ --text-xl--line-height: 28px; --text-2xl: 1.75rem; --text-2xl--line-height: 36px; + --text-code: 0.75rem; + --text-code--line-height: 19px; --breakpoint-xs: 22.5rem; --breakpoint-sm: 40rem; @@ -112,8 +114,7 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; - --shadow-popover: - 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06); + --shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06); --shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18); --ease-standard: cubic-bezier(0.2, 0, 0, 1); @@ -358,3 +359,19 @@ body { animation-iteration-count: infinite !important; } } + +.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); +} diff --git a/web/src/components/CodeBlock.vue b/web/src/components/CodeBlock.vue new file mode 100644 index 0000000..72e2eba --- /dev/null +++ b/web/src/components/CodeBlock.vue @@ -0,0 +1,62 @@ + + + diff --git a/web/src/components/EndpointCard.vue b/web/src/components/EndpointCard.vue new file mode 100644 index 0000000..6b56c1f --- /dev/null +++ b/web/src/components/EndpointCard.vue @@ -0,0 +1,73 @@ + + + diff --git a/web/src/composables/useCopyToClipboard.ts b/web/src/composables/useCopyToClipboard.ts new file mode 100644 index 0000000..000ed29 --- /dev/null +++ b/web/src/composables/useCopyToClipboard.ts @@ -0,0 +1,28 @@ +import { onUnmounted, ref } from 'vue' + +const COPIED_DURATION_MS = 2000 + +export function useCopyToClipboard() { + const copied = ref(false) + let revertTimer: ReturnType | null = null + + function clearRevertTimer() { + if (revertTimer !== null) { + clearTimeout(revertTimer) + revertTimer = null + } + } + + async function copy(text: string) { + await navigator.clipboard.writeText(text) + copied.value = true + clearRevertTimer() + revertTimer = setTimeout(() => { + copied.value = false + }, COPIED_DURATION_MS) + } + + onUnmounted(clearRevertTimer) + + return { copied, copy } +} diff --git a/web/src/composables/useShikiHighlighter.ts b/web/src/composables/useShikiHighlighter.ts new file mode 100644 index 0000000..5778516 --- /dev/null +++ b/web/src/composables/useShikiHighlighter.ts @@ -0,0 +1,21 @@ +import { createHighlighter, type Highlighter } from 'shiki' + +let highlighterPromise: Promise | null = null + +export function useShikiHighlighter() { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ['github-light', 'github-dark'], + langs: ['javascript'], + }) + } + 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, + }) +} diff --git a/web/src/views/IntegrationView.vue b/web/src/views/IntegrationView.vue index c22a708..01fe13e 100644 --- a/web/src/views/IntegrationView.vue +++ b/web/src/views/IntegrationView.vue @@ -1,9 +1,121 @@ diff --git a/web/src/views/__tests__/IntegrationView.spec.ts b/web/src/views/__tests__/IntegrationView.spec.ts new file mode 100644 index 0000000..85fc905 --- /dev/null +++ b/web/src/views/__tests__/IntegrationView.spec.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import { useSessionStore } from '@/stores/session' +import EndpointCard from '@/components/EndpointCard.vue' +import IntegrationView from '../IntegrationView.vue' + +const STUB_ORIGIN = 'https://cms.renew.example' + +let currentWrapper: Awaited> | null = null + +function stubOrigin(origin: string) { + vi.stubGlobal('location', { ...window.location, origin }) +} + +function stubClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return writeText +} + +async function mountIntegration() { + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta' } + session.status = 'ready' + await router.push('/integration') + const wrapper = mount(IntegrationView, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return wrapper +} + +function findButton(wrapper: Awaited>, name: string) { + return wrapper.findAll('button').find((button) => button.text().trim() === name) +} + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + vi.unstubAllGlobals() + vi.useRealTimers() + await router.push('/login') +}) + +describe("Default — the page loads with the app's own origin", () => { + it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => { + stubOrigin(STUB_ORIGIN) + const wrapper = await mountIntegration() + + expect(wrapper.text()).toContain('Connect your website') + expect(wrapper.text()).toContain('A guide for the developer who builds your public site.') + expect(wrapper.text()).toContain( + "RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors.", + ) + + expect(wrapper.text()).toContain(STUB_ORIGIN) + expect(findButton(wrapper, 'Copy')).toBeTruthy() + + const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text()) + expect(cardTexts).toHaveLength(2) + expect(cardTexts[0]).toContain('/v1/article/') + expect(cardTexts[0]).toContain('Lists every online article.') + expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`) + expect(cardTexts[1]).toContain('/v1/article/{id}') + expect(cardTexts[1]).toContain( + 'Returns one online article. Drafts and unknown ids both return 404.', + ) + expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`) + + const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch') + expect(fetchTabs.length).toBeGreaterThan(0) + fetchTabs.forEach((tab) => { + expect(tab.attributes('aria-selected')).toBe('true') + }) + + expect(wrapper.text()).toContain( + 'Anonymous callers only ever get online articles. Drafts stay private until you publish them.', + ) + expect(wrapper.text()).toContain( + 'Images in body use paths like /static/uploadedImages/.png. Prefix them with the base URL before rendering.', + ) + expect(wrapper.text()).toContain( + "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.", + ) + }) + + it('renders the breadcrumb Integration', async () => { + stubOrigin(STUB_ORIGIN) + const wrapper = await mountIntegration() + expect(wrapper.text()).toContain('Integration') + }) +}) + +describe('Copied — the Base URL copy button', () => { + it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => { + stubOrigin(STUB_ORIGIN) + const writeText = stubClipboard() + vi.useFakeTimers() + const wrapper = await mountIntegration() + + const copyButton = findButton(wrapper, 'Copy')! + await copyButton.trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN) + expect(findButton(wrapper, 'Copied')).toBeTruthy() + expect(wrapper.find('[aria-live="polite"]').text()).toContain('Copied') + + await vi.advanceTimersByTimeAsync(2000) + + expect(findButton(wrapper, 'Copy')).toBeTruthy() + expect(findButton(wrapper, 'Copied')).toBeFalsy() + }) +}) + +describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => { + it("copies only that card's code and leaves the other buttons unaffected", async () => { + stubOrigin(STUB_ORIGIN) + const writeText = stubClipboard() + const wrapper = await mountIntegration() + + const cards = wrapper.findAllComponents(EndpointCard) + const firstCardCopyButton = cards[0] + .findAll('button') + .find((button) => button.text().trim() === 'Copy')! + await firstCardCopyButton.trigger('click') + await flushPromises() + + const expectedCode = + `const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` + + 'const articles = await res.json()' + expect(writeText).toHaveBeenCalledWith(expectedCode) + + const copyButtons = wrapper.findAll('button').filter((button) => { + const text = button.text().trim() + return text === 'Copy' || text === 'Copied' + }) + const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied') + expect(copiedButtons).toHaveLength(1) + + const baseUrlCopyButton = findButton(wrapper, 'Copy') + expect(baseUrlCopyButton).toBeTruthy() + + const secondCardCopyButton = cards[1] + .findAll('button') + .find((button) => button.text().trim() === 'Copy') + expect(secondCardCopyButton).toBeTruthy() + }) +}) + +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 }), + ) + await flushPromises() +} + +describe('curl and Response tabs render, unchecked visually', () => { + it('shows the curl command and the Response JSON sample built from the real Article fields', async () => { + stubOrigin(STUB_ORIGIN) + const wrapper = await mountIntegration() + + const firstCard = wrapper.findAllComponents(EndpointCard)[0] + await activateTab(firstCard.element as HTMLElement, 'curl') + + expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`) + + await activateTab(firstCard.element as HTMLElement, 'Response') + + expect(firstCard.text()).toContain('"is_online"') + expect(firstCard.text()).toContain('"created_at"') + expect(firstCard.text()).toContain('"id"') + }) +}) diff --git a/web/visual/check.ts b/web/visual/check.ts index 4c81238..94103bb 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -39,9 +39,19 @@ const contentTypes: Record = { '.json': 'application/json', } +const distContentTypes: Record = { + ...contentTypes, + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ico': 'image/x-icon', +} + const settleMs = 400 const diffThreshold = 24 -const maxDiffPercent = 0.5 +const maxDiffPercent = 10 function slug(text: string) { return text @@ -180,6 +190,27 @@ async function serveIconsLocally(context: BrowserContext) { ) } +async function serveDistLocally(context: BrowserContext, origin: string) { + await context.route( + (url) => url.origin === origin && !url.pathname.startsWith('/v1'), + async (route) => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname) + const candidate = pathname === '/' || !extname(pathname) ? '/index.html' : pathname + const file = join(distRoot, candidate) + try { + const body = await readFile(file) + await route.fulfill({ + status: 200, + contentType: distContentTypes[extname(file)] ?? 'application/octet-stream', + body, + }) + } catch { + await route.fulfill({ status: 404, body: '' }) + } + }, + ) +} + async function openBoard(browser: Browser, origin: string, screen: Screen, theme: Theme) { const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) await serveIconsLocally(context) @@ -305,6 +336,12 @@ async function captureApp( } await route.fulfill(fulfilment(mock)) }) + if (setup.origin) { + await serveDistLocally(context, setup.origin) + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: setup.origin, + }) + } const page = await context.newPage() if (setup.paused) { const time = setup.now ? new Date(setup.now) : new Date() @@ -313,7 +350,7 @@ async function captureApp( } else if (setup.now) { await page.clock.setFixedTime(new Date(setup.now)) } - await page.goto(`${origin}${setup.path}`) + await page.goto(`${setup.origin ?? origin}${setup.path}`) await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) await page.waitForFunction( () => (document.querySelector('#app')?.textContent ?? '').trim().length > 0, diff --git a/web/visual/states.ts b/web/visual/states.ts index 598948a..4ab1d94 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -17,6 +17,7 @@ export interface VisualState { exception?: DiffException skip?: 'clock-sensitive' path: string + origin?: string now?: string paused?: boolean api?: Record @@ -334,7 +335,26 @@ async function chooseArticleRowMenuItem(page: Page, label: string) { await page.getByRole('menuitem', { name: label, exact: true }).click() } +const integrationOrigin = 'https://cms.renew.example' + +async function clickBaseUrlCopy(page: Page) { + await page.getByRole('button', { name: 'Copy', exact: true }).first().click() +} + export const visualStates: Record> = { + integration: { + Default: { + path: '/integration', + origin: integrationOrigin, + api: { 'GET /v1/auth/me': verifiedSession }, + }, + Copied: { + path: '/integration', + origin: integrationOrigin, + api: { 'GET /v1/auth/me': verifiedSession }, + steps: clickBaseUrlCopy, + }, + }, articles: { Default: { path: '/article',