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
@@ -155,7 +155,7 @@ Feature: API keys screen
|
||||
"\"Nightly media import\" is ready. Copy the key and store it somewhere safe."
|
||||
And a warning alert (UAlert, color warning, triangle-alert icon) reads "You won't be able to
|
||||
see this key again."
|
||||
And the full key from the response is shown in a monospace box with a "Copy" button (icon copy)
|
||||
And the full key from the response is shown in a CodeSnippet with the single tab "API key" and a "Copy" button (icon copy, accessible name "Copy code"), focused when the modal opens
|
||||
And the note "Closing this window discards the key from the screen. Copy it first." is shown
|
||||
And the only button is a primary "Done"
|
||||
|
||||
@@ -181,8 +181,7 @@ Feature: API keys screen
|
||||
Given the key reveal is open
|
||||
When the user activates "Copy"
|
||||
Then the full key is written to the clipboard
|
||||
And the button shows icon check and text "Copied" in the success color, announced to assistive
|
||||
technology
|
||||
And the button shows icon check and text "Copied" in the success color, announced politely
|
||||
When 2 seconds elapse
|
||||
Then the button reverts to icon copy and text "Copy"
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ layout cases at desktop and below-md (mock `matchMedia`).
|
||||
errors clear when fixed.
|
||||
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
|
||||
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
|
||||
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
|
||||
"Done".
|
||||
key; the reveal: title, text, warning alert, key equal to the mocked `key` inside a CodeSnippet with
|
||||
the single tab "API key", "Copy" (named "Copy code") focused on open, note, only "Done".
|
||||
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
|
||||
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
|
||||
dates from `new Date()` in the test.
|
||||
@@ -137,7 +137,7 @@ States:
|
||||
- [ ] Create key
|
||||
- [ ] Create key: validation
|
||||
- [ ] Create key: name too long
|
||||
- [ ] One-time reveal
|
||||
- [ ] One-time reveal (re-run after the CodeSnippet migration, within 10 %)
|
||||
- [ ] One-time reveal: copied (timer-driven, so it is checked)
|
||||
- [ ] Revoke: confirm
|
||||
- [ ] Revoked (list with the revoked row and the toast)
|
||||
|
||||
@@ -7,7 +7,7 @@ Feature: Integration screen (API reference)
|
||||
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 screen makes one call, GET /v1/image-variants, for owner and admin sessions only (features/integration_images.feature)
|
||||
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
|
||||
"Everything a developer needs to build on RenewCMS."
|
||||
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
|
||||
@@ -37,7 +37,7 @@ Feature: Integration screen (API reference)
|
||||
Scenario: Default, every card collapsed
|
||||
When the user navigates to /integration
|
||||
Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy)
|
||||
And a "Base URL" label sits above a CodeSnippet with the single tab "Base URL" showing window.location.origin and a "Copy" button (icon copy)
|
||||
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
|
||||
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
|
||||
|
||||
@@ -70,7 +70,7 @@ Feature: Integration screen (API reference)
|
||||
| Name | Type | Required | Rule |
|
||||
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
|
||||
| body | string | No | No length limit. |
|
||||
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
|
||||
And one CodeSnippet with the tabs fetch, curl and Response shows, fetch active, with a "Copy" button
|
||||
And an "Errors" list shows a status chip with a one-line caption for 400, 401
|
||||
|
||||
Scenario: Query parameters table, new UI beyond the board, user-approved
|
||||
@@ -114,7 +114,7 @@ Feature: Integration screen (API reference)
|
||||
Scenario: Authentication section
|
||||
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
|
||||
And card "API key" reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator."
|
||||
And its CodeBlock has tabs curl and fetch for GET /v1/auth/me, using the current origin
|
||||
And its CodeSnippet has tabs curl and fetch for GET /v1/auth/me, using the current origin
|
||||
And it says "Keys start with `rcms_` and are shown in full once, when you create them."
|
||||
And a "Manage API keys" link navigates to /integration/api-keys
|
||||
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
@@ -139,7 +139,7 @@ Feature: Integration screen (API reference)
|
||||
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
|
||||
| 429 | Too many requests. Please try again later. |
|
||||
| 500 | Internal server error |
|
||||
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
|
||||
And a CodeSnippet with the single tab "Error response · text/plain" shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
|
||||
And the table does not use the SPA copy of docs/design/states.md
|
||||
|
||||
Scenario Outline: Per-endpoint error captions use the real strings
|
||||
@@ -169,15 +169,15 @@ Feature: Integration screen (API reference)
|
||||
Given the app is served from origin "{origin}"
|
||||
When the user activates the Base URL "Copy" button
|
||||
Then "{origin}" is written to the clipboard
|
||||
And the button shows icon check and "Copied", announced to assistive technology
|
||||
And the button, named "Copy code", shows icon check and "Copied", announced politely
|
||||
When 2 seconds elapse
|
||||
Then the button reverts to icon copy and "Copy"
|
||||
|
||||
Scenario: Copied, CodeBlock is independent of Base URL
|
||||
Scenario: Copied, CodeSnippet is independent of Base URL
|
||||
Given a card is expanded
|
||||
When the user activates that CodeBlock's "Copy JavaScript example" button
|
||||
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
|
||||
And the Base URL button and other CodeBlocks are unaffected
|
||||
When the user activates that CodeSnippet's "Copy code" button
|
||||
Then the active tab's exact code string is written to the clipboard and only that button reads "Copied"
|
||||
And the Base URL button and other CodeSnippets are unaffected
|
||||
|
||||
Scenario: Examples use the current origin and the API key header
|
||||
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
|
||||
@@ -189,5 +189,7 @@ Feature: Integration screen (API reference)
|
||||
Then the page scrolls to that anchor and the item reports aria-current "true"
|
||||
And on mobile the "Jump to" select opens and lists the same sections
|
||||
|
||||
Scenario: The page stays static
|
||||
Then no /v1 request is made while the page renders or while any card is expanded
|
||||
Scenario: Only the image variants calls
|
||||
Then the only /v1 request is GET /v1/image-variants, sent for owner and admin sessions on mount, on "Try again" and on window focus
|
||||
And expanding or collapsing any card makes no /v1 request
|
||||
And a session that is neither owner nor admin makes no /v1 request
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
|
||||
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
|
||||
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
|
||||
`navigator.clipboard.writeText` per case. Mock only GET /v1/image-variants (see integration_images.qa.md). A spec fails if any other `/v1` call
|
||||
is made.
|
||||
|
||||
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
||||
@@ -44,12 +44,12 @@ Component specs, one per design state:
|
||||
invite rows show "Owner only".
|
||||
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
|
||||
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
|
||||
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
|
||||
active in one CodeSnippet, and the Errors list with 400 and 401.
|
||||
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
|
||||
announced; reverts after 2 s.
|
||||
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
|
||||
4. **Copied: CodeSnippet**: expand a card; click "Copy code". Clipboard gets the active tab's code,
|
||||
only that button reads "Copied", and it reverts after 2 s.
|
||||
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
|
||||
5. **Authentication**: both cards, a CodeSnippet with curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
|
||||
href /integration/api-keys, Note 1, Note 2.
|
||||
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
|
||||
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
|
||||
@@ -61,6 +61,7 @@ Component specs, one per design state:
|
||||
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
|
||||
Authorization header only on non-public routes. Not visually checked.
|
||||
10. **Notes**: format note, image-prefix note, CORS note.
|
||||
11. **Base URL and error sample**: both are CodeSnippets (single tabs "Base URL" and "Error response · text/plain").
|
||||
|
||||
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
|
||||
|
||||
@@ -78,7 +79,7 @@ diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and
|
||||
- [ ] Default (exception: owner-only chips and rate-limit line)
|
||||
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
|
||||
- [ ] Copied: base URL (must pass 10 %)
|
||||
- [ ] Copied: CodeBlock (must pass 10 %)
|
||||
- [ ] Copied: CodeSnippet (must pass 10 %)
|
||||
- [ ] Authentication (must pass 10 %; Note 1 text changed)
|
||||
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
|
||||
- [ ] Errors (exception: 409 row)
|
||||
@@ -88,7 +89,7 @@ None of these states is clock-sensitive, so none is skipped.
|
||||
|
||||
## Manual
|
||||
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
|
||||
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
|
||||
CodeSnippet Copy, the pre (tabindex 0), Jump to on mobile.
|
||||
- Hover and motion on ghost Copy buttons and rows.
|
||||
- Find-in-page and a deep link to an `#ep-...` anchor.
|
||||
- Real origin in a production build and under `task dev`.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
Feature: Integration: images and variants, and CodeSnippet as the only code block
|
||||
|
||||
As a developer building a site on RenewCMS
|
||||
I want the Integration page to explain image variants and give me copyable, correct URLs
|
||||
So that I can request the right image size without reading the source
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/integration.md ("Integration: images and ?v="),
|
||||
docs/design/screens/integration-api-keys.md, docs/design/components.md (CodeSnippet), the boards
|
||||
docs/design/source/IntegrationImages.dc.html and CodeSnippet.dc.html, and docs/design/routes.md
|
||||
And this slice is SPA only: no Go change, and the image API is the one of features/image-variants.feature
|
||||
And /integration is a verified route rendered as IntegrationPage inside DashboardShell, per
|
||||
features/integration.feature
|
||||
And the section "Images and variants" has the anchor id "images", sits where the board draws it (after the API groups and before "Session only" and "Errors"), and has an entry "Images and variants" in the section index and the "Jump to" select
|
||||
And the section index and the "Jump to" select list the sections in page order, so the "Images and variants" entry sits right before "Session only"
|
||||
And the board and its own index disagree on this position, which is logged for the design pass
|
||||
And the section title is an h2 "Images and variants" (18/26) with sub headings h3 (14/22)
|
||||
And the section intro reads "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."
|
||||
And the section calls, with a relative URL through useApi only, GET /v1/image-variants, an owner/admin-only route
|
||||
And an item has the fields name, width, height, fit, format, quality, generate_on_upload, enabled,
|
||||
generated_files and cache_size, and items arrive in name order
|
||||
And only items with enabled true are "enabled variants"; the order of the API response is kept
|
||||
And "first variant" below means the first enabled variant, "second" and "third" the next ones, each falling back to the first when absent
|
||||
And {origin} is window.location.origin and {image} is the builder image, "north-wall.jpg", a fixed sample with no media lookup
|
||||
And a variant URL is "{origin}/media/{image}?v={name}"
|
||||
And every visible string goes through t(...), with the English text as the key and its French entry in
|
||||
web/src/i18n/messages.ts
|
||||
And the section uses Nuxt UI components and design tokens only, with no raw colour
|
||||
And the list is read through the useImageVariantsStore
|
||||
And the board sample data is {origin} "https://cms.renew.example", image "north-wall.jpg" and these variants, in this order:
|
||||
| name | width | height | format |
|
||||
| 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 |
|
||||
|
||||
Rule: CodeSnippet is the only code block
|
||||
|
||||
Scenario: CodeSnippet anatomy
|
||||
Given a CodeSnippet with the tabs curl, HTML and JavaScript
|
||||
Then a header row at least 44 px high holds the tab list (aria-label "Language") on the left and the Copy button on the right
|
||||
And the selected tab has text --ui-text-highlighted, weight 500 and a 2 px --ui-primary underline
|
||||
And unselected tabs have --ui-text-toned and weight 400
|
||||
And below it a pre with tabindex 0 shows the active tab's code in IBM Plex Mono 12/19, scrolls horizontally and has no syntax colouring
|
||||
And the background is --ui-bg-elevated with a --ui-border border and no corner radius
|
||||
And the Copy button is at least 44 px wide, reads "Copy" with icon copy and has the accessible name "Copy code"
|
||||
And each tab and the Copy button show a 2 px --ui-primary focus ring when focused with the keyboard
|
||||
|
||||
Scenario: A CodeSnippet with one tab shows a plain label
|
||||
Given a CodeSnippet with a single tab "Base URL"
|
||||
Then the label is plain --ui-text-muted text, not a tab, with no underline
|
||||
|
||||
Scenario: Copy writes the active tab and reads Copied for 2 s
|
||||
Given a CodeSnippet with the tabs curl, HTML and JavaScript and the JavaScript tab active
|
||||
When the user activates "Copy"
|
||||
Then the JavaScript code is written to the clipboard
|
||||
And the button shows icon check and "Copied" in --ui-success
|
||||
And the text lives in an aria-live "polite" region, so "Copied" is announced politely
|
||||
When 2 seconds elapse
|
||||
Then the button reads "Copy" with icon copy again
|
||||
|
||||
Scenario: Two CodeSnippets copy independently
|
||||
Given the page shows several CodeSnippets
|
||||
When the user activates "Copy" on one of them
|
||||
Then only that one reads "Copied"
|
||||
|
||||
Scenario Outline: Every code block of the product is a CodeSnippet
|
||||
Then <place> renders as a CodeSnippet with <tabs>
|
||||
And no CodeBlock, no shiki highlighted block and no hand-rolled copy button remains there
|
||||
Examples:
|
||||
| place | tabs |
|
||||
| Base URL on /integration (label "Base URL" above it) | one tab "Base URL" with {origin} |
|
||||
| the Authentication "API key" example | tabs curl and fetch, GET /v1/auth/me |
|
||||
| an expanded EndpointCard | tabs fetch, curl and Response, fetch active |
|
||||
| the Errors section sample | one tab "Error response · text/plain" |
|
||||
| the "Request an image" snippet | tabs curl, HTML and JavaScript |
|
||||
| the URL builder output | one tab "URL" |
|
||||
| the one-time key of ApiKeyReveal on /integration/api-keys | one tab "API key" |
|
||||
|
||||
Scenario: The old component is removed
|
||||
Then web/src/components/CodeBlock.vue no longer exists and nothing imports it, including EndpointCard.vue and IntegrationView.vue
|
||||
And web/src/components/CodeText.vue stays, because it renders inline code in prose and not a code block
|
||||
And the shiki highlighter helpers and styles used only by CodeBlock are removed when nothing else uses them
|
||||
And the hand-rolled Base URL box, the error sample pre and the key box of ApiKeyReveal are gone
|
||||
|
||||
Scenario: The one-time key reveal keeps its behaviour
|
||||
Given the ApiKeyReveal dialog opens after POST /v1/api-key answers 201
|
||||
Then the key is shown in a CodeSnippet with one tab "API key" and "Copy"
|
||||
And the Copy button receives focus when the dialog opens, as before
|
||||
And activating it writes the full key to the clipboard and reads "Copied" for 2 s, announced politely
|
||||
And the warning "You won't be able to see this key again." and the text "Closing this window discards the key from the screen. Copy it first." stay unchanged
|
||||
|
||||
Rule: The variants list is reloaded
|
||||
|
||||
Scenario: Reload triggers
|
||||
Given the session role is "owner" or "admin"
|
||||
Then GET /v1/image-variants is sent on mount
|
||||
And it is sent again on "Try again"
|
||||
And it is sent again on every window focus event while /integration is shown
|
||||
And a reload keeps the active snippet tab and the builder selection when the selected variant is still enabled
|
||||
|
||||
Scenario: A non-admin never calls the API
|
||||
Given the session role is neither "owner" nor "admin"
|
||||
Then no request to GET /v1/image-variants is sent on mount, on focus or on any other trigger
|
||||
|
||||
Rule: Section content
|
||||
|
||||
Background:
|
||||
Given the session role is "owner"
|
||||
|
||||
Scenario: I1. Many variants
|
||||
Given GET /v1/image-variants answers 200 with the twelve sample variants enabled and "print" (disabled)
|
||||
When the user opens /integration#images
|
||||
Then the h3 "Request an image" shows a CodeSnippet with tabs "curl", "HTML", "JavaScript", with HTML active
|
||||
And the h3 "Variants" shows "12 enabled variants"
|
||||
And the table has the columns Name, Dimensions, Format and Example URL and 12 rows, in API order
|
||||
And the "thumb" row reads "thumb", "400×300", "WebP", "/media/north-wall.jpg?v=thumb"
|
||||
And the "card" row reads "800 × auto"
|
||||
And the table body scrolls vertically at 264 px, because there are more than 6 rows
|
||||
And the note reads "Disabled variants are not served. A request for one returns 400 "Unknown variant"."
|
||||
And "print" is not a row
|
||||
And the h3 "URL builder" shows the Image, Variant (default "thumb"), URL (CodeSnippet) and Preview
|
||||
And the h3 "Responses" shows the responses table
|
||||
|
||||
Scenario: The disabled-variants note matches the backend
|
||||
Given a variant "print" is disabled
|
||||
When /media/north-wall.jpg?v=print is requested
|
||||
Then the backend answers 400 application/json with {"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"print"} and does not fall back to the original
|
||||
And the note is shown only when the response contains at least one disabled variant
|
||||
|
||||
Scenario: The snippets use the first variants
|
||||
Given the sample variants
|
||||
Then the curl tab reads: curl -O "https://cms.renew.example/media/north-wall.jpg?v=thumb"
|
||||
And the HTML tab reads:
|
||||
"""
|
||||
<img
|
||||
src="https://cms.renew.example/media/north-wall.jpg?v=card"
|
||||
srcset="https://cms.renew.example/media/north-wall.jpg?v=thumb 400w,
|
||||
https://cms.renew.example/media/north-wall.jpg?v=card 800w,
|
||||
https://cms.renew.example/media/north-wall.jpg?v=hero 1200w"
|
||||
sizes="(min-width: 800px) 800px, 100vw"
|
||||
alt="North wall">
|
||||
"""
|
||||
And the JavaScript tab reads:
|
||||
"""
|
||||
const res = await fetch("https://cms.renew.example/media/north-wall.jpg?v=thumb")
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const blob = await res.blob()
|
||||
"""
|
||||
And the srcset widths 400w, 800w and 1200w are sample values, not read from the variants
|
||||
|
||||
Scenario: Tables and snippet scroll on narrow screens
|
||||
Given a viewport of 390 px
|
||||
Then the variants table keeps a minimum width of 520 px and scrolls horizontally inside its border
|
||||
And the Responses table keeps a minimum width of 560 px and scrolls horizontally
|
||||
And the CodeSnippet body scrolls horizontally and the page itself does not
|
||||
|
||||
Scenario: I2. One variant
|
||||
Given GET /v1/image-variants answers 200 with only "thumb" enabled
|
||||
Then the Variants heading reads "1 enabled variant" and the table has one row without a vertical scroll limit
|
||||
And the three snippets use "thumb" for the first, second and third variant
|
||||
And the "Disabled variants are not served" note is not shown
|
||||
|
||||
Scenario: I3. No variants, admin
|
||||
Given the session role is "admin"
|
||||
And GET /v1/image-variants answers 200 with {"items": []} or only disabled variants
|
||||
Then an empty block with the layers icon shows "No image variants yet"
|
||||
And its text reads "Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image."
|
||||
And a primary button "Go to image variants" navigates to /settings/media/variants
|
||||
And the snippets use the placeholder <variant-name>
|
||||
And no table and no URL builder are drawn
|
||||
And the Responses table is drawn
|
||||
|
||||
Scenario: I4. No variants, not an admin
|
||||
Given the session role is neither "owner" nor "admin"
|
||||
Then no request to GET /v1/image-variants is sent
|
||||
And the empty block shows "No image variants yet"
|
||||
And its text reads "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."
|
||||
And there is no button and no link
|
||||
And the snippets use the placeholder <variant-name>
|
||||
|
||||
Scenario: The empty-state copy is corrected against the backend
|
||||
Then the I3 and I4 texts above replace the design copy "the ?v= parameter is not accepted and every request returns the original image", which is false
|
||||
And the backend returns 400 unknown_variant for any ?v= value when no variants exist, and serves the original only without ?v
|
||||
And the board copy is logged as wrong for the design pass, and docs/design is not edited
|
||||
|
||||
Scenario: I5. Variant removed
|
||||
Given the URL builder has "hero" selected
|
||||
And a reload of the list returns no enabled "hero"
|
||||
Then the Variant field reads "hero (removed)" and has a --ui-warning border
|
||||
And the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=hero"
|
||||
And a warning alert (role alert, triangle-alert icon) reads the title "The variant "hero" no longer exists"
|
||||
And its text reads "There is no fallback to the original. Requests with ?v=hero return status 400 with the body { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }. Remove the parameter to get the original image, or pick another variant."
|
||||
And the Preview is dimmed to 50 %, 4 / 3, and reads "No preview: unknown variant"
|
||||
|
||||
Scenario: A reload on window focus can trigger I5
|
||||
Given the builder has "hero" selected and the list was loaded with "hero" enabled
|
||||
When "hero" is disabled or deleted elsewhere and the window receives a focus event
|
||||
Then GET /v1/image-variants is sent again and I5 is shown
|
||||
|
||||
Scenario: I6. Loading
|
||||
Given GET /v1/image-variants has not answered
|
||||
Then three skeleton blocks of 140, 200 and 120 px (--ui-bg-accented) fill a region with aria-busy "true"
|
||||
And the "Request an image" snippet, the variants table, the URL builder and the Responses table are not drawn
|
||||
And the intro paragraph is drawn
|
||||
|
||||
Scenario Outline: I7. Error loading
|
||||
Given GET /v1/image-variants fails with <failure>
|
||||
Then an error alert (role alert, circle-x icon) reads "Couldn't load image variants"
|
||||
And its text reads "The snippets below still work. The variant list and the URL builder need a connection."
|
||||
And a button "Try again" is shown
|
||||
And the "Request an image" snippet is drawn with the placeholder <variant-name>
|
||||
And no table, no URL builder and no Responses table are drawn
|
||||
When the user activates "Try again"
|
||||
Then GET /v1/image-variants is sent again
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network failure |
|
||||
|
||||
Scenario: A 403 on the list is handled as not an admin
|
||||
Given the session role is "owner" but GET /v1/image-variants answers 403
|
||||
Then I4 is shown
|
||||
And a 401 is left to the global handler of features/api_client_global_error_handling.feature
|
||||
|
||||
Scenario: I8. Snippet copied
|
||||
Given the sample variants and the HTML tab active
|
||||
When the user activates "Copy" on the "Request an image" snippet
|
||||
Then the HTML code is written to the clipboard
|
||||
And the button reads "Copied" with icon check for 2 s, announced politely
|
||||
And the URL builder's Copy button still reads "Copy"
|
||||
|
||||
Scenario: The URL builder
|
||||
Given the sample variants
|
||||
Then the Image field is a select showing "north-wall.jpg" and the Variant field is a select listing the enabled variants
|
||||
When the user picks "square" in Variant
|
||||
Then the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=square"
|
||||
And the Preview is a framed placeholder with aspect-ratio width / height of the variant, and 4 / 3 when the width or the height is null, shows the label "square · 600×600 · JPEG" and is announced with role img and the same aria-label
|
||||
And the Preview is at most 240 px high and full width
|
||||
And no image is requested from the server
|
||||
|
||||
Scenario: I9. URL builder copied
|
||||
Given the URL builder shows "https://cms.renew.example/media/north-wall.jpg?v=thumb"
|
||||
When the user activates "Copy" on the URL snippet
|
||||
Then that exact URL is written to the clipboard
|
||||
And the button reads "Copied" for 2 s, announced politely
|
||||
And the "Request an image" snippet's button still reads "Copy"
|
||||
|
||||
Scenario: The Responses table
|
||||
Given any state except Loading and Error loading
|
||||
Then the table has the columns Request, Status and Response and these rows:
|
||||
| request | status | response | body |
|
||||
| /media/photo.png?v=thumb | 200 | The variant image. Generated on first request. | |
|
||||
| /media/photo.png?v=unknown | 400 | Unknown variant | { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" } |
|
||||
| /media/photo.png?width=400 | 400 | Unsupported parameter | { "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" } |
|
||||
| /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 |
|
||||
And the body of the 400 rows is application/json and monospace --ui-text-muted below the response text
|
||||
And the 404 body is text/plain "404 page not found"
|
||||
And the 413 and 500 bodies are text/plain
|
||||
And the table has no 409 and no 422 row
|
||||
And the 413 and 500 rows are added although the board lacks them, because the handler returns them, and this is logged for the design pass
|
||||
|
||||
Scenario: The error sample tab names its content type
|
||||
Then the Errors section sample keeps its single tab "Error response · text/plain"
|
||||
And the image JSON errors are covered only by the Responses table, where their bodies are application/json
|
||||
And no snippet labelled text/plain shows an unknown_variant or unsupported_parameter body
|
||||
|
||||
Scenario: The page calls only the image variants route
|
||||
When the user opens /integration as an owner or admin
|
||||
Then GET /v1/image-variants is sent once on mount, plus once per "Try again" and once per window focus event
|
||||
And no other /v1 call is made
|
||||
And for any other role no /v1 call is made
|
||||
|
||||
Rule: The existing Integration surfaces use CodeSnippet
|
||||
|
||||
Scenario: Authentication example
|
||||
Then the "API key" card shows one CodeSnippet with tabs curl and fetch for GET /v1/auth/me, curl active, using {origin}
|
||||
And there is no outer UTabs wrapper and no label "Shell" or "JavaScript"
|
||||
|
||||
Scenario: Endpoint card examples
|
||||
When the user expands a card
|
||||
Then one CodeSnippet holds the tabs fetch, curl and Response, fetch active
|
||||
And the tab list uses the underline style, not the UTabs link style
|
||||
And the Copy button copies the active tab and its accessible name is "Copy code"
|
||||
And the "Errors" list below it is unchanged
|
||||
|
||||
Scenario: Base URL and error sample
|
||||
Then "Base URL" is a label above a CodeSnippet whose only tab is "Base URL" and whose code is {origin}
|
||||
And the Errors sample is a CodeSnippet reading "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, "This action needs a real session."
|
||||
|
||||
Scenario: Copy buttons of the Integration page are independent
|
||||
When the user copies the Base URL
|
||||
Then only the Base URL button reads "Copied"
|
||||
@@ -0,0 +1,114 @@
|
||||
# QA: Integration images and variants, and CodeSnippet as the only code block
|
||||
|
||||
SPA only. No Go test, no live server, no SMTP, no real SQLite.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Common setup: mount IntegrationPage inside DashboardShell at /integration with a test router, a resolved
|
||||
verified session whose role is set per case, and `window.location.origin` stubbed to
|
||||
`https://cms.renew.example`. Mock `/v1` through the same `useApi` seam as `variantsTestKit.ts`.
|
||||
Stub `navigator.clipboard.writeText` per case and use fake timers. A spec fails on any `/v1` call
|
||||
that is not listed for the case.
|
||||
|
||||
Variants fixture: the twelve board variants of the feature plus "print" (enabled false), in the board order, with
|
||||
full item fields (fit, quality, generate_on_upload, generated_files, cache_size).
|
||||
|
||||
CodeSnippet component spec (no router, no `/v1`):
|
||||
1. Three tabs: header with tablist aria-label "Language", the first tab selected with an underline class,
|
||||
the Copy button named "Copy code".
|
||||
2. A click on a tab switches the pre content. The Copy button writes the active tab's code.
|
||||
3. One tab renders a muted label, not a role tab.
|
||||
4. Copy: clipboard called with the code, "Copied" and icon check inside an aria-live "polite" element,
|
||||
back to "Copy" after 2000 ms (advance 1999 and 2000). Two instances copy independently.
|
||||
5. The pre has tabindex "0" and no syntax-highlight markup.
|
||||
|
||||
Integration images specs, one per design state, each with the mocked `/v1` call:
|
||||
1. **Many variants** (owner): GET /v1/image-variants -> 200 with the fixture. Assert the section order (Media
|
||||
group, Images and variants, Session only, Errors), anchor "images", the section index entry, "12 enabled variants",
|
||||
12 table rows with "print" absent, the 264 px max-height class on the table body, the disabled note, exact curl,
|
||||
HTML and JavaScript strings, HTML tab active, the Responses rows and bodies.
|
||||
2. **One variant**: 200 with only "thumb" enabled. "1 enabled variant", one row, no max-height limit, the three
|
||||
snippet slots all use "thumb", no disabled note.
|
||||
3. **No variants, admin**: role admin, 200 with {"items": []}. Empty block, text, the "Go to image variants" link with
|
||||
href /settings/media/variants, placeholder <variant-name> in the snippets, no table, no builder, Responses present.
|
||||
Repeat with a fixture containing only disabled variants.
|
||||
4. **No variants, not an admin**: role "member" (or no role). Assert no GET /v1/image-variants call on mount or on a
|
||||
window focus event, the non-admin text, no link and no button. Add: role owner with a 403 answer renders the same state.
|
||||
5. **Variant removed**: load the fixture, select "hero", make the mock return a list without "hero", then dispatch
|
||||
`window.dispatchEvent(new Event('focus'))`. Assert a second GET, "hero (removed)", the warning alert title and text
|
||||
with the exact 400 body, the URL snippet ends with ?v=hero, the preview label "No preview: unknown variant", 4 / 3
|
||||
aspect and 50 % opacity. Repeat with the reload triggered by "Try again" after a failed first load.
|
||||
6. **Loading**: GET /v1/image-variants pending. Assert three skeletons inside an aria-busy region, and no snippet,
|
||||
no table, no builder, no Responses table.
|
||||
7. **Error loading**: 500, then a rejected fetch. Assert the alert copy, the snippet present with
|
||||
<variant-name>, no table and no builder, no Responses table. Click "Try again" and assert a second call
|
||||
and the success state replaces the alert.
|
||||
8. **Snippet copied**: click Copy on the "Request an image" snippet. Clipboard receives the active (HTML) code, button
|
||||
reads "Copied" in an aria-live polite region, reverts after 2 s, and the builder's button is unaffected.
|
||||
9. **URL builder copied**: the clipboard receives "https://cms.renew.example/media/north-wall.jpg?v=thumb".
|
||||
Pick "square" first and assert the URL, preview label "square · 600×600 · JPEG" and the 600 / 600 aspect ratio.
|
||||
|
||||
Behaviour specs:
|
||||
10. **Reload triggers**: mount sends one GET; each focus event sends one more; "Try again" sends one more; a reload with
|
||||
the selected variant still enabled keeps the active tab and the selection. A non-admin sends none on any trigger.
|
||||
11. **Responses table**: the two 400 bodies are the exact JSON strings of the feature; the 404 body is
|
||||
"404 page not found"; the 413 body is "The source image is too large to generate this variant." and the 500 body is
|
||||
"Unable to read the file"; no 409 or 422 row.
|
||||
12. **Error sample label**: the Errors sample tab reads "Error response · text/plain" and no snippet in the images
|
||||
section carries a text/plain label.
|
||||
|
||||
Migration specs (update or replace the existing assertions of `IntegrationView.spec.ts`, the EndpointCard spec
|
||||
and the ApiKeys specs):
|
||||
13. Base URL is a CodeSnippet with the single tab "Base URL", origin in the code, Copy -> "Copied" -> "Copy" after 2 s.
|
||||
14. Authentication example: tabs curl and fetch for GET /v1/auth/me, no "Shell" or "JavaScript" label.
|
||||
15. Expanded EndpointCard (POST /v1/article/): tabs fetch, curl, Response in one CodeSnippet, fetch active,
|
||||
Copy copies the active tab, only that button reads "Copied". The Errors list is unchanged.
|
||||
16. Errors sample is a CodeSnippet containing the four sample lines.
|
||||
17. ApiKeyReveal: the key is in a CodeSnippet with the single tab "API key", the Copy button is focused when the
|
||||
dialog opens, and Copy writes the full key and reads "Copied" for 2 s.
|
||||
18. Source guard: nothing imports `@/components/CodeBlock.vue` (EndpointCard.vue and IntegrationView.vue included),
|
||||
the file is deleted, and `CodeText.vue` still exists.
|
||||
19. The Integration page makes the calls listed in the feature for owner and admin, and none for other roles. The old
|
||||
"page stays static" assertions in `IntegrationView.spec.ts` are changed accordingly.
|
||||
20. Every new string has a French entry (the existing i18n completeness spec must pass).
|
||||
|
||||
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
|
||||
|
||||
Every capture must be within 10 %. Group "Integration: images" (board `IntegrationImages.dc.html` through AppFrame),
|
||||
setups in `web/visual/states.ts`, with the board's sample data: origin `https://cms.renew.example`, image
|
||||
`north-wall.jpg`, the twelve variants of the feature (plus disabled "print" so the note shows in Many), role admin
|
||||
unless stated.
|
||||
|
||||
| State | Mock |
|
||||
|---|---|
|
||||
| Many variants | GET /v1/image-variants -> 12 enabled + "print" disabled, HTML tab active, builder on "thumb" |
|
||||
| One variant | -> only "thumb" |
|
||||
| No variants, admin | -> {"items": []}, role admin |
|
||||
| No variants, not an admin | role without owner/admin, no call |
|
||||
| Variant removed | builder on "hero", list without "hero" after the reload (first 5 as the board) |
|
||||
| Loading | request never resolves |
|
||||
| Error loading | status 500 |
|
||||
| Snippet copied | Many with the snippet in the Copied state (clipboard stubbed) |
|
||||
| URL builder copied | Many with the URL snippet in the Copied state |
|
||||
|
||||
Approved visual exceptions (user, own turn), each with `approvedBy` "Florian Sylvain":
|
||||
- I2 One variant, desktop light, `maxPercent` just above the measured value (about 16 %), reason "board draws the striped preview offset by 32 px".
|
||||
- I8 Snippet copied, desktop light, `maxPercent` just above the measured value (about 12 %), same reason.
|
||||
- Errors, mobile, `maxPercent` just above the measured value (about 18.5 %), reason "the board changed; already 17.33 % at HEAD". Listed as a design-pass item.
|
||||
- The `integration-api-keys` mobile failures (14 to 24 %) are NOT excepted. They predate this slice (floating inset dialog versus the board's flush bottom sheet, an overlay-pattern slice). They stay red and documented, and this slice's gate is scoped to the Integration screen.
|
||||
|
||||
Also re-run the existing groups affected by the migration and keep them within 10 %: Integration ("1. Default",
|
||||
"2. Group scrolled, one card expanded", "3a. Copied: base URL", "3b. Copied: CodeSnippet", "4. Authentication",
|
||||
"6. Errors") and API keys ("One-time reveal", "One-time reveal: copied"). The board names stay as the design gives
|
||||
them. Existing pre-approved exceptions stay as recorded. Skipped: none are clock-sensitive.
|
||||
|
||||
## Manual checks (not coverable by screenshots)
|
||||
- Keyboard: tab order inside a CodeSnippet is tabs, then Copy, then the `pre` (tabindex 0). Arrow keys move
|
||||
between tabs. Enter and Space activate Copy. The focus ring is 2 px primary.
|
||||
- A screen reader announces "Copied" politely after Copy, and not again on "Copy" returning.
|
||||
- Hover and motion: Copy and tabs hover states, no motion on copy other than the label swap.
|
||||
- Find-in-page and the index entry "Images and variants" scroll to `#images`; opening `/integration#images` lands on the section.
|
||||
- The Variants table scrolls inside its 264 px body with the mouse wheel and keyboard, and the header stays visible.
|
||||
- Real clipboard in a browser (secure context) copies the exact snippet text.
|
||||
- Against a running server: `?v=<disabled>`, `?v=<unknown>` and `?width=400` answer 400 application/json with the
|
||||
documented bodies, and a missing image answers 404 text/plain "404 page not found".
|
||||
@@ -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