feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -0,0 +1,532 @@
|
|||||||
|
Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||||
|
|
||||||
|
As a RenewCMS user who can edit articles
|
||||||
|
I want to fill in an article's search and sharing data, see suggestions and previews, and see what the frontend receives
|
||||||
|
So that the API returns good SEO data for every article without blocking me from saving or publishing
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the screen is EditorView (/article/:id/edit) inside DashboardShell, per docs/design/routes.md
|
||||||
|
And the visual spec of record is docs/design/screens/editor.md (groups "Editor: search and sharing" with 24 states and "Editor: what the frontend gets" with 6 states), docs/design/seo-slices.md, docs/design/components.md (SearchSharingSection, SeoPanel, SoftCounter, SearchPreview, SocialCardPreview, SuggestionList, ApiPreviewDialog, SlugField, MediaField) and docs/design/states.md ("SEO round"). Exact copy is read from those files and is not repeated here unless a scenario needs it
|
||||||
|
And the board frames of docs/design/source/RenewCMS Screens Back-office.dc.html, AppFrame.dc.html and DirectoryFrame.dc.html are the layout tiebreaker
|
||||||
|
And this slice is SPA only and reuses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change
|
||||||
|
And the SPA calls, with relative URLs through useApi only: GET /v1/article/{id} and PATCH /v1/article/{id} (SEO fields slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex), POST /v1/article/{id}/seo/check, GET /v1/article/{id}/seo, GET /v1/site/ and the media routes of the shared picker
|
||||||
|
And POST /v1/article/{id}/seo/check takes the optional SEO fields (and body) of PATCH as overrides and answers {status, suggestions: [{code, field, params}], slug: {available, suggestion}} with status "ready", "suggestions" or "hidden"
|
||||||
|
And the SPA never re-implements a suggestion rule. It renders the suggestions, the order and the status the check returns. The copy of each code lives in the SPA and has a French entry
|
||||||
|
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
|
||||||
|
And a suggestion's field maps to a link label: seo_title "meta title", seo_description "meta description", cover_image_alt and cover_image_id "cover image", slug "URL slug", author_ids "authors", body "body"
|
||||||
|
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design). They colour a counter and never block
|
||||||
|
And suggestions never disable Save, "Save and publish" or Publish
|
||||||
|
And the sample article is 12, "How we price a bathroom renovation", online on the site Renew (renew.example, id 1), slug "how-we-price-a-bathroom-renovation", with no SEO field set, and the author "Inès Duarte"
|
||||||
|
And the sample cover media is "north-wall.jpg" (the site social image) and the sample panel values are those of the board frames "Panel: filled in" and "Panel: outside the soft ranges"
|
||||||
|
And every visible string goes through t(...) with the English text as the key and a French entry in web/src/i18n/messages.ts
|
||||||
|
And the screen uses Nuxt UI components and design tokens only, with no raw colors, and no comment is added anywhere
|
||||||
|
And a scenario tagged @not-in-design uses copy or behaviour that docs/design does not define: it takes the proposal of this feature, has a French entry, and is listed in the .qa.md to be fed back to the design
|
||||||
|
|
||||||
|
Rule: The "Search and sharing" section sits under "Site and authors", collapsed by default
|
||||||
|
|
||||||
|
Scenario: Position, in the sidebar and in the Details sheet
|
||||||
|
Given article 12 is loaded and nothing is remembered for this browser
|
||||||
|
When the user opens /article/12/edit on desktop
|
||||||
|
Then the details sidebar shows a collapsed "Search and sharing" section directly under "Site and authors"
|
||||||
|
And on mobile (390 px) the section is not visible until the Details sheet is opened, and inside the sheet it sits under "Site and authors"
|
||||||
|
|
||||||
|
Scenario Outline: The one-line summary follows the check
|
||||||
|
Given the section is collapsed
|
||||||
|
And POST /v1/article/12/seo/check answers <check>
|
||||||
|
Then the summary line reads "<summary>" in <colour>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| check | summary | colour |
|
||||||
|
| pending | Checking… | the muted colour |
|
||||||
|
| status "ready", no suggestions | Ready | the muted colour |
|
||||||
|
| status "suggestions", 1 suggestion | 1 suggestion | the warning colour |
|
||||||
|
| status "suggestions", 2 suggestions | 2 suggestions | the warning colour |
|
||||||
|
| status "hidden", 2 suggestions | Hidden from search | the muted colour |
|
||||||
|
|
||||||
|
Scenario: The existing Editor states show the section
|
||||||
|
Then every editor state that has the details sidebar shows the collapsed section with "Ready"
|
||||||
|
And the Loading state shows the section header with a skeleton summary, as for "Site and authors"
|
||||||
|
And the Not found state shows no section
|
||||||
|
|
||||||
|
Scenario: The choice is remembered per browser, with its own key
|
||||||
|
Given the section is collapsed by default
|
||||||
|
When the user opens it and later opens another article in the same browser
|
||||||
|
Then it is open there too
|
||||||
|
When the user collapses it and reloads
|
||||||
|
Then it is collapsed
|
||||||
|
And the choice is kept in localStorage under its own key, independent of the "Site and authors" key, and nothing is sent to the API
|
||||||
|
And the sidebar and the Details sheet share that key
|
||||||
|
|
||||||
|
Scenario Outline: A storage that throws never breaks the editor
|
||||||
|
Given localStorage.<call> throws a SecurityError
|
||||||
|
When the user opens the editor and toggles the section
|
||||||
|
Then the editor renders, the section opens and closes for this page view, and no error reaches the user
|
||||||
|
And reading falls back to "collapsed" and writing is silently skipped
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| call |
|
||||||
|
| getItem |
|
||||||
|
| setItem |
|
||||||
|
|
||||||
|
Scenario: The section opens by itself when asked from elsewhere
|
||||||
|
When the user activates "Edit search and sharing", "What the frontend gets" or a suggestion link
|
||||||
|
Then the panel or the dialog opens whatever the stored choice is, and the stored choice is not changed
|
||||||
|
|
||||||
|
Rule: The open section: address, suggestions and actions
|
||||||
|
|
||||||
|
Scenario: Open and ready
|
||||||
|
Given the section is open and the check answers status "ready"
|
||||||
|
Then it shows "Address" with "renew.example/how-we-price-a-bathroom-renovation" (the host of the site's base_url, without the scheme, then the slug)
|
||||||
|
And "All good" with "Nothing to suggest for this article."
|
||||||
|
And the actions "Edit search and sharing" and "What the frontend gets"
|
||||||
|
|
||||||
|
Scenario: Open with suggestions, at most three
|
||||||
|
Given the check answers title_long (74 characters), cover_alt, no_h and short, in that order
|
||||||
|
Then exactly three suggestions are shown, in the order of the response, each with its message and a "Go to <field>" link: "Go to meta title", "Go to cover image", "Go to body"
|
||||||
|
And a "+1 more in Search and sharing" action follows
|
||||||
|
When the user activates "+1 more in Search and sharing"
|
||||||
|
Then the panel opens with the suggestion list in view
|
||||||
|
And with three suggestions or fewer no "+N more" action is shown
|
||||||
|
|
||||||
|
Scenario Outline: A suggestion link goes to its field
|
||||||
|
Given the check answers a suggestion whose field is "<field>"
|
||||||
|
When the user activates its "Go to <label>" link
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| field | label | result |
|
||||||
|
| seo_title | meta title | the panel opens with focus on the meta title input |
|
||||||
|
| seo_description | meta description | the panel opens with focus on the meta description |
|
||||||
|
| cover_image_alt | cover image | the panel opens with focus on the alt text input |
|
||||||
|
| cover_image_id | cover image | the panel opens with focus on the cover "Choose image" button |
|
||||||
|
| slug | URL slug | the panel opens with focus on the slug field |
|
||||||
|
| body | body | focus moves to the article body in the editor |
|
||||||
|
| author_ids | authors | the "Site and authors" section opens and focus moves to its list |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: Copy of the codes the design does not draw
|
||||||
|
Given the check answers the code "<code>" with params <params>
|
||||||
|
Then the message reads "<message>" and has a French entry
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| code | params | message |
|
||||||
|
| title_empty | {} | The meta title is empty. |
|
||||||
|
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. About 70 to 160 reads best in results. |
|
||||||
|
| cover_none | {} | This article has no cover image. |
|
||||||
|
| slug_long | {"length": 80, "max": 75} | The URL slug is 80 characters. Shorter addresses are easier to share. |
|
||||||
|
| no_au | {} | This article has no author. |
|
||||||
|
| body_alt | {"count": 2} | 2 images in the body have no alt text. |
|
||||||
|
| short | {"length": 40, "min": 300} | The body is 40 characters. Longer articles tend to do better in search. |
|
||||||
|
|
||||||
|
Scenario Outline: The messages the design does draw use the params of the response
|
||||||
|
Given the check answers the code "<code>" with params <params>
|
||||||
|
Then the message reads "<message>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| code | params | message |
|
||||||
|
| title_long | {"length": 74, "max": 60} | The meta title is 74 characters. Search engines may cut it off after about 60. |
|
||||||
|
| desc_short | {"length": 41, "min": 70} | The description is 41 characters. About 70 to 160 reads best in results. |
|
||||||
|
| cover_alt | {} | The cover image has no alt text. Add a short description of it. |
|
||||||
|
| no_h | {} | The body has no headings. H2 headings help readers scan the article. |
|
||||||
|
|
||||||
|
Scenario: Checking, with the rest of the section usable
|
||||||
|
Given POST /v1/article/12/seo/check is pending
|
||||||
|
Then the section shows "Checking this article…"
|
||||||
|
And the address, "Edit search and sharing" and "What the frontend gets" stay usable
|
||||||
|
|
||||||
|
Scenario: Hidden from search
|
||||||
|
Given the article has noindex true
|
||||||
|
When the section is open
|
||||||
|
Then it shows "Hidden from search engines. This only sets a flag in the API."
|
||||||
|
|
||||||
|
Scenario: Another site
|
||||||
|
Given the article is on the site Journal (journal.renew.example)
|
||||||
|
Then the address reads "journal.renew.example/how-we-price-a-bathroom-renovation"
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: The check could not run
|
||||||
|
Given POST /v1/article/12/seo/check fails with <failure>
|
||||||
|
Then the summary line is not shown as Ready and the section shows "Couldn't check this article." with "Try again"
|
||||||
|
And no toast is shown and the article is unchanged
|
||||||
|
When the user activates "Try again"
|
||||||
|
Then the check is requested again
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| status 500 |
|
||||||
|
| a network error |
|
||||||
|
|
||||||
|
Scenario Outline: A session problem during the check follows the existing rules
|
||||||
|
When POST /v1/article/12/seo/check answers <status>
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| status | result |
|
||||||
|
| 401 | the user lands on /login?redirect=/article/12/edit&reason=expired, the draft is kept |
|
||||||
|
| 404 | the "Not found" editor state is shown |
|
||||||
|
|
||||||
|
Rule: Suggestions and the slug check come from the server, after a pause in typing
|
||||||
|
|
||||||
|
Scenario: The first check runs when the editor loads
|
||||||
|
When GET /v1/article/12 answers 200
|
||||||
|
Then exactly one POST /v1/article/12/seo/check is sent with the body {}
|
||||||
|
And no check is sent while GET /v1/article/12 is pending or fails
|
||||||
|
|
||||||
|
Scenario: Only changed fields are sent to the check
|
||||||
|
Given the user changed the meta title to a 74-character text and nothing else
|
||||||
|
When the check runs
|
||||||
|
Then the POST body is {"seo_title": "<the 74 characters>"} and holds no other field
|
||||||
|
|
||||||
|
Scenario: An unsaved body edit is part of the check
|
||||||
|
Given the user edited the body in the editor
|
||||||
|
When the check runs
|
||||||
|
Then the POST body holds "body" with the edited html, so no_h, body_alt and short reflect what is on screen
|
||||||
|
|
||||||
|
Scenario Outline: The check waits for a pause in typing
|
||||||
|
Given the user types in <field>
|
||||||
|
When the user keeps typing with less than the debounce delay between keystrokes
|
||||||
|
Then no check is sent
|
||||||
|
When the user pauses for the debounce delay
|
||||||
|
Then exactly one POST /v1/article/12/seo/check is sent with the latest value
|
||||||
|
And the suggestions refresh and the summary line follows
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| field |
|
||||||
|
| meta title |
|
||||||
|
| meta description |
|
||||||
|
| excerpt |
|
||||||
|
| alt text |
|
||||||
|
| social title |
|
||||||
|
| canonical URL |
|
||||||
|
| slug |
|
||||||
|
|
||||||
|
Scenario: A late answer never overwrites a newer one
|
||||||
|
Given check A was sent, then check B after a further edit
|
||||||
|
When B answers first and A answers later
|
||||||
|
Then the suggestions and the status are those of B
|
||||||
|
And when only A is still pending, the previous request is aborted
|
||||||
|
|
||||||
|
Scenario: Discrete changes check immediately
|
||||||
|
When the user toggles "Hide from search engines", picks or removes a cover or a social image, or chooses "Use <slug>-2"
|
||||||
|
Then one check is sent without waiting for the debounce delay
|
||||||
|
|
||||||
|
Scenario: Nothing is saved by a check
|
||||||
|
Then no PATCH is sent by any check and the status stays unchanged
|
||||||
|
|
||||||
|
Scenario: The panel check state
|
||||||
|
Given the panel is open and a check is pending
|
||||||
|
Then the suggestions area shows "Checking this article…" and the fields stay usable
|
||||||
|
|
||||||
|
Rule: The panel (desktop slide-over, mobile 92% sheet)
|
||||||
|
|
||||||
|
Scenario: Opening
|
||||||
|
When the user activates "Edit search and sharing" on desktop
|
||||||
|
Then a slide-over 960 px wide opens with the title "Search and sharing", the fields, the previews, "Cancel" and "Save changes"
|
||||||
|
And on mobile a sheet covering 92% of the height opens over the Details sheet, with the suggestions first, then the fields, then the two previews
|
||||||
|
And a check is sent on open
|
||||||
|
|
||||||
|
Scenario: Defaults, every value is a fallback and says so
|
||||||
|
Given the article has no SEO field set
|
||||||
|
Then the panel shows "URL slug" with the Auto badge and "Generated from the title until you edit it."
|
||||||
|
And "Meta title" with the chip "Using article title" and "Recommended 30 to 60 characters. This is a guide, not a limit."
|
||||||
|
And "Meta description" with the chip "Using first sentence of the body" and "Recommended 70 to 160 characters."
|
||||||
|
And "Excerpt" with "A short summary. It is also sent as the article teaser."
|
||||||
|
And "Cover image" with "Using the site social image (north-wall.jpg)."
|
||||||
|
And "Social sharing" collapsed with "Optional" and "Uses the meta values"
|
||||||
|
And "Advanced" collapsed with "Canonical URL: automatic"
|
||||||
|
And "Hide from search engines" off with "Sets noindex in the API. It doesn't change who can read the article."
|
||||||
|
And "Search result", "Social card" and "Suggestions" blocks, "What the frontend gets", "Cancel" and "Save changes"
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: The other fallback chips
|
||||||
|
Given <state>
|
||||||
|
Then the chip reads "<chip>" and has a French entry
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| state | chip |
|
||||||
|
| no meta description but an excerpt | Using the excerpt |
|
||||||
|
| no meta description, no excerpt, no body text | Using the site default description |
|
||||||
|
| no cover and no site social image | No image |
|
||||||
|
|
||||||
|
Scenario: Filled in
|
||||||
|
Given the article has its own meta title, description, excerpt, cover with alt text and the check answers no suggestion
|
||||||
|
Then the fallback chips are gone and the suggestion block shows "All good" with "Nothing to suggest for this article."
|
||||||
|
|
||||||
|
Scenario Outline: Soft counters are a guide
|
||||||
|
Given the user types <n> characters in <field>
|
||||||
|
Then the counter reads "<n> / <max>" in the mono face and is <colour>
|
||||||
|
And Save changes stays enabled and nothing is blocked
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| field | n | max | colour |
|
||||||
|
| meta title | 29 | 60 | amber (--ui-warning) |
|
||||||
|
| meta title | 30 | 60 | neutral |
|
||||||
|
| meta title | 60 | 60 | neutral |
|
||||||
|
| meta title | 74 | 60 | amber (--ui-warning) |
|
||||||
|
| meta description | 41 | 160 | amber (--ui-warning) |
|
||||||
|
| meta description | 70 | 160 | neutral |
|
||||||
|
| meta description | 160 | 160 | neutral |
|
||||||
|
| meta description | 161 | 160 | amber (--ui-warning) |
|
||||||
|
|
||||||
|
Scenario: Outside the soft ranges
|
||||||
|
Given the user typed the 74-character meta title and the 33-character description of the board
|
||||||
|
Then both counters are amber and never red
|
||||||
|
And the suggestion block shows the title_long and desc_short messages once the check answers
|
||||||
|
And the search result preview shows the title cut off with "Cut off here, the way a search engine would."
|
||||||
|
|
||||||
|
Scenario: The API length limits are not reachable
|
||||||
|
Given the API refuses seo_title above 120, seo_description above 320, cover_image_alt above 200, social_title above 120 and social_description above 320 characters
|
||||||
|
Then each input stops accepting characters at its limit, so the API 400 for those fields is never produced by typing
|
||||||
|
|
||||||
|
Scenario: Excerpt over 300
|
||||||
|
When the user types a 301-character excerpt
|
||||||
|
Then the excerpt shows an inline error and "Save changes" is disabled until it is 300 or fewer
|
||||||
|
And no request is sent for it
|
||||||
|
|
||||||
|
Scenario: Slug edited, Auto and Reset
|
||||||
|
Given the slug is in Auto mode
|
||||||
|
When the user activates the pencil
|
||||||
|
Then the Auto badge is gone, the field is editable and "Reset to match the title" appears
|
||||||
|
When the user activates "Reset to match the title"
|
||||||
|
Then the field shows the slug generated from the title with the Auto badge, and the slug is sent as "" on save so that the API regenerates it
|
||||||
|
And a check with {"slug": ""} confirms its availability
|
||||||
|
|
||||||
|
Scenario: Cover and alt text
|
||||||
|
When the user activates "Choose image" in "Cover image"
|
||||||
|
Then the shared Media picker opens titled "Choose a cover image" with "The cover for this article." and the action "Use image"
|
||||||
|
When the user picks "north-wall.jpg"
|
||||||
|
Then the cover shows its thumbnail and name, an "Alt text" field appears with "Needed before the cover counts as ready. Sent as image alt in the API." and the placeholder "Describe the image for people who can't see it"
|
||||||
|
And a cover without alt text raises cover_alt as a suggestion, never as an error
|
||||||
|
When the user removes the cover
|
||||||
|
Then the "Using the site social image (north-wall.jpg)." line returns and cover_image_id is sent as null on save
|
||||||
|
|
||||||
|
Scenario: Social sharing
|
||||||
|
When the user opens "Social sharing"
|
||||||
|
Then "Social title" shows "Using meta title", "Social description" shows "Using meta description" and "Social image" shows "Using the cover image."
|
||||||
|
When the user fills a social title, a description and picks a social image
|
||||||
|
Then the three "Using …" chips disappear, "Customised" is shown and the social card shows the overrides
|
||||||
|
|
||||||
|
Scenario Outline: Advanced, canonical URL
|
||||||
|
Given "Advanced" is open
|
||||||
|
When the canonical URL field holds "<value>" and loses focus
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| value | result |
|
||||||
|
| renew.example/costs | the field shows "Enter a full address starting with https://" and the field is invalid |
|
||||||
|
| http://renew.example/a | the field shows "Enter a full address starting with https://" |
|
||||||
|
| https://renew.example/a | no error is shown |
|
||||||
|
| | no error is shown and "Canonical URL: automatic" is shown when collapsed |
|
||||||
|
|
||||||
|
Scenario: Canonical refused by the API
|
||||||
|
When PATCH /v1/article/12 answers 422 "invalid_url"
|
||||||
|
Then "Advanced" opens, the canonical field shows "Enter a full address starting with https://" and the values are kept
|
||||||
|
|
||||||
|
Scenario: Hide from search engines
|
||||||
|
When the user turns the switch on
|
||||||
|
Then the helper "Sets noindex in the API. It doesn't change who can read the article." is shown and noindex true is sent on save
|
||||||
|
And the next check answers status "hidden"
|
||||||
|
|
||||||
|
Scenario Outline: The search result preview
|
||||||
|
Given the meta title is "How we price a bathroom renovation: surveys, plumbing, tiling and finishes" and the site title template is "%title% | %site%"
|
||||||
|
When the Search result toggle is on "<device>"
|
||||||
|
Then the title shown is cut at <title> characters (the template included) with an ellipsis and the description at <desc> characters
|
||||||
|
And the width of the preview changes with the device
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| device | title | desc |
|
||||||
|
| Desktop | 60 | 160 |
|
||||||
|
| Mobile | 70 | 120 |
|
||||||
|
|
||||||
|
Scenario: The previews follow the typing, with the fallbacks
|
||||||
|
Given the user clears the meta title
|
||||||
|
Then the preview shows the article title with the site's title_template applied
|
||||||
|
And the social card shows 1.91:1 image, the host, the title and a 2-line description, with fallback chips
|
||||||
|
|
||||||
|
Scenario: Keyboard
|
||||||
|
Then Tab visits the fields in the order drawn, Escape closes the panel as "Cancel" does, and focus returns to "Edit search and sharing"
|
||||||
|
|
||||||
|
Rule: Slug availability
|
||||||
|
|
||||||
|
Scenario: Checked on blur
|
||||||
|
Given the user typed "bathroom-costs" in the slug field
|
||||||
|
When the field loses focus
|
||||||
|
Then one POST /v1/article/12/seo/check is sent with {"slug": "bathroom-costs"}
|
||||||
|
When it answers slug {"available": false, "suggestion": "bathroom-costs-2"}
|
||||||
|
Then the field shows 'The slug "bathroom-costs" is already used on Renew.' and the action "Use bathroom-costs-2"
|
||||||
|
|
||||||
|
Scenario: Use the suggestion
|
||||||
|
Given the slug-taken state is shown
|
||||||
|
When the user activates "Use bathroom-costs-2"
|
||||||
|
Then the slug field holds "bathroom-costs-2" in manual mode, the error and the action disappear and a check confirms it
|
||||||
|
|
||||||
|
Scenario: Slug taken on save
|
||||||
|
Given the user saved with the slug "bathroom-costs"
|
||||||
|
When PATCH /v1/article/12 answers 409 "slug_taken"
|
||||||
|
Then the panel stays open with the slug-taken state, the action "Use <suggestion>" is offered (the suggestion comes from a follow-up check, as the 409 headers are not readable through useApi) and the other values stay
|
||||||
|
And nothing of the edit is applied to the article and the status stays "Unsaved changes"
|
||||||
|
|
||||||
|
Scenario: A slug the article already owns is available
|
||||||
|
Given the check answers slug {"available": true, "suggestion": null}
|
||||||
|
Then no error and no action are shown
|
||||||
|
|
||||||
|
Rule: Saving, publishing and leaving treat SEO edits like any other edit
|
||||||
|
|
||||||
|
Scenario: Save changes commits the panel to the draft and saves
|
||||||
|
Given the user changed the meta title in the panel and nothing else
|
||||||
|
When the user activates "Save changes"
|
||||||
|
Then exactly one PATCH /v1/article/12 is sent containing seo_title only
|
||||||
|
And fields that did not change are not sent, and the unchanged body, site_id and author_ids are not sent
|
||||||
|
|
||||||
|
Scenario: Saving state
|
||||||
|
Given "Save changes" was activated
|
||||||
|
Then while the request runs every field of the panel is disabled at 40%, the status reads "Saving…" and the page behind stays as it was
|
||||||
|
|
||||||
|
Scenario: Saved
|
||||||
|
When the PATCH answers 200
|
||||||
|
Then the panel closes, the editor status reads "Saved", the summary and the suggestions refresh with a new check
|
||||||
|
And a success toast shows "Search and sharing saved" with "Your search and sharing settings are up to date."
|
||||||
|
|
||||||
|
Scenario Outline: Save failed
|
||||||
|
When the PATCH fails with <failure>
|
||||||
|
Then the panel stays open with the notice "Couldn't save search and sharing. Your changes are still here. Try again in a moment."
|
||||||
|
And the values are kept, the fields are enabled again and the editor shows its "Save failed" state with Retry
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| status 500 |
|
||||||
|
| a network error |
|
||||||
|
|
||||||
|
Scenario: A body or site edit pending in the editor is saved too
|
||||||
|
Given the user edited the body in the editor and then changed the meta title in the panel
|
||||||
|
When the user activates "Save changes"
|
||||||
|
Then one PATCH is sent containing body and seo_title
|
||||||
|
|
||||||
|
Scenario: The editor Save button treats SEO edits like any other edit
|
||||||
|
Given the panel was closed after "Save changes" failed, with the draft holding seo_title
|
||||||
|
Then the editor status reads "Unsaved changes", Save is enabled and "Publish" reads "Save and publish"
|
||||||
|
When the user activates Save, presses Ctrl or Cmd S, or activates "Save and publish"
|
||||||
|
Then the PATCH holds seo_title and the publish request follows for "Save and publish"
|
||||||
|
|
||||||
|
Scenario: Leaving with unsaved SEO edits
|
||||||
|
Given the draft holds an unsaved SEO value
|
||||||
|
When the user navigates away
|
||||||
|
Then the "Leave without saving?" dialog is shown
|
||||||
|
|
||||||
|
Scenario: Session expiry keeps the SEO draft
|
||||||
|
Given the draft holds unsaved SEO values
|
||||||
|
When PATCH answers 401
|
||||||
|
Then the draft, SEO values included, is mirrored to sessionStorage and the user lands on /login?redirect=/article/12/edit
|
||||||
|
And after logging in the editor restores the draft, shows "Unsaved changes" and the panel fields show the restored values
|
||||||
|
|
||||||
|
Scenario: A storage that throws never loses the editor
|
||||||
|
Given sessionStorage throws on every call
|
||||||
|
Then saving, leaving and the mirror still work with the values in memory
|
||||||
|
|
||||||
|
Scenario: Suggestions never block
|
||||||
|
Given the check answers suggestions or the status "hidden"
|
||||||
|
Then Save, "Save and publish", Publish and Ctrl/Cmd S stay enabled when the draft is dirty and no request is delayed or refused because of them
|
||||||
|
|
||||||
|
Scenario: Cancel
|
||||||
|
Given the user typed in the panel
|
||||||
|
When the user activates "Cancel"
|
||||||
|
Then the panel closes and the values typed in it are discarded without changing the editor status
|
||||||
|
And no request is sent
|
||||||
|
|
||||||
|
Scenario: Offline
|
||||||
|
Given the connection dropped
|
||||||
|
Then the check and the dialog GET fail as in "The check could not run" and "Error", and writing continues
|
||||||
|
|
||||||
|
Scenario Outline: Roles
|
||||||
|
Given the session role is "<role>"
|
||||||
|
Then the section, the panel and the dialog are available
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| role |
|
||||||
|
| owner |
|
||||||
|
| admin |
|
||||||
|
| verified |
|
||||||
|
|
||||||
|
Rule: "What the frontend gets" (ApiPreviewDialog)
|
||||||
|
|
||||||
|
Scenario: Opening from the section and from the panel
|
||||||
|
When the user activates "What the frontend gets" in the section or in the panel
|
||||||
|
Then GET /v1/article/12/seo is requested once on each opening
|
||||||
|
And on desktop a 720 px dialog opens, on mobile a bottom sheet
|
||||||
|
|
||||||
|
Scenario: Formatted
|
||||||
|
Then the dialog shows "What the frontend gets", "The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.", the tabs "Formatted" and "Raw JSON", "Copy JSON" and "Close"
|
||||||
|
And Formatted lists the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at and json_ld as a compact key and value list
|
||||||
|
And each value that came from a fallback (per the fallbacks map) carries a chip
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: Fallback chips in the dialog
|
||||||
|
Given fallbacks.<key> is "<source>"
|
||||||
|
Then the chip next to <key> reads "<chip>" and has a French entry
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| key | source | chip |
|
||||||
|
| title | title | Using article title |
|
||||||
|
| description | body | Using first sentence of the body |
|
||||||
|
| description | excerpt | Using the excerpt |
|
||||||
|
| description | site | Using the site default description |
|
||||||
|
| image | cover | Using the cover image |
|
||||||
|
| image | site | Using the site social image |
|
||||||
|
| canonical | site | Using the site address and the slug |
|
||||||
|
| title | seo_title | no chip |
|
||||||
|
| image | none | No image |
|
||||||
|
|
||||||
|
Scenario: Raw JSON
|
||||||
|
When the user activates "Raw JSON"
|
||||||
|
Then the response is shown exactly as the API returned it, as pretty-printed JSON in the mono face
|
||||||
|
|
||||||
|
Scenario: Copy JSON
|
||||||
|
Given the response is loaded
|
||||||
|
When the user activates "Copy JSON"
|
||||||
|
Then the raw object is written to the clipboard as pretty-printed JSON
|
||||||
|
And the button reads "Copied" for 2 seconds and "Copied" is announced in a live region
|
||||||
|
And after 2 seconds it reads "Copy JSON" again
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: The clipboard is refused
|
||||||
|
Given navigator.clipboard is unavailable or rejects
|
||||||
|
When the user activates "Copy JSON"
|
||||||
|
Then the button keeps reading "Copy JSON", no "Copied" is announced and no error reaches the user
|
||||||
|
|
||||||
|
Scenario: Draft article
|
||||||
|
Given article 12 is a draft
|
||||||
|
Then the dialog shows "This article is a draft. The API returns this data only to signed-in callers until it is published." above the data
|
||||||
|
And the data is shown, since the owner may read drafts
|
||||||
|
|
||||||
|
Scenario: Loading
|
||||||
|
Given GET /v1/article/12/seo is pending
|
||||||
|
Then the dialog shows a loading state and "Copy JSON" is disabled
|
||||||
|
|
||||||
|
Scenario Outline: Error with retry
|
||||||
|
When GET /v1/article/12/seo fails with <failure>
|
||||||
|
Then the dialog shows "Couldn't load the SEO data", "GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment." and "Try again"
|
||||||
|
And the article, the draft and the status are unchanged
|
||||||
|
When the user activates "Try again"
|
||||||
|
Then GET /v1/article/12/seo is requested again
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| status 500 |
|
||||||
|
| a network error |
|
||||||
|
|
||||||
|
Scenario: The dialog is read only
|
||||||
|
Then it sends no PATCH and no POST, and "Close" or Escape closes it and returns focus to its opener
|
||||||
|
|
||||||
|
Rule: Layering, copy and visual conformance
|
||||||
|
|
||||||
|
Scenario: Every call goes through useApi
|
||||||
|
Then no component calls fetch directly and every URL is relative
|
||||||
|
|
||||||
|
Scenario: French
|
||||||
|
Given the language is "fr"
|
||||||
|
Then every new string, including the messages of the 11 suggestion codes, the "Go to <field>" labels, the chips and the @not-in-design copy, is the French message
|
||||||
|
And a missing French entry fails the i18n spec
|
||||||
|
|
||||||
|
Scenario: Visual conformance
|
||||||
|
Then `task visual:check -- editor` passes every Editor frame, existing ones and the 30 new ones (24 + 6), at 1440x900 and 390x844, in light and dark
|
||||||
|
And no per-state exception is used without the user's approval
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# QA: Editor, search and sharing
|
||||||
|
|
||||||
|
SPA slice only. No Go change: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists. A wrong contract is reported as a backend defect.
|
||||||
|
|
||||||
|
Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
|
||||||
|
|
||||||
|
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `useApi`'s `request` per case for GET and PATCH /v1/article/{id}, POST /v1/article/{id}/seo/check, GET /v1/article/{id}/seo, GET /v1/site/ and the media routes of the picker. Mount EditorView inside DashboardShell with a resolved session and a test router. Mock matchMedia for desktop and mobile. Use fakes for localStorage and sessionStorage. Use Vitest fake timers for the debounce delay and the 2 s "Copied", and do not freeze the system date. The clipboard is a stub on navigator.clipboard. Report the web tests as run or NOT RUN, never as passing when not run. Names below are suggestions; one spec file per numbered group, under `web/src/views/__tests__/` (`EditorSeo*.spec.ts`).
|
||||||
|
|
||||||
|
Regression: every existing Editor spec (EditorView, EditorMetaSection, EditorAuthorList, EditorAddAuthor, EditorImageUpload, EditorMediaPicker, EditorVariants, ArticleEditor*, EditorStatusBar) now sees a POST /v1/article/{id}/seo/check on load. Give them a default mock (`{status: "ready", suggestions: [], slug: {available: true, suggestion: null}}`) rather than loosening unmocked-call checks, and show they still pass.
|
||||||
|
|
||||||
|
1. Section: position under "Site and authors", collapsed default, summary Examples (pending, ready, 1 and 2 suggestions, hidden) with the colour class, Loading skeleton summary, no section on 404, mobile sheet placement.
|
||||||
|
2. Memory, with the storage fallback (see "Red/green proof"): own key distinct from `renewcms-meta-section-open`, shared by sidebar and sheet, wins over nothing else, both `getItem` and `setItem` throwing SecurityError, no API call. Opening via "Edit search and sharing", "What the frontend gets" or a suggestion link does not change the stored choice.
|
||||||
|
3. Open section: address from the site's base_url host plus the slug, other site, ready block, up to three suggestions in response order with "Go to <field>" links, "+N more", checking text, hidden line, check failure with "Try again", 401 and 404 on the check.
|
||||||
|
4. Suggestion copy: each of the 11 codes renders its message from params (the four drawn in the design with the exact copy, seven @not-in-design) and has a French entry; the link label Examples and their focus targets (seo_title, seo_description, cover_image_alt, cover_image_id, slug, body, author_ids).
|
||||||
|
5. Check scheduling: exactly one check on load with `{}`; only changed fields in the body; unsaved body included; debounce Examples with fake timers (rapid typing sends nothing, a pause sends one with the latest value) for every text field; discrete changes check immediately; a late answer never overwrites a newer one (resolve out of order) and the stale request is aborted; no PATCH from any check; no client-side rule (a test that feeds a 74-character title and a check answer without `title_long` still shows no `title_long` message).
|
||||||
|
6. Panel: opening on desktop and mobile with the right container, defaults with every chip, other chips (@not-in-design), filled in, soft counter Examples at 29, 30, 60, 61 (title) and 69, 70, 160, 161 (description) with the amber class and no error colour, maxlength at the API limits, excerpt 300 vs 301 with Save changes disabled, slug Auto/pencil/Reset (sends `""`), cover picker with "Use image", alt text appearing with a cover, cover removal sending null, social customised, canonical Examples (on blur and the 422 `invalid_url` reopening Advanced), noindex switch, preview truncation Examples (60 and 160 desktop, 70 and 120 mobile, template included), previews follow typing and fallbacks, Escape/Cancel discards, focus return.
|
||||||
|
7. Slug availability: check on blur with `{"slug": …}`, taken state and `Use <slug>-2`, the suggestion applied and re-checked, 409 `slug_taken` on save keeps the panel open and offers the suggestion through a follow-up check, nothing applied, an owned slug shows no error.
|
||||||
|
8. Save integration: "Save changes" sends one PATCH with `seo_title` only; body pending is sent along; unchanged fields are not sent (assert the exact JSON); Saving disables fields; Saved closes the panel, refreshes the check and shows the toast; 500 and network failure keep the panel, the notice and the values; editor Save, Ctrl/Cmd S and "Save and publish" after a failed panel save; the leave dialog with an unsaved SEO value; the 401 mirror to sessionStorage and the restored draft with the panel fields; sessionStorage throwing; suggestions never disable Save or Publish; Cancel sends nothing; offline; the three roles.
|
||||||
|
9. ApiPreviewDialog: one GET per opening from both openers, desktop dialog and mobile sheet, Formatted keys and the chip Examples, Raw JSON equals the response, Copy JSON writes the pretty-printed raw object, "Copied" for 2 s (fake timers) and the live-region announcement, a rejected or missing clipboard, draft note when `is_online` is false, loading with Copy disabled, error with "Try again" for 500 and a network error, read-only (no PATCH, no POST), Escape/Close focus return.
|
||||||
|
10. i18n: every new `t()` key has a French entry (extend the i18n spec); one French walk through the section, the panel and the dialog.
|
||||||
|
11. API types: `Article` carries the SEO fields and `Site` carries `site_name`, `title_template`, `default_description` and `default_robots`; `useArticleDraft` mirrors the SEO fields in sessionStorage and sends only changed ones (unit test of `savePayload` through `save()`).
|
||||||
|
|
||||||
|
### Red/green proof for the storage fallback (required in the qa report)
|
||||||
|
|
||||||
|
`metaSectionMemory.ts` already wraps its storage calls in try/catch. For the new key, the spec "A storage that throws never breaks the editor" (both `getItem` and `setItem`) must be shown failing without the fix:
|
||||||
|
1. Write the spec with a localStorage whose `getItem` and `setItem` throw `SecurityError`.
|
||||||
|
2. Temporarily remove the try/catch from the new read/write helpers (or call localStorage directly), run `task test:web -- EditorSeoMemory`, and record the red run (the editor throws or the toggle does nothing).
|
||||||
|
3. Restore the helpers, run again, and record the green run.
|
||||||
|
4. Report both outputs. The temporary revert is not committed.
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- editor`, desktop and mobile, light and dark)
|
||||||
|
|
||||||
|
Setups go in `web/visual/states.ts`, one setup per state, with mocked /v1 and the board's own sample data (article 12 "How we price a bathroom renovation", Renew, "Inès Duarte", media "north-wall.jpg" and "kitchen-after.webp", values from the frames). Hard gate: every capture within 10 %, with no per-state exception. Reference frames: `AppFrame.dc.html` (sidebar section), `DirectoryFrame.dc.html` (SeoPanel, SlugField, MediaField, picker) and the board `RenewCMS Screens Back-office.dc.html`.
|
||||||
|
|
||||||
|
- Existing Editor frames with the sidebar: the collapsed section with "Ready" under "Site and authors". Their setups need a default POST check mock. Loading shows the skeleton summary. Not found has none.
|
||||||
|
- Editor: search and sharing (24): Collapsed ready, suggestions, hidden; Open ready, suggestions (4 codes: title_long, cover_alt, no_h, short), checking, hidden, other site (`site: 1` = Journal); Panel defaults (codes: cover_none), filled in, outside the soft ranges, slug edited, slug taken, cover without alt, choose cover, social sharing open (with scrollY 560 on desktop), social customised, advanced with invalid canonical (scrollY 640), hidden from search (scrollY 700), mobile search preview, checking, saving, save failed; Saved (toast).
|
||||||
|
- Editor: what the frontend gets (6): Formatted, Raw JSON, Copied, Draft article, Loading, Error.
|
||||||
|
- States that wait on a timer (the debounce, the 2 s "Copied") are set up by their end state; none depends on the date or time, so none is `skip: clock-sensitive`.
|
||||||
|
- Re-run Articles, Sites, Authors, Profile, Settings and Home: report any capture that moves above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
|
||||||
|
|
||||||
|
## Exit conditions
|
||||||
|
|
||||||
|
- All existing Editor frames and the 30 new frames pass `task visual:check -- editor` with no per-state exception.
|
||||||
|
- Vitest specs reported honestly, run or NOT RUN, with the red/green proof attached.
|
||||||
|
- `task lint` and `bun lint` clean; no comment added.
|
||||||
|
|
||||||
|
## Copy and behaviour not in the design (feed back to docs/design later)
|
||||||
|
|
||||||
|
Each has a French entry in `web/src/i18n/messages.ts`.
|
||||||
|
|
||||||
|
1. The copy of the codes the design does not draw: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
|
||||||
|
2. The other fallback chips (excerpt, site default description, no image) and the chips of the dialog.
|
||||||
|
3. The check failure text "Couldn't check this article." with "Try again" (no state in the design).
|
||||||
|
4. The "Go to authors" and "Go to URL slug" labels, and where "Go to body" and "Go to authors" move focus.
|
||||||
|
5. The clipboard-refused behaviour (no copy, no state).
|
||||||
|
6. The excerpt-over-300 inline error text, and the "Save changes" disabled rule.
|
||||||
|
|
||||||
|
## Manual checks screenshots cannot cover
|
||||||
|
|
||||||
|
- Keyboard: tab order through the section, the panel (fields, Advanced, switch, previews, buttons) and the dialog; Escape; focus returns to the opener; focus lands on the field named by "Go to …"; "Copied" read by a screen reader.
|
||||||
|
- Hover, focus ring and motion: slide-over and sheet transitions, the amber counter, the chip styles.
|
||||||
|
- Real `task dev`: edit a title, description and cover, watch the check update after a pause, take a slug used by another article (409 flow and "Use …-2"), Save, then open "What the frontend gets" and compare it with `curl` on GET /v1/article/{id}/seo; hide from search and see "Hidden from search"; clipboard on a real browser over http://localhost.
|
||||||
|
- Storage: block site data in the browser and check that the editor still works and the section still toggles.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import FallbackChip from '@/components/FallbackChip.vue'
|
||||||
|
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
||||||
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
|
import { useResolvedSeo } from '@/composables/useResolvedSeo'
|
||||||
|
import { apiPreviewRows } from '@/lib/seoApiPreview'
|
||||||
|
|
||||||
|
type DialogView = 'formatted' | 'raw'
|
||||||
|
|
||||||
|
const SKELETON_WIDTHS = ['w-2/5', 'w-4/5', 'w-3/5', 'w-7/10', 'w-1/2', 'w-3/4']
|
||||||
|
|
||||||
|
const props = defineProps<{ articleId: number; isOnline: boolean }>()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const view = ref<DialogView>('formatted')
|
||||||
|
const { phase, resolved, load } = useResolvedSeo(props.articleId)
|
||||||
|
const { copied, copy } = useCopyToClipboard()
|
||||||
|
|
||||||
|
const endpoint = computed(() => `GET /v1/article/${props.articleId}/seo`)
|
||||||
|
const views = computed(() => [
|
||||||
|
{ value: 'formatted' as const, label: t('Formatted') },
|
||||||
|
{ value: 'raw' as const, label: t('Raw JSON') },
|
||||||
|
])
|
||||||
|
const rows = computed(() => (resolved.value ? apiPreviewRows(resolved.value) : []))
|
||||||
|
const rawJson = computed(() => JSON.stringify(resolved.value, null, 2))
|
||||||
|
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
|
||||||
|
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
view.value = 'formatted'
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function copyRaw() {
|
||||||
|
try {
|
||||||
|
await copy(rawJson.value)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="open"
|
||||||
|
:title="t('What the frontend gets')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'The SEO data returned by {0}, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.',
|
||||||
|
[endpoint],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:close="{
|
||||||
|
'aria-label': t('Close'),
|
||||||
|
class: 'size-7 justify-center text-muted max-md:size-11',
|
||||||
|
}"
|
||||||
|
:ui="{
|
||||||
|
overlay: 'z-60',
|
||||||
|
content: `${SHEET_CONTENT_CLASS} z-60 sm:top-10 sm:max-w-180`,
|
||||||
|
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||||
|
title: 'text-lg/6 font-semibold',
|
||||||
|
description: 'mt-1 text-sm text-muted text-pretty max-md:pe-16',
|
||||||
|
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||||
|
footer: SHEET_FOOTER_CLASS,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-3" data-testid="api-preview">
|
||||||
|
<UAlert
|
||||||
|
v-if="!isOnline"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
|
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:ui="{ root: 'items-start gap-2 px-3 py-2.5', description: 'text-toned' }"
|
||||||
|
data-testid="api-preview-draft"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="w-50">
|
||||||
|
<SegmentedTabs v-model="view" :items="views" />
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:icon="copyIcon"
|
||||||
|
:disabled="phase !== 'ready'"
|
||||||
|
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
data-testid="api-preview-copy"
|
||||||
|
@click="copyRaw"
|
||||||
|
>
|
||||||
|
{{ copyLabel }}
|
||||||
|
</UButton>
|
||||||
|
<span class="sr-only" role="status" aria-live="polite">{{
|
||||||
|
copied ? t('Copied') : ''
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="phase === 'error'"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
data-testid="api-preview-error"
|
||||||
|
>
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
role="alert"
|
||||||
|
:title="t('Couldn\'t load the SEO data')"
|
||||||
|
:description="
|
||||||
|
t('{0} failed. Your article is unchanged. Try again in a moment.', [
|
||||||
|
endpoint,
|
||||||
|
])
|
||||||
|
"
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: t('Try again'),
|
||||||
|
icon: 'i-lucide-rotate-cw',
|
||||||
|
color: 'neutral',
|
||||||
|
variant: 'outline',
|
||||||
|
class: 'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center md:h-7',
|
||||||
|
onClick: load,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:ui="{
|
||||||
|
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||||
|
title: 'font-semibold',
|
||||||
|
description: 'text-toned',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="phase !== 'ready'"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
aria-busy="true"
|
||||||
|
data-testid="api-preview-loading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="width in SKELETON_WIDTHS"
|
||||||
|
:key="width"
|
||||||
|
class="block h-3.5 bg-accented"
|
||||||
|
:class="width"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-else-if="view === 'raw'"
|
||||||
|
tabindex="0"
|
||||||
|
class="max-h-110 overflow-auto md:max-h-100 bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
|
||||||
|
data-testid="api-preview-raw"
|
||||||
|
>{{ rawJson }}</pre
|
||||||
|
>
|
||||||
|
<dl v-else class="flex flex-col" data-testid="api-preview-formatted">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.key"
|
||||||
|
class="flex flex-col gap-0.5 border-b border-muted py-1.5 md:flex-row md:gap-3"
|
||||||
|
>
|
||||||
|
<dt class="shrink-0 font-mono text-xs/5 text-muted md:w-32.5">
|
||||||
|
{{ row.key }}
|
||||||
|
</dt>
|
||||||
|
<dd class="m-0 flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<span class="break-all text-highlighted">{{ row.value }}</span>
|
||||||
|
<FallbackChip v-if="row.chip" :label="row.chip" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
|
||||||
|
data-testid="api-preview-close"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ t('Close') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -158,7 +158,7 @@ async function copyUrl() {
|
|||||||
:ui="{
|
:ui="{
|
||||||
content:
|
content:
|
||||||
'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||||
header: 'min-h-0 gap-3 px-4 pt-2 pb-0 sm:px-4',
|
header: 'min-h-13 items-center gap-3 border-b-0 px-4 pt-2 pb-0 sm:px-4',
|
||||||
title: 'text-lg/6 font-semibold',
|
title: 'text-lg/6 font-semibold',
|
||||||
description: 'sr-only',
|
description: 'sr-only',
|
||||||
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
|
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const picker = computed(() => ({
|
|||||||
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
||||||
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
||||||
confirmLabel: t('Use image'),
|
confirmLabel: t('Use image'),
|
||||||
|
square: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function retryUsers() {
|
function retryUsers() {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const props = withDefaults(
|
|||||||
saving?: boolean
|
saving?: boolean
|
||||||
dirty?: boolean
|
dirty?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
wide?: boolean
|
||||||
|
submitDisabled?: boolean
|
||||||
notice?: DirectoryNotice | null
|
notice?: DirectoryNotice | null
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -25,6 +27,8 @@ const props = withDefaults(
|
|||||||
saving: false,
|
saving: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
|
wide: false,
|
||||||
|
submitDisabled: false,
|
||||||
notice: null,
|
notice: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -46,13 +50,15 @@ const closeButton = computed(() => ({
|
|||||||
color: 'neutral' as const,
|
color: 'neutral' as const,
|
||||||
variant: 'ghost' as const,
|
variant: 'ghost' as const,
|
||||||
'aria-label': t('Close'),
|
'aria-label': t('Close'),
|
||||||
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
|
class: 'size-7 justify-center text-muted max-md:top-4.5 max-md:end-1.5 max-md:size-11',
|
||||||
ui: { leadingIcon: 'size-4' },
|
ui: { leadingIcon: 'size-4' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const overlayUi = computed(() => ({
|
const overlayUi = computed(() => ({
|
||||||
content: isDesktop.value
|
content: isDesktop.value
|
||||||
? 'w-full max-w-120'
|
? props.wide
|
||||||
|
? 'w-full max-w-240'
|
||||||
|
: 'w-full max-w-120'
|
||||||
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||||
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
|
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
|
||||||
wrapper: 'min-w-0 flex-1',
|
wrapper: 'min-w-0 flex-1',
|
||||||
@@ -185,7 +191,7 @@ function onOpenChange(isOpen: boolean) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
:form="formId"
|
:form="formId"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
:disabled="saving"
|
:disabled="saving || submitDisabled"
|
||||||
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
|
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
|
||||||
>
|
>
|
||||||
{{ primaryLabel }}
|
{{ primaryLabel }}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ label: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-5 max-w-full items-center gap-1 self-start border border-dashed border-accented px-1.5 font-mono text-2xs/4 font-medium whitespace-nowrap text-toned"
|
||||||
|
data-testid="fallback-chip"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-corner-down-right" class="size-2.5 shrink-0" />
|
||||||
|
<span class="truncate">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -814,6 +814,7 @@ onUnmounted(() => {
|
|||||||
:in-article-ids="inArticleIds"
|
:in-article-ids="inArticleIds"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
:plain="choose !== undefined"
|
:plain="choose !== undefined"
|
||||||
|
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
|
||||||
:hint="choose?.hint"
|
:hint="choose?.hint"
|
||||||
@search="searchLibrary"
|
@search="searchLibrary"
|
||||||
@select="selectItem"
|
@select="selectItem"
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ const props = withDefaults(
|
|||||||
avatarName?: string
|
avatarName?: string
|
||||||
square?: boolean
|
square?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
autofocusChoose?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ help: '', avatarName: '', square: false, disabled: false },
|
{ help: '', avatarName: '', square: false, disabled: false, autofocusChoose: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
const imageId = defineModel<number | null>({ default: null })
|
const imageId = defineModel<number | null>({ default: null })
|
||||||
@@ -87,6 +88,7 @@ function remove() {
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="BUTTON_CLASS"
|
:class="BUTTON_CLASS"
|
||||||
:ui="{ leadingIcon: 'size-3.5' }"
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
:data-autofocus="autofocusChoose ? '' : undefined"
|
||||||
@click="pickerOpen = true"
|
@click="pickerOpen = true"
|
||||||
>
|
>
|
||||||
{{ chooseLabel }}
|
{{ chooseLabel }}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const props = defineProps<{
|
|||||||
variants?: ImageVariant[]
|
variants?: ImageVariant[]
|
||||||
variantsReady?: boolean
|
variantsReady?: boolean
|
||||||
plain?: boolean
|
plain?: boolean
|
||||||
|
tileRatio?: 'square' | 'landscape'
|
||||||
hint?: string
|
hint?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ const emit = defineEmits<{
|
|||||||
upload: []
|
upload: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const tileAspect = computed(() =>
|
||||||
|
props.tileRatio === 'landscape' ? 'aspect-4/3' : 'aspect-square',
|
||||||
|
)
|
||||||
|
|
||||||
const alt = defineModel<string>('alt', { default: '' })
|
const alt = defineModel<string>('alt', { default: '' })
|
||||||
const variant = defineModel<string>('variant', { default: '' })
|
const variant = defineModel<string>('variant', { default: '' })
|
||||||
|
|
||||||
@@ -242,7 +247,7 @@ onBeforeUnmount(clearDebounce)
|
|||||||
data-testid="picker-skeleton-tile"
|
data-testid="picker-skeleton-tile"
|
||||||
class="flex flex-col gap-1.5"
|
class="flex flex-col gap-1.5"
|
||||||
>
|
>
|
||||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
<USkeleton class="w-full rounded-none bg-accented" :class="tileAspect" />
|
||||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,7 +284,8 @@ onBeforeUnmount(clearDebounce)
|
|||||||
<MediaThumb
|
<MediaThumb
|
||||||
:item="item"
|
:item="item"
|
||||||
size="md"
|
size="md"
|
||||||
class="aspect-square w-full border border-default"
|
class="w-full border border-default"
|
||||||
|
:class="tileAspect"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="isInArticle(item)"
|
v-if="isInArticle(item)"
|
||||||
@@ -303,7 +309,10 @@ onBeforeUnmount(clearDebounce)
|
|||||||
data-testid="picker-skeleton-tile"
|
data-testid="picker-skeleton-tile"
|
||||||
class="flex flex-col gap-1.5"
|
class="flex flex-col gap-1.5"
|
||||||
>
|
>
|
||||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
<USkeleton
|
||||||
|
class="w-full rounded-none bg-accented"
|
||||||
|
:class="tileAspect"
|
||||||
|
/>
|
||||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -353,7 +362,11 @@ onBeforeUnmount(clearDebounce)
|
|||||||
/>
|
/>
|
||||||
<UFormField
|
<UFormField
|
||||||
:label="t('Alt text (optional)')"
|
:label="t('Alt text (optional)')"
|
||||||
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
|
:help="
|
||||||
|
t(
|
||||||
|
'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.',
|
||||||
|
)
|
||||||
|
"
|
||||||
:ui="{
|
:ui="{
|
||||||
label: 'font-medium text-highlighted',
|
label: 'font-medium text-highlighted',
|
||||||
help: 'text-xs/5 text-muted',
|
help: 'text-xs/5 text-muted',
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import FallbackChip from '@/components/FallbackChip.vue'
|
||||||
|
import SegmentedTabs from '@/components/SegmentedTabs.vue'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import { hostOf } from '@/lib/directory'
|
||||||
|
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||||
|
import {
|
||||||
|
PREVIEW_LIMITS,
|
||||||
|
siteDisplayName,
|
||||||
|
previewDescription,
|
||||||
|
previewDescriptionSource,
|
||||||
|
previewTitle,
|
||||||
|
previewTitleSource,
|
||||||
|
titleIsCut,
|
||||||
|
truncateText,
|
||||||
|
type PreviewDevice,
|
||||||
|
type SeoPreviewInput,
|
||||||
|
} from '@/lib/seoPreview'
|
||||||
|
|
||||||
|
const SLUG_PATH_LIMIT = 36
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
input: SeoPreviewInput
|
||||||
|
site: Site | undefined
|
||||||
|
slug: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{ value: 'desktop' as const, label: t('Desktop') },
|
||||||
|
{ value: 'mobile' as const, label: t('Mobile') },
|
||||||
|
])
|
||||||
|
const fullTitle = computed(() => previewTitle(props.input))
|
||||||
|
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
|
||||||
|
const shownDescription = computed(() =>
|
||||||
|
truncateText(previewDescription(props.input), PREVIEW_LIMITS[device.value].description),
|
||||||
|
)
|
||||||
|
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
|
||||||
|
const siteName = computed(() => siteDisplayName(props.site))
|
||||||
|
const siteInitial = computed(() => [...siteName.value][0] ?? '')
|
||||||
|
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
||||||
|
const chips = computed(() => {
|
||||||
|
const titleChip =
|
||||||
|
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
|
||||||
|
const description = descriptionChip(previewDescriptionSource(props.input))
|
||||||
|
return [...titleChip, ...(description ? [description] : [])]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2" data-testid="search-preview">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-highlighted">{{ t('Search result') }}</span>
|
||||||
|
<div class="w-37.5">
|
||||||
|
<SegmentedTabs v-model="device" compact :items="items" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UCard
|
||||||
|
variant="outline"
|
||||||
|
:ui="{
|
||||||
|
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
|
||||||
|
body: 'flex flex-col gap-1 p-3.5 sm:p-3.5',
|
||||||
|
}"
|
||||||
|
:class="device === 'mobile' ? 'md:w-70' : ''"
|
||||||
|
data-testid="search-preview-card"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex size-5.5 shrink-0 items-center justify-center rounded-full bg-elevated text-2xs/none font-semibold text-highlighted"
|
||||||
|
>{{ siteInitial }}</span
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
|
||||||
|
<span class="truncate text-2xs/3.5 text-muted"
|
||||||
|
>{{ host }} › {{ slug.slice(0, SLUG_PATH_LIMIT) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mt-1 break-words text-info"
|
||||||
|
:class="device === 'desktop' ? 'text-lead/6' : 'text-lg/5.25'"
|
||||||
|
data-testid="search-preview-title"
|
||||||
|
>{{ shownTitle }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm/4.75 break-words text-toned"
|
||||||
|
data-testid="search-preview-description"
|
||||||
|
>{{ shownDescription }}</span
|
||||||
|
>
|
||||||
|
</UCard>
|
||||||
|
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
|
||||||
|
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
||||||
|
</div>
|
||||||
|
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
|
||||||
|
t('Cut off here, the way a search engine would.')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, useId } from 'vue'
|
||||||
|
import SuggestionList from '@/components/SuggestionList.vue'
|
||||||
|
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||||
|
import type { SeoCheckStatus, SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
remembered: boolean | null
|
||||||
|
address: string
|
||||||
|
phase: SeoCheckPhase
|
||||||
|
status: SeoCheckStatus | null
|
||||||
|
suggestions: SeoSuggestion[]
|
||||||
|
sheet?: boolean
|
||||||
|
}>(),
|
||||||
|
{ sheet: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:remembered': [open: boolean]
|
||||||
|
edit: [focus?: SeoFocus]
|
||||||
|
preview: []
|
||||||
|
retry: []
|
||||||
|
go: [field: SuggestionField]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bodyId = useId()
|
||||||
|
|
||||||
|
const isOpen = computed(() => props.remembered ?? false)
|
||||||
|
const checking = computed(() => isChecking(props.phase))
|
||||||
|
const hidden = computed(() => !checking.value && props.status === 'hidden')
|
||||||
|
const summary = computed(() => {
|
||||||
|
if (props.phase === 'failed') {
|
||||||
|
return t("Couldn't check this article.")
|
||||||
|
}
|
||||||
|
if (checking.value) {
|
||||||
|
return t('Checking…')
|
||||||
|
}
|
||||||
|
if (props.status === 'hidden') {
|
||||||
|
return t('Hidden from search')
|
||||||
|
}
|
||||||
|
if (props.suggestions.length === 1) {
|
||||||
|
return t('1 suggestion')
|
||||||
|
}
|
||||||
|
if (props.suggestions.length > 1) {
|
||||||
|
return t('{0} suggestions', [props.suggestions.length])
|
||||||
|
}
|
||||||
|
return t('Ready')
|
||||||
|
})
|
||||||
|
const warning = computed(
|
||||||
|
() => !checking.value && props.status !== 'hidden' && props.suggestions.length > 0,
|
||||||
|
)
|
||||||
|
const buttonClass = computed(
|
||||||
|
() =>
|
||||||
|
`${props.sheet ? 'h-11' : 'h-8'} w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0`,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex flex-col gap-3 border-b border-default pb-4" data-testid="seo-section">
|
||||||
|
<UCollapsible :open="isOpen" @update:open="emit('update:remembered', $event)">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
class="min-h-6 w-full justify-start gap-2 p-0 text-sm font-semibold text-highlighted"
|
||||||
|
:aria-controls="bodyId"
|
||||||
|
data-testid="seo-toggle"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="size-3.5 shrink-0 transition-transform"
|
||||||
|
:class="{ '-rotate-90': !isOpen }"
|
||||||
|
/>
|
||||||
|
{{ t('Search and sharing') }}
|
||||||
|
</UButton>
|
||||||
|
<template #content>
|
||||||
|
<div :id="bodyId" class="mt-3 flex flex-col gap-3" data-testid="seo-body">
|
||||||
|
<div class="flex flex-col gap-1 bg-elevated p-2" data-testid="seo-address">
|
||||||
|
<span class="text-xs text-muted">{{ t('Address') }}</span>
|
||||||
|
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||||
|
address
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hidden" class="flex gap-2 text-warning" data-testid="seo-hidden">
|
||||||
|
<UIcon name="i-lucide-eye-off" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span class="text-pretty text-toned">{{
|
||||||
|
t('Hidden from search engines. This only sets a flag in the API.')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<SuggestionList
|
||||||
|
compact
|
||||||
|
:phase="phase"
|
||||||
|
:suggestions="suggestions"
|
||||||
|
@go="emit('go', $event)"
|
||||||
|
@more="emit('edit', 'suggestions')"
|
||||||
|
@retry="emit('retry')"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
:class="buttonClass"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
data-testid="seo-edit"
|
||||||
|
@click="emit('edit')"
|
||||||
|
>
|
||||||
|
{{ t('Edit search and sharing') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-braces"
|
||||||
|
:class="buttonClass"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
data-testid="seo-preview"
|
||||||
|
@click="emit('preview')"
|
||||||
|
>
|
||||||
|
{{ t('What the frontend gets') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCollapsible>
|
||||||
|
<span
|
||||||
|
v-if="!isOpen"
|
||||||
|
class="truncate pl-5.5"
|
||||||
|
:class="warning ? 'text-warning' : 'text-muted'"
|
||||||
|
data-testid="seo-summary"
|
||||||
|
:title="summary"
|
||||||
|
>{{ summary }}</span
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts" generic="V extends string">
|
||||||
|
import { SEGMENT_TAB_ACTIVE } from '@/lib/segmentStyles'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: { value: V; label: string }[]
|
||||||
|
modelValue: V
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: V] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UTabs
|
||||||
|
:model-value="modelValue"
|
||||||
|
:items="items"
|
||||||
|
variant="pill"
|
||||||
|
color="neutral"
|
||||||
|
:content="false"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
list: 'gap-0 rounded-none border border-accented bg-default p-0',
|
||||||
|
indicator: 'hidden',
|
||||||
|
trigger: [
|
||||||
|
'flex-1 rounded-none px-0 font-normal text-toned',
|
||||||
|
SEGMENT_TAB_ACTIVE,
|
||||||
|
compact ? 'h-11 text-xs md:h-6' : 'h-11 text-sm md:h-8',
|
||||||
|
],
|
||||||
|
label: 'truncate',
|
||||||
|
}"
|
||||||
|
@update:model-value="emit('update:modelValue', String($event) as V)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useId } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ title: string; tag?: string; summary: string }>()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const bodyId = useId()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCollapsible
|
||||||
|
v-model:open="open"
|
||||||
|
:ui="{ root: 'flex flex-col gap-3 border border-default p-3' }"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
class="min-h-11 w-full justify-start gap-2 p-0 text-sm text-highlighted md:min-h-6"
|
||||||
|
:aria-controls="bodyId"
|
||||||
|
data-testid="seo-disclosure-toggle"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-down"
|
||||||
|
class="size-3.5 shrink-0 transition-transform"
|
||||||
|
:class="{ '-rotate-90': !open }"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold">{{ title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tag"
|
||||||
|
class="inline-flex min-h-5 items-center border border-dashed border-accented px-1.5 py-px font-mono text-2xs/4 font-medium text-toned"
|
||||||
|
>{{ tag }}</span
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-right text-xs font-normal text-muted">{{
|
||||||
|
summary
|
||||||
|
}}</span>
|
||||||
|
</UButton>
|
||||||
|
<template #content>
|
||||||
|
<div :id="bodyId" class="flex flex-col gap-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCollapsible>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
|
||||||
|
import DirectoryField from '@/components/DirectoryField.vue'
|
||||||
|
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||||
|
import FallbackChip from '@/components/FallbackChip.vue'
|
||||||
|
import MediaField from '@/components/MediaField.vue'
|
||||||
|
import SearchPreview from '@/components/SearchPreview.vue'
|
||||||
|
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||||
|
import SlugField from '@/components/SlugField.vue'
|
||||||
|
import SocialCardPreview from '@/components/SocialCardPreview.vue'
|
||||||
|
import SoftCounter from '@/components/SoftCounter.vue'
|
||||||
|
import SuggestionList from '@/components/SuggestionList.vue'
|
||||||
|
import type { SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import { useMediaNames } from '@/composables/useMediaNames'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import {
|
||||||
|
SEO_LIMITS,
|
||||||
|
canonicalIsValid,
|
||||||
|
charCount,
|
||||||
|
normalizeSlug,
|
||||||
|
typedSlug,
|
||||||
|
type ArticleSeo,
|
||||||
|
} from '@/lib/articleSeo'
|
||||||
|
import { slugify } from '@/lib/directory'
|
||||||
|
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||||
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
|
import {
|
||||||
|
DESCRIPTION_RANGE,
|
||||||
|
SOCIAL_TITLE_RANGE,
|
||||||
|
TITLE_RANGE,
|
||||||
|
hasSocialOverrides,
|
||||||
|
siteDisplayName,
|
||||||
|
previewDescription,
|
||||||
|
previewDescriptionSource,
|
||||||
|
socialImageSource,
|
||||||
|
type PreviewDevice,
|
||||||
|
type SeoPreviewInput,
|
||||||
|
} from '@/lib/seoPreview'
|
||||||
|
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
|
const PANEL_FIELDS: SuggestionField[] = [
|
||||||
|
'seo_title',
|
||||||
|
'seo_description',
|
||||||
|
'cover_image_alt',
|
||||||
|
'cover_image_id',
|
||||||
|
'slug',
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ArticleSeo
|
||||||
|
savedSlug: string
|
||||||
|
articleTitle: string
|
||||||
|
body: string
|
||||||
|
site: Site | undefined
|
||||||
|
phase: SeoCheckPhase
|
||||||
|
suggestions: SeoSuggestion[]
|
||||||
|
slugVerdict: { available: boolean; suggestion: string | null } | null
|
||||||
|
checkedSlug: string | undefined
|
||||||
|
slugTakenOnSave: string | null
|
||||||
|
canonicalRejected: boolean
|
||||||
|
saving: boolean
|
||||||
|
notice: DirectoryNotice | null
|
||||||
|
focus: SeoFocus | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ArticleSeo]
|
||||||
|
check: [immediate: boolean]
|
||||||
|
submit: []
|
||||||
|
close: []
|
||||||
|
preview: []
|
||||||
|
go: [field: SuggestionField]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
||||||
|
const [DefineFields, Fields] = createReusableTemplate()
|
||||||
|
const [DefinePreviews, Previews] = createReusableTemplate()
|
||||||
|
const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
|
const names = useMediaNames()
|
||||||
|
const layout = ref<HTMLElement | null>(null)
|
||||||
|
const focusTarget = ref<SeoFocus | null>(props.focus)
|
||||||
|
const device = ref<PreviewDevice>('desktop')
|
||||||
|
const socialOpen = ref(false)
|
||||||
|
const advancedOpen = ref(props.canonicalRejected)
|
||||||
|
const canonicalMessage = ref('')
|
||||||
|
const generatedSlug = computed(() => slugify(props.articleTitle, SEO_LIMITS.slug))
|
||||||
|
const slugManual = ref(
|
||||||
|
props.modelValue.slug !== '' && props.modelValue.slug !== generatedSlug.value,
|
||||||
|
)
|
||||||
|
const slugText = ref(props.modelValue.slug || generatedSlug.value)
|
||||||
|
|
||||||
|
const seo = computed(() => props.modelValue)
|
||||||
|
const siteName = computed(() => siteDisplayName(props.site))
|
||||||
|
const previewInput = computed<SeoPreviewInput>(() => ({
|
||||||
|
seo: seo.value,
|
||||||
|
title: props.articleTitle,
|
||||||
|
body: props.body,
|
||||||
|
site: props.site,
|
||||||
|
}))
|
||||||
|
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||||
|
const slugMode = computed(() => {
|
||||||
|
if (slugManual.value) {
|
||||||
|
return 'manual' as const
|
||||||
|
}
|
||||||
|
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||||
|
})
|
||||||
|
const siteImageId = computed(() => props.site?.default_social_image_id ?? null)
|
||||||
|
const siteImageName = computed(() => {
|
||||||
|
const id = siteImageId.value
|
||||||
|
if (id === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||||
|
})
|
||||||
|
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||||
|
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||||
|
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||||
|
const cardImageItem = computed(() => {
|
||||||
|
const items = {
|
||||||
|
social: socialItem.value,
|
||||||
|
cover: coverItem.value,
|
||||||
|
site: names.itemOf(siteImageId.value),
|
||||||
|
none: null,
|
||||||
|
}
|
||||||
|
return items[cardSource.value]
|
||||||
|
})
|
||||||
|
const cardImageName = computed(() => {
|
||||||
|
const labels = {
|
||||||
|
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||||
|
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||||
|
site: siteImageName.value,
|
||||||
|
none: '',
|
||||||
|
}
|
||||||
|
return labels[cardSource.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const slugTaken = computed(() => {
|
||||||
|
if (props.slugTakenOnSave !== null && props.slugTakenOnSave === seo.value.slug) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const verdict = props.slugVerdict
|
||||||
|
return (
|
||||||
|
verdict !== null &&
|
||||||
|
!verdict.available &&
|
||||||
|
props.checkedSlug !== undefined &&
|
||||||
|
props.checkedSlug === seo.value.slug
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const slugSuggestion = computed(() =>
|
||||||
|
slugTaken.value && props.checkedSlug === seo.value.slug
|
||||||
|
? (props.slugVerdict?.suggestion ?? '')
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
const slugError = computed(() =>
|
||||||
|
slugTaken.value
|
||||||
|
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
const slugUrl = computed(() => `${props.site?.base_url ?? ''}/${displaySlug.value}`)
|
||||||
|
|
||||||
|
const descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
|
||||||
|
const descriptionChipLabel = computed(() =>
|
||||||
|
descriptionChip(previewDescriptionSource(previewInput.value)),
|
||||||
|
)
|
||||||
|
const excerptTooLong = computed(() => charCount(seo.value.excerpt) > SEO_LIMITS.excerpt)
|
||||||
|
const excerptError = computed(() =>
|
||||||
|
excerptTooLong.value ? t('Shorten the excerpt to 300 characters or fewer.') : '',
|
||||||
|
)
|
||||||
|
const coverHelp = computed(() => {
|
||||||
|
if (seo.value.cover_image_id !== null || siteImageId.value === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return siteImageName.value
|
||||||
|
? t('Using the site social image ({0}).', [siteImageName.value])
|
||||||
|
: t('Using the site social image.')
|
||||||
|
})
|
||||||
|
const coverPicker = computed(() =>
|
||||||
|
pickerCopy(t('Choose a cover image'), t('The cover for this article.')),
|
||||||
|
)
|
||||||
|
const socialPicker = computed(() =>
|
||||||
|
pickerCopy(t('Choose a social image'), t('The image shown when this article is shared.')),
|
||||||
|
)
|
||||||
|
const customised = computed(() => hasSocialOverrides(seo.value))
|
||||||
|
const socialSummary = computed(() => {
|
||||||
|
if (customised.value) {
|
||||||
|
return t('Customised')
|
||||||
|
}
|
||||||
|
return socialOpen.value ? '' : t('Uses the meta values')
|
||||||
|
})
|
||||||
|
const advancedSummary = computed(() => {
|
||||||
|
if (advancedOpen.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||||
|
})
|
||||||
|
const canonicalError = computed(() =>
|
||||||
|
canonicalMessage.value || props.canonicalRejected
|
||||||
|
? t('Enter a full address starting with https://')
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
const canonicalPlaceholder = computed(() => slugUrl.value)
|
||||||
|
|
||||||
|
function pickerCopy(title: string, description: string) {
|
||||||
|
return { title, description, confirmLabel: t('Use image') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||||
|
emit('check', immediate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeText(
|
||||||
|
key:
|
||||||
|
| 'seo_title'
|
||||||
|
| 'seo_description'
|
||||||
|
| 'excerpt'
|
||||||
|
| 'cover_image_alt'
|
||||||
|
| 'social_title'
|
||||||
|
| 'social_description',
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
update({ [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeCanonical(value: string) {
|
||||||
|
canonicalMessage.value = ''
|
||||||
|
update({ canonical_url: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalBlocked() {
|
||||||
|
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
canonicalMessage.value = 'invalid'
|
||||||
|
advancedOpen.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCanonical() {
|
||||||
|
canonicalBlocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoSlug() {
|
||||||
|
return generatedSlug.value === props.savedSlug ? props.savedSlug : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockSlug() {
|
||||||
|
slugManual.value = true
|
||||||
|
slugText.value = generatedSlug.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSlug(value: string) {
|
||||||
|
slugText.value = typedSlug(value)
|
||||||
|
update({ slug: normalizeSlug(slugText.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSlug() {
|
||||||
|
slugManual.value = false
|
||||||
|
slugText.value = generatedSlug.value
|
||||||
|
update({ slug: autoSlug() }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSuggestedSlug(suggestion: string) {
|
||||||
|
slugManual.value = true
|
||||||
|
slugText.value = suggestion
|
||||||
|
update({ slug: suggestion }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSlug() {
|
||||||
|
if (slugManual.value) {
|
||||||
|
emit('check', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCover(id: number | null) {
|
||||||
|
update({ cover_image_id: id }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSocialImage(id: number | null) {
|
||||||
|
update({ social_image_id: id }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNoindex(value: boolean) {
|
||||||
|
update({ noindex: value }, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(item: MediaItem | null) {
|
||||||
|
if (item) {
|
||||||
|
names.remember(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(field: SuggestionField) {
|
||||||
|
if (!PANEL_FIELDS.includes(field)) {
|
||||||
|
emit('go', field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focusTarget.value = field
|
||||||
|
nextTick(() => layout.value?.querySelector<HTMLElement>('[data-autofocus]')?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (excerptTooLong.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canonicalBlocked()) {
|
||||||
|
emit('submit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.canonicalRejected,
|
||||||
|
(rejected) => {
|
||||||
|
if (rejected) {
|
||||||
|
advancedOpen.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||||
|
})
|
||||||
|
|
||||||
|
const DISABLED_FIELD = 'disabled:opacity-40'
|
||||||
|
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
|
||||||
|
const monoFieldUi = {
|
||||||
|
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||||
|
}
|
||||||
|
const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DefineSuggestions>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 focus:outline-none"
|
||||||
|
:data-autofocus="focusTarget === 'suggestions' ? '' : undefined"
|
||||||
|
:tabindex="focusTarget === 'suggestions' ? -1 : undefined"
|
||||||
|
data-testid="seo-panel-suggestions"
|
||||||
|
>
|
||||||
|
<span v-if="isDesktop" class="font-semibold text-highlighted">{{
|
||||||
|
t('Suggestions')
|
||||||
|
}}</span>
|
||||||
|
<SuggestionList
|
||||||
|
:phase="phase"
|
||||||
|
:suggestions="suggestions"
|
||||||
|
@go="goTo"
|
||||||
|
@retry="emit('check', true)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DefineSuggestions>
|
||||||
|
|
||||||
|
<DefinePreviewButton>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-braces"
|
||||||
|
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center max-md:text-sm md:h-7"
|
||||||
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
data-testid="seo-panel-preview"
|
||||||
|
@click="emit('preview')"
|
||||||
|
>
|
||||||
|
{{ t('What the frontend gets') }}
|
||||||
|
</UButton>
|
||||||
|
</DefinePreviewButton>
|
||||||
|
|
||||||
|
<DefinePreviews>
|
||||||
|
<SearchPreview
|
||||||
|
v-model:device="device"
|
||||||
|
:input="previewInput"
|
||||||
|
:site="site"
|
||||||
|
:slug="displaySlug"
|
||||||
|
/>
|
||||||
|
<SocialCardPreview
|
||||||
|
:input="previewInput"
|
||||||
|
:site="site"
|
||||||
|
:image-source="cardSource"
|
||||||
|
:image-name="cardImageName"
|
||||||
|
:image-item="cardImageItem"
|
||||||
|
/>
|
||||||
|
</DefinePreviews>
|
||||||
|
|
||||||
|
<DefineFields>
|
||||||
|
<div class="flex min-w-0 flex-col gap-4.5">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<SlugField
|
||||||
|
:label="t('URL slug')"
|
||||||
|
:mode="slugMode"
|
||||||
|
:value="displaySlug"
|
||||||
|
:error="slugError"
|
||||||
|
:suggestion="slugSuggestion"
|
||||||
|
:help="
|
||||||
|
t(
|
||||||
|
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.',
|
||||||
|
[siteName],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:manual-help="
|
||||||
|
t('Lowercase letters, numbers and hyphens. Unique on {0}.', [siteName])
|
||||||
|
"
|
||||||
|
:reset-label="t('Reset to match the title')"
|
||||||
|
:placeholder="t('Generated from the title')"
|
||||||
|
:edit-label="t('Edit slug')"
|
||||||
|
:maxlength="SEO_LIMITS.slug"
|
||||||
|
:autofocus="focusTarget === 'slug'"
|
||||||
|
@update:value="typeSlug"
|
||||||
|
@unlock="unlockSlug"
|
||||||
|
@reset="resetSlug"
|
||||||
|
@suggest="useSuggestedSlug"
|
||||||
|
@blur="checkSlug"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2">
|
||||||
|
<UIcon name="i-lucide-link" class="size-3 shrink-0 text-muted" />
|
||||||
|
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||||
|
slugUrl
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Meta title')"
|
||||||
|
optional
|
||||||
|
:help="t('Recommended 30 to 60 characters. This is a guide, not a limit.')"
|
||||||
|
>
|
||||||
|
<template #hint>
|
||||||
|
<SoftCounter
|
||||||
|
:value="charCount(seo.seo_title)"
|
||||||
|
:min="TITLE_RANGE.min"
|
||||||
|
:max="TITLE_RANGE.max"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<UInput
|
||||||
|
:model-value="seo.seo_title"
|
||||||
|
:placeholder="articleTitle"
|
||||||
|
:maxlength="SEO_LIMITS.seo_title"
|
||||||
|
:data-autofocus="focusTarget === 'seo_title' ? '' : undefined"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="fieldUi"
|
||||||
|
@update:model-value="typeText('seo_title', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<FallbackChip v-if="!seo.seo_title" :label="chipLabel('article_title')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Meta description')"
|
||||||
|
optional
|
||||||
|
:help="t('Recommended 70 to 160 characters.')"
|
||||||
|
>
|
||||||
|
<template #hint>
|
||||||
|
<SoftCounter
|
||||||
|
:value="charCount(seo.seo_description)"
|
||||||
|
:min="DESCRIPTION_RANGE.min"
|
||||||
|
:max="DESCRIPTION_RANGE.max"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<UTextarea
|
||||||
|
:model-value="seo.seo_description"
|
||||||
|
:placeholder="descriptionPlaceholder"
|
||||||
|
:maxlength="SEO_LIMITS.seo_description"
|
||||||
|
:data-autofocus="focusTarget === 'seo_description' ? '' : undefined"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
:ui="areaUi"
|
||||||
|
@update:model-value="typeText('seo_description', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<FallbackChip
|
||||||
|
v-if="descriptionChipLabel && !seo.seo_description"
|
||||||
|
:label="descriptionChipLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Excerpt')"
|
||||||
|
optional
|
||||||
|
:error="excerptError"
|
||||||
|
:help="
|
||||||
|
t(
|
||||||
|
'Used as the description when there is none, and as the teaser for the frontend.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #hint>
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
:class="excerptTooLong ? 'text-error' : 'text-muted'"
|
||||||
|
data-testid="excerpt-counter"
|
||||||
|
>{{ charCount(seo.excerpt) }} / {{ SEO_LIMITS.excerpt }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<UTextarea
|
||||||
|
:model-value="seo.excerpt"
|
||||||
|
:placeholder="t('A short summary. It is also sent as the article teaser.')"
|
||||||
|
:color="excerptTooLong ? 'error' : undefined"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
:ui="areaUi"
|
||||||
|
@update:model-value="typeText('excerpt', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<MediaField
|
||||||
|
:model-value="seo.cover_image_id"
|
||||||
|
:item="coverItem"
|
||||||
|
:label="t('Cover image')"
|
||||||
|
:help="coverHelp"
|
||||||
|
:picker="coverPicker"
|
||||||
|
:disabled="saving"
|
||||||
|
:autofocus-choose="focusTarget === 'cover_image_id'"
|
||||||
|
@update:item="remember"
|
||||||
|
@update:model-value="setCover"
|
||||||
|
/>
|
||||||
|
<DirectoryField
|
||||||
|
v-if="seo.cover_image_id !== null"
|
||||||
|
:label="t('Alt text')"
|
||||||
|
:help="
|
||||||
|
seo.cover_image_alt
|
||||||
|
? ''
|
||||||
|
: t(
|
||||||
|
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="seo.cover_image_alt"
|
||||||
|
:placeholder="t('Describe the image for people who can\'t see it')"
|
||||||
|
:maxlength="SEO_LIMITS.cover_image_alt"
|
||||||
|
:data-autofocus="focusTarget === 'cover_image_alt' ? '' : undefined"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="fieldUi"
|
||||||
|
@update:model-value="typeText('cover_image_alt', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SeoDisclosure
|
||||||
|
v-model:open="socialOpen"
|
||||||
|
:title="t('Social sharing')"
|
||||||
|
:tag="t('Optional')"
|
||||||
|
:summary="socialSummary"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<DirectoryField :label="t('Social title')" optional>
|
||||||
|
<template #hint>
|
||||||
|
<SoftCounter
|
||||||
|
:value="charCount(seo.social_title)"
|
||||||
|
:min="SOCIAL_TITLE_RANGE.min"
|
||||||
|
:max="SOCIAL_TITLE_RANGE.max"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<UInput
|
||||||
|
:model-value="seo.social_title"
|
||||||
|
:placeholder="seo.seo_title || articleTitle"
|
||||||
|
:maxlength="SEO_LIMITS.social_title"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="fieldUi"
|
||||||
|
@update:model-value="typeText('social_title', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<DirectoryField :label="t('Social description')" optional>
|
||||||
|
<UTextarea
|
||||||
|
:model-value="seo.social_description"
|
||||||
|
:placeholder="descriptionPlaceholder"
|
||||||
|
:maxlength="SEO_LIMITS.social_description"
|
||||||
|
:rows="3"
|
||||||
|
class="w-full"
|
||||||
|
:ui="areaUi"
|
||||||
|
@update:model-value="typeText('social_description', String($event))"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<FallbackChip
|
||||||
|
v-if="!seo.social_description"
|
||||||
|
:label="chipLabel('meta_description')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MediaField
|
||||||
|
:model-value="seo.social_image_id"
|
||||||
|
:item="socialItem"
|
||||||
|
:label="t('Social image')"
|
||||||
|
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||||
|
:picker="socialPicker"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:item="remember"
|
||||||
|
@update:model-value="setSocialImage"
|
||||||
|
/>
|
||||||
|
</SeoDisclosure>
|
||||||
|
|
||||||
|
<SeoDisclosure
|
||||||
|
v-model:open="advancedOpen"
|
||||||
|
:title="t('Advanced')"
|
||||||
|
:summary="advancedSummary"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Canonical URL')"
|
||||||
|
optional
|
||||||
|
:error="canonicalError"
|
||||||
|
:help="t('Only change this if the same content lives at another address.')"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="seo.canonical_url"
|
||||||
|
:placeholder="canonicalPlaceholder"
|
||||||
|
:maxlength="SEO_LIMITS.canonical_url"
|
||||||
|
:color="canonicalError ? 'error' : undefined"
|
||||||
|
:aria-invalid="canonicalError ? 'true' : undefined"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="monoFieldUi"
|
||||||
|
@update:model-value="typeCanonical(String($event))"
|
||||||
|
@focusout="checkCanonical"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<FallbackChip v-if="!seo.canonical_url" :label="chipLabel('canonical')" />
|
||||||
|
</div>
|
||||||
|
</SeoDisclosure>
|
||||||
|
|
||||||
|
<USwitch
|
||||||
|
:model-value="seo.noindex"
|
||||||
|
:label="t('Hide from search engines')"
|
||||||
|
:description="
|
||||||
|
t('Sets noindex in the API. It doesn\'t change who can read the article.')
|
||||||
|
"
|
||||||
|
:ui="{
|
||||||
|
root: 'items-start gap-3',
|
||||||
|
base: 'mt-0.5 h-4.5 w-8 data-[disabled]:opacity-40',
|
||||||
|
thumb: 'size-3.5',
|
||||||
|
label: 'font-medium text-highlighted',
|
||||||
|
description: 'text-xs/4.5 text-pretty text-muted',
|
||||||
|
}"
|
||||||
|
@update:model-value="setNoindex(Boolean($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DefineFields>
|
||||||
|
|
||||||
|
<DirectoryPanel
|
||||||
|
wide
|
||||||
|
:title="t('Search and sharing')"
|
||||||
|
:submit-label="t('Save changes')"
|
||||||
|
:saving="saving"
|
||||||
|
:submit-disabled="excerptTooLong"
|
||||||
|
:notice="notice"
|
||||||
|
@close="emit('close')"
|
||||||
|
@submit="submit"
|
||||||
|
>
|
||||||
|
<div v-if="isDesktop" ref="layout" class="flex items-start gap-7">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Fields />
|
||||||
|
</div>
|
||||||
|
<div class="sticky top-0 flex w-85 shrink-0 flex-col gap-4.5">
|
||||||
|
<Previews />
|
||||||
|
<Suggestions />
|
||||||
|
<PreviewButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else ref="layout" class="flex flex-col gap-4.5">
|
||||||
|
<Suggestions />
|
||||||
|
<Fields />
|
||||||
|
<Previews />
|
||||||
|
<PreviewButton />
|
||||||
|
</div>
|
||||||
|
</DirectoryPanel>
|
||||||
|
</template>
|
||||||
@@ -18,6 +18,10 @@ const props = withDefaults(
|
|||||||
locked?: boolean
|
locked?: boolean
|
||||||
lockedHelp?: string
|
lockedHelp?: string
|
||||||
editLabel?: string
|
editLabel?: string
|
||||||
|
resetLabel?: string
|
||||||
|
placeholder?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
maxlength?: number
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
name: undefined,
|
name: undefined,
|
||||||
@@ -28,6 +32,10 @@ const props = withDefaults(
|
|||||||
locked: false,
|
locked: false,
|
||||||
lockedHelp: '',
|
lockedHelp: '',
|
||||||
editLabel: '',
|
editLabel: '',
|
||||||
|
resetLabel: '',
|
||||||
|
placeholder: '',
|
||||||
|
autofocus: false,
|
||||||
|
maxlength: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +50,7 @@ const emit = defineEmits<{
|
|||||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||||
const inputUi = computed(() => ({
|
const inputUi = computed(() => ({
|
||||||
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
|
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
|
||||||
}))
|
}))
|
||||||
const showReset = computed(
|
const showReset = computed(
|
||||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||||
@@ -57,6 +65,8 @@ const shownHelp = computed(() => {
|
|||||||
return props.help
|
return props.help
|
||||||
})
|
})
|
||||||
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||||
|
const resetText = computed(() => props.resetLabel || t('Reset to match the name'))
|
||||||
|
const placeholderText = computed(() => props.placeholder || t('Generated from the name'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -65,7 +75,9 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
|||||||
<UInput
|
<UInput
|
||||||
:model-value="value"
|
:model-value="value"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:placeholder="t('Generated from the name')"
|
:placeholder="placeholderText"
|
||||||
|
:maxlength="maxlength"
|
||||||
|
:data-autofocus="autofocus ? '' : undefined"
|
||||||
:color="error ? 'error' : undefined"
|
:color="error ? 'error' : undefined"
|
||||||
:highlight="mode === 'manual' && !error"
|
:highlight="mode === 'manual' && !error"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -120,7 +132,7 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
|||||||
:ui="{ leadingIcon: 'size-3' }"
|
:ui="{ leadingIcon: 'size-3' }"
|
||||||
@click="emit('reset')"
|
@click="emit('reset')"
|
||||||
>
|
>
|
||||||
{{ t('Reset to match the name') }}
|
{{ resetText }}
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import FallbackChip from '@/components/FallbackChip.vue'
|
||||||
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import { hostOf } from '@/lib/directory'
|
||||||
|
import { chipLabel } from '@/lib/seoCopy'
|
||||||
|
import {
|
||||||
|
socialDescription,
|
||||||
|
socialTitle,
|
||||||
|
type SeoPreviewInput,
|
||||||
|
type SocialImageSource,
|
||||||
|
} from '@/lib/seoPreview'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
input: SeoPreviewInput
|
||||||
|
site: Site | undefined
|
||||||
|
imageSource: SocialImageSource
|
||||||
|
imageName: string
|
||||||
|
imageItem: MediaItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imageFailed = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageItem?.url,
|
||||||
|
() => {
|
||||||
|
imageFailed.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const host = computed(() => hostOf(props.site?.base_url ?? ''))
|
||||||
|
const title = computed(() => socialTitle(props.input))
|
||||||
|
const description = computed(() => socialDescription(props.input))
|
||||||
|
const chips = computed(() => {
|
||||||
|
const imageChips: Record<SocialImageSource, string | null> = {
|
||||||
|
social: null,
|
||||||
|
cover: chipLabel('cover_image'),
|
||||||
|
site: chipLabel('site_image'),
|
||||||
|
none: chipLabel('no_image'),
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
props.input.seo.social_title ? null : chipLabel('meta_title'),
|
||||||
|
props.input.seo.social_description ? null : chipLabel('meta_description'),
|
||||||
|
imageChips[props.imageSource],
|
||||||
|
].filter((chip): chip is string => chip !== null)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2" data-testid="social-card">
|
||||||
|
<span class="font-semibold text-highlighted">{{ t('Social card') }}</span>
|
||||||
|
<UCard
|
||||||
|
variant="outline"
|
||||||
|
:ui="{
|
||||||
|
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
|
||||||
|
body: 'p-0 sm:p-0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="social-card-image relative flex aspect-191/100 items-center justify-center overflow-hidden font-mono text-2xs text-muted"
|
||||||
|
data-testid="social-card-image"
|
||||||
|
>
|
||||||
|
<span v-if="imageSource !== 'none'">{{ imageName }}</span>
|
||||||
|
<img
|
||||||
|
v-if="imageItem && !imageFailed"
|
||||||
|
:src="imageItem.url"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
@error="imageFailed = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 border-t border-default px-3 py-2.5">
|
||||||
|
<span class="text-2xs/4 text-muted uppercase">{{ host }}</span>
|
||||||
|
<span
|
||||||
|
class="truncate font-semibold text-highlighted"
|
||||||
|
data-testid="social-card-title"
|
||||||
|
>{{ title }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="line-clamp-2 text-xs/4.5 text-toned"
|
||||||
|
data-testid="social-card-description"
|
||||||
|
>{{ description }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
|
||||||
|
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.social-card-image {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px),
|
||||||
|
var(--rc-sand);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ value: number; min: number; max: number }>()
|
||||||
|
|
||||||
|
const outside = computed(
|
||||||
|
() => props.value > 0 && (props.value < props.min || props.value > props.max),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
:class="outside ? 'text-warning' : 'text-muted'"
|
||||||
|
data-testid="soft-counter"
|
||||||
|
>{{ value }} / {{ max }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||||
|
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||||
|
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
const COMPACT_LIMIT = 3
|
||||||
|
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
phase: SeoCheckPhase
|
||||||
|
suggestions: SeoSuggestion[]
|
||||||
|
compact?: boolean
|
||||||
|
}>(),
|
||||||
|
{ compact: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
go: [field: SuggestionField]
|
||||||
|
more: []
|
||||||
|
retry: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const shown = computed(() =>
|
||||||
|
props.compact ? props.suggestions.slice(0, COMPACT_LIMIT) : props.suggestions,
|
||||||
|
)
|
||||||
|
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
|
||||||
|
const checking = computed(() => isChecking(props.phase))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 bg-elevated p-3"
|
||||||
|
:aria-busy="checking ? 'true' : undefined"
|
||||||
|
data-testid="suggestion-list"
|
||||||
|
>
|
||||||
|
<template v-if="phase === 'failed'">
|
||||||
|
<div class="flex items-start gap-2 text-toned">
|
||||||
|
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0 text-error" />
|
||||||
|
<span>{{ t("Couldn't check this article.") }}</span>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-rotate-cw"
|
||||||
|
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||||
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
@click="emit('retry')"
|
||||||
|
>
|
||||||
|
{{ t('Try again') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="checking">
|
||||||
|
<div class="flex items-center gap-2 text-toned" role="status">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||||
|
<span>{{ t('Checking this article…') }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-for="width in SKELETON_WIDTHS"
|
||||||
|
:key="width"
|
||||||
|
class="block h-3 bg-accented"
|
||||||
|
:class="width"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="suggestions.length === 0" class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
|
||||||
|
<span class="text-muted">{{ t('Nothing to suggest for this article.') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="suggestion in shown"
|
||||||
|
:key="suggestion.code"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
data-testid="suggestion"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-lightbulb" class="mt-0.5 size-3.5 shrink-0 text-warning" />
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span class="text-pretty text-highlighted">{{
|
||||||
|
suggestionMessage(suggestion)
|
||||||
|
}}</span>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
icon="i-lucide-arrow-right"
|
||||||
|
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||||
|
:ui="{ leadingIcon: 'size-3' }"
|
||||||
|
@click="emit('go', suggestion.field)"
|
||||||
|
>
|
||||||
|
{{ suggestionLinkLabel(suggestion.field) }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="!compact"
|
||||||
|
class="text-xs/4.5 text-pretty text-muted"
|
||||||
|
data-testid="suggestions-note"
|
||||||
|
>{{ t('Suggestions never block saving or publishing.') }}</span
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
v-if="hiddenCount > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
class="min-h-11 self-start p-0 text-xs text-muted md:min-h-5"
|
||||||
|
data-testid="suggestions-more"
|
||||||
|
@click="emit('more')"
|
||||||
|
>
|
||||||
|
{{ t('+{0} more in Search and sharing', [hiddenCount]) }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||||
|
import { EMPTY_SEO, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
|
function reply(status: number, body: unknown = null, text = '') {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => text,
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved: ArticleSeo = {
|
||||||
|
...EMPTY_SEO,
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
cover_image_id: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
function loaded(sent = vi.fn()) {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
|
||||||
|
const draft = useArticleDraft(12)
|
||||||
|
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
|
||||||
|
return { draft, sent }
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastBody(sent: ReturnType<typeof vi.fn>) {
|
||||||
|
return JSON.parse((sent.mock.calls.at(-1)![1] as RequestInit).body as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The API types carry the SEO fields', () => {
|
||||||
|
it('reads every SEO field of an article, and falls back to empty values for a list item', () => {
|
||||||
|
const article: Article = {
|
||||||
|
id: 12,
|
||||||
|
title: 'T',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
slug: 'a-slug',
|
||||||
|
excerpt: 'An excerpt',
|
||||||
|
seo_title: 'Title',
|
||||||
|
seo_description: 'Description',
|
||||||
|
cover_image_id: 4,
|
||||||
|
cover_image_alt: 'Alt',
|
||||||
|
social_title: 'Social',
|
||||||
|
social_description: 'Social description',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: 'https://renew.example/a',
|
||||||
|
noindex: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(seoOf(article)).toEqual({
|
||||||
|
slug: 'a-slug',
|
||||||
|
excerpt: 'An excerpt',
|
||||||
|
seo_title: 'Title',
|
||||||
|
seo_description: 'Description',
|
||||||
|
cover_image_id: 4,
|
||||||
|
cover_image_alt: 'Alt',
|
||||||
|
social_title: 'Social',
|
||||||
|
social_description: 'Social description',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: 'https://renew.example/a',
|
||||||
|
noindex: true,
|
||||||
|
})
|
||||||
|
expect(seoOf({ slug: 'only-a-slug' })).toEqual({ ...EMPTY_SEO, slug: 'only-a-slug' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the site fields the previews need', () => {
|
||||||
|
const site: Site = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Renew',
|
||||||
|
key: 'renew',
|
||||||
|
base_url: 'https://renew.example',
|
||||||
|
default_social_image_id: null,
|
||||||
|
default_robots: 'index',
|
||||||
|
locale: null,
|
||||||
|
site_name: 'Renew',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: 'About Renew',
|
||||||
|
is_default: true,
|
||||||
|
article_count: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect([site.site_name, site.title_template, site.default_description]).toEqual([
|
||||||
|
'Renew',
|
||||||
|
'%title% | %site%',
|
||||||
|
'About Renew',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The draft sends only the SEO fields that changed', () => {
|
||||||
|
it('sends seo_title alone for a meta title edit', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
|
||||||
|
draft.editSeo({ seo_title: 'A new meta title' })
|
||||||
|
expect(draft.state.value).toBe('dirty')
|
||||||
|
await draft.save()
|
||||||
|
|
||||||
|
expect(lastBody(sent)).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends null for a removed image and an empty string for a reset slug', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
|
||||||
|
draft.editSeo({ cover_image_id: null, slug: '' })
|
||||||
|
await draft.save()
|
||||||
|
|
||||||
|
expect(lastBody(sent)).toEqual({ cover_image_id: null, slug: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no request when the edited value equals the saved one', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
|
||||||
|
draft.editSeo({ seo_title: '' })
|
||||||
|
|
||||||
|
expect(draft.state.value).toBe('clean')
|
||||||
|
await draft.save()
|
||||||
|
expect(sent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a pending body edit and a SEO edit in the same PATCH', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
|
||||||
|
draft.edit('<p>Edited</p>')
|
||||||
|
draft.editSeo({ noindex: true })
|
||||||
|
await draft.save()
|
||||||
|
|
||||||
|
expect(sent).toHaveBeenCalledTimes(1)
|
||||||
|
expect(lastBody(sent)).toEqual({ noindex: true, body: '<p>Edited</p>' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts the values the API returns, such as a regenerated slug', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
sent.mockResolvedValue(reply(200, { slug: 'regenerated-slug' }))
|
||||||
|
|
||||||
|
draft.editSeo({ slug: '' })
|
||||||
|
await draft.save()
|
||||||
|
|
||||||
|
expect(draft.seo.value.slug).toBe('regenerated-slug')
|
||||||
|
expect(draft.savedSeo.value.slug).toBe('regenerated-slug')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The SEO fields are mirrored in sessionStorage', () => {
|
||||||
|
it('stores them on a 401 and restores them as an unsaved draft', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
sent.mockResolvedValue(reply(401))
|
||||||
|
draft.editSeo({ seo_title: 'Kept across the login' })
|
||||||
|
|
||||||
|
const outcome = await draft.save()
|
||||||
|
|
||||||
|
expect(outcome).toBe('unauthorized')
|
||||||
|
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)).toMatchObject({
|
||||||
|
seo_title: 'Kept across the login',
|
||||||
|
})
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const restored = useArticleDraft(12)
|
||||||
|
restored.load('<p>Body</p>', new Date(), undefined, saved)
|
||||||
|
expect(restored.state.value).toBe('dirty')
|
||||||
|
expect(restored.seo.value.seo_title).toBe('Kept across the login')
|
||||||
|
expect(restored.savedSeo.value.seo_title).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the mirror after a successful save', async () => {
|
||||||
|
const { draft } = loaded()
|
||||||
|
draft.editSeo({ seo_title: 'Soon saved' })
|
||||||
|
draft.mirrorToStorage()
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).not.toBeNull()
|
||||||
|
|
||||||
|
await draft.save()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps working in memory when sessionStorage throws', async () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blocked', 'SecurityError')
|
||||||
|
})
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blocked', 'SecurityError')
|
||||||
|
})
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
draft.editSeo({ seo_title: 'In memory' })
|
||||||
|
draft.mirrorToStorage()
|
||||||
|
|
||||||
|
const outcome = await draft.save()
|
||||||
|
|
||||||
|
expect(outcome).toBe('saved')
|
||||||
|
expect(lastBody(sent)).toEqual({ seo_title: 'In memory' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The save outcomes for a refused SEO edit', () => {
|
||||||
|
it('answers slug-taken for a 409 slug_taken and keeps the draft dirty', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
sent.mockResolvedValue(reply(409, null, 'slug_taken'))
|
||||||
|
draft.editSeo({ slug: 'bathroom-costs' })
|
||||||
|
|
||||||
|
const outcome = await draft.save()
|
||||||
|
|
||||||
|
expect(outcome).toBe('slug-taken')
|
||||||
|
expect(draft.state.value).toBe('dirty')
|
||||||
|
expect(draft.savedSeo.value.slug).toBe('how-we-price-a-bathroom-renovation')
|
||||||
|
expect(draft.seo.value.slug).toBe('bathroom-costs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers invalid-url for a 422 invalid_url and keeps the draft dirty', async () => {
|
||||||
|
const { draft, sent } = loaded()
|
||||||
|
sent.mockResolvedValue(reply(422, null, 'invalid_url'))
|
||||||
|
draft.editSeo({ canonical_url: 'https://' })
|
||||||
|
|
||||||
|
const outcome = await draft.save()
|
||||||
|
|
||||||
|
expect(outcome).toBe('invalid-url')
|
||||||
|
expect(draft.state.value).toBe('dirty')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ApiError, useApi } from '@/composables/useApi'
|
import { ApiError, useApi } from '@/composables/useApi'
|
||||||
import type { Article, Byline } from '@/composables/useArticles'
|
import type { Article, Byline } from '@/composables/useArticles'
|
||||||
import { sameIds, toBylines } from '@/lib/articleMeta'
|
import { sameIds, toBylines } from '@/lib/articleMeta'
|
||||||
|
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
||||||
|
|
||||||
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||||
@@ -13,6 +14,8 @@ export type SaveOutcome =
|
|||||||
| 'unauthorized'
|
| 'unauthorized'
|
||||||
| 'site-gone'
|
| 'site-gone'
|
||||||
| 'author-gone'
|
| 'author-gone'
|
||||||
|
| 'slug-taken'
|
||||||
|
| 'invalid-url'
|
||||||
|
|
||||||
export interface ArticleMeta {
|
export interface ArticleMeta {
|
||||||
siteId: number | null
|
siteId: number | null
|
||||||
@@ -27,6 +30,13 @@ function metaStorageKey(articleId: number) {
|
|||||||
return `renewcms:article-draft-meta:${articleId}`
|
return `renewcms:article-draft-meta:${articleId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seoStorageKey(articleId: number) {
|
||||||
|
return `renewcms:article-draft-seo:${articleId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_TAKEN_CODE = 'slug_taken'
|
||||||
|
const INVALID_URL_CODE = 'invalid_url'
|
||||||
|
|
||||||
function readStorage(key: string): string | null {
|
function readStorage(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(key)
|
return sessionStorage.getItem(key)
|
||||||
@@ -67,6 +77,18 @@ function readMirroredMeta(articleId: number): ArticleMeta | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMirroredSeo(articleId: number, base: ArticleSeo): ArticleSeo | null {
|
||||||
|
const raw = readStorage(seoStorageKey(articleId))
|
||||||
|
if (raw === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return seoOf(JSON.parse(raw), base)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function readMirroredDraft(articleId: number): string | null {
|
export function readMirroredDraft(articleId: number): string | null {
|
||||||
return readStorage(draftStorageKey(articleId))
|
return readStorage(draftStorageKey(articleId))
|
||||||
}
|
}
|
||||||
@@ -78,6 +100,8 @@ export function useArticleDraft(articleId: number) {
|
|||||||
const bodyEdited = ref(false)
|
const bodyEdited = ref(false)
|
||||||
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||||
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||||
|
const seo = ref<ArticleSeo>({ ...EMPTY_SEO })
|
||||||
|
const savedSeo = ref<ArticleSeo>({ ...EMPTY_SEO })
|
||||||
const businessState = ref<ArticleDraftBusinessState>('clean')
|
const businessState = ref<ArticleDraftBusinessState>('clean')
|
||||||
const savedAt = ref<Date | null>(null)
|
const savedAt = ref<Date | null>(null)
|
||||||
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
|
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
|
||||||
@@ -112,6 +136,10 @@ export function useArticleDraft(articleId: number) {
|
|||||||
return !sameIds(meta.value.authors, savedMeta.value.authors)
|
return !sameIds(meta.value.authors, savedMeta.value.authors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seoPatch() {
|
||||||
|
return seoChanges(seo.value, savedSeo.value)
|
||||||
|
}
|
||||||
|
|
||||||
function mirrorToStorage() {
|
function mirrorToStorage() {
|
||||||
if (bodyEdited.value) {
|
if (bodyEdited.value) {
|
||||||
writeStorage(draftStorageKey(articleId), body.value)
|
writeStorage(draftStorageKey(articleId), body.value)
|
||||||
@@ -125,22 +153,35 @@ export function useArticleDraft(articleId: number) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (Object.keys(seoPatch()).length > 0) {
|
||||||
|
writeStorage(seoStorageKey(articleId), JSON.stringify(seo.value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStorage() {
|
function clearStorage() {
|
||||||
removeStorage(draftStorageKey(articleId))
|
removeStorage(draftStorageKey(articleId))
|
||||||
removeStorage(metaStorageKey(articleId))
|
removeStorage(metaStorageKey(articleId))
|
||||||
|
removeStorage(seoStorageKey(articleId))
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
|
function load(
|
||||||
|
initialBody: string,
|
||||||
|
initialSavedAt: Date,
|
||||||
|
initialMeta?: ArticleMeta,
|
||||||
|
initialSeo?: ArticleSeo,
|
||||||
|
) {
|
||||||
const mirrored = readMirroredDraft(articleId)
|
const mirrored = readMirroredDraft(articleId)
|
||||||
const mirroredMeta = readMirroredMeta(articleId)
|
const mirroredMeta = readMirroredMeta(articleId)
|
||||||
savedAt.value = initialSavedAt
|
savedAt.value = initialSavedAt
|
||||||
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
||||||
meta.value = mirroredMeta ?? savedMeta.value
|
meta.value = mirroredMeta ?? savedMeta.value
|
||||||
|
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
|
||||||
|
const mirroredSeo = readMirroredSeo(articleId, savedSeo.value)
|
||||||
|
seo.value = mirroredSeo ?? savedSeo.value
|
||||||
bodyEdited.value = mirrored !== null
|
bodyEdited.value = mirrored !== null
|
||||||
body.value = mirrored ?? initialBody
|
body.value = mirrored ?? initialBody
|
||||||
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
|
const restored = mirrored !== null || mirroredMeta !== null || mirroredSeo !== null
|
||||||
|
businessState.value = restored ? 'dirty' : 'clean'
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(newBody: string) {
|
function edit(newBody: string) {
|
||||||
@@ -154,8 +195,24 @@ export function useArticleDraft(articleId: number) {
|
|||||||
businessState.value = 'dirty'
|
businessState.value = 'dirty'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editSeo(next: Partial<ArticleSeo>) {
|
||||||
|
const merged = { ...seo.value, ...next }
|
||||||
|
if (Object.keys(seoChanges(merged, seo.value)).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seo.value = merged
|
||||||
|
businessState.value = 'dirty'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (businessState.value === 'error') {
|
||||||
|
businessState.value = 'dirty'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function savePayload() {
|
function savePayload() {
|
||||||
return {
|
return {
|
||||||
|
...seoPatch(),
|
||||||
...(bodyEdited.value ? { body: body.value } : {}),
|
...(bodyEdited.value ? { body: body.value } : {}),
|
||||||
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
||||||
...(authorsChanged()
|
...(authorsChanged()
|
||||||
@@ -164,6 +221,14 @@ export function useArticleDraft(articleId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectedOutcome(err: unknown): SaveOutcome | null {
|
||||||
|
const code = codeOf(err)
|
||||||
|
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
||||||
|
return 'slug-taken'
|
||||||
|
}
|
||||||
|
return statusOf(err) === 422 && code === INVALID_URL_CODE ? 'invalid-url' : null
|
||||||
|
}
|
||||||
|
|
||||||
function goneOutcome(err: unknown): SaveOutcome | null {
|
function goneOutcome(err: unknown): SaveOutcome | null {
|
||||||
if (statusOf(err) !== 404) {
|
if (statusOf(err) !== 404) {
|
||||||
return null
|
return null
|
||||||
@@ -179,20 +244,28 @@ export function useArticleDraft(articleId: number) {
|
|||||||
businessState.value = 'saving'
|
businessState.value = 'saving'
|
||||||
const payload = savePayload()
|
const payload = savePayload()
|
||||||
try {
|
try {
|
||||||
if (Object.keys(payload).length > 0) {
|
const saved =
|
||||||
await request<Article>(`/v1/article/${articleId}?format=html`, {
|
Object.keys(payload).length > 0
|
||||||
method: 'PATCH',
|
? await request<Article>(`/v1/article/${articleId}?format=html`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
body: JSON.stringify(payload),
|
||||||
}
|
})
|
||||||
|
: undefined
|
||||||
businessState.value = 'saved'
|
businessState.value = 'saved'
|
||||||
savedAt.value = new Date()
|
savedAt.value = new Date()
|
||||||
savedMeta.value = meta.value
|
savedMeta.value = meta.value
|
||||||
|
savedSeo.value = seoOf(saved, seo.value)
|
||||||
|
seo.value = savedSeo.value
|
||||||
bodyEdited.value = false
|
bodyEdited.value = false
|
||||||
clearStorage()
|
clearStorage()
|
||||||
return 'saved'
|
return 'saved'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const rejected = rejectedOutcome(err)
|
||||||
|
if (rejected) {
|
||||||
|
businessState.value = 'dirty'
|
||||||
|
return rejected
|
||||||
|
}
|
||||||
const gone = goneOutcome(err)
|
const gone = goneOutcome(err)
|
||||||
if (gone) {
|
if (gone) {
|
||||||
businessState.value = 'error'
|
businessState.value = 'error'
|
||||||
@@ -212,5 +285,20 @@ export function useArticleDraft(articleId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { body, meta, state, savedAt, load, edit, editMeta, save }
|
return {
|
||||||
|
body,
|
||||||
|
meta,
|
||||||
|
seo,
|
||||||
|
savedSeo,
|
||||||
|
bodyEdited,
|
||||||
|
mirrorToStorage,
|
||||||
|
state,
|
||||||
|
savedAt,
|
||||||
|
load,
|
||||||
|
edit,
|
||||||
|
editMeta,
|
||||||
|
editSeo,
|
||||||
|
markDirty,
|
||||||
|
save,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { Byline } from '@/lib/articleTypes'
|
import type { Byline } from '@/lib/articleTypes'
|
||||||
import type { ArticleStatus } from '@/lib/articleListQuery'
|
import type { ArticleStatus } from '@/lib/articleListQuery'
|
||||||
|
|
||||||
export type { Byline }
|
export type { Byline }
|
||||||
|
|
||||||
export interface Article {
|
export interface Article extends Partial<ArticleSeo> {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { MediaItem, MediaListResponse } from '@/composables/useMedia'
|
||||||
|
|
||||||
|
const LOOKUP_QUERY = 'page=1&size=100&sort=newest'
|
||||||
|
|
||||||
|
export function useMediaNames() {
|
||||||
|
const { request } = useApi()
|
||||||
|
const items = ref<Record<number, MediaItem>>({})
|
||||||
|
|
||||||
|
function remember(item: MediaItem) {
|
||||||
|
items.value = { ...items.value, [item.id]: item }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(ids: (number | null | undefined)[]) {
|
||||||
|
const wanted = ids.filter((id): id is number => typeof id === 'number')
|
||||||
|
if (wanted.every((id) => id in items.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const page = await request<MediaListResponse>(`/v1/media/?${LOOKUP_QUERY}`)
|
||||||
|
const found = (page?.items ?? []).filter((item) => wanted.includes(item.id))
|
||||||
|
found.forEach(remember)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemOf(id: number | null | undefined) {
|
||||||
|
return typeof id === 'number' ? (items.value[id] ?? null) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { itemOf, remember, resolve }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
export type ResolvedSeoPhase = 'idle' | 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
export function useResolvedSeo(articleId: number) {
|
||||||
|
const { request } = useApi()
|
||||||
|
const phase = ref<ResolvedSeoPhase>('idle')
|
||||||
|
const resolved = ref<ResolvedSeo | null>(null)
|
||||||
|
let controller: AbortController | null = null
|
||||||
|
let ticket = 0
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
const current = ++ticket
|
||||||
|
phase.value = 'loading'
|
||||||
|
try {
|
||||||
|
const answer = await request<ResolvedSeo>(`/v1/article/${articleId}/seo`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (current === ticket) {
|
||||||
|
resolved.value = answer
|
||||||
|
phase.value = 'ready'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current === ticket) {
|
||||||
|
phase.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
controller?.abort()
|
||||||
|
ticket += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(stop)
|
||||||
|
|
||||||
|
return { phase, resolved, load, stop }
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { ApiError, useApi } from '@/composables/useApi'
|
||||||
|
import type { SeoCheckBody, SeoCheckResult } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
export const SEO_CHECK_DEBOUNCE_MS = 500
|
||||||
|
|
||||||
|
export type SeoCheckPhase = 'idle' | 'pending' | 'ready' | 'failed'
|
||||||
|
|
||||||
|
export function isChecking(phase: SeoCheckPhase) {
|
||||||
|
return phase === 'pending' || phase === 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoCheckOptions {
|
||||||
|
articleId: number
|
||||||
|
body: () => SeoCheckBody
|
||||||
|
onGone: () => void
|
||||||
|
onUnauthorized?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSeoCheck(options: SeoCheckOptions) {
|
||||||
|
const { request } = useApi()
|
||||||
|
const result = ref<SeoCheckResult | null>(null)
|
||||||
|
const phase = ref<SeoCheckPhase>('idle')
|
||||||
|
const checkedSlug = ref<string | undefined>(undefined)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let controller: AbortController | null = null
|
||||||
|
let ticket = 0
|
||||||
|
let lastPayload: string | null = null
|
||||||
|
const answers = new Map<string, { result: SeoCheckResult; slug: string | undefined }>()
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
clearTimer()
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null
|
||||||
|
run()
|
||||||
|
}, SEO_CHECK_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(body: SeoCheckBody, payload: string) {
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
const current = ++ticket
|
||||||
|
phase.value = 'pending'
|
||||||
|
try {
|
||||||
|
const answer = await request<SeoCheckResult>(
|
||||||
|
`/v1/article/${options.articleId}/seo/check`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (current !== ticket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.value = answer
|
||||||
|
checkedSlug.value = body.slug
|
||||||
|
phase.value = 'ready'
|
||||||
|
answers.set(payload, { result: answer, slug: body.slug })
|
||||||
|
} catch (err) {
|
||||||
|
if (current !== ticket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
options.onGone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
options.onUnauthorized?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phase.value = 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(force = false) {
|
||||||
|
clearTimer()
|
||||||
|
const body = options.body()
|
||||||
|
const payload = JSON.stringify(body)
|
||||||
|
if (!force && payload === lastPayload && phase.value !== 'failed') {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
lastPayload = payload
|
||||||
|
return send(body, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNow() {
|
||||||
|
return run(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFresh() {
|
||||||
|
return run(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
const payload = JSON.stringify(options.body())
|
||||||
|
const answer = answers.get(payload)
|
||||||
|
clearTimer()
|
||||||
|
controller?.abort()
|
||||||
|
ticket += 1
|
||||||
|
if (!answer) {
|
||||||
|
lastPayload = null
|
||||||
|
phase.value = result.value ? 'ready' : 'idle'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPayload = payload
|
||||||
|
result.value = answer.result
|
||||||
|
checkedSlug.value = answer.slug
|
||||||
|
phase.value = 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
answers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
clearTimer()
|
||||||
|
controller?.abort()
|
||||||
|
ticket += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(stop)
|
||||||
|
|
||||||
|
const status = computed(() => result.value?.status ?? null)
|
||||||
|
const suggestions = computed(() => result.value?.suggestions ?? [])
|
||||||
|
const slugVerdict = computed(() => result.value?.slug ?? null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
phase,
|
||||||
|
status,
|
||||||
|
suggestions,
|
||||||
|
slugVerdict,
|
||||||
|
checkedSlug,
|
||||||
|
schedule,
|
||||||
|
runNow,
|
||||||
|
runFresh,
|
||||||
|
restore,
|
||||||
|
invalidate,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ export interface Site {
|
|||||||
default_social_image_id: number | null
|
default_social_image_id: number | null
|
||||||
default_robots: SiteRobots
|
default_robots: SiteRobots
|
||||||
locale: string | null
|
locale: string | null
|
||||||
|
site_name?: string
|
||||||
|
title_template?: string
|
||||||
|
default_description?: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
article_count: number
|
article_count: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2003,6 +2003,135 @@ export const messages = {
|
|||||||
'Supprimer un auteur, en déplaçant ses articles avec reassign_to',
|
'Supprimer un auteur, en déplaçant ses articles avec reassign_to',
|
||||||
'Create your own author profile, as any verified user':
|
'Create your own author profile, as any verified user':
|
||||||
'Créer votre propre profil d’auteur, en tant qu’utilisateur vérifié',
|
'Créer votre propre profil d’auteur, en tant qu’utilisateur vérifié',
|
||||||
|
'Search and sharing': 'Recherche et partage',
|
||||||
|
'Hidden from search': 'Masqué des recherches',
|
||||||
|
'1 suggestion': '1 suggestion',
|
||||||
|
'{0} suggestions': '{0} suggestions',
|
||||||
|
Ready: 'Prêt',
|
||||||
|
Address: 'Adresse',
|
||||||
|
'Hidden from search engines. This only sets a flag in the API.':
|
||||||
|
'Masqué des moteurs de recherche. Cela ne fait que positionner un indicateur dans l’API.',
|
||||||
|
'Edit search and sharing': 'Modifier la recherche et le partage',
|
||||||
|
'What the frontend gets': 'Ce que reçoit le frontend',
|
||||||
|
'Checking this article…': 'Vérification de cet article…',
|
||||||
|
"Couldn't check this article.": 'Impossible de vérifier cet article.',
|
||||||
|
'All good': 'Tout est bon',
|
||||||
|
'Nothing to suggest for this article.': 'Rien à suggérer pour cet article.',
|
||||||
|
'+{0} more in Search and sharing': '+{0} de plus dans Recherche et partage',
|
||||||
|
Suggestions: 'Suggestions',
|
||||||
|
'The meta title is {0} characters. Search engines may cut it off after about {1}.':
|
||||||
|
'Le titre meta fait {0} caractères. Les moteurs de recherche peuvent le couper après environ {1}.',
|
||||||
|
'The meta title is empty.': 'Le titre meta est vide.',
|
||||||
|
'The description is {0} characters. About 70 to 160 reads best in results.':
|
||||||
|
'La description fait {0} caractères. Entre 70 et 160 environ se lit mieux dans les résultats.',
|
||||||
|
'The cover image has no alt text. Add a short description of it.':
|
||||||
|
'L’image de couverture n’a pas de texte alternatif. Ajoutez-en une courte description.',
|
||||||
|
'This article has no cover image.': 'Cet article n’a pas d’image de couverture.',
|
||||||
|
'The URL slug is {0} characters. Shorter addresses are easier to share.':
|
||||||
|
'Le slug de l’URL fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
|
||||||
|
'This article has no author.': 'Cet article n’a pas d’auteur.',
|
||||||
|
'The body has no headings. H2 headings help readers scan the article.':
|
||||||
|
'Le corps n’a pas de titres. Les titres H2 aident les lecteurs à parcourir l’article.',
|
||||||
|
'1 image in the body has no alt text.': '1 image du corps n’a pas de texte alternatif.',
|
||||||
|
'{0} images in the body have no alt text.':
|
||||||
|
'{0} images du corps n’ont pas de texte alternatif.',
|
||||||
|
'The body is {0} characters. Longer articles tend to do better in search.':
|
||||||
|
'Le corps fait {0} caractères. Les articles plus longs ont tendance à mieux se classer.',
|
||||||
|
'Go to meta title': 'Aller au titre meta',
|
||||||
|
'Go to meta description': 'Aller à la description meta',
|
||||||
|
'Go to cover image': 'Aller à l’image de couverture',
|
||||||
|
'Go to URL slug': 'Aller au slug de l’URL',
|
||||||
|
'Go to authors': 'Aller aux auteurs',
|
||||||
|
'Go to body': 'Aller au corps',
|
||||||
|
'URL slug': 'Slug de l’URL',
|
||||||
|
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||||
|
'Généré à partir du titre tant que vous ne le modifiez pas. Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||||
|
'Lowercase letters, numbers and hyphens. Unique on {0}.':
|
||||||
|
'Lettres minuscules, chiffres et tirets. Unique sur {0}.',
|
||||||
|
'Reset to match the title': 'Rétablir selon le titre',
|
||||||
|
'Generated from the title': 'Généré à partir du titre',
|
||||||
|
'The slug "{0}" is already used on {1}.': 'Le slug « {0} » est déjà utilisé sur {1}.',
|
||||||
|
'Meta title': 'Titre meta',
|
||||||
|
'Recommended 30 to 60 characters. This is a guide, not a limit.':
|
||||||
|
'30 à 60 caractères recommandés. C’est un repère, pas une limite.',
|
||||||
|
'Using article title': 'Titre de l’article utilisé',
|
||||||
|
'Meta description': 'Description meta',
|
||||||
|
'Recommended 70 to 160 characters.': '70 à 160 caractères recommandés.',
|
||||||
|
'Using first sentence of the body': 'Première phrase du corps utilisée',
|
||||||
|
'Using the excerpt': 'Extrait utilisé',
|
||||||
|
'Using the site default description': 'Description par défaut du site utilisée',
|
||||||
|
Excerpt: 'Extrait',
|
||||||
|
'A short summary. It is also sent as the article teaser.':
|
||||||
|
'Un court résumé. Il est aussi envoyé comme accroche de l’article.',
|
||||||
|
'Used as the description when there is none, and as the teaser for the frontend.':
|
||||||
|
'Utilisé comme description quand il n’y en a pas, et comme accroche pour le frontend.',
|
||||||
|
'Shorten the excerpt to 300 characters or fewer.':
|
||||||
|
'Raccourcissez l’extrait à 300 caractères ou moins.',
|
||||||
|
'Cover image': 'Image de couverture',
|
||||||
|
'Choose a cover image': 'Choisir une image de couverture',
|
||||||
|
'The cover for this article.': 'La couverture de cet article.',
|
||||||
|
'Using the site social image ({0}).': 'Image de partage du site utilisée ({0}).',
|
||||||
|
'Using the site social image.': 'Image de partage du site utilisée.',
|
||||||
|
'Alt text': 'Texte alternatif',
|
||||||
|
"Describe the image for people who can't see it":
|
||||||
|
'Décrivez l’image pour les personnes qui ne la voient pas',
|
||||||
|
'Needed before the cover counts as ready. Sent as image alt in the API.':
|
||||||
|
'Nécessaire avant que la couverture soit prête. Envoyé comme texte alternatif de l’image dans l’API.',
|
||||||
|
'Social sharing': 'Partage social',
|
||||||
|
Optional: 'Facultatif',
|
||||||
|
'Uses the meta values': 'Utilise les valeurs meta',
|
||||||
|
Customised: 'Personnalisé',
|
||||||
|
'Social title': 'Titre social',
|
||||||
|
'Using meta title': 'Titre meta utilisé',
|
||||||
|
'Social description': 'Description sociale',
|
||||||
|
'Using meta description': 'Description meta utilisée',
|
||||||
|
'Social image': 'Image sociale',
|
||||||
|
'Choose a social image': 'Choisir une image sociale',
|
||||||
|
'The image shown when this article is shared.':
|
||||||
|
'L’image affichée quand cet article est partagé.',
|
||||||
|
'Using the cover image.': 'Image de couverture utilisée.',
|
||||||
|
Advanced: 'Avancé',
|
||||||
|
'Canonical URL': 'URL canonique',
|
||||||
|
'Canonical URL: automatic': 'URL canonique : automatique',
|
||||||
|
'Canonical URL set': 'URL canonique définie',
|
||||||
|
'Using site address and slug': 'Adresse du site et slug utilisés',
|
||||||
|
'Only change this if the same content lives at another address.':
|
||||||
|
'Ne modifiez ceci que si le même contenu existe à une autre adresse.',
|
||||||
|
'Enter a full address starting with https://':
|
||||||
|
'Saisissez une adresse complète commençant par https://',
|
||||||
|
'Hide from search engines': 'Masquer des moteurs de recherche',
|
||||||
|
"Sets noindex in the API. It doesn't change who can read the article.":
|
||||||
|
'Positionne noindex dans l’API. Cela ne change pas qui peut lire l’article.',
|
||||||
|
'Search result': 'Résultat de recherche',
|
||||||
|
Desktop: 'Ordinateur',
|
||||||
|
Mobile: 'Mobile',
|
||||||
|
'Cut off here, the way a search engine would.':
|
||||||
|
'Coupé ici, comme le ferait un moteur de recherche.',
|
||||||
|
'Social card': 'Carte sociale',
|
||||||
|
'Using cover image': 'Image de couverture utilisée',
|
||||||
|
'Using site social image': 'Image de partage du site utilisée',
|
||||||
|
'No image': 'Aucune image',
|
||||||
|
Formatted: 'Formaté',
|
||||||
|
'Raw JSON': 'JSON brut',
|
||||||
|
'Copy JSON': 'Copier le JSON',
|
||||||
|
"The SEO data returned by {0}, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.":
|
||||||
|
'Les données SEO renvoyées par {0}, en lecture seule. RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta : c’est votre frontend qui le fait.',
|
||||||
|
'This article is a draft. The API returns this data only to signed-in callers until it is published.':
|
||||||
|
'Cet article est un brouillon. L’API ne renvoie ces données qu’aux appelants connectés tant qu’il n’est pas publié.',
|
||||||
|
"Couldn't load the SEO data": 'Impossible de charger les données SEO',
|
||||||
|
'{0} failed. Your article is unchanged. Try again in a moment.':
|
||||||
|
'{0} a échoué. Votre article est inchangé. Réessayez dans un instant.',
|
||||||
|
'1 social link': '1 lien social',
|
||||||
|
'{0} social links': '{0} liens sociaux',
|
||||||
|
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.":
|
||||||
|
'Impossible d’enregistrer la recherche et le partage. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||||
|
'Search and sharing saved': 'Recherche et partage enregistrés',
|
||||||
|
'Your search and sharing settings are up to date.':
|
||||||
|
'Vos réglages de recherche et de partage sont à jour.',
|
||||||
|
'Suggestions never block saving or publishing.':
|
||||||
|
'Les suggestions ne bloquent jamais l’enregistrement ni la publication.',
|
||||||
|
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.":
|
||||||
|
'Décrit l’image pour les personnes qui utilisent un lecteur d’écran, et s’affiche si elle ne charge pas. Laissez vide si l’image est purement décorative.',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages
|
export type MessageKey = keyof typeof messages
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { initializeLocale, setLocale, t } from './index'
|
||||||
|
import { messages } from './messages'
|
||||||
|
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||||
|
import { apiPreviewRows } from '@/lib/seoApiPreview'
|
||||||
|
import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
'components/ApiPreviewDialog.vue',
|
||||||
|
'components/FallbackChip.vue',
|
||||||
|
'components/SearchPreview.vue',
|
||||||
|
'components/SearchSharingSection.vue',
|
||||||
|
'components/SeoDisclosure.vue',
|
||||||
|
'components/SeoPanel.vue',
|
||||||
|
'components/SocialCardPreview.vue',
|
||||||
|
'components/SoftCounter.vue',
|
||||||
|
'components/SuggestionList.vue',
|
||||||
|
'lib/seoApiPreview.ts',
|
||||||
|
'lib/seoCopy.ts',
|
||||||
|
'lib/seoPreview.ts',
|
||||||
|
'views/EditorView.vue',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SAME_IN_FRENCH = new Set([
|
||||||
|
'Articles',
|
||||||
|
'1 suggestion',
|
||||||
|
'{0} suggestions',
|
||||||
|
'Suggestions',
|
||||||
|
'Mobile',
|
||||||
|
])
|
||||||
|
|
||||||
|
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||||
|
|
||||||
|
function keysIn(source: string) {
|
||||||
|
return [...source.matchAll(LITERAL)].map((match) =>
|
||||||
|
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = SOURCES.flatMap((file) =>
|
||||||
|
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
initializeLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
initializeLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search and sharing: every string has a French entry', () => {
|
||||||
|
it('finds the strings of the section, the panel, the dialog and the previews', () => {
|
||||||
|
expect(keys.length).toBeGreaterThan(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||||
|
'%s: "%s" has its own French entry',
|
||||||
|
(_file, key) => {
|
||||||
|
const french = (messages as Record<string, string>)[key as string]
|
||||||
|
expect(french).toMatch(/\S/)
|
||||||
|
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||||
|
expect(french).not.toBe(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search and sharing in French', () => {
|
||||||
|
const codes: SuggestionCode[] = [
|
||||||
|
'title_long',
|
||||||
|
'title_empty',
|
||||||
|
'desc_short',
|
||||||
|
'desc_long',
|
||||||
|
'cover_alt',
|
||||||
|
'cover_none',
|
||||||
|
'slug_long',
|
||||||
|
'no_au',
|
||||||
|
'no_h',
|
||||||
|
'body_alt',
|
||||||
|
'short',
|
||||||
|
]
|
||||||
|
|
||||||
|
function suggestionOf(code: SuggestionCode): SeoSuggestion {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
field: 'body',
|
||||||
|
params: { length: 74, max: 60, min: 70, count: 2 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('translates the messages of the 11 suggestion codes', () => {
|
||||||
|
const english = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||||
|
setLocale('fr')
|
||||||
|
const french = codes.map((code) => suggestionMessage(suggestionOf(code)))
|
||||||
|
|
||||||
|
french.forEach((message, index) => {
|
||||||
|
expect(message).not.toBe(english[index])
|
||||||
|
expect(message).not.toContain('{0}')
|
||||||
|
})
|
||||||
|
expect(french[0]).toBe(
|
||||||
|
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the singular and the plural body_alt message', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 1 } })).toBe(
|
||||||
|
'1 image du corps n’a pas de texte alternatif.',
|
||||||
|
)
|
||||||
|
expect(suggestionMessage({ code: 'body_alt', field: 'body', params: { count: 2 } })).toBe(
|
||||||
|
'2 images du corps n’ont pas de texte alternatif.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the Go to labels of every field', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
'seo_title',
|
||||||
|
'seo_description',
|
||||||
|
'cover_image_alt',
|
||||||
|
'cover_image_id',
|
||||||
|
'slug',
|
||||||
|
'author_ids',
|
||||||
|
'body',
|
||||||
|
] as const
|
||||||
|
).map(suggestionLinkLabel),
|
||||||
|
).toEqual([
|
||||||
|
'Aller au titre meta',
|
||||||
|
'Aller à la description meta',
|
||||||
|
'Aller à l’image de couverture',
|
||||||
|
'Aller à l’image de couverture',
|
||||||
|
'Aller au slug de l’URL',
|
||||||
|
'Aller aux auteurs',
|
||||||
|
'Aller au corps',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the chips and the dates of the dialog', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
const resolved = {
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
canonical_url: 'https://renew.example/t',
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: { type: 'article', image: 'u', image_alt: 'a' },
|
||||||
|
twitter: { card: 'summary' },
|
||||||
|
authors: [{ name: 'Inès', url: '', same_as: ['a', 'b'] }],
|
||||||
|
published_at: '2026-03-12T09:14:00Z',
|
||||||
|
modified_at: '2026-10-15T11:02:00Z',
|
||||||
|
fallbacks: { title: 'title', description: 'excerpt', image: 'site', canonical: 'site' },
|
||||||
|
json_ld: { '@type': 'Article' },
|
||||||
|
} as unknown as ResolvedSeo
|
||||||
|
|
||||||
|
const rows = apiPreviewRows(resolved)
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.chip)).toEqual([
|
||||||
|
'Titre de l’article utilisé',
|
||||||
|
'Extrait utilisé',
|
||||||
|
'Adresse du site et slug utilisés',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'Image de partage du site utilisée',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
])
|
||||||
|
expect(rows.find((row) => row.key === 'authors')!.value).toBe('Inès · 2 liens sociaux')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the section strings', () => {
|
||||||
|
setLocale('fr')
|
||||||
|
|
||||||
|
expect(t('Search and sharing')).toBe('Recherche et partage')
|
||||||
|
expect(t('Edit search and sharing')).toBe('Modifier la recherche et le partage')
|
||||||
|
expect(t('What the frontend gets')).toBe('Ce que reçoit le frontend')
|
||||||
|
expect(t('Search and sharing saved')).toBe('Recherche et partage enregistrés')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isChecking } from '@/composables/useSeoCheck'
|
||||||
|
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||||
|
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
function suggestion(code: string, params: Record<string, number> = {}): SeoSuggestion {
|
||||||
|
return { code: code as SuggestionCode, field: 'body', params }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('suggestionMessage', () => {
|
||||||
|
it('uses the same wording for short and long descriptions with the length filled in', () => {
|
||||||
|
const short = suggestionMessage(suggestion('desc_short', { length: 12 }))
|
||||||
|
const long = suggestionMessage(suggestion('desc_long', { length: 200 }))
|
||||||
|
|
||||||
|
expect(short).toContain('12')
|
||||||
|
expect(long).toContain('200')
|
||||||
|
expect(short.replace('12', '#')).toBe(long.replace('200', '#'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between the singular and plural body image wording', () => {
|
||||||
|
const one = suggestionMessage(suggestion('body_alt', { count: 1 }))
|
||||||
|
const many = suggestionMessage(suggestion('body_alt', { count: 3 }))
|
||||||
|
|
||||||
|
expect(one).not.toBe(many)
|
||||||
|
expect(many).toContain('3')
|
||||||
|
expect(one).not.toContain('1 images')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the raw code for an unknown code', () => {
|
||||||
|
expect(suggestionMessage(suggestion('brand_new'))).toBe('brand_new')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('suggestionLinkLabel', () => {
|
||||||
|
it('returns an empty label for an unknown field', () => {
|
||||||
|
expect(suggestionLinkLabel('nope' as never)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isChecking', () => {
|
||||||
|
it.each([
|
||||||
|
['idle', true],
|
||||||
|
['pending', true],
|
||||||
|
['ready', false],
|
||||||
|
['failed', false],
|
||||||
|
] as const)('%s is %s', (phase, expected) => {
|
||||||
|
expect(isChecking(phase)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { EMPTY_SEO } from '@/lib/articleSeo'
|
||||||
|
import {
|
||||||
|
applyTitleTemplate,
|
||||||
|
bodyText,
|
||||||
|
firstSentence,
|
||||||
|
previewTitle,
|
||||||
|
siteDisplayName,
|
||||||
|
socialTitle,
|
||||||
|
} from '@/lib/seoPreview'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
|
const site = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Renew',
|
||||||
|
key: 'renew',
|
||||||
|
base_url: 'https://renew.example',
|
||||||
|
default_social_image_id: null,
|
||||||
|
default_robots: 'index',
|
||||||
|
locale: null,
|
||||||
|
site_name: '',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
is_default: true,
|
||||||
|
article_count: 1,
|
||||||
|
} as Site
|
||||||
|
|
||||||
|
describe('applyTitleTemplate', () => {
|
||||||
|
it('replaces every occurrence in a single pass', () => {
|
||||||
|
expect(applyTitleTemplate('%site%: %title% (%title%)', 'A', 'S')).toBe('S: A (A)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expand replacement patterns or substitute tokens inside the title', () => {
|
||||||
|
expect(applyTitleTemplate('%title% | %site%', "Cost $& $' $` %site%", 'Renew')).toBe(
|
||||||
|
"Cost $& $' $` %site% | Renew",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default template and trims', () => {
|
||||||
|
expect(applyTitleTemplate('', 'A', '')).toBe('A |')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bodyText', () => {
|
||||||
|
it('separates block elements with a space', () => {
|
||||||
|
expect(bodyText('<p>One.</p><p>Two</p>')).toBe('One. Two')
|
||||||
|
expect(firstSentence('<p>One.</p><p>Two</p>')).toBe('One.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips script and style text', () => {
|
||||||
|
expect(bodyText('<style>p{}</style><p>Kept</p><script>alert(1)</script>')).toBe('Kept')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not run markup', () => {
|
||||||
|
const img = '<img src=x onerror="document.body.dataset.ran=1">'
|
||||||
|
bodyText(img)
|
||||||
|
expect(document.body.dataset.ran).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('titles', () => {
|
||||||
|
const input = { seo: { ...EMPTY_SEO }, title: 'Article', body: '', site }
|
||||||
|
|
||||||
|
it('uses the site name, then the site key name, for the template', () => {
|
||||||
|
expect(siteDisplayName(site)).toBe('Renew')
|
||||||
|
expect(previewTitle(input)).toBe('Article | Renew')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the templated title for the social title', () => {
|
||||||
|
expect(socialTitle(input)).toBe('Article | Renew')
|
||||||
|
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, seo_title: 'Meta' } })).toBe(
|
||||||
|
'Meta | Renew',
|
||||||
|
)
|
||||||
|
expect(socialTitle({ ...input, seo: { ...EMPTY_SEO, social_title: 'Own' } })).toBe('Own')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
export const SEO_KEYS = [
|
||||||
|
'slug',
|
||||||
|
'excerpt',
|
||||||
|
'seo_title',
|
||||||
|
'seo_description',
|
||||||
|
'cover_image_id',
|
||||||
|
'cover_image_alt',
|
||||||
|
'social_title',
|
||||||
|
'social_description',
|
||||||
|
'social_image_id',
|
||||||
|
'canonical_url',
|
||||||
|
'noindex',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SeoKey = (typeof SEO_KEYS)[number]
|
||||||
|
|
||||||
|
export interface ArticleSeo {
|
||||||
|
slug: string
|
||||||
|
excerpt: string
|
||||||
|
seo_title: string
|
||||||
|
seo_description: string
|
||||||
|
cover_image_id: number | null
|
||||||
|
cover_image_alt: string
|
||||||
|
social_title: string
|
||||||
|
social_description: string
|
||||||
|
social_image_id: number | null
|
||||||
|
canonical_url: string
|
||||||
|
noindex: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeoPatch = Partial<ArticleSeo>
|
||||||
|
|
||||||
|
export const EMPTY_SEO: ArticleSeo = {
|
||||||
|
slug: '',
|
||||||
|
excerpt: '',
|
||||||
|
seo_title: '',
|
||||||
|
seo_description: '',
|
||||||
|
cover_image_id: null,
|
||||||
|
cover_image_alt: '',
|
||||||
|
social_title: '',
|
||||||
|
social_description: '',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: '',
|
||||||
|
noindex: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEO_LIMITS = {
|
||||||
|
slug: 100,
|
||||||
|
excerpt: 300,
|
||||||
|
seo_title: 120,
|
||||||
|
seo_description: 320,
|
||||||
|
cover_image_alt: 200,
|
||||||
|
social_title: 120,
|
||||||
|
social_description: 320,
|
||||||
|
canonical_url: 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_SLUG_RUN = /[^a-z0-9]+/g
|
||||||
|
const EDGE_DASHES = /^-+|-+$/g
|
||||||
|
|
||||||
|
const IMAGE_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
|
||||||
|
|
||||||
|
function hasExpectedType(key: SeoKey, value: unknown) {
|
||||||
|
if (IMAGE_KEYS.includes(key)) {
|
||||||
|
return value === null || typeof value === 'number'
|
||||||
|
}
|
||||||
|
return typeof value === (key === 'noindex' ? 'boolean' : 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoOf(source: object | null | undefined, base: ArticleSeo = EMPTY_SEO): ArticleSeo {
|
||||||
|
const known = (source ?? {}) as Record<string, unknown>
|
||||||
|
const next: Record<string, unknown> = { ...base }
|
||||||
|
for (const key of SEO_KEYS) {
|
||||||
|
if (key in known && hasExpectedType(key, known[key])) {
|
||||||
|
next[key] = known[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next as unknown as ArticleSeo
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoChanges(current: ArticleSeo, saved: ArticleSeo): SeoPatch {
|
||||||
|
const changes: Record<string, unknown> = {}
|
||||||
|
for (const key of SEO_KEYS) {
|
||||||
|
if (current[key] !== saved[key]) {
|
||||||
|
changes[key] = current[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes as SeoPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSeo(left: ArticleSeo, right: ArticleSeo) {
|
||||||
|
return Object.keys(seoChanges(left, right)).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function charCount(text: string) {
|
||||||
|
return [...text].length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlug(raw: string) {
|
||||||
|
return raw.toLowerCase().replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typedSlug(raw: string) {
|
||||||
|
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAbsoluteHttpsUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
return url.protocol === 'https:' && url.hostname !== ''
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalIsValid(value: string) {
|
||||||
|
return value.trim() === '' || isAbsoluteHttpsUrl(value.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function booleanMemory(key: string) {
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw === 'true' ? true : raw === 'false' ? false : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function store(open: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, String(open))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { read, store }
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ export interface ImageChoice {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
hint?: string
|
hint?: string
|
||||||
|
square?: boolean
|
||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
|
import { booleanMemory } from '@/lib/booleanMemory'
|
||||||
|
|
||||||
export const META_SECTION_OPEN_KEY = 'renewcms-meta-section-open'
|
export const META_SECTION_OPEN_KEY = 'renewcms-meta-section-open'
|
||||||
const HINT_DISMISSED_PREFIX = 'renewcms-author-hint-dismissed'
|
const HINT_DISMISSED_PREFIX = 'renewcms-author-hint-dismissed'
|
||||||
|
|
||||||
export function readMetaSectionOpen() {
|
const memory = booleanMemory(META_SECTION_OPEN_KEY)
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(META_SECTION_OPEN_KEY)
|
|
||||||
return raw === 'true' ? true : raw === 'false' ? false : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeMetaSectionOpen(open: boolean) {
|
export const readMetaSectionOpen = memory.read
|
||||||
try {
|
export const storeMetaSectionOpen = memory.store
|
||||||
localStorage.setItem(META_SECTION_OPEN_KEY, String(open))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hintDismissedKey(userId: number | undefined) {
|
export function hintDismissedKey(userId: number | undefined) {
|
||||||
return `${HINT_DISMISSED_PREFIX}:${userId ?? 'anonymous'}`
|
return `${HINT_DISMISSED_PREFIX}:${userId ?? 'anonymous'}`
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ export function segmentClass(selected: boolean, block = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
|
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
|
||||||
|
|
||||||
|
export const SEGMENT_TAB_ACTIVE =
|
||||||
|
'data-[state=active]:bg-secondary-200 data-[state=active]:font-medium data-[state=active]:text-primary-900 dark:data-[state=active]:bg-secondary-300'
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { t, locale } from '@/i18n'
|
||||||
|
import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy'
|
||||||
|
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||||
|
import type { ResolvedSeo } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
]
|
||||||
|
const EMPTY_VALUE = '–'
|
||||||
|
|
||||||
|
export interface ApiPreviewRow {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
chip: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value: number) {
|
||||||
|
return value.toString().padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatApiDate(iso: string | null) {
|
||||||
|
if (!iso) {
|
||||||
|
return EMPTY_VALUE
|
||||||
|
}
|
||||||
|
const date = new Date(iso)
|
||||||
|
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
return locale.value === 'fr'
|
||||||
|
? `${formatAbsoluteDateFromIso(iso)}, ${clock}`
|
||||||
|
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleChip(source: string) {
|
||||||
|
return source === 'title' ? chipLabel('article_title') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageChip(source: string) {
|
||||||
|
const keys: Record<string, ChipKey> = {
|
||||||
|
cover: 'cover_image',
|
||||||
|
site: 'site_image',
|
||||||
|
none: 'no_image',
|
||||||
|
}
|
||||||
|
const key = keys[source]
|
||||||
|
return key ? chipLabel(key) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalChip(source: string) {
|
||||||
|
return source === 'site' ? chipLabel('canonical') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorLine(author: ResolvedSeo['authors'][number]) {
|
||||||
|
const links = author.same_as.length
|
||||||
|
return [
|
||||||
|
author.name,
|
||||||
|
author.url ? author.url.replace(/^https?:\/\//, '') : '',
|
||||||
|
links === 0 ? '' : links === 1 ? t('1 social link') : t('{0} social links', [links]),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonLdSummary(jsonLd: Record<string, unknown>) {
|
||||||
|
const fields = Object.keys(jsonLd).filter((key) => !key.startsWith('@'))
|
||||||
|
return [String(jsonLd['@type'] ?? ''), fields.join(', ')].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] {
|
||||||
|
const { fallbacks } = resolved
|
||||||
|
return [
|
||||||
|
{ key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) },
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
value: resolved.description,
|
||||||
|
chip: descriptionChip(fallbacks.description),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'canonical_url',
|
||||||
|
value: resolved.canonical_url,
|
||||||
|
chip: canonicalChip(fallbacks.canonical),
|
||||||
|
},
|
||||||
|
{ key: 'robots', value: resolved.robots, chip: null },
|
||||||
|
{ key: 'og.type', value: resolved.og.type, chip: null },
|
||||||
|
{ key: 'og.image', value: resolved.og.image, chip: imageChip(fallbacks.image) },
|
||||||
|
{ key: 'og.image_alt', value: resolved.og.image_alt, chip: null },
|
||||||
|
{ key: 'twitter.card', value: resolved.twitter.card, chip: null },
|
||||||
|
{
|
||||||
|
key: 'authors',
|
||||||
|
value: resolved.authors.map(authorLine).join('; ') || EMPTY_VALUE,
|
||||||
|
chip: null,
|
||||||
|
},
|
||||||
|
{ key: 'published_at', value: formatApiDate(resolved.published_at), chip: null },
|
||||||
|
{ key: 'modified_at', value: formatApiDate(resolved.modified_at), chip: null },
|
||||||
|
{ key: 'json_ld', value: jsonLdSummary(resolved.json_ld), chip: null },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { DescriptionSource } from '@/lib/seoPreview'
|
||||||
|
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
|
function descriptionLength(params: Record<string, number>) {
|
||||||
|
return t('The description is {0} characters. About 70 to 160 reads best in results.', [
|
||||||
|
params.length,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<SuggestionCode, (params: Record<string, number>) => string> = {
|
||||||
|
title_long: (params) =>
|
||||||
|
t('The meta title is {0} characters. Search engines may cut it off after about {1}.', [
|
||||||
|
params.length,
|
||||||
|
params.max,
|
||||||
|
]),
|
||||||
|
title_empty: () => t('The meta title is empty.'),
|
||||||
|
desc_short: descriptionLength,
|
||||||
|
desc_long: descriptionLength,
|
||||||
|
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
|
||||||
|
cover_none: () => t('This article has no cover image.'),
|
||||||
|
slug_long: (params) =>
|
||||||
|
t('The URL slug is {0} characters. Shorter addresses are easier to share.', [
|
||||||
|
params.length,
|
||||||
|
]),
|
||||||
|
no_au: () => t('This article has no author.'),
|
||||||
|
no_h: () => t('The body has no headings. H2 headings help readers scan the article.'),
|
||||||
|
body_alt: (params) =>
|
||||||
|
params.count === 1
|
||||||
|
? t('1 image in the body has no alt text.')
|
||||||
|
: t('{0} images in the body have no alt text.', [params.count]),
|
||||||
|
short: (params) =>
|
||||||
|
t('The body is {0} characters. Longer articles tend to do better in search.', [
|
||||||
|
params.length,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_LABELS: Record<SuggestionField, () => string> = {
|
||||||
|
seo_title: () => t('Go to meta title'),
|
||||||
|
seo_description: () => t('Go to meta description'),
|
||||||
|
cover_image_alt: () => t('Go to cover image'),
|
||||||
|
cover_image_id: () => t('Go to cover image'),
|
||||||
|
slug: () => t('Go to URL slug'),
|
||||||
|
author_ids: () => t('Go to authors'),
|
||||||
|
body: () => t('Go to body'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestionMessage(suggestion: SeoSuggestion) {
|
||||||
|
const message = MESSAGES[suggestion.code]
|
||||||
|
return message ? message(suggestion.params ?? {}) : suggestion.code
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestionLinkLabel(field: SuggestionField) {
|
||||||
|
const label = LINK_LABELS[field]
|
||||||
|
return label ? label() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIPS = {
|
||||||
|
article_title: () => t('Using article title'),
|
||||||
|
body: () => t('Using first sentence of the body'),
|
||||||
|
excerpt: () => t('Using the excerpt'),
|
||||||
|
site_description: () => t('Using the site default description'),
|
||||||
|
cover_image: () => t('Using cover image'),
|
||||||
|
site_image: () => t('Using site social image'),
|
||||||
|
no_image: () => t('No image'),
|
||||||
|
canonical: () => t('Using site address and slug'),
|
||||||
|
meta_title: () => t('Using meta title'),
|
||||||
|
meta_description: () => t('Using meta description'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChipKey = keyof typeof CHIPS
|
||||||
|
|
||||||
|
export function chipLabel(key: ChipKey) {
|
||||||
|
return CHIPS[key]()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptionChip(source: DescriptionSource | string) {
|
||||||
|
const keys: Record<string, ChipKey> = {
|
||||||
|
excerpt: 'excerpt',
|
||||||
|
body: 'body',
|
||||||
|
site: 'site_description',
|
||||||
|
}
|
||||||
|
const key = keys[source]
|
||||||
|
return key ? chipLabel(key) : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { charCount } from '@/lib/articleSeo'
|
||||||
|
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
|
export type PreviewDevice = 'desktop' | 'mobile'
|
||||||
|
|
||||||
|
export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description: number }> = {
|
||||||
|
desktop: { title: 60, description: 160 },
|
||||||
|
mobile: { title: 70, description: 120 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TITLE_RANGE = { min: 30, max: 60 }
|
||||||
|
export const DESCRIPTION_RANGE = { min: 70, max: 160 }
|
||||||
|
export const SOCIAL_TITLE_RANGE = { min: 0, max: 90 }
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '%title% | %site%'
|
||||||
|
const SENTENCE_END = /^(.+?[.!?])(\s|$)/s
|
||||||
|
|
||||||
|
export type TitleSource = 'seo_title' | 'title'
|
||||||
|
export type DescriptionSource = 'seo_description' | 'excerpt' | 'body' | 'site' | 'none'
|
||||||
|
|
||||||
|
export function truncateText(text: string, max: number) {
|
||||||
|
const characters = [...text]
|
||||||
|
return characters.length > max
|
||||||
|
? `${characters
|
||||||
|
.slice(0, max - 1)
|
||||||
|
.join('')
|
||||||
|
.trimEnd()}…`
|
||||||
|
: text
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_BOUNDARY = /<\/?(p|div|h[1-6]|li|ul|ol|blockquote|pre|tr|br|hr)\b[^>]*>/gi
|
||||||
|
const SKIPPED_ELEMENTS = 'script, style'
|
||||||
|
|
||||||
|
export function bodyText(html: string) {
|
||||||
|
const parsed = new DOMParser().parseFromString(
|
||||||
|
html.replace(BLOCK_BOUNDARY, ' $& '),
|
||||||
|
'text/html',
|
||||||
|
)
|
||||||
|
parsed.querySelectorAll(SKIPPED_ELEMENTS).forEach((element) => element.remove())
|
||||||
|
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstSentence(html: string) {
|
||||||
|
const text = bodyText(html)
|
||||||
|
const match = SENTENCE_END.exec(text)
|
||||||
|
return match ? match[1]! : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_TOKEN = /%title%|%site%/g
|
||||||
|
|
||||||
|
export function applyTitleTemplate(template: string, title: string, siteName: string) {
|
||||||
|
return (template || DEFAULT_TEMPLATE)
|
||||||
|
.replace(TEMPLATE_TOKEN, (token) => (token === '%title%' ? title : siteName))
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteDisplayName(site: Site | undefined) {
|
||||||
|
return site?.site_name || site?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoPreviewInput {
|
||||||
|
seo: ArticleSeo
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
site: Site | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewTitleSource(input: SeoPreviewInput): TitleSource {
|
||||||
|
return input.seo.seo_title.trim() ? 'seo_title' : 'title'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewTitle(input: SeoPreviewInput) {
|
||||||
|
const base = input.seo.seo_title.trim() || input.title
|
||||||
|
return applyTitleTemplate(input.site?.title_template ?? '', base, siteDisplayName(input.site))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewDescriptionSource(input: SeoPreviewInput): DescriptionSource {
|
||||||
|
if (input.seo.seo_description.trim()) {
|
||||||
|
return 'seo_description'
|
||||||
|
}
|
||||||
|
if (input.seo.excerpt.trim()) {
|
||||||
|
return 'excerpt'
|
||||||
|
}
|
||||||
|
if (firstSentence(input.body)) {
|
||||||
|
return 'body'
|
||||||
|
}
|
||||||
|
return input.site?.default_description ? 'site' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewDescription(input: SeoPreviewInput) {
|
||||||
|
const sources: Record<DescriptionSource, string> = {
|
||||||
|
seo_description: input.seo.seo_description.trim(),
|
||||||
|
excerpt: input.seo.excerpt.trim(),
|
||||||
|
body: firstSentence(input.body),
|
||||||
|
site: input.site?.default_description ?? '',
|
||||||
|
none: '',
|
||||||
|
}
|
||||||
|
return sources[previewDescriptionSource(input)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleIsCut(title: string, device: PreviewDevice) {
|
||||||
|
return charCount(title) > PREVIEW_LIMITS[device].title
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socialTitle(input: SeoPreviewInput) {
|
||||||
|
return input.seo.social_title.trim() || previewTitle(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socialDescription(input: SeoPreviewInput) {
|
||||||
|
return input.seo.social_description.trim() || previewDescription(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSocialOverrides(seo: ArticleSeo) {
|
||||||
|
return Boolean(seo.social_title || seo.social_description || seo.social_image_id !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialImageSource = 'social' | 'cover' | 'site' | 'none'
|
||||||
|
|
||||||
|
export function socialImageSource(seo: ArticleSeo, siteHasImage: boolean): SocialImageSource {
|
||||||
|
if (seo.social_image_id !== null) {
|
||||||
|
return 'social'
|
||||||
|
}
|
||||||
|
if (seo.cover_image_id !== null) {
|
||||||
|
return 'cover'
|
||||||
|
}
|
||||||
|
return siteHasImage ? 'site' : 'none'
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { booleanMemory } from '@/lib/booleanMemory'
|
||||||
|
|
||||||
|
export const SEO_SECTION_OPEN_KEY = 'renewcms-seo-section-open'
|
||||||
|
|
||||||
|
const memory = booleanMemory(SEO_SECTION_OPEN_KEY)
|
||||||
|
|
||||||
|
export const readSeoSectionOpen = memory.read
|
||||||
|
export const storeSeoSectionOpen = memory.store
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { SeoPatch } from '@/lib/articleSeo'
|
||||||
|
|
||||||
|
export type SeoCheckStatus = 'ready' | 'suggestions' | 'hidden'
|
||||||
|
|
||||||
|
export type SuggestionCode =
|
||||||
|
| 'title_long'
|
||||||
|
| 'title_empty'
|
||||||
|
| 'desc_short'
|
||||||
|
| 'desc_long'
|
||||||
|
| 'cover_alt'
|
||||||
|
| 'cover_none'
|
||||||
|
| 'slug_long'
|
||||||
|
| 'no_au'
|
||||||
|
| 'no_h'
|
||||||
|
| 'body_alt'
|
||||||
|
| 'short'
|
||||||
|
|
||||||
|
export type SuggestionField =
|
||||||
|
| 'seo_title'
|
||||||
|
| 'seo_description'
|
||||||
|
| 'cover_image_alt'
|
||||||
|
| 'cover_image_id'
|
||||||
|
| 'slug'
|
||||||
|
| 'author_ids'
|
||||||
|
| 'body'
|
||||||
|
|
||||||
|
export interface SeoSuggestion {
|
||||||
|
code: SuggestionCode
|
||||||
|
field: SuggestionField
|
||||||
|
params: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoCheckResult {
|
||||||
|
status: SeoCheckStatus
|
||||||
|
suggestions: SeoSuggestion[]
|
||||||
|
slug: { available: boolean; suggestion: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeoCheckBody = SeoPatch & { body?: string }
|
||||||
|
|
||||||
|
export type SeoFocus = SuggestionField | 'suggestions'
|
||||||
|
|
||||||
|
export interface ResolvedSeo {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
canonical_url: string
|
||||||
|
robots: string
|
||||||
|
og: {
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
image_alt: string
|
||||||
|
url: string
|
||||||
|
site_name: string
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
twitter: {
|
||||||
|
card: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
image_alt: string
|
||||||
|
}
|
||||||
|
image: { url: string; alt: string; source: string }
|
||||||
|
authors: { name: string; url: string; same_as: string[] }[]
|
||||||
|
published_at: string | null
|
||||||
|
modified_at: string
|
||||||
|
fallbacks: { title: string; description: string; image: string; canonical: string }
|
||||||
|
json_ld: Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { createReusableTemplate } from '@vueuse/core'
|
import { createReusableTemplate } from '@vueuse/core'
|
||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||||
@@ -11,6 +11,9 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
|||||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
import ArticleDetails from '@/components/ArticleDetails.vue'
|
||||||
import MetaSection from '@/components/MetaSection.vue'
|
import MetaSection from '@/components/MetaSection.vue'
|
||||||
|
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||||
|
import SeoPanel from '@/components/SeoPanel.vue'
|
||||||
|
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||||
import SaveStatus from '@/components/SaveStatus.vue'
|
import SaveStatus from '@/components/SaveStatus.vue'
|
||||||
import PublishControl from '@/components/PublishControl.vue'
|
import PublishControl from '@/components/PublishControl.vue'
|
||||||
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
||||||
@@ -21,18 +24,26 @@ import { usePageTitleOverride } from '@/composables/documentTitle'
|
|||||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import { useArticleActions } from '@/composables/useArticleActions'
|
import { useArticleActions } from '@/composables/useArticleActions'
|
||||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||||
|
import { useSeoCheck } from '@/composables/useSeoCheck'
|
||||||
import { useEditorImages } from '@/composables/useEditorImages'
|
import { useEditorImages } from '@/composables/useEditorImages'
|
||||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||||
import { useSites, type Site } from '@/composables/useSites'
|
import { useSites, type Site } from '@/composables/useSites'
|
||||||
import { toBylines } from '@/lib/articleMeta'
|
import { toBylines } from '@/lib/articleMeta'
|
||||||
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
|
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
|
||||||
|
import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
|
||||||
|
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
|
import { hostOf, slugify } from '@/lib/directory'
|
||||||
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
|
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
|
||||||
import { bodyCounts } from '@/lib/bodyCounts'
|
import { bodyCounts } from '@/lib/bodyCounts'
|
||||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||||
|
|
||||||
const SKELETON_DELAY_MS = 150
|
const SKELETON_DELAY_MS = 150
|
||||||
|
const FOCUS_RETRIES = 10
|
||||||
|
const FOCUS_RETRY_MS = 30
|
||||||
|
|
||||||
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||||
|
|
||||||
@@ -73,6 +84,16 @@ const detailsOpen = ref(false)
|
|||||||
const publishPending = ref<string | undefined>(undefined)
|
const publishPending = ref<string | undefined>(undefined)
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
||||||
|
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const panelValues = ref<ArticleSeo | null>(null)
|
||||||
|
const panelFocus = ref<SeoFocus | null>(null)
|
||||||
|
const panelNotice = ref<DirectoryNotice | null>(null)
|
||||||
|
const panelSaving = ref(false)
|
||||||
|
const slugTakenOnSave = ref<string | null>(null)
|
||||||
|
const canonicalRejected = ref(false)
|
||||||
|
const apiPreviewOpen = ref(false)
|
||||||
|
let panelSaveFailed = false
|
||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function clearSkeletonTimer() {
|
function clearSkeletonTimer() {
|
||||||
@@ -84,7 +105,8 @@ function clearSkeletonTimer() {
|
|||||||
|
|
||||||
async function fetchSites() {
|
async function fetchSites() {
|
||||||
try {
|
try {
|
||||||
return await listSites()
|
const list = await listSites()
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -95,6 +117,184 @@ function rememberMetaSection(open: boolean) {
|
|||||||
storeMetaSectionOpen(open)
|
storeMetaSectionOpen(open)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberSeoSection(open: boolean) {
|
||||||
|
seoRemembered.value = open
|
||||||
|
storeSeoSectionOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoInEffect = computed(() => panelValues.value ?? draft.seo.value)
|
||||||
|
const siteOfArticle = computed(() =>
|
||||||
|
sites.value.find((site) => site.id === draft.meta.value.siteId),
|
||||||
|
)
|
||||||
|
const addressSlug = computed(
|
||||||
|
() => draft.seo.value.slug || slugify(article.value?.title ?? '', SEO_LIMITS.slug),
|
||||||
|
)
|
||||||
|
const address = computed(
|
||||||
|
() => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
function checkBody(): SeoCheckBody {
|
||||||
|
const changes = seoChanges(seoInEffect.value, draft.savedSeo.value)
|
||||||
|
if (charCount(changes.excerpt ?? '') > SEO_LIMITS.excerpt) {
|
||||||
|
delete changes.excerpt
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...changes,
|
||||||
|
...(draft.bodyEdited.value ? { body: draft.body.value } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = useSeoCheck({
|
||||||
|
articleId,
|
||||||
|
body: checkBody,
|
||||||
|
onGone: () => {
|
||||||
|
loadState.value = 'notFound'
|
||||||
|
},
|
||||||
|
onUnauthorized: draft.mirrorToStorage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function editBody(newBody: string) {
|
||||||
|
draft.edit(newBody)
|
||||||
|
check.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel(focus: SeoFocus | null = null) {
|
||||||
|
panelFocus.value = focus
|
||||||
|
panelValues.value = { ...draft.seo.value }
|
||||||
|
panelNotice.value = null
|
||||||
|
slugTakenOnSave.value = null
|
||||||
|
canonicalRejected.value = false
|
||||||
|
panelOpen.value = true
|
||||||
|
check.runFresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(discardEdits = true) {
|
||||||
|
panelOpen.value = false
|
||||||
|
panelValues.value = null
|
||||||
|
if (panelSaveFailed) {
|
||||||
|
panelSaveFailed = false
|
||||||
|
draft.markDirty()
|
||||||
|
}
|
||||||
|
if (discardEdits) {
|
||||||
|
check.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanelValues(values: ArticleSeo) {
|
||||||
|
panelValues.value = values
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanelCheck(immediate: boolean) {
|
||||||
|
if (immediate) {
|
||||||
|
check.runNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
check.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusBody() {
|
||||||
|
detailsOpen.value = false
|
||||||
|
previewOn.value = false
|
||||||
|
nextTick(() => editorRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorsFocusTarget() {
|
||||||
|
const scope = detailsOpen.value
|
||||||
|
? '[role="dialog"] [data-testid="meta-section"]'
|
||||||
|
: '[data-testid="article-details"] [data-testid="meta-section"]'
|
||||||
|
return document.querySelector<HTMLElement>(
|
||||||
|
`${scope} [data-testid="author-row"] button, ${scope} button[aria-haspopup="listbox"]`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWhenRendered(find: () => HTMLElement | null, retries = FOCUS_RETRIES) {
|
||||||
|
const target = find()
|
||||||
|
if (target) {
|
||||||
|
target.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (retries > 0) {
|
||||||
|
setTimeout(() => focusWhenRendered(find, retries - 1), FOCUS_RETRY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAuthors() {
|
||||||
|
remembered.value = true
|
||||||
|
focusWhenRendered(authorsFocusTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToField(field: SuggestionField) {
|
||||||
|
if (field === 'body') {
|
||||||
|
closePanel()
|
||||||
|
focusBody()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (field === 'author_ids') {
|
||||||
|
closePanel()
|
||||||
|
focusAuthors()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openPanel(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPanelFailure(outcome: SaveOutcome) {
|
||||||
|
if (outcome === 'slug-taken') {
|
||||||
|
slugTakenOnSave.value = panelValues.value?.slug ?? draft.seo.value.slug
|
||||||
|
check.runFresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outcome === 'invalid-url') {
|
||||||
|
canonicalRejected.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panelSaveFailed = true
|
||||||
|
panelNotice.value = {
|
||||||
|
title: t(
|
||||||
|
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.",
|
||||||
|
),
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceSeoSaved() {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
get title() {
|
||||||
|
return t('Search and sharing saved')
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('Your search and sharing settings are up to date.')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSeoPanel() {
|
||||||
|
const values = panelValues.value
|
||||||
|
if (!values || panelSaving.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.editSeo(values)
|
||||||
|
if (draft.state.value !== 'dirty') {
|
||||||
|
closePanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panelNotice.value = null
|
||||||
|
panelSaving.value = true
|
||||||
|
const outcome = await runSave()
|
||||||
|
panelSaving.value = false
|
||||||
|
if (outcome === 'saved') {
|
||||||
|
closePanel(false)
|
||||||
|
announceSeoSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['not-found', 'site-gone', 'author-gone', 'unauthorized'].includes(outcome)) {
|
||||||
|
closePanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showPanelFailure(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadArticle() {
|
async function loadArticle() {
|
||||||
loadState.value = 'loading'
|
loadState.value = 'loading'
|
||||||
showSkeleton.value = false
|
showSkeleton.value = false
|
||||||
@@ -110,11 +310,17 @@ async function loadArticle() {
|
|||||||
])
|
])
|
||||||
article.value = result
|
article.value = result
|
||||||
sites.value = siteList
|
sites.value = siteList
|
||||||
draft.load(result.body, new Date(result.updated_at), {
|
draft.load(
|
||||||
siteId: result.site_id ?? null,
|
result.body,
|
||||||
authors: toBylines(result.authors ?? []),
|
new Date(result.updated_at),
|
||||||
})
|
{
|
||||||
|
siteId: result.site_id ?? null,
|
||||||
|
authors: toBylines(result.authors ?? []),
|
||||||
|
},
|
||||||
|
seoOf(result),
|
||||||
|
)
|
||||||
loadState.value = 'loaded'
|
loadState.value = 'loaded'
|
||||||
|
check.runNow()
|
||||||
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
||||||
variantCatalog.load()
|
variantCatalog.load()
|
||||||
}
|
}
|
||||||
@@ -155,8 +361,16 @@ const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.va
|
|||||||
|
|
||||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||||
|
|
||||||
async function runSave() {
|
async function runSave(): Promise<SaveOutcome> {
|
||||||
const outcome = await draft.save()
|
const outcome = await draft.save()
|
||||||
|
if (outcome === 'saved') {
|
||||||
|
check.invalidate()
|
||||||
|
check.runFresh()
|
||||||
|
}
|
||||||
|
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
|
||||||
|
openPanel('slug')
|
||||||
|
showPanelFailure(outcome)
|
||||||
|
}
|
||||||
if (outcome === 'site-gone') {
|
if (outcome === 'site-gone') {
|
||||||
directoryToasts.siteGone()
|
directoryToasts.siteGone()
|
||||||
sites.value = await fetchSites()
|
sites.value = await fetchSites()
|
||||||
@@ -173,6 +387,7 @@ async function runSave() {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performSave() {
|
async function performSave() {
|
||||||
@@ -199,6 +414,9 @@ async function setOnline(makeOnline: boolean) {
|
|||||||
}
|
}
|
||||||
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
||||||
try {
|
try {
|
||||||
|
if (makeOnline && draft.state.value === 'dirty' && (await runSave()) !== 'saved') {
|
||||||
|
return
|
||||||
|
}
|
||||||
await actions.publish(article.value, makeOnline, {
|
await actions.publish(article.value, makeOnline, {
|
||||||
onUpdated: (updated) => {
|
onUpdated: (updated) => {
|
||||||
article.value = updated
|
article.value = updated
|
||||||
@@ -335,6 +553,19 @@ onBeforeRouteLeave(async () => {
|
|||||||
@update:authors="draft.editMeta({ authors: $event })"
|
@update:authors="draft.editMeta({ authors: $event })"
|
||||||
@update:remembered="rememberMetaSection"
|
@update:remembered="rememberMetaSection"
|
||||||
/>
|
/>
|
||||||
|
<SearchSharingSection
|
||||||
|
:remembered="seoRemembered"
|
||||||
|
:address="address"
|
||||||
|
:phase="check.phase.value"
|
||||||
|
:status="check.status.value"
|
||||||
|
:suggestions="check.suggestions.value"
|
||||||
|
:sheet="sheet"
|
||||||
|
@update:remembered="rememberSeoSection"
|
||||||
|
@edit="openPanel"
|
||||||
|
@preview="apiPreviewOpen = true"
|
||||||
|
@retry="check.runFresh"
|
||||||
|
@go="goToField"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ArticleDetails>
|
</ArticleDetails>
|
||||||
</DefineDetailsPanel>
|
</DefineDetailsPanel>
|
||||||
@@ -385,6 +616,13 @@ onBeforeRouteLeave(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
|
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||||
|
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||||
|
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||||
|
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
|
||||||
|
</div>
|
||||||
|
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
|
||||||
|
</div>
|
||||||
<USkeleton class="h-3 w-full rounded-none" />
|
<USkeleton class="h-3 w-full rounded-none" />
|
||||||
<USkeleton class="h-3 w-full rounded-none" />
|
<USkeleton class="h-3 w-full rounded-none" />
|
||||||
<USkeleton class="h-3 w-3/5 rounded-none" />
|
<USkeleton class="h-3 w-3/5 rounded-none" />
|
||||||
@@ -489,7 +727,7 @@ onBeforeRouteLeave(async () => {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
:model-value="draft.body.value"
|
:model-value="draft.body.value"
|
||||||
:article-id="articleId"
|
:article-id="articleId"
|
||||||
@update:model-value="draft.edit"
|
@update:model-value="editBody"
|
||||||
@request-image="onRequestImage"
|
@request-image="onRequestImage"
|
||||||
@request-remove-image="onRequestRemoveImage"
|
@request-remove-image="onRequestRemoveImage"
|
||||||
>
|
>
|
||||||
@@ -509,6 +747,35 @@ onBeforeRouteLeave(async () => {
|
|||||||
</ArticleEditor>
|
</ArticleEditor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SeoPanel
|
||||||
|
v-if="panelOpen && panelValues"
|
||||||
|
:model-value="panelValues"
|
||||||
|
:saved-slug="draft.savedSeo.value.slug"
|
||||||
|
:article-title="article.title"
|
||||||
|
:body="draft.body.value"
|
||||||
|
:site="siteOfArticle"
|
||||||
|
:phase="check.phase.value"
|
||||||
|
:suggestions="check.suggestions.value"
|
||||||
|
:slug-verdict="check.slugVerdict.value"
|
||||||
|
:checked-slug="check.checkedSlug.value"
|
||||||
|
:slug-taken-on-save="slugTakenOnSave"
|
||||||
|
:canonical-rejected="canonicalRejected"
|
||||||
|
:saving="panelSaving"
|
||||||
|
:notice="panelNotice"
|
||||||
|
:focus="panelFocus"
|
||||||
|
@update:model-value="updatePanelValues"
|
||||||
|
@check="onPanelCheck"
|
||||||
|
@submit="saveSeoPanel"
|
||||||
|
@close="closePanel"
|
||||||
|
@preview="apiPreviewOpen = true"
|
||||||
|
@go="goToField"
|
||||||
|
/>
|
||||||
|
<ApiPreviewDialog
|
||||||
|
v-model:open="apiPreviewOpen"
|
||||||
|
:article-id="articleId"
|
||||||
|
:is-online="article.is_online"
|
||||||
|
/>
|
||||||
|
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
v-model:open="imageUploaderOpen"
|
v-model:open="imageUploaderOpen"
|
||||||
:initial-file="imageUploaderFile"
|
:initial-file="imageUploaderFile"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
import RootComponent from '@/App.vue'
|
import RootComponent from '@/App.vue'
|
||||||
import { articleMeta, oneSite } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
@@ -116,6 +116,7 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||||
const routes = {
|
const routes = {
|
||||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||||
|
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
|
||||||
...handlers,
|
...handlers,
|
||||||
}
|
}
|
||||||
return vi.fn((url: string, init?: RequestInit) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
import RootComponent from '@/App.vue'
|
import RootComponent from '@/App.vue'
|
||||||
import { articleMeta, oneSite } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
import { fixture } from './mediaTestKit'
|
import { fixture } from './mediaTestKit'
|
||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
@@ -71,6 +71,9 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
|||||||
if (url === '/v1/site/') {
|
if (url === '/v1/site/') {
|
||||||
return mockResponse(200, oneSite)
|
return mockResponse(200, oneSite)
|
||||||
}
|
}
|
||||||
|
if (url === '/v1/article/12/seo/check') {
|
||||||
|
return mockResponse(200, readyCheck)
|
||||||
|
}
|
||||||
if (url === '/v1/media/?page=1&size=25') {
|
if (url === '/v1/media/?page=1&size=25') {
|
||||||
return mockResponse(200, libraryPage())
|
return mockResponse(200, libraryPage())
|
||||||
}
|
}
|
||||||
@@ -182,7 +185,8 @@ describe('Insert puts the chosen image in the body with its alt text', () => {
|
|||||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
const methods = vi
|
const methods = vi
|
||||||
.mocked(global.fetch)
|
.mocked(global.fetch)
|
||||||
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
|
.mock.calls.filter(([url]) => !String(url).endsWith('/seo/check'))
|
||||||
|
.map(([, init]) => (init as RequestInit | undefined)?.method)
|
||||||
expect(methods.every((method) => method === undefined)).toBe(true)
|
expect(methods.every((method) => method === undefined)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ describe('Editor section: the choice is remembered per browser', () => {
|
|||||||
expect(summary()).toContain('No authors')
|
expect(summary()).toContain('No authors')
|
||||||
expect(
|
expect(
|
||||||
crowded.calls.filter(
|
crowded.calls.filter(
|
||||||
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
|
(call) =>
|
||||||
|
(call.method !== 'GET' && !call.path.endsWith('/seo/check')) ||
|
||||||
|
call.path.startsWith('/v1/author'),
|
||||||
),
|
),
|
||||||
).toEqual([])
|
).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { failWith, metaKit, pending, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import { typeInto } from './directoryTestKit'
|
||||||
|
import {
|
||||||
|
ARTICLE_TITLE,
|
||||||
|
LONG_TITLE,
|
||||||
|
READY,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
checkBodies,
|
||||||
|
checkCalls,
|
||||||
|
fieldByLabel,
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
sectionBody,
|
||||||
|
seoBackend,
|
||||||
|
suggestion,
|
||||||
|
} from './seoTestKit'
|
||||||
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
|
import MediaField from '@/components/MediaField.vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { SEO_CHECK_DEBOUNCE_MS } from '@/composables/useSeoCheck'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
function fakeTimers() {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(ms = SEO_CHECK_DEBOUNCE_MS) {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editBody(html: string) {
|
||||||
|
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRequests() {
|
||||||
|
const mocked = vi.mocked(useApi).mock.results.at(-1)!.value as {
|
||||||
|
request: { mock: { calls: [string, RequestInit | undefined][] } }
|
||||||
|
}
|
||||||
|
return mocked.request.mock.calls.filter(([path]) => path.endsWith('/seo/check'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('The first check runs when the editor loads', () => {
|
||||||
|
it('sends exactly one check with an empty body once the article is loaded', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
|
||||||
|
expect(checkBodies(backend)).toEqual([{}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no check while the article is pending', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
backend.responders['GET /v1/article/12'] = () => gate.promise
|
||||||
|
await mountSeo(backend)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no check when the article fails to load', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['GET /v1/article/12'] = failWith(500)
|
||||||
|
await mountSeo(backend)
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('What the check is sent', () => {
|
||||||
|
it('sends only the changed field', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||||
|
await pause()
|
||||||
|
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ seo_title: LONG_TITLE })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the unsaved body edit so the suggestions reflect what is on screen', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
fakeTimers()
|
||||||
|
|
||||||
|
await editBody('<p>Edited on screen</p>')
|
||||||
|
await pause()
|
||||||
|
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ body: '<p>Edited on screen</p>' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The check waits for a pause in typing', () => {
|
||||||
|
type Field = { name: string; value: string; key: string; prepare?: () => Promise<void> }
|
||||||
|
|
||||||
|
async function openAdvanced() {
|
||||||
|
const toggle = Array.from(
|
||||||
|
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||||
|
).find((button) => textOf(button).startsWith('Advanced'))!
|
||||||
|
toggle.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSocial() {
|
||||||
|
const toggle = Array.from(
|
||||||
|
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||||
|
).find((button) => textOf(button).startsWith('Social sharing'))!
|
||||||
|
toggle.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockSlug() {
|
||||||
|
panelRoot()!.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: Field[] = [
|
||||||
|
{ name: 'Meta title', value: 'A new meta title', key: 'seo_title' },
|
||||||
|
{ name: 'Meta description', value: 'A new meta description', key: 'seo_description' },
|
||||||
|
{ name: 'Excerpt', value: 'A new excerpt', key: 'excerpt' },
|
||||||
|
{ name: 'Alt text', value: 'A tiled wall', key: 'cover_image_alt' },
|
||||||
|
{ name: 'Social title', value: 'A social title', key: 'social_title', prepare: openSocial },
|
||||||
|
{
|
||||||
|
name: 'Canonical URL',
|
||||||
|
value: 'https://renew.example/costs',
|
||||||
|
key: 'canonical_url',
|
||||||
|
prepare: openAdvanced,
|
||||||
|
},
|
||||||
|
{ name: 'URL slug', value: 'bathroom-costs', key: 'slug', prepare: unlockSlug },
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(fields)('sends one check with the latest $name only after the pause', async (field) => {
|
||||||
|
const backend = await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
|
||||||
|
await openPanel()
|
||||||
|
await field.prepare?.()
|
||||||
|
fakeTimers()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
const input = fieldByLabel(panelRoot()!, field.name)!
|
||||||
|
|
||||||
|
await typeInto(input, field.value.slice(0, 4))
|
||||||
|
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
|
||||||
|
await typeInto(input, field.value)
|
||||||
|
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before)
|
||||||
|
|
||||||
|
await pause(100)
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ [field.key]: field.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes the suggestions and the summary line after the pause', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
answerChecks(
|
||||||
|
backend,
|
||||||
|
checkAnswer([suggestion('title_long', 'seo_title', { length: 74, max: 60 })]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||||
|
await pause()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
textOf(panelRoot()!.querySelector('[data-testid="seo-panel-suggestions"]')),
|
||||||
|
).toContain('The meta title is 74 characters.')
|
||||||
|
expect(textOf(sectionBody())).toContain('The meta title is 74 characters.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A late answer never overwrites a newer one', () => {
|
||||||
|
it('shows the suggestions of the newer check and aborts the previous request', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
const gates: ReturnType<typeof pending<unknown>>[] = []
|
||||||
|
answerChecks(backend, () => {
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
gates.push(gate)
|
||||||
|
return gate.promise
|
||||||
|
})
|
||||||
|
const title = fieldByLabel(panelRoot()!, 'Meta title')!
|
||||||
|
|
||||||
|
await typeInto(title, 'First edit')
|
||||||
|
await pause()
|
||||||
|
await typeInto(title, 'Second edit')
|
||||||
|
await pause()
|
||||||
|
expect(gates).toHaveLength(2)
|
||||||
|
const [first, second] = checkRequests().slice(-2)
|
||||||
|
expect((first![1]!.signal as AbortSignal).aborted).toBe(true)
|
||||||
|
expect((second![1]!.signal as AbortSignal).aborted).toBe(false)
|
||||||
|
|
||||||
|
gates[1]!.resolve(checkAnswer([suggestion('cover_none', 'cover_image_id')], 'suggestions'))
|
||||||
|
await flushPromises()
|
||||||
|
gates[0]!.resolve(checkAnswer([], 'hidden'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(textOf(sectionBody())).toContain('This article has no cover image.')
|
||||||
|
expect(textOf(sectionBody())).not.toContain('Hidden from search engines')
|
||||||
|
expect(textOf(panelRoot()!)).toContain('This article has no cover image.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Discrete changes check immediately', () => {
|
||||||
|
it('checks without waiting when "Hide from search engines" is toggled', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
|
||||||
|
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks without waiting when the cover is picked and when it is removed', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
const cover = metaKit.wrapper!.findAllComponents(MediaField)[0]!
|
||||||
|
|
||||||
|
cover.vm.$emit('update:modelValue', 2)
|
||||||
|
await flushPromises()
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ cover_image_id: 2 })
|
||||||
|
|
||||||
|
cover.vm.$emit('update:modelValue', null)
|
||||||
|
await flushPromises()
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 2)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks without waiting when a social image is picked', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
Array.from(
|
||||||
|
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||||
|
)
|
||||||
|
.find((button) => textOf(button).startsWith('Social sharing'))!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
const social = metaKit.wrapper!.findAllComponents(MediaField)[1]!
|
||||||
|
|
||||||
|
social.vm.$emit('update:modelValue', 2)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ social_image_id: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Nothing is saved by a check', () => {
|
||||||
|
it('sends no PATCH for any check and leaves the status unchanged', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||||
|
await pause()
|
||||||
|
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(checkCalls(backend).length).toBeGreaterThan(2)
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The server owns the suggestion rules', () => {
|
||||||
|
it('shows no title_long message for a 74-character title when the check does not answer it', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
answerChecks(backend, READY)
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
|
||||||
|
await pause()
|
||||||
|
|
||||||
|
expect(LONG_TITLE).toHaveLength(74)
|
||||||
|
expect(textOf(panelRoot()!)).not.toContain('characters. Search engines may cut it off')
|
||||||
|
expect(textOf(panelRoot()!)).toContain('All good')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Discarding the panel', () => {
|
||||||
|
it('aborts a pending check for the discarded values so it cannot overwrite the summary', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
fakeTimers()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
answerChecks(backend, () => gate.promise)
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
|
||||||
|
await pause()
|
||||||
|
const [request] = checkRequests().slice(-1)
|
||||||
|
|
||||||
|
const cancel = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||||
|
(button) => textOf(button) === 'Cancel',
|
||||||
|
)!
|
||||||
|
cancel.click()
|
||||||
|
await flushPromises()
|
||||||
|
gate.resolve(checkAnswer([suggestion('title_long', 'seo_title')]))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect((request![1]!.signal as AbortSignal).aborted).toBe(true)
|
||||||
|
expect(textOf(sectionBody())).not.toContain('The meta title is')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The panel check state', () => {
|
||||||
|
it('sends a check on open and shows Checking this article… while it is pending, with the fields usable', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
answerChecks(backend, () => gate.promise)
|
||||||
|
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
|
||||||
|
'Checking this article…',
|
||||||
|
)
|
||||||
|
const title = fieldByLabel(panel, 'Meta title')!
|
||||||
|
expect(title.disabled).toBe(false)
|
||||||
|
expect(title.placeholder).toBe(ARTICLE_TITLE)
|
||||||
|
void openSection
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import { click, failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import {
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
seoBackend,
|
||||||
|
seoCalls,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolvedSeo(fallbacks: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: 'A bathroom renovation is priced by room size.',
|
||||||
|
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: {
|
||||||
|
type: 'article',
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: 'A bathroom renovation is priced by room size.',
|
||||||
|
image: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
image_alt: 'A tiled bathroom wall during renovation',
|
||||||
|
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||||
|
site_name: 'Renew',
|
||||||
|
locale: 'en',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: 'A bathroom renovation is priced by room size.',
|
||||||
|
image: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
image_alt: 'A tiled bathroom wall during renovation',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
url: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
alt: 'A tiled bathroom wall during renovation',
|
||||||
|
source: 'cover_image',
|
||||||
|
},
|
||||||
|
authors: [
|
||||||
|
{
|
||||||
|
name: 'Inès Duarte',
|
||||||
|
url: 'https://ines-duarte.example',
|
||||||
|
same_as: ['https://www.linkedin.com/in/ines-duarte'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
published_at: '2026-03-12T09:14:00Z',
|
||||||
|
modified_at: '2026-10-15T11:02:00Z',
|
||||||
|
fallbacks: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'body',
|
||||||
|
image: 'cover',
|
||||||
|
canonical: 'site',
|
||||||
|
...fallbacks,
|
||||||
|
},
|
||||||
|
json_ld: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: 'How we price a bathroom renovation',
|
||||||
|
datePublished: '2026-03-12T09:14:00Z',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountWithDialog(options: { online?: boolean; seo?: unknown; width?: number } = {}) {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.article.is_online = options.online ?? true
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = () => options.seo ?? resolvedSeo()
|
||||||
|
await mountSeo(backend, options.width ? { width: options.width } : {})
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDialog() {
|
||||||
|
await openSection()
|
||||||
|
const opener = document.body.querySelector<HTMLElement>('[data-testid="seo-preview"]')!
|
||||||
|
opener.focus()
|
||||||
|
opener.click()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
return document.body
|
||||||
|
.querySelector<HTMLElement>('[data-testid="api-preview"]')!
|
||||||
|
.closest('[role="dialog"]') as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTab(label: string) {
|
||||||
|
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||||
|
(candidate) => textOf(candidate) === label,
|
||||||
|
)!
|
||||||
|
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||||
|
tab.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubClipboard(writeText: ReturnType<typeof vi.fn> | undefined) {
|
||||||
|
vi.stubGlobal('navigator', { ...navigator, clipboard: writeText ? { writeText } : undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Opening from the section and from the panel', () => {
|
||||||
|
it('requests GET /v1/article/12/seo once on each opening, from both openers', async () => {
|
||||||
|
const backend = await mountWithDialog()
|
||||||
|
|
||||||
|
await openDialog()
|
||||||
|
expect(seoCalls(backend)).toHaveLength(1)
|
||||||
|
await click('Close')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await openDialog()
|
||||||
|
expect(seoCalls(backend)).toHaveLength(2)
|
||||||
|
await click('Close')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const panel = await openPanel()
|
||||||
|
await click('What the frontend gets', panel)
|
||||||
|
await flushPromises()
|
||||||
|
expect(seoCalls(backend)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a 720 px dialog on desktop', async () => {
|
||||||
|
await mountWithDialog()
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
expect(dialog.className).toContain('max-w-180')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacks above the Details sheet it can be opened from', async () => {
|
||||||
|
await mountWithDialog({ width: MOBILE_WIDTH })
|
||||||
|
await click('Details')
|
||||||
|
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-preview"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
const dialog = document
|
||||||
|
.querySelector('[data-testid="api-preview"]')!
|
||||||
|
.closest('[role="dialog"]') as HTMLElement
|
||||||
|
expect(dialog.className).toContain('z-60')
|
||||||
|
expect(document.body.querySelector('[data-slot="overlay"].z-60')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a bottom sheet on mobile', async () => {
|
||||||
|
await mountWithDialog({ width: MOBILE_WIDTH })
|
||||||
|
await click('Details')
|
||||||
|
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-preview"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const dialog = document
|
||||||
|
.querySelector('[data-testid="api-preview"]')!
|
||||||
|
.closest('[role="dialog"]') as HTMLElement
|
||||||
|
expect(dialog.className).toContain('max-sm:!bottom-0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Formatted', () => {
|
||||||
|
it('shows the title, the description, the tabs and the buttons', async () => {
|
||||||
|
await mountWithDialog()
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
const text = textOf(dialog)
|
||||||
|
expect(text).toContain('What the frontend gets')
|
||||||
|
expect(text).toContain(
|
||||||
|
"The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.",
|
||||||
|
)
|
||||||
|
expect(text).toContain('Formatted')
|
||||||
|
expect(text).toContain('Raw JSON')
|
||||||
|
expect(text).toContain('Copy JSON')
|
||||||
|
expect(text).toContain('Close')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists the keys as a compact key and value list', async () => {
|
||||||
|
await mountWithDialog()
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
const keys = Array.from(dialog.querySelectorAll('dt')).map((node) => textOf(node))
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'canonical_url',
|
||||||
|
'robots',
|
||||||
|
'og.type',
|
||||||
|
'og.image',
|
||||||
|
'og.image_alt',
|
||||||
|
'twitter.card',
|
||||||
|
'authors',
|
||||||
|
'published_at',
|
||||||
|
'modified_at',
|
||||||
|
'json_ld',
|
||||||
|
])
|
||||||
|
const formatted = textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))
|
||||||
|
expect(formatted).toContain('How we price a bathroom renovation | Renew')
|
||||||
|
expect(formatted).toContain('Inès Duarte · ines-duarte.example · 1 social link')
|
||||||
|
expect(formatted).toContain('12 Mar 2026, 09:14')
|
||||||
|
expect(formatted).toContain('Article · headline, datePublished')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ key: 'title', source: 'title', chip: 'Using article title' },
|
||||||
|
{ key: 'description', source: 'body', chip: 'Using first sentence of the body' },
|
||||||
|
{ key: 'description', source: 'excerpt', chip: 'Using the excerpt' },
|
||||||
|
{ key: 'description', source: 'site', chip: 'Using the site default description' },
|
||||||
|
{ key: 'image', source: 'cover', chip: 'Using cover image' },
|
||||||
|
{ key: 'image', source: 'site', chip: 'Using site social image' },
|
||||||
|
{ key: 'canonical', source: 'site', chip: 'Using site address and slug' },
|
||||||
|
{ key: 'image', source: 'none', chip: 'No image' },
|
||||||
|
])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => {
|
||||||
|
await mountWithDialog({
|
||||||
|
seo: resolvedSeo({
|
||||||
|
title: 'seo_title',
|
||||||
|
description: 'seo_description',
|
||||||
|
image: 'social',
|
||||||
|
canonical: 'canonical_url',
|
||||||
|
[key]: source,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
const chips = Array.from(dialog.querySelectorAll('[data-testid="fallback-chip"]')).map(
|
||||||
|
(node) => textOf(node),
|
||||||
|
)
|
||||||
|
expect(chips).toEqual([chip])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no chip for a value that is not a fallback', async () => {
|
||||||
|
await mountWithDialog({
|
||||||
|
seo: resolvedSeo({
|
||||||
|
title: 'seo_title',
|
||||||
|
description: 'seo_description',
|
||||||
|
image: 'social',
|
||||||
|
canonical: 'canonical_url',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
expect(dialog.querySelectorAll('[data-testid="fallback-chip"]')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Raw JSON', () => {
|
||||||
|
it('shows the response exactly as the API returned it, pretty printed in the mono face', async () => {
|
||||||
|
const seo = resolvedSeo()
|
||||||
|
await mountWithDialog({ seo })
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
await selectTab('Raw JSON')
|
||||||
|
|
||||||
|
const raw = dialog.querySelector<HTMLElement>('[data-testid="api-preview-raw"]')!
|
||||||
|
expect(raw.textContent).toBe(JSON.stringify(seo, null, 2))
|
||||||
|
expect(raw.className).toContain('font-mono')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Copy JSON', () => {
|
||||||
|
it('writes the pretty printed raw object, reads Copied for 2 seconds, announces it, then reads Copy JSON again', async () => {
|
||||||
|
const seo = resolvedSeo()
|
||||||
|
await mountWithDialog({ seo })
|
||||||
|
const dialog = await openDialog()
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
|
stubClipboard(writeText)
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
|
||||||
|
await click('Copy JSON', dialog)
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(JSON.stringify(seo, null, 2))
|
||||||
|
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
|
||||||
|
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('Copied')
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1900)
|
||||||
|
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
|
||||||
|
await vi.advanceTimersByTimeAsync(200)
|
||||||
|
await flushPromises()
|
||||||
|
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
|
||||||
|
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['rejects', vi.fn().mockRejectedValue(new Error('denied'))],
|
||||||
|
['is unavailable', undefined],
|
||||||
|
])('keeps reading Copy JSON when the clipboard %s', async (_name, writeText) => {
|
||||||
|
await mountWithDialog()
|
||||||
|
const dialog = await openDialog()
|
||||||
|
stubClipboard(writeText)
|
||||||
|
|
||||||
|
await click('Copy JSON', dialog)
|
||||||
|
|
||||||
|
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
|
||||||
|
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Draft article', () => {
|
||||||
|
it('shows the draft note above the data, and the data', async () => {
|
||||||
|
await mountWithDialog({ online: false })
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
const note = dialog.querySelector('[data-testid="api-preview-draft"]')!
|
||||||
|
expect(textOf(note)).toContain(
|
||||||
|
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
|
||||||
|
)
|
||||||
|
expect(textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))).toContain(
|
||||||
|
'robots',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no draft note for an online article', async () => {
|
||||||
|
await mountWithDialog({ online: true })
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
expect(dialog.querySelector('[data-testid="api-preview-draft"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Loading', () => {
|
||||||
|
it('shows a loading state and disables Copy JSON while the request is pending', async () => {
|
||||||
|
const backend = await mountWithDialog()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = () => gate.promise
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
expect(dialog.querySelector('[data-testid="api-preview-loading"]')).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
dialog.querySelector<HTMLButtonElement>('[data-testid="api-preview-copy"]')!.disabled,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error with retry', () => {
|
||||||
|
it.each([
|
||||||
|
['status 500', failWith(500)],
|
||||||
|
['a network error', networkFailure],
|
||||||
|
])(
|
||||||
|
'shows the error and requests the data again on Try again after %s',
|
||||||
|
async (_name, failure) => {
|
||||||
|
const backend = await mountWithDialog()
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = failure
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
const text = textOf(dialog)
|
||||||
|
expect(text).toContain("Couldn't load the SEO data")
|
||||||
|
expect(text).toContain(
|
||||||
|
'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.',
|
||||||
|
)
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
const before = seoCalls(backend).length
|
||||||
|
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = () => resolvedSeo()
|
||||||
|
await click('Try again', dialog)
|
||||||
|
|
||||||
|
expect(seoCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(dialog.querySelector('[data-testid="api-preview-formatted"]')).not.toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The dialog is read only', () => {
|
||||||
|
it('sends no PATCH and no POST, and Close returns focus to its opener', async () => {
|
||||||
|
const backend = await mountWithDialog()
|
||||||
|
const callsBefore = backend.calls.length
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
await click('Close', dialog)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(backend.calls.slice(callsBefore).filter((call) => call.method !== 'GET')).toEqual([])
|
||||||
|
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
document.body.querySelector('[data-testid="seo-preview"]'),
|
||||||
|
)
|
||||||
|
void panelRoot
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes with Escape and returns focus to its opener', async () => {
|
||||||
|
await mountWithDialog()
|
||||||
|
const dialog = await openDialog()
|
||||||
|
|
||||||
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
document.body.querySelector('[data-testid="seo-preview"]'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { initializeLocale, setLocale } from '@/i18n'
|
||||||
|
import { click, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import {
|
||||||
|
FOUR_SUGGESTIONS,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
panelRoot,
|
||||||
|
seoBackend,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
setLocale('fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await unmountMeta()
|
||||||
|
localStorage.clear()
|
||||||
|
initializeLocale()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('French', () => {
|
||||||
|
it('walks through the section, the panel and the dialog in French', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = () => ({
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
canonical_url: 'https://renew.example/x',
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: { type: 'article', image: '', image_alt: '' },
|
||||||
|
twitter: { card: 'summary' },
|
||||||
|
image: { url: '', alt: '', source: 'none' },
|
||||||
|
authors: [],
|
||||||
|
published_at: null,
|
||||||
|
modified_at: '2026-09-26T11:02:00Z',
|
||||||
|
fallbacks: { title: 'title', description: 'body', image: 'none', canonical: 'site' },
|
||||||
|
json_ld: { '@type': 'Article' },
|
||||||
|
})
|
||||||
|
await mountSeo(backend)
|
||||||
|
expect(document.body.querySelector('[data-testid="seo-summary"]')!.textContent).toBe(
|
||||||
|
'4 suggestions',
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = await openSection()
|
||||||
|
const section = textOf(body)
|
||||||
|
expect(section).toContain('Adresse')
|
||||||
|
expect(section).toContain(
|
||||||
|
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
|
||||||
|
)
|
||||||
|
expect(section).toContain('Aller au titre meta')
|
||||||
|
expect(section).toContain('+1 de plus dans Recherche et partage')
|
||||||
|
expect(section).toContain('Modifier la recherche et le partage')
|
||||||
|
|
||||||
|
const panel = await openPanel()
|
||||||
|
const panelContent = textOf(panel)
|
||||||
|
expect(panelContent).toContain('Recherche et partage')
|
||||||
|
expect(panelContent).toContain('Slug de l’URL')
|
||||||
|
expect(panelContent).toContain('Titre de l’article utilisé')
|
||||||
|
expect(panelContent).toContain('Partage social')
|
||||||
|
expect(panelContent).toContain('Avancé')
|
||||||
|
expect(panelContent).toContain('Masquer des moteurs de recherche')
|
||||||
|
expect(panelContent).toContain('Résultat de recherche')
|
||||||
|
expect(panelContent).toContain('Carte sociale')
|
||||||
|
expect(panelContent).toContain('Enregistrer les modifications')
|
||||||
|
await click('Annuler', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
|
||||||
|
await click('Ce que reçoit le frontend')
|
||||||
|
await flushPromises()
|
||||||
|
const dialog = textOf(document.body.querySelector('[data-testid="api-preview"]'))
|
||||||
|
expect(dialog).toContain('Formaté')
|
||||||
|
expect(dialog).toContain('JSON brut')
|
||||||
|
expect(dialog).toContain('Copier le JSON')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { config, flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||||
|
import { SEO_SECTION_OPEN_KEY } from '@/lib/seoSectionMemory'
|
||||||
|
import { click, unmountMeta } from './metaTestKit'
|
||||||
|
import {
|
||||||
|
FOUR_SUGGESTIONS,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
section,
|
||||||
|
sectionBody,
|
||||||
|
sectionToggle,
|
||||||
|
seoBackend,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
config.global.config.errorHandler = undefined
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
function securityError() {
|
||||||
|
return new DOMException('Access denied', 'SecurityError')
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVueErrors() {
|
||||||
|
const uncaught: unknown[] = []
|
||||||
|
const previous = config.global.config.errorHandler
|
||||||
|
config.global.config.errorHandler = (error) => {
|
||||||
|
uncaught.push(error)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uncaught,
|
||||||
|
stop: () => {
|
||||||
|
config.global.config.errorHandler = previous
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('The choice is remembered per browser, with its own key', () => {
|
||||||
|
it('uses a key of its own, independent of the Site and authors key', () => {
|
||||||
|
expect(SEO_SECTION_OPEN_KEY).not.toBe(META_SECTION_OPEN_KEY)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an opening for the next article and a collapse after a reload, sending nothing to the API', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
await mountSeo(backend)
|
||||||
|
const callsBefore = backend.calls.length
|
||||||
|
await openSection()
|
||||||
|
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||||
|
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
|
||||||
|
expect(backend.calls).toHaveLength(callsBefore)
|
||||||
|
await unmountMeta()
|
||||||
|
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
expect(sectionBody()).not.toBeNull()
|
||||||
|
sectionToggle().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('false')
|
||||||
|
await unmountMeta()
|
||||||
|
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
expect(sectionBody()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares the choice between the sidebar and the Details sheet', async () => {
|
||||||
|
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||||
|
await click('Details')
|
||||||
|
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||||
|
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
|
||||||
|
expect(sidebar.querySelector('[data-testid="seo-body"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change the stored choice when the panel or the dialog is opened from the section', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
const writes = vi.spyOn(Storage.prototype, 'setItem')
|
||||||
|
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(panel).not.toBeNull()
|
||||||
|
expect(writes).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
|
||||||
|
expect(section()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A storage that throws never breaks the editor', () => {
|
||||||
|
it.each(['getItem', 'setItem'] as const)(
|
||||||
|
'renders the editor and toggles the section for this page view when localStorage.%s throws',
|
||||||
|
async (call) => {
|
||||||
|
const errors = collectVueErrors()
|
||||||
|
vi.spyOn(Storage.prototype, call).mockImplementation(() => {
|
||||||
|
throw securityError()
|
||||||
|
})
|
||||||
|
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
|
||||||
|
expect(section()).not.toBeNull()
|
||||||
|
expect(sectionBody()).toBeNull()
|
||||||
|
sectionToggle().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(sectionBody()).not.toBeNull()
|
||||||
|
sectionToggle().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(sectionBody()).toBeNull()
|
||||||
|
errors.stop()
|
||||||
|
expect(errors.uncaught).toEqual([])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('falls back to collapsed when reading throws, even if a choice was stored', async () => {
|
||||||
|
localStorage.setItem(SEO_SECTION_OPEN_KEY, 'true')
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw securityError()
|
||||||
|
})
|
||||||
|
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
|
||||||
|
expect(sectionBody()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently skips the write when setItem throws', async () => {
|
||||||
|
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw securityError()
|
||||||
|
})
|
||||||
|
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
await openSection()
|
||||||
|
|
||||||
|
expect(setItem).toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import { click, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import { blur, typeInto } from './directoryTestKit'
|
||||||
|
import {
|
||||||
|
ARTICLE_SLUG,
|
||||||
|
ARTICLE_TITLE,
|
||||||
|
FIRST_SENTENCE,
|
||||||
|
LONG_TITLE,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
checkBodies,
|
||||||
|
counterTexts,
|
||||||
|
fieldByLabel,
|
||||||
|
mountSeo,
|
||||||
|
openDisclosure,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
renewSite,
|
||||||
|
saveChangesButton,
|
||||||
|
seoBackend,
|
||||||
|
suggestion,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
function panelText() {
|
||||||
|
return textOf(panelRoot()!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chips(root: ParentNode = panelRoot()!) {
|
||||||
|
return Array.from(root.querySelectorAll('[data-testid="fallback-chip"]')).map((chip) =>
|
||||||
|
textOf(chip),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchElement() {
|
||||||
|
return panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function filledArticle() {
|
||||||
|
return {
|
||||||
|
seo_title: 'What a bathroom renovation costs',
|
||||||
|
seo_description:
|
||||||
|
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.',
|
||||||
|
excerpt: 'How we price a bathroom renovation, from the survey to the final invoice.',
|
||||||
|
cover_image_id: 1,
|
||||||
|
cover_image_alt: 'A tiled bathroom wall during renovation',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Opening', () => {
|
||||||
|
it('opens a 960 px slide-over on desktop with the fields, the previews, Cancel and Save changes', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
expect(panel.className).toContain('max-w-240')
|
||||||
|
expect(textOf(panel.querySelector('h2, [data-slot="title"]'))).toBe('Search and sharing')
|
||||||
|
expect(panelText()).toContain('Search result')
|
||||||
|
expect(panelText()).toContain('Social card')
|
||||||
|
expect(panelText()).toContain('Cancel')
|
||||||
|
expect(textOf(saveChangesButton(panel))).toBe('Save changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a sheet on mobile with the suggestions first, then the fields, then the two previews', async () => {
|
||||||
|
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||||
|
await click('Details')
|
||||||
|
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
sheet.querySelector<HTMLElement>('[data-testid="seo-edit"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const panel = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||||
|
).find((dialog) => dialog.querySelector('[data-testid="seo-panel-suggestions"]'))!
|
||||||
|
expect(panel.className).not.toContain('max-w-240')
|
||||||
|
expect(panel.className).toContain('h-23/25')
|
||||||
|
const text = textOf(panel)
|
||||||
|
const positions = ['All good', 'URL slug', 'Search result', 'Social card'].map((part) =>
|
||||||
|
text.indexOf(part),
|
||||||
|
)
|
||||||
|
expect(Math.min(...positions)).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Defaults, every value is a fallback and says so', () => {
|
||||||
|
it('shows the fallback of every field and its chip for an article with no SEO field set', async () => {
|
||||||
|
const backend = seoBackend({ sites: [renewSite({ default_social_image_id: 1 })] })
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openPanel()
|
||||||
|
|
||||||
|
const text = panelText()
|
||||||
|
expect(text).toContain('URL slug')
|
||||||
|
expect(text).toContain('Auto')
|
||||||
|
expect(text).toContain('Generated from the title until you edit it.')
|
||||||
|
expect(text).toContain('Meta title')
|
||||||
|
expect(text).toContain('Recommended 30 to 60 characters. This is a guide, not a limit.')
|
||||||
|
expect(text).toContain('Meta description')
|
||||||
|
expect(text).toContain('Recommended 70 to 160 characters.')
|
||||||
|
expect(text).toContain('Excerpt')
|
||||||
|
expect(text).toContain('Using the site social image (north-wall.jpg).')
|
||||||
|
expect(text).toContain('Social sharing')
|
||||||
|
expect(text).toContain('Optional')
|
||||||
|
expect(text).toContain('Uses the meta values')
|
||||||
|
expect(text).toContain('Advanced')
|
||||||
|
expect(text).toContain('Canonical URL: automatic')
|
||||||
|
expect(text).toContain('Hide from search engines')
|
||||||
|
expect(text).toContain(
|
||||||
|
"Sets noindex in the API. It doesn't change who can read the article.",
|
||||||
|
)
|
||||||
|
expect(text).toContain('Suggestions')
|
||||||
|
expect(text).toContain('What the frontend gets')
|
||||||
|
expect(chips()).toContain('Using article title')
|
||||||
|
expect(chips()).toContain('Using first sentence of the body')
|
||||||
|
expect(switchElement().getAttribute('aria-checked')).toBe('false')
|
||||||
|
expect(fieldByLabel(panelRoot()!, 'Meta title')!.placeholder).toBe(ARTICLE_TITLE)
|
||||||
|
expect(fieldByLabel(panelRoot()!, 'Meta description')!.placeholder).toBe(FIRST_SENTENCE)
|
||||||
|
expect(fieldByLabel(panelRoot()!, 'Excerpt')!.placeholder).toBe(
|
||||||
|
'A short summary. It is also sent as the article teaser.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the excerpt chip when there is an excerpt but no meta description', async () => {
|
||||||
|
await mountSeo(seoBackend({ article: { excerpt: 'An excerpt long enough to be used.' } }))
|
||||||
|
await openPanel()
|
||||||
|
|
||||||
|
expect(chips()).toContain('Using the excerpt')
|
||||||
|
expect(chips()).not.toContain('Using first sentence of the body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the site default description chip when there is no description, excerpt or body text', async () => {
|
||||||
|
const backend = seoBackend({
|
||||||
|
article: { body: '' },
|
||||||
|
sites: [renewSite({ default_description: 'One or two sentences about the site.' })],
|
||||||
|
})
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openPanel()
|
||||||
|
|
||||||
|
expect(chips()).toContain('Using the site default description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the No image chip on the social card when there is no cover and no site social image', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
|
||||||
|
expect(chips(panelRoot()!.querySelector('[data-testid="social-card"]')!)).toContain(
|
||||||
|
'No image',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the fallback chips and shows All good when the article has its own values', async () => {
|
||||||
|
await mountSeo(seoBackend({ article: filledArticle() }))
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
const fieldChips = chips(panel).filter(
|
||||||
|
(chip) => !['Using meta title', 'Using meta description'].includes(chip),
|
||||||
|
)
|
||||||
|
expect(fieldChips).not.toContain('Using article title')
|
||||||
|
expect(fieldChips).not.toContain('Using first sentence of the body')
|
||||||
|
expect(fieldChips).not.toContain('Using the excerpt')
|
||||||
|
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
|
||||||
|
'All good Nothing to suggest for this article.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Soft counters are a guide', () => {
|
||||||
|
it.each([
|
||||||
|
{ field: 'Meta title', n: 29, max: 60, amber: true },
|
||||||
|
{ field: 'Meta title', n: 30, max: 60, amber: false },
|
||||||
|
{ field: 'Meta title', n: 60, max: 60, amber: false },
|
||||||
|
{ field: 'Meta title', n: 74, max: 60, amber: true },
|
||||||
|
{ field: 'Meta description', n: 41, max: 160, amber: true },
|
||||||
|
{ field: 'Meta description', n: 70, max: 160, amber: false },
|
||||||
|
{ field: 'Meta description', n: 160, max: 160, amber: false },
|
||||||
|
{ field: 'Meta description', n: 161, max: 160, amber: true },
|
||||||
|
])('reads $n / $max for the $field and amber is $amber', async ({ field, n, max, amber }) => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, field)!, 'x'.repeat(n))
|
||||||
|
|
||||||
|
const counter = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
|
||||||
|
).find((node) => textOf(node) === `${n} / ${max}`)!
|
||||||
|
expect(counter).toBeInstanceOf(HTMLElement)
|
||||||
|
expect(counter.className).toContain('font-mono')
|
||||||
|
expect(counter.className).toContain(amber ? 'text-warning' : 'text-muted')
|
||||||
|
expect(counter.className).not.toContain('text-error')
|
||||||
|
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns both counters amber, never red, outside the soft ranges, and cuts the preview off', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(
|
||||||
|
backend,
|
||||||
|
checkAnswer([
|
||||||
|
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||||
|
suggestion('desc_short', 'seo_description', { length: 33, min: 70 }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
|
||||||
|
await typeInto(
|
||||||
|
fieldByLabel(panel, 'Meta description')!,
|
||||||
|
'What a bathroom renovation costs.',
|
||||||
|
)
|
||||||
|
|
||||||
|
const counters = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
|
||||||
|
).slice(0, 2)
|
||||||
|
expect(counters.map((node) => node.className)).toEqual([
|
||||||
|
expect.stringContaining('text-warning'),
|
||||||
|
expect.stringContaining('text-warning'),
|
||||||
|
])
|
||||||
|
expect(counters.some((node) => node.className.includes('text-error'))).toBe(false)
|
||||||
|
const suggestions = textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))
|
||||||
|
expect(suggestions).toContain('The meta title is 74 characters.')
|
||||||
|
expect(suggestions).toContain('The description is 33 characters.')
|
||||||
|
expect(panelText()).toContain('Cut off here, the way a search engine would.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The API length limits are not reachable', () => {
|
||||||
|
it('stops each input at the limit the API refuses beyond', async () => {
|
||||||
|
await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
|
||||||
|
const panel = await openPanel()
|
||||||
|
await openDisclosure('Social sharing')
|
||||||
|
await openDisclosure('Advanced')
|
||||||
|
|
||||||
|
const limits: Record<string, string> = {
|
||||||
|
'Meta title': '120',
|
||||||
|
'Meta description': '320',
|
||||||
|
'Alt text': '200',
|
||||||
|
'Social title': '120',
|
||||||
|
'Social description': '320',
|
||||||
|
'Canonical URL': '2048',
|
||||||
|
'URL slug': '100',
|
||||||
|
}
|
||||||
|
for (const [label, max] of Object.entries(limits)) {
|
||||||
|
expect(fieldByLabel(panel, label)!.getAttribute('maxlength'), label).toBe(max)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Excerpt over 300', () => {
|
||||||
|
it('shows an inline error and disables Save changes until it is 300 or fewer, sending nothing for it', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const excerpt = fieldByLabel(panel, 'Excerpt')!
|
||||||
|
|
||||||
|
await typeInto(excerpt, 'x'.repeat(301))
|
||||||
|
await vi.advanceTimersByTimeAsync(600)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelText()).toContain('Shorten the excerpt to 300 characters or fewer.')
|
||||||
|
expect(panelText()).toContain('301 / 300')
|
||||||
|
expect(saveChangesButton(panel).disabled).toBe(true)
|
||||||
|
expect(checkBodies(backend).every((body) => !('excerpt' in (body ?? {})))).toBe(true)
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
|
||||||
|
await typeInto(excerpt, 'x'.repeat(300))
|
||||||
|
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||||
|
expect(panelText()).not.toContain('Shorten the excerpt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Slug edited, Auto and Reset', () => {
|
||||||
|
it('drops the Auto badge and offers Reset when the pencil is used, and Reset brings Auto back', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(panelText()).toContain('Auto')
|
||||||
|
|
||||||
|
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
const slug = fieldByLabel(panel, 'URL slug')!
|
||||||
|
expect(panelText()).not.toContain('Generated from the title until you edit it.')
|
||||||
|
expect(slug.readOnly).toBe(false)
|
||||||
|
expect(slug.value).toBe(ARTICLE_SLUG)
|
||||||
|
expect(panelText()).toContain('Reset to match the title')
|
||||||
|
|
||||||
|
await typeInto(slug, 'bathroom-costs')
|
||||||
|
expect(slug.value).toBe('bathroom-costs')
|
||||||
|
|
||||||
|
await click('Reset to match the title')
|
||||||
|
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
|
||||||
|
expect(panelText()).toContain('Auto')
|
||||||
|
expect(panelText()).not.toContain('Reset to match the title')
|
||||||
|
void backend
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the slug as an empty string when a custom slug is reset, and checks it', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend({ article: { slug: 'bathroom-costs' } }))
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs')
|
||||||
|
expect(panelText()).toContain('Reset to match the title')
|
||||||
|
|
||||||
|
await click('Reset to match the title')
|
||||||
|
|
||||||
|
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ slug: '' })
|
||||||
|
saveChangesButton(panel).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(patchBodies(backend)).toEqual([{ slug: '' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalises what is typed to lowercase letters, numbers and hyphens', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, 'URL slug')!, 'Bathroom Costs!')
|
||||||
|
|
||||||
|
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cover and alt text', () => {
|
||||||
|
async function pickNorthWall(panel: HTMLElement) {
|
||||||
|
const choose = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||||
|
(button) => textOf(button) === 'Choose image',
|
||||||
|
)!
|
||||||
|
choose.click()
|
||||||
|
await flushPromises()
|
||||||
|
const dialogs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||||
|
const picker = dialogs.at(-1)!
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens the shared picker titled Choose a cover image with Use image, and shows the alt field once a cover is picked', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
const picker = await pickNorthWall(panel)
|
||||||
|
|
||||||
|
expect(textOf(picker)).toContain('Choose a cover image')
|
||||||
|
expect(textOf(picker)).toContain('The cover for this article.')
|
||||||
|
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(picker.querySelectorAll<HTMLButtonElement>('button'))
|
||||||
|
.find((button) => textOf(button) === 'Use image')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelText()).toContain('north-wall.jpg')
|
||||||
|
const alt = fieldByLabel(panel, 'Alt text')!
|
||||||
|
expect(alt.placeholder).toBe("Describe the image for people who can't see it")
|
||||||
|
expect(panelText()).toContain(
|
||||||
|
'Needed before the cover counts as ready. Sent as image alt in the API.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('brings the site social image line back and sends a null cover on save when the cover is removed', async () => {
|
||||||
|
const backend = await mountSeo(
|
||||||
|
seoBackend({
|
||||||
|
article: { cover_image_id: 1, cover_image_alt: 'A wall' },
|
||||||
|
sites: [renewSite({ default_social_image_id: 2 })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(panelText()).not.toContain('Using the site social image')
|
||||||
|
|
||||||
|
await click('Remove', panel)
|
||||||
|
|
||||||
|
expect(panelText()).toContain('Using the site social image (kitchen-after.webp).')
|
||||||
|
saveChangesButton(panel).click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(patchBodies(backend)).toEqual([{ cover_image_id: null }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Social sharing', () => {
|
||||||
|
it('shows the Using chips while nothing is set, and Customised with the overrides on the card once it is', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await openDisclosure('Social sharing')
|
||||||
|
|
||||||
|
expect(chips()).toEqual(
|
||||||
|
expect.arrayContaining(['Using meta title', 'Using meta description']),
|
||||||
|
)
|
||||||
|
expect(panelText()).toContain('Using the cover image.')
|
||||||
|
expect(panelText()).not.toContain('Customised')
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, 'Social title')!, 'Bathroom costs, explained')
|
||||||
|
await typeInto(
|
||||||
|
fieldByLabel(panel, 'Social description')!,
|
||||||
|
'A plain guide to what drives the price.',
|
||||||
|
)
|
||||||
|
|
||||||
|
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
|
||||||
|
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
|
||||||
|
'Bathroom costs, explained',
|
||||||
|
)
|
||||||
|
expect(textOf(card.querySelector('[data-testid="social-card-description"]'))).toBe(
|
||||||
|
'A plain guide to what drives the price.',
|
||||||
|
)
|
||||||
|
expect(chips()).not.toContain('Using meta title')
|
||||||
|
expect(chips()).not.toContain('Using meta description')
|
||||||
|
expect(panelText()).toContain('Customised')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Advanced, canonical URL', () => {
|
||||||
|
it.each([
|
||||||
|
{ value: 'renew.example/costs', error: true },
|
||||||
|
{ value: 'http://renew.example/a', error: true },
|
||||||
|
{ value: 'https://renew.example/a', error: false },
|
||||||
|
{ value: '', error: false },
|
||||||
|
])('for "$value" the error is shown: $error', async ({ value, error }) => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await openDisclosure('Advanced')
|
||||||
|
const canonical = fieldByLabel(panel, 'Canonical URL')!
|
||||||
|
|
||||||
|
await typeInto(canonical, value)
|
||||||
|
await blur(canonical)
|
||||||
|
|
||||||
|
const message = 'Enter a full address starting with https://'
|
||||||
|
expect(panelText().includes(message)).toBe(error)
|
||||||
|
expect(canonical.getAttribute('aria-invalid') === 'true').toBe(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Canonical URL: automatic when collapsed and empty', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
await openPanel()
|
||||||
|
|
||||||
|
expect(panelText()).toContain('Canonical URL: automatic')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hide from search engines', () => {
|
||||||
|
it('keeps the helper visible and turns the switch on, and the next check answers hidden', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
answerChecks(backend, checkAnswer([], 'hidden'))
|
||||||
|
|
||||||
|
switchElement().click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(switchElement().getAttribute('aria-checked')).toBe('true')
|
||||||
|
expect(textOf(panel)).toContain(
|
||||||
|
"Sets noindex in the API. It doesn't change who can read the article.",
|
||||||
|
)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
|
||||||
|
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||||
|
'Hidden from search engines. This only sets a flag in the API.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The search result preview', () => {
|
||||||
|
it.each([
|
||||||
|
{ device: 'Desktop', title: 60, description: 160 },
|
||||||
|
{ device: 'Mobile', title: 70, description: 120 },
|
||||||
|
])(
|
||||||
|
'cuts the title at $title and the description at $description on $device',
|
||||||
|
async (limits) => {
|
||||||
|
await mountSeo(
|
||||||
|
seoBackend({
|
||||||
|
article: { seo_title: LONG_TITLE, seo_description: 'd'.repeat(200) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
const tab = Array.from(panel.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||||
|
(candidate) => textOf(candidate) === limits.device,
|
||||||
|
)!
|
||||||
|
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||||
|
tab.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const title = textOf(panel.querySelector('[data-testid="search-preview-title"]'))
|
||||||
|
const description = textOf(
|
||||||
|
panel.querySelector('[data-testid="search-preview-description"]'),
|
||||||
|
)
|
||||||
|
expect([...title]).toHaveLength(limits.title)
|
||||||
|
expect(title.endsWith('…')).toBe(true)
|
||||||
|
expect([...description]).toHaveLength(limits.description)
|
||||||
|
expect(description.endsWith('…')).toBe(true)
|
||||||
|
const card = panel.querySelector<HTMLElement>('[data-testid="search-preview-card"]')!
|
||||||
|
expect(card.className.includes('md:w-70')).toBe(limits.device === 'Mobile')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The previews follow the typing, with the fallbacks', () => {
|
||||||
|
it('shows the article title with the site title template when the meta title is cleared', async () => {
|
||||||
|
await mountSeo(seoBackend({ article: { seo_title: 'Typed meta title' } }))
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
|
||||||
|
'Typed meta title | Renew',
|
||||||
|
)
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, '')
|
||||||
|
|
||||||
|
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
|
||||||
|
`${ARTICLE_TITLE} | Renew`,
|
||||||
|
)
|
||||||
|
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
|
||||||
|
expect(card.querySelector('[data-testid="social-card-image"]')!.className).toContain(
|
||||||
|
'aspect-191/100',
|
||||||
|
)
|
||||||
|
expect(textOf(card)).toContain('renew.example')
|
||||||
|
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
|
||||||
|
`${ARTICLE_TITLE} | Renew`,
|
||||||
|
)
|
||||||
|
expect(card.querySelector('[data-testid="social-card-description"]')!.className).toContain(
|
||||||
|
'line-clamp-2',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Keyboard', () => {
|
||||||
|
it('closes the panel with Escape as Cancel does, discards the typing and returns focus to Edit search and sharing', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
|
||||||
|
|
||||||
|
panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(document.activeElement).toBe(document.body.querySelector('[data-testid="seo-edit"]'))
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
|
||||||
|
await openPanel()
|
||||||
|
expect(fieldByLabel(panelRoot()!, 'Meta title')!.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards the typing with Cancel without changing the status', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
|
||||||
|
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
void openSection
|
||||||
|
void counterTexts
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import {
|
||||||
|
click,
|
||||||
|
failWith,
|
||||||
|
metaKit,
|
||||||
|
networkFailure,
|
||||||
|
pending,
|
||||||
|
textOf,
|
||||||
|
unmountMeta,
|
||||||
|
} from './metaTestKit'
|
||||||
|
import { typeInto } from './directoryTestKit'
|
||||||
|
import {
|
||||||
|
FOUR_SUGGESTIONS,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
checkBodies,
|
||||||
|
checkCalls,
|
||||||
|
fieldByLabel,
|
||||||
|
mountSeo,
|
||||||
|
openDisclosure,
|
||||||
|
openPanel,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
saveChangesButton,
|
||||||
|
seoBackend,
|
||||||
|
} from './seoTestKit'
|
||||||
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
const NOTICE =
|
||||||
|
"Couldn't save search and sharing. Your changes are still here. Try again in a moment."
|
||||||
|
|
||||||
|
function toasts() {
|
||||||
|
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveButton() {
|
||||||
|
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTitle(panel: HTMLElement, value = 'A new meta title') {
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(panel: HTMLElement) {
|
||||||
|
saveChangesButton(panel).click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editBody(html: string) {
|
||||||
|
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failedPanelSave(failure = failWith(500)) {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failure
|
||||||
|
await submit(panel)
|
||||||
|
return { backend, panel }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Save changes commits the panel to the draft and saves', () => {
|
||||||
|
it('sends exactly one PATCH holding seo_title only', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
await changeTitle(panel)
|
||||||
|
await submit(panel)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([{ seo_title: 'A new meta title' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the fields that changed and no unchanged one', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await openDisclosure('Social sharing')
|
||||||
|
await openDisclosure('Advanced')
|
||||||
|
|
||||||
|
await typeInto(fieldByLabel(panel, 'Excerpt')!, 'A short summary')
|
||||||
|
await typeInto(fieldByLabel(panel, 'Social title')!, 'Shared title')
|
||||||
|
await typeInto(fieldByLabel(panel, 'Canonical URL')!, 'https://renew.example/costs')
|
||||||
|
panel.querySelector<HTMLElement>('button[role="switch"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await submit(panel)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([
|
||||||
|
{
|
||||||
|
excerpt: 'A short summary',
|
||||||
|
social_title: 'Shared title',
|
||||||
|
canonical_url: 'https://renew.example/costs',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the pending body edit in the same PATCH', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
await editBody('<p>Edited on screen</p>')
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
await changeTitle(panel)
|
||||||
|
await submit(panel)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([
|
||||||
|
{ seo_title: 'A new meta title', body: '<p>Edited on screen</p>' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes without a request when nothing changed', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
await submit(panel)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saving state', () => {
|
||||||
|
it('disables every field of the panel and reads Saving… while the request runs', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = () => gate.promise
|
||||||
|
|
||||||
|
await submit(panel)
|
||||||
|
|
||||||
|
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(true)
|
||||||
|
expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true)
|
||||||
|
expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true)
|
||||||
|
expect(textOf(panel)).toContain('Saving…')
|
||||||
|
expect(
|
||||||
|
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
|
||||||
|
).toContain('Save')
|
||||||
|
expect(document.body.textContent).toContain('Saving…')
|
||||||
|
gate.resolve({})
|
||||||
|
await flushPromises()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saved', () => {
|
||||||
|
it('closes the panel, reads Saved, refreshes the check and toasts', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
|
||||||
|
await submit(panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(document.body.textContent).toContain('Saved')
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({})
|
||||||
|
expect(toasts()).toContain('Search and sharing saved')
|
||||||
|
expect(toasts()).toContain('Your search and sharing settings are up to date.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Saved refreshes the check', () => {
|
||||||
|
it('shows the answer of the new check, not the cached one, after the panel closes', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||||
|
|
||||||
|
await submit(panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||||
|
'The meta title is 74 characters.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Save failed', () => {
|
||||||
|
it.each([
|
||||||
|
['status 500', failWith(500)],
|
||||||
|
['a network error', networkFailure],
|
||||||
|
])('keeps the panel open with the notice and the values on %s', async (_name, failure) => {
|
||||||
|
const { panel } = await failedPanelSave(failure)
|
||||||
|
|
||||||
|
expect(panelRoot()).not.toBeNull()
|
||||||
|
expect(textOf(panel)).toContain(NOTICE)
|
||||||
|
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A new meta title')
|
||||||
|
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
|
||||||
|
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||||
|
expect(document.body.textContent).toContain('Save failed')
|
||||||
|
expect(document.body.textContent).toContain('Retry')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The editor Save button treats SEO edits like any other edit', () => {
|
||||||
|
async function closeAfterFailure() {
|
||||||
|
const { backend, panel } = await failedPanelSave()
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = () => backend.article
|
||||||
|
backend.responders['PATCH /v1/article/12/publish'] = () => ({
|
||||||
|
...backend.article,
|
||||||
|
is_online: true,
|
||||||
|
})
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reads Unsaved changes, enables Save and offers Save and publish', async () => {
|
||||||
|
await closeAfterFailure()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Unsaved changes')
|
||||||
|
expect(saveButton().disabled).toBe(false)
|
||||||
|
expect(document.body.textContent).toContain('Save and publish')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends seo_title with the Save button', async () => {
|
||||||
|
const backend = await closeAfterFailure()
|
||||||
|
|
||||||
|
saveButton().click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends seo_title with Ctrl+S', async () => {
|
||||||
|
const backend = await closeAfterFailure()
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends seo_title with Cmd+S', async () => {
|
||||||
|
const backend = await closeAfterFailure()
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves with Save and publish and then sends the publish request', async () => {
|
||||||
|
const backend = await closeAfterFailure()
|
||||||
|
const before = backend.calls.length
|
||||||
|
|
||||||
|
await click('Save and publish')
|
||||||
|
|
||||||
|
const after = backend.calls.slice(before).filter((call) => call.method === 'PATCH')
|
||||||
|
expect(after.map((call) => call.path)).toEqual(['/v1/article/12', '/v1/article/12/publish'])
|
||||||
|
expect(after[0]!.body).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not publish when the save in front of it fails', async () => {
|
||||||
|
const backend = await closeAfterFailure()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failWith(500)
|
||||||
|
|
||||||
|
await click('Save and publish')
|
||||||
|
|
||||||
|
expect(backend.calls.filter((call) => call.path === '/v1/article/12/publish')).toHaveLength(
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Leaving with unsaved SEO edits', () => {
|
||||||
|
it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => {
|
||||||
|
const { panel } = await failedPanelSave()
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const leaving = router.push('/article')
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
||||||
|
'Leave without saving?',
|
||||||
|
)
|
||||||
|
await click('Keep editing')
|
||||||
|
await leaving
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A storage that throws never loses the editor', () => {
|
||||||
|
it('saves, and asks before leaving, with the values in memory when sessionStorage throws', async () => {
|
||||||
|
;['getItem', 'setItem', 'removeItem'].forEach((method) =>
|
||||||
|
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(function (
|
||||||
|
this: Storage,
|
||||||
|
) {
|
||||||
|
if (this === sessionStorage) {
|
||||||
|
throw new DOMException('blocked', 'SecurityError')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failWith(500)
|
||||||
|
await submit(panel)
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const leaving = router.push('/article')
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
||||||
|
'Leave without saving?',
|
||||||
|
)
|
||||||
|
await click('Keep editing')
|
||||||
|
await leaving
|
||||||
|
|
||||||
|
backend.responders['PATCH /v1/article/12'] = () => backend.article
|
||||||
|
saveButton().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Suggestions never block', () => {
|
||||||
|
it.each([
|
||||||
|
['suggestions', checkAnswer(FOUR_SUGGESTIONS)],
|
||||||
|
['hidden', checkAnswer(FOUR_SUGGESTIONS, 'hidden')],
|
||||||
|
])(
|
||||||
|
'keeps Save, Save and publish and Ctrl+S working when the check says %s',
|
||||||
|
async (_name, answer) => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, answer)
|
||||||
|
await mountSeo(backend)
|
||||||
|
await editBody('<p>Edited</p>')
|
||||||
|
|
||||||
|
expect(saveButton().disabled).toBe(false)
|
||||||
|
expect(document.body.textContent).toContain('Save and publish')
|
||||||
|
const publish = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||||
|
).find((button) => textOf(button) === 'Save and publish')!
|
||||||
|
expect(publish.disabled).toBe(false)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||||
|
await flushPromises()
|
||||||
|
expect(patchBodies(backend)).toEqual([{ body: '<p>Edited</p>' }])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel', () => {
|
||||||
|
it('discards the typing without a request and without changing the status', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
await changeTitle(panel)
|
||||||
|
const calls = backend.calls.length
|
||||||
|
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(document.body.textContent).not.toContain('Unsaved changes')
|
||||||
|
expect(backend.calls.slice(calls).filter((call) => call.method !== 'GET')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Offline', () => {
|
||||||
|
it('shows the failed check, fails the dialog and lets the writing continue', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = networkFailure
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = networkFailure
|
||||||
|
await mountSeo(backend)
|
||||||
|
document.body.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
|
||||||
|
"Couldn't check this article.",
|
||||||
|
)
|
||||||
|
await click('What the frontend gets')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain("Couldn't load the SEO data")
|
||||||
|
|
||||||
|
await editBody('<p>Still writing</p>')
|
||||||
|
expect(document.body.textContent).toContain('Unsaved changes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Roles', () => {
|
||||||
|
it.each(['owner', 'admin', 'verified'] as const)(
|
||||||
|
'makes the section, the panel and the dialog available to a %s',
|
||||||
|
async (role) => {
|
||||||
|
const backend = seoBackend({ role })
|
||||||
|
backend.responders['GET /v1/article/12/seo'] = () => ({
|
||||||
|
title: 'T',
|
||||||
|
description: 'D',
|
||||||
|
canonical_url: 'https://renew.example/x',
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: { type: 'article', image: '', image_alt: '' },
|
||||||
|
twitter: { card: 'summary' },
|
||||||
|
image: { url: '', alt: '', source: 'none' },
|
||||||
|
authors: [],
|
||||||
|
published_at: null,
|
||||||
|
modified_at: '2026-09-26T11:02:00Z',
|
||||||
|
fallbacks: {
|
||||||
|
title: 'title',
|
||||||
|
description: 'body',
|
||||||
|
image: 'none',
|
||||||
|
canonical: 'site',
|
||||||
|
},
|
||||||
|
json_ld: { '@type': 'Article' },
|
||||||
|
})
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(textOf(panel)).toContain('Search and sharing')
|
||||||
|
await click('Cancel', panel)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await click('What the frontend gets')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="api-preview"]')).not.toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import { click, failWith, metaSection, pending, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import {
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
checkCalls,
|
||||||
|
mountSeo,
|
||||||
|
section,
|
||||||
|
sectionSummary,
|
||||||
|
seoBackend,
|
||||||
|
suggestion,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountMeta)
|
||||||
|
|
||||||
|
describe('Position, in the sidebar and in the Details sheet', () => {
|
||||||
|
it('shows a collapsed section directly under Site and authors in the details sidebar', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
|
||||||
|
const details = document.body.querySelector('[data-testid="article-details"]')!
|
||||||
|
const seo = section()!
|
||||||
|
expect(details.contains(seo)).toBe(true)
|
||||||
|
expect(textOf(seo)).toContain('Search and sharing')
|
||||||
|
expect(seo.previousElementSibling).toBe(metaSection())
|
||||||
|
expect(seo.querySelector('[data-testid="seo-toggle"]')!.getAttribute('aria-expanded')).toBe(
|
||||||
|
'false',
|
||||||
|
)
|
||||||
|
expect(seo.querySelector('[data-testid="seo-body"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the section out of sight on mobile until the Details sheet is opened, under Site and authors', async () => {
|
||||||
|
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="article-details"]')!.className).toContain(
|
||||||
|
'hidden',
|
||||||
|
)
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
|
||||||
|
await click('Details')
|
||||||
|
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
const inSheet = section(sheet)!
|
||||||
|
expect(inSheet.previousElementSibling).toBe(
|
||||||
|
sheet.querySelector('[data-testid="meta-section"]'),
|
||||||
|
)
|
||||||
|
expect(sectionSummary(inSheet)?.textContent).toBe('Ready')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The one-line summary follows the check', () => {
|
||||||
|
it.each([
|
||||||
|
{ name: 'pending', check: null, summary: 'Checking…', warning: false },
|
||||||
|
{ name: 'ready', check: checkAnswer([]), summary: 'Ready', warning: false },
|
||||||
|
{
|
||||||
|
name: 'one suggestion',
|
||||||
|
check: checkAnswer([suggestion('cover_none', 'cover_image_id')]),
|
||||||
|
summary: '1 suggestion',
|
||||||
|
warning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'two suggestions',
|
||||||
|
check: checkAnswer([
|
||||||
|
suggestion('cover_none', 'cover_image_id'),
|
||||||
|
suggestion('no_au', 'author_ids'),
|
||||||
|
]),
|
||||||
|
summary: '2 suggestions',
|
||||||
|
warning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hidden with two suggestions',
|
||||||
|
check: checkAnswer(
|
||||||
|
[suggestion('cover_none', 'cover_image_id'), suggestion('no_au', 'author_ids')],
|
||||||
|
'hidden',
|
||||||
|
),
|
||||||
|
summary: 'Hidden from search',
|
||||||
|
warning: false,
|
||||||
|
},
|
||||||
|
])('reads "$summary" for a $name check', async ({ check, summary, warning }) => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
answerChecks(backend, () => (check ? check : gate.promise))
|
||||||
|
await mountSeo(backend)
|
||||||
|
|
||||||
|
const line = sectionSummary()!
|
||||||
|
expect(line.textContent).toBe(summary)
|
||||||
|
expect(line.className).toContain(warning ? 'text-warning' : 'text-muted')
|
||||||
|
expect(line.className).not.toContain(warning ? 'text-muted' : 'text-warning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads Couldn't check this article. rather than Ready when the check fails", async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = failWith(500)
|
||||||
|
await mountSeo(backend)
|
||||||
|
|
||||||
|
expect(sectionSummary()!.textContent).toBe("Couldn't check this article.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The existing Editor states show the section', () => {
|
||||||
|
it('shows the collapsed section with Ready in the loaded editor', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
|
||||||
|
expect(sectionSummary()!.textContent).toBe('Ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the section header with a skeleton summary while the article loads', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
backend.responders['GET /v1/article/12'] = () => gate.promise
|
||||||
|
await mountSeo(backend)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
|
||||||
|
expect(textOf(skeleton)).toContain('Search and sharing')
|
||||||
|
expect(skeleton.querySelector('[data-testid="skeleton-seo"]')).not.toBeNull()
|
||||||
|
expect(section()).toBeNull()
|
||||||
|
expect(checkCalls(backend)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no section in the Not found state', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['GET /v1/article/12'] = failWith(404)
|
||||||
|
await mountSeo(backend)
|
||||||
|
|
||||||
|
expect(section()).toBeNull()
|
||||||
|
expect(checkCalls(backend)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import router from '@/router'
|
||||||
|
import RootComponent from '@/App.vue'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
|
import { typeInto } from './directoryTestKit'
|
||||||
|
import { oneSite } from './editorMetaKit'
|
||||||
|
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
||||||
|
|
||||||
|
function reply(status: number, body: unknown = null, text = '') {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => text,
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = {
|
||||||
|
id: 12,
|
||||||
|
title: 'How we price a bathroom renovation',
|
||||||
|
body: '<p>Intro</p>',
|
||||||
|
is_online: false,
|
||||||
|
site_id: 1,
|
||||||
|
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
excerpt: '',
|
||||||
|
seo_title: '',
|
||||||
|
seo_description: '',
|
||||||
|
cover_image_id: null,
|
||||||
|
cover_image_alt: '',
|
||||||
|
social_title: '',
|
||||||
|
social_description: '',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: '',
|
||||||
|
noindex: false,
|
||||||
|
created_at: '2026-03-12T09:14:00Z',
|
||||||
|
updated_at: '2026-09-26T11:02:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||||
|
|
||||||
|
type Handler = () => Response | Promise<Response>
|
||||||
|
|
||||||
|
let wrapper: ReturnType<typeof mount> | null = null
|
||||||
|
|
||||||
|
function stubFetch(handlers: Record<string, Handler>) {
|
||||||
|
const routes: Record<string, Handler> = {
|
||||||
|
'GET /v1/site/': () => reply(200, oneSite),
|
||||||
|
'GET /v1/article/12?format=html': () => reply(200, article),
|
||||||
|
'POST /v1/article/12/seo/check': () => reply(200, ready),
|
||||||
|
'GET /v1/media/?page=1&size=100&sort=newest': () => reply(200, { items: [] }),
|
||||||
|
...handlers,
|
||||||
|
}
|
||||||
|
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
const handler = routes[`${init?.method ?? 'GET'} ${url}`]
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`Unexpected fetch call: ${init?.method ?? 'GET'} ${url}`)
|
||||||
|
}
|
||||||
|
return Promise.resolve(handler())
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountEditor() {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
setViewportWidth(DESKTOP_WIDTH)
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { verified: true, username: 'marta' }
|
||||||
|
session.status = 'ready'
|
||||||
|
await router.push('/article/12/edit')
|
||||||
|
wrapper = mount(RootComponent, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
await router.push('/login')
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A session problem during the check follows the existing rules', () => {
|
||||||
|
it('lands on the login page with the redirect back to the editor when the check answers 401', async () => {
|
||||||
|
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(401) })
|
||||||
|
|
||||||
|
await mountEditor()
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
|
expect(router.currentRoute.value.query).toMatchObject({
|
||||||
|
redirect: '/article/12/edit',
|
||||||
|
reason: 'expired',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Not found editor state when the check answers 404', async () => {
|
||||||
|
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(404) })
|
||||||
|
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||||
|
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Session expiry keeps the SEO draft', () => {
|
||||||
|
it('mirrors the draft to sessionStorage on 401 and restores it, with the panel fields, after logging in', async () => {
|
||||||
|
stubFetch({ 'PATCH /v1/article/12?format=html': () => reply(401) })
|
||||||
|
await mountEditor()
|
||||||
|
const panel = await openPanel()
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A title kept across the login')
|
||||||
|
saveChangesButton(panel).click()
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
|
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||||
|
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
|
||||||
|
expect(mirrored.seo_title).toBe('A title kept across the login')
|
||||||
|
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
stubFetch({})
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Unsaved changes')
|
||||||
|
const restored = await openPanel()
|
||||||
|
expect(fieldByLabel(restored, 'Meta title')!.value).toBe('A title kept across the login')
|
||||||
|
expect(panelRoot()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { click, failWith, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import { blur, typeInto } from './directoryTestKit'
|
||||||
|
import {
|
||||||
|
checkAnswer,
|
||||||
|
checkBodies,
|
||||||
|
checkCalls,
|
||||||
|
answerChecks,
|
||||||
|
fieldByLabel,
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
saveChangesButton,
|
||||||
|
seoBackend,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountMeta)
|
||||||
|
|
||||||
|
const TAKEN = checkAnswer([], 'ready', { available: false, suggestion: 'bathroom-costs-2' })
|
||||||
|
|
||||||
|
async function typeSlug(panel: HTMLElement, value: string) {
|
||||||
|
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
const slug = fieldByLabel(panel, 'URL slug')!
|
||||||
|
await typeInto(slug, value)
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugButtons(panel: HTMLElement) {
|
||||||
|
return Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).map((button) =>
|
||||||
|
textOf(button),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Slug availability is checked on blur', () => {
|
||||||
|
it('sends one check with the typed slug and shows the taken state with the suggestion', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
answerChecks(backend, TAKEN)
|
||||||
|
|
||||||
|
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||||
|
await blur(slug)
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs' })
|
||||||
|
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
|
||||||
|
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the suggested slug in manual mode, clears the error and the action and checks it', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
answerChecks(backend, TAKEN)
|
||||||
|
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||||
|
await blur(slug)
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
answerChecks(backend, checkAnswer([]))
|
||||||
|
|
||||||
|
await click('Use bathroom-costs-2', panel)
|
||||||
|
|
||||||
|
const field = fieldByLabel(panel, 'URL slug')!
|
||||||
|
expect(field.value).toBe('bathroom-costs-2')
|
||||||
|
expect(field.readOnly).toBe(false)
|
||||||
|
expect(textOf(panel)).not.toContain('is already used on Renew.')
|
||||||
|
expect(slugButtons(panel)).not.toContain('Use bathroom-costs-2')
|
||||||
|
expect(textOf(panel)).toContain('Reset to match the title')
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs-2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows neither an error nor an action for a slug the article already owns', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
answerChecks(backend, checkAnswer([], 'ready', { available: true, suggestion: null }))
|
||||||
|
|
||||||
|
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||||
|
await blur(slug)
|
||||||
|
|
||||||
|
expect(textOf(panel)).not.toContain('is already used')
|
||||||
|
expect(slugButtons(panel).some((label) => label.startsWith('Use '))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Slug taken on save', () => {
|
||||||
|
it('keeps the panel open with the taken state and the suggestion of a follow-up check, applying nothing', async () => {
|
||||||
|
const backend = await mountSeo(seoBackend())
|
||||||
|
const panel = await openPanel()
|
||||||
|
const slug = await typeSlug(panel, 'bathroom-costs')
|
||||||
|
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A kept title')
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failWith(409, 'slug_taken')
|
||||||
|
answerChecks(backend, TAKEN)
|
||||||
|
void slug
|
||||||
|
|
||||||
|
saveChangesButton(panel).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(panelRoot()).not.toBeNull()
|
||||||
|
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
|
||||||
|
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
|
||||||
|
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A kept title')
|
||||||
|
expect(checkBodies(backend).at(-1)).toEqual({
|
||||||
|
slug: 'bathroom-costs',
|
||||||
|
seo_title: 'A kept title',
|
||||||
|
})
|
||||||
|
expect(patchBodies(backend)).toEqual([
|
||||||
|
{ seo_title: 'A kept title', slug: 'bathroom-costs' },
|
||||||
|
])
|
||||||
|
expect(document.body.textContent).toContain('Unsaved changes')
|
||||||
|
expect(document.body.textContent).not.toContain('Save failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import {
|
||||||
|
click,
|
||||||
|
failWith,
|
||||||
|
networkFailure,
|
||||||
|
pending,
|
||||||
|
renewSites,
|
||||||
|
textOf,
|
||||||
|
unmountMeta,
|
||||||
|
} from './metaTestKit'
|
||||||
|
import {
|
||||||
|
ARTICLE_SLUG,
|
||||||
|
ARTICLE_TITLE,
|
||||||
|
FOUR_SUGGESTIONS,
|
||||||
|
answerChecks,
|
||||||
|
checkAnswer,
|
||||||
|
checkCalls,
|
||||||
|
mountSeo,
|
||||||
|
openSection,
|
||||||
|
panelRoot,
|
||||||
|
patchBodies,
|
||||||
|
renewSite,
|
||||||
|
sectionBody,
|
||||||
|
sectionSummary,
|
||||||
|
seoBackend,
|
||||||
|
suggestion,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
|
||||||
|
|
||||||
|
Range.prototype.getClientRects = emptyRectList
|
||||||
|
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountMeta)
|
||||||
|
|
||||||
|
function suggestionRows() {
|
||||||
|
return Array.from(sectionBody()!.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toasts() {
|
||||||
|
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('The open section: address, suggestions and actions', () => {
|
||||||
|
it('shows the address, All good and the two actions when the check is ready', async () => {
|
||||||
|
await mountSeo(seoBackend())
|
||||||
|
const body = await openSection()
|
||||||
|
|
||||||
|
const text = textOf(body)
|
||||||
|
expect(text).toContain('Address')
|
||||||
|
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
|
||||||
|
expect(text).not.toContain('https://')
|
||||||
|
expect(text).toContain('All good')
|
||||||
|
expect(text).toContain('Nothing to suggest for this article.')
|
||||||
|
expect(text).toContain('Edit search and sharing')
|
||||||
|
expect(text).toContain('What the frontend gets')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the host of the article site and the slug for another site', async () => {
|
||||||
|
const journal = renewSite({
|
||||||
|
...renewSites()[1]!,
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
base_url: 'https://journal.renew.example',
|
||||||
|
})
|
||||||
|
const backend = seoBackend({ sites: [renewSite(), journal], article: { site_id: 2 } })
|
||||||
|
await mountSeo(backend)
|
||||||
|
const body = await openSection()
|
||||||
|
|
||||||
|
expect(textOf(body)).toContain(`journal.renew.example/${ARTICLE_SLUG}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows at most three suggestions, in the order of the response, each with its link', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
|
||||||
|
const rows = suggestionRows()
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows.map((row) => textOf(row))).toEqual([
|
||||||
|
'The meta title is 74 characters. Search engines may cut it off after about 60. Go to meta title',
|
||||||
|
'The cover image has no alt text. Add a short description of it. Go to cover image',
|
||||||
|
'The body has no headings. H2 headings help readers scan the article. Go to body',
|
||||||
|
])
|
||||||
|
expect(textOf(sectionBody())).toContain('+1 more in Search and sharing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the panel with the suggestion list in view from "+1 more in Search and sharing"', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
|
||||||
|
await click('+1 more in Search and sharing')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const panel = panelRoot()!
|
||||||
|
expect(panel).not.toBeNull()
|
||||||
|
const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')!
|
||||||
|
expect(document.activeElement).toBe(suggestions)
|
||||||
|
expect(textOf(suggestions)).toContain('The body is 40 characters.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no "+N more" action with three suggestions or fewer', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS.slice(0, 3)))
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
|
||||||
|
expect(suggestionRows()).toHaveLength(3)
|
||||||
|
expect(textOf(sectionBody())).not.toContain('more in Search and sharing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Checking this article… and keeps the address and both actions usable while the check is pending', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
const gate = pending<unknown>()
|
||||||
|
answerChecks(backend, () => gate.promise)
|
||||||
|
await mountSeo(backend)
|
||||||
|
const body = await openSection()
|
||||||
|
|
||||||
|
const text = textOf(body)
|
||||||
|
expect(text).toContain('Checking this article…')
|
||||||
|
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
|
||||||
|
const edit = body.querySelector<HTMLButtonElement>('[data-testid="seo-edit"]')!
|
||||||
|
const preview = body.querySelector<HTMLButtonElement>('[data-testid="seo-preview"]')!
|
||||||
|
expect(edit.disabled).toBe(false)
|
||||||
|
expect(preview.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains the flag when the article is hidden from search', async () => {
|
||||||
|
const backend = seoBackend({ article: { noindex: true } })
|
||||||
|
answerChecks(backend, checkAnswer([], 'hidden'))
|
||||||
|
await mountSeo(backend)
|
||||||
|
const body = await openSection()
|
||||||
|
|
||||||
|
expect(textOf(body)).toContain(
|
||||||
|
'Hidden from search engines. This only sets a flag in the API.',
|
||||||
|
)
|
||||||
|
expect(sectionSummary()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Where a suggestion link goes', () => {
|
||||||
|
async function followLink(field: Parameters<typeof suggestion>[1], label: string) {
|
||||||
|
const backend = seoBackend({ article: { cover_image_id: 1 } })
|
||||||
|
answerChecks(
|
||||||
|
backend,
|
||||||
|
checkAnswer([suggestion('title_long', field, { length: 74, max: 60 })]),
|
||||||
|
)
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
await click(label)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
await flushPromises()
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
field: 'seo_title',
|
||||||
|
label: 'Go to meta title',
|
||||||
|
focused: (panel: HTMLElement) =>
|
||||||
|
panel.querySelector(`[placeholder="${ARTICLE_TITLE}"]`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'seo_description',
|
||||||
|
label: 'Go to meta description',
|
||||||
|
focused: (panel: HTMLElement) => panel.querySelector('textarea[maxlength="320"]'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'cover_image_alt',
|
||||||
|
label: 'Go to cover image',
|
||||||
|
focused: (panel: HTMLElement) =>
|
||||||
|
panel.querySelector(
|
||||||
|
'[placeholder="Describe the image for people who can\'t see it"]',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'slug',
|
||||||
|
label: 'Go to URL slug',
|
||||||
|
focused: (panel: HTMLElement) => panel.querySelector('input[maxlength="100"]'),
|
||||||
|
},
|
||||||
|
] as const)(
|
||||||
|
'opens the panel with focus on the $field field',
|
||||||
|
async ({ field, label, focused }) => {
|
||||||
|
await followLink(field, label)
|
||||||
|
|
||||||
|
const panel = panelRoot()!
|
||||||
|
expect(panel).not.toBeNull()
|
||||||
|
expect(document.activeElement).toBe(focused(panel))
|
||||||
|
expect(document.activeElement).not.toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('opens the panel with focus on the cover Choose image button', async () => {
|
||||||
|
await followLink('cover_image_id', 'Go to cover image')
|
||||||
|
|
||||||
|
const active = document.activeElement as HTMLElement
|
||||||
|
expect(panelRoot()!.contains(active)).toBe(true)
|
||||||
|
expect(textOf(active)).toMatch(/Choose image|Change image/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus to the article body for a body suggestion', async () => {
|
||||||
|
const focused = vi.spyOn(HTMLElement.prototype, 'focus')
|
||||||
|
|
||||||
|
await followLink('body', 'Go to body')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
expect(
|
||||||
|
focused.mock.contexts.some((target) =>
|
||||||
|
(target as HTMLElement).classList.contains('ProseMirror'),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens Site and authors and moves focus to its list for an authors suggestion', async () => {
|
||||||
|
await followLink('author_ids', 'Go to authors')
|
||||||
|
|
||||||
|
expect(panelRoot()).toBeNull()
|
||||||
|
const meta = document.body.querySelector('[data-testid="meta-section"]')!
|
||||||
|
expect(meta.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
||||||
|
expect(meta.contains(document.activeElement)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The messages follow the params of the response', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'title_long',
|
||||||
|
'seo_title',
|
||||||
|
{ length: 74, max: 60 },
|
||||||
|
'The meta title is 74 characters. Search engines may cut it off after about 60.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'desc_short',
|
||||||
|
'seo_description',
|
||||||
|
{ length: 41, min: 70 },
|
||||||
|
'The description is 41 characters. About 70 to 160 reads best in results.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cover_alt',
|
||||||
|
'cover_image_alt',
|
||||||
|
{},
|
||||||
|
'The cover image has no alt text. Add a short description of it.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'no_h',
|
||||||
|
'body',
|
||||||
|
{},
|
||||||
|
'The body has no headings. H2 headings help readers scan the article.',
|
||||||
|
],
|
||||||
|
['title_empty', 'seo_title', {}, 'The meta title is empty.'],
|
||||||
|
[
|
||||||
|
'desc_long',
|
||||||
|
'seo_description',
|
||||||
|
{ length: 170, max: 160 },
|
||||||
|
'The description is 170 characters. About 70 to 160 reads best in results.',
|
||||||
|
],
|
||||||
|
['cover_none', 'cover_image_id', {}, 'This article has no cover image.'],
|
||||||
|
[
|
||||||
|
'slug_long',
|
||||||
|
'slug',
|
||||||
|
{ length: 80, max: 75 },
|
||||||
|
'The URL slug is 80 characters. Shorter addresses are easier to share.',
|
||||||
|
],
|
||||||
|
['no_au', 'author_ids', {}, 'This article has no author.'],
|
||||||
|
['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'],
|
||||||
|
['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'],
|
||||||
|
[
|
||||||
|
'short',
|
||||||
|
'body',
|
||||||
|
{ length: 40, min: 300 },
|
||||||
|
'The body is 40 characters. Longer articles tend to do better in search.',
|
||||||
|
],
|
||||||
|
] as const)('renders %s with %j', async (code, field, params, message) => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
answerChecks(backend, checkAnswer([suggestion(code, field, { ...params })]))
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openSection()
|
||||||
|
|
||||||
|
expect(textOf(suggestionRows()[0])).toContain(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The check could not run', () => {
|
||||||
|
it.each([
|
||||||
|
['status 500', failWith(500)],
|
||||||
|
['a network error', networkFailure],
|
||||||
|
])(
|
||||||
|
"shows Couldn't check this article. with Try again and requests the check again on %s",
|
||||||
|
async (_name, failure) => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = failure
|
||||||
|
await mountSeo(backend)
|
||||||
|
const body = await openSection()
|
||||||
|
|
||||||
|
expect(textOf(body)).toContain("Couldn't check this article.")
|
||||||
|
expect(textOf(body)).not.toContain('All good')
|
||||||
|
expect(toasts()).toBe('')
|
||||||
|
expect(patchBodies(backend)).toEqual([])
|
||||||
|
const callsBefore = checkCalls(backend).length
|
||||||
|
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = () => checkAnswer([])
|
||||||
|
await click('Try again')
|
||||||
|
|
||||||
|
expect(checkCalls(backend)).toHaveLength(callsBefore + 1)
|
||||||
|
expect(textOf(body)).toContain('All good')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('shows the Not found editor state when the check answers 404', async () => {
|
||||||
|
const backend = seoBackend()
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
|
||||||
|
await mountSeo(backend)
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("This article doesn't exist.")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
|||||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
import RootComponent from '@/App.vue'
|
import RootComponent from '@/App.vue'
|
||||||
import { messages } from '@/i18n/messages'
|
import { messages } from '@/i18n/messages'
|
||||||
import { articleMeta, oneSite } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
import { fixture, textOf } from './mediaTestKit'
|
import { fixture, textOf } from './mediaTestKit'
|
||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
@@ -127,6 +127,9 @@ async function mountEditor(options: EditorOptions = {}) {
|
|||||||
if (url === '/v1/site/') {
|
if (url === '/v1/site/') {
|
||||||
return reply(200, oneSite)
|
return reply(200, oneSite)
|
||||||
}
|
}
|
||||||
|
if (url === '/v1/article/12/seo/check') {
|
||||||
|
return reply(200, readyCheck)
|
||||||
|
}
|
||||||
if (url === '/v1/media/?page=1&size=25') {
|
if (url === '/v1/media/?page=1&size=25') {
|
||||||
return reply(200, libraryPage())
|
return reply(200, libraryPage())
|
||||||
}
|
}
|
||||||
@@ -158,7 +161,10 @@ function variantRequests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeRequests() {
|
function writeRequests() {
|
||||||
return fetchMock.mock.calls.filter(([, init]) => (init as RequestInit | undefined)?.method)
|
return fetchMock.mock.calls.filter(
|
||||||
|
([url, init]) =>
|
||||||
|
(init as RequestInit | undefined)?.method && !String(url).endsWith('/seo/check'),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findButton(text: string, root: ParentNode = document.body) {
|
function findButton(text: string, root: ParentNode = document.body) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||||
import RootComponent from '@/App.vue'
|
import RootComponent from '@/App.vue'
|
||||||
import { articleMeta, oneSite } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||||
const routes = {
|
const routes = {
|
||||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||||
|
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
|
||||||
...handlers,
|
...handlers,
|
||||||
}
|
}
|
||||||
return vi.fn((url: string, init?: RequestInit) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
@@ -690,7 +691,11 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
|
|||||||
expect(props.confirmLabel).toBe('Remove image')
|
expect(props.confirmLabel).toBe('Remove image')
|
||||||
expect(props.cancelLabel).toBe('Cancel')
|
expect(props.cancelLabel).toBe('Cancel')
|
||||||
expect(props.tone).toBe('neutral')
|
expect(props.tone).toBe('neutral')
|
||||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
expect(requestCalls(wrapper)).toEqual([
|
||||||
|
'GET /v1/article/12?format=html',
|
||||||
|
'GET /v1/site/',
|
||||||
|
'POST /v1/article/12/seo/check',
|
||||||
|
])
|
||||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -743,7 +748,11 @@ describe('Cancelling the confirm changes nothing', () => {
|
|||||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||||
expect(toastAdd).not.toHaveBeenCalled()
|
expect(toastAdd).not.toHaveBeenCalled()
|
||||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
expect(requestCalls(wrapper)).toEqual([
|
||||||
|
'GET /v1/article/12?format=html',
|
||||||
|
'GET /v1/site/',
|
||||||
|
'POST /v1/article/12/seo/check',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -767,7 +776,11 @@ describe('Confirming removes the image from the body only, marks the draft dirty
|
|||||||
title: 'Image removed',
|
title: 'Image removed',
|
||||||
description: "It's still in your library.",
|
description: "It's still in your library.",
|
||||||
})
|
})
|
||||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
expect(requestCalls(wrapper)).toEqual([
|
||||||
|
'GET /v1/article/12?format=html',
|
||||||
|
'GET /v1/site/',
|
||||||
|
'POST /v1/article/12/seo/check',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import router from '@/router'
|
|||||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { articleMeta, oneSite } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
|
|
||||||
type Handler = () => Response
|
type Handler = () => Response
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ let wrapper: ReturnType<typeof mount> | null = null
|
|||||||
function stubFetch(handlers: Record<string, Handler>) {
|
function stubFetch(handlers: Record<string, Handler>) {
|
||||||
const routes: Record<string, Handler> = {
|
const routes: Record<string, Handler> = {
|
||||||
'GET /v1/site/': () => reply(200, oneSite),
|
'GET /v1/site/': () => reply(200, oneSite),
|
||||||
|
'POST /v1/article/12/seo/check': () => reply(200, readyCheck),
|
||||||
'GET /v1/article/12?format=html': () => reply(200, article),
|
'GET /v1/article/12?format=html': () => reply(200, article),
|
||||||
...handlers,
|
...handlers,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,9 @@ export const leaFontaine: Byline = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
||||||
|
|
||||||
|
export const readyCheck = {
|
||||||
|
status: 'ready',
|
||||||
|
suggestions: [],
|
||||||
|
slug: { available: true, suggestion: null },
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { Article, Byline } from '@/composables/useArticles'
|
|||||||
import type { Author } from '@/composables/useAuthors'
|
import type { Author } from '@/composables/useAuthors'
|
||||||
import type { Site } from '@/composables/useSites'
|
import type { Site } from '@/composables/useSites'
|
||||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
|
import { SEO_KEYS } from '@/lib/articleSeo'
|
||||||
import { slugify } from '@/lib/directory'
|
import { slugify } from '@/lib/directory'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,14 @@ export interface MetaBackend extends Backend {
|
|||||||
meSlugAdjusted?: boolean
|
meSlugAdjusted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readyCheck() {
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
suggestions: [],
|
||||||
|
slug: { available: true, suggestion: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const MARTA_EMAIL = 'marta@renew.example'
|
export const MARTA_EMAIL = 'marta@renew.example'
|
||||||
const ARTICLE_ID = 12
|
const ARTICLE_ID = 12
|
||||||
|
|
||||||
@@ -146,6 +155,11 @@ function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
|
|||||||
if (typeof body.body === 'string') {
|
if (typeof body.body === 'string') {
|
||||||
backend.article.body = body.body
|
backend.article.body = body.body
|
||||||
}
|
}
|
||||||
|
for (const key of SEO_KEYS) {
|
||||||
|
if (key in body) {
|
||||||
|
Object.assign(backend.article, { [key]: body[key] })
|
||||||
|
}
|
||||||
|
}
|
||||||
return structuredClone(backend.article)
|
return structuredClone(backend.article)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +220,9 @@ function createMyAuthor(backend: MetaBackend) {
|
|||||||
function route(backend: MetaBackend, call: Call): unknown {
|
function route(backend: MetaBackend, call: Call): unknown {
|
||||||
const { method, path, query, body } = call
|
const { method, path, query, body } = call
|
||||||
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
|
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
|
||||||
|
if (/^\/v1\/article\/\d+\/seo\/check$/.test(path) && method === 'POST') {
|
||||||
|
return readyCheck()
|
||||||
|
}
|
||||||
if (articleMatch && method === 'GET') {
|
if (articleMatch && method === 'GET') {
|
||||||
return structuredClone(backend.article)
|
return structuredClone(backend.article)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import type { Call } from './directoryTestKit'
|
||||||
|
import {
|
||||||
|
createMetaBackend,
|
||||||
|
mountMeta,
|
||||||
|
renewSites,
|
||||||
|
textOf,
|
||||||
|
type MetaBackend,
|
||||||
|
type MetaMountOptions,
|
||||||
|
} from './metaTestKit'
|
||||||
|
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
|
export const EDITOR_URL = '/article/12/edit'
|
||||||
|
export const ARTICLE_TITLE = 'How we price a bathroom renovation'
|
||||||
|
export const ARTICLE_SLUG = 'how-we-price-a-bathroom-renovation'
|
||||||
|
export const FIRST_SENTENCE =
|
||||||
|
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||||
|
export const ARTICLE_BODY = `<p>${FIRST_SENTENCE} Every project starts with a visit.</p><h2>What goes into the price</h2><p>Stripping out and making good.</p>`
|
||||||
|
export const LONG_TITLE =
|
||||||
|
'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
|
||||||
|
export const NORTH_WALL = {
|
||||||
|
id: 1,
|
||||||
|
filename: 'north-wall.jpg',
|
||||||
|
url: '/static/uploadedImages/north-wall.jpg',
|
||||||
|
size: 4089446,
|
||||||
|
content_type: 'image/jpeg',
|
||||||
|
width: 4032,
|
||||||
|
height: 3024,
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
uploaded_by: 'Marta Alves',
|
||||||
|
created_at: '2026-09-12T12:00:00Z',
|
||||||
|
used_in: 1,
|
||||||
|
duplicate: false,
|
||||||
|
variant_count: 0,
|
||||||
|
variant_size: 0,
|
||||||
|
}
|
||||||
|
export const KITCHEN_AFTER = { ...NORTH_WALL, id: 2, filename: 'kitchen-after.webp' }
|
||||||
|
|
||||||
|
export const READY = {
|
||||||
|
status: 'ready',
|
||||||
|
suggestions: [],
|
||||||
|
slug: { available: true, suggestion: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestion(
|
||||||
|
code: SuggestionCode,
|
||||||
|
field: SeoSuggestion['field'],
|
||||||
|
params: Record<string, number> = {},
|
||||||
|
): SeoSuggestion {
|
||||||
|
return { code, field, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAnswer(
|
||||||
|
suggestions: SeoSuggestion[],
|
||||||
|
status: 'ready' | 'suggestions' | 'hidden' = suggestions.length ? 'suggestions' : 'ready',
|
||||||
|
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
|
||||||
|
) {
|
||||||
|
return { status, suggestions, slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FOUR_SUGGESTIONS = [
|
||||||
|
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||||
|
suggestion('cover_alt', 'cover_image_alt'),
|
||||||
|
suggestion('no_h', 'body'),
|
||||||
|
suggestion('short', 'body', { length: 40, min: 300 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
export function renewSite(overrides: Partial<Site> = {}): Site {
|
||||||
|
return {
|
||||||
|
...renewSites()[0]!,
|
||||||
|
site_name: 'Renew',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoBackend(
|
||||||
|
options: {
|
||||||
|
article?: Partial<MetaBackend['article']>
|
||||||
|
sites?: Site[]
|
||||||
|
check?: unknown
|
||||||
|
role?: 'owner' | 'admin' | 'verified'
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const backend = createMetaBackend({
|
||||||
|
sites: options.sites ?? [renewSite()],
|
||||||
|
role: options.role,
|
||||||
|
})
|
||||||
|
Object.assign(backend.article, {
|
||||||
|
title: ARTICLE_TITLE,
|
||||||
|
body: ARTICLE_BODY,
|
||||||
|
slug: ARTICLE_SLUG,
|
||||||
|
excerpt: '',
|
||||||
|
seo_title: '',
|
||||||
|
seo_description: '',
|
||||||
|
cover_image_id: null,
|
||||||
|
cover_image_alt: '',
|
||||||
|
social_title: '',
|
||||||
|
social_description: '',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: '',
|
||||||
|
noindex: false,
|
||||||
|
...options.article,
|
||||||
|
})
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = () => options.check ?? READY
|
||||||
|
backend.responders['GET /v1/media/'] = () => ({
|
||||||
|
items: [NORTH_WALL, KITCHEN_AFTER],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
counts: { all: 2, unused: 0, duplicates: 0 },
|
||||||
|
})
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mountSeo(backend: MetaBackend, options: MetaMountOptions = {}) {
|
||||||
|
await mountMeta('editor', EDITOR_URL, { ...options, backend })
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCalls(backend: MetaBackend) {
|
||||||
|
return backend.calls.filter(
|
||||||
|
(call) => call.method === 'POST' && call.path.endsWith('/seo/check'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkBodies(backend: MetaBackend) {
|
||||||
|
return checkCalls(backend).map((call) => call.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchBodies(backend: MetaBackend) {
|
||||||
|
return backend.calls
|
||||||
|
.filter((call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path))
|
||||||
|
.map((call) => call.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoCalls(backend: MetaBackend) {
|
||||||
|
return backend.calls.filter((call) => call.method === 'GET' && call.path.endsWith('/seo'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function answerChecks(backend: MetaBackend, answer: unknown | ((call: Call) => unknown)) {
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = (call) =>
|
||||||
|
typeof answer === 'function' ? (answer as (call: Call) => unknown)(call) : answer
|
||||||
|
}
|
||||||
|
|
||||||
|
export function section(root: ParentNode = document.body) {
|
||||||
|
return root.querySelector<HTMLElement>('[data-testid="seo-section"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionToggle(root: ParentNode = document.body) {
|
||||||
|
return root.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionSummary(root: ParentNode = document.body) {
|
||||||
|
return root.querySelector<HTMLElement>('[data-testid="seo-summary"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionBody(root: ParentNode = document.body) {
|
||||||
|
return root.querySelector<HTMLElement>('[data-testid="seo-body"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSection() {
|
||||||
|
if (!sectionBody()) {
|
||||||
|
sectionToggle().click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
return sectionBody()!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelRoot() {
|
||||||
|
return (
|
||||||
|
document.body
|
||||||
|
.querySelector<HTMLElement>('[data-testid="seo-panel-suggestions"]')
|
||||||
|
?.closest('[role="dialog"]') ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPanel() {
|
||||||
|
await openSection()
|
||||||
|
const edit = document.body.querySelector<HTMLElement>('[data-testid="seo-edit"]')!
|
||||||
|
edit.focus()
|
||||||
|
edit.click()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
await flushPromises()
|
||||||
|
return panelRoot()!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDisclosure(title: string) {
|
||||||
|
const toggle = Array.from(
|
||||||
|
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
|
||||||
|
).find((button) => textOf(button).startsWith(title))!
|
||||||
|
toggle.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
|
||||||
|
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||||
|
`[placeholder="${placeholder}"]`,
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldByLabel(root: ParentNode, label: string) {
|
||||||
|
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
|
||||||
|
textOf(candidate).startsWith(label),
|
||||||
|
)
|
||||||
|
const id = element?.getAttribute('for')
|
||||||
|
return id ? (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function counterTexts(root: ParentNode) {
|
||||||
|
return Array.from(root.querySelectorAll('[data-testid="soft-counter"]')).map((node) =>
|
||||||
|
textOf(node),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChangesButton(root: ParentNode) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||||
|
(candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…',
|
||||||
|
)!
|
||||||
|
}
|
||||||
@@ -129,6 +129,50 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
],
|
],
|
||||||
alias: {},
|
alias: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Editor: search and sharing',
|
||||||
|
states: [
|
||||||
|
'Collapsed: ready',
|
||||||
|
'Collapsed: suggestions',
|
||||||
|
'Collapsed: hidden',
|
||||||
|
'Open: ready',
|
||||||
|
'Open: suggestions',
|
||||||
|
'Open: checking',
|
||||||
|
'Open: hidden',
|
||||||
|
'Open: other site',
|
||||||
|
'Panel: defaults',
|
||||||
|
'Panel: filled in',
|
||||||
|
'Panel: outside the soft ranges',
|
||||||
|
'Panel: slug edited',
|
||||||
|
'Panel: slug taken',
|
||||||
|
'Panel: cover without alt',
|
||||||
|
'Panel: choose cover',
|
||||||
|
'Panel: social sharing open',
|
||||||
|
'Panel: social customised',
|
||||||
|
'Panel: advanced, invalid canonical',
|
||||||
|
'Panel: hidden from search',
|
||||||
|
'Panel: mobile search preview',
|
||||||
|
'Panel: checking',
|
||||||
|
'Panel: saving',
|
||||||
|
'Panel: save failed',
|
||||||
|
'Saved (toast)',
|
||||||
|
],
|
||||||
|
alias: { 'Saved (toast)': 'Saved' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Editor: what the frontend gets',
|
||||||
|
states: [
|
||||||
|
'Formatted',
|
||||||
|
'Raw JSON',
|
||||||
|
'Copied',
|
||||||
|
'Draft article',
|
||||||
|
'Dialog: loading',
|
||||||
|
'Dialog: error',
|
||||||
|
],
|
||||||
|
alias: { 'Dialog: loading': 'Loading', 'Dialog: error': 'Error' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
profile: [
|
profile: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ function updateApi(
|
|||||||
|
|
||||||
const editorNow = '2026-09-26T11:02:00Z'
|
const editorNow = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
|
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||||
|
|
||||||
const bathroomBody =
|
const bathroomBody =
|
||||||
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
|
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
|
||||||
'<h2>What goes into the price</h2>' +
|
'<h2>What goes into the price</h2>' +
|
||||||
@@ -371,6 +373,7 @@ function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
|||||||
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
||||||
'GET /v1/site/': boardSiteList(),
|
'GET /v1/site/': boardSiteList(),
|
||||||
'GET /v1/author/': boardAuthorList(),
|
'GET /v1/author/': boardAuthorList(),
|
||||||
|
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5419,6 +5422,470 @@ const authorsStates: Record<string, VisualState> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seoDescription =
|
||||||
|
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||||
|
const seoLongTitle = 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
|
||||||
|
const seoCoverAlt = 'A tiled bathroom wall during renovation'
|
||||||
|
const seoFilledTitle = 'What a bathroom renovation costs'
|
||||||
|
const seoFilledDescription =
|
||||||
|
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.'
|
||||||
|
const seoFilledExcerpt = 'How we price a bathroom renovation, from the survey to the final invoice.'
|
||||||
|
|
||||||
|
const seoBody =
|
||||||
|
`<p>${seoDescription} We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>` +
|
||||||
|
'<h2>What goes into the price</h2>' +
|
||||||
|
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
|
||||||
|
'<p>We don’t add a margin on materials you choose yourself.</p>'
|
||||||
|
|
||||||
|
function seoSuggestion(code: string, field: string, params: Record<string, number> = {}) {
|
||||||
|
return { code, field, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoSuggestions: Record<string, ReturnType<typeof seoSuggestion>> = {
|
||||||
|
title_long: seoSuggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||||
|
desc_short: seoSuggestion('desc_short', 'seo_description', { length: 41, min: 70 }),
|
||||||
|
cover_alt: seoSuggestion('cover_alt', 'cover_image_alt'),
|
||||||
|
cover_none: seoSuggestion('cover_none', 'cover_image_id'),
|
||||||
|
no_h: seoSuggestion('no_h', 'body'),
|
||||||
|
short: seoSuggestion('short', 'body', { length: 120, min: 300 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoCheck(
|
||||||
|
codes: string[],
|
||||||
|
status = codes.length ? 'suggestions' : 'ready',
|
||||||
|
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
|
||||||
|
): ApiMock {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { status, suggestions: codes.map((code) => seoSuggestions[code]), slug },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoTakenSlug = { available: false, suggestion: 'bathroom-costs-2' }
|
||||||
|
|
||||||
|
function seoSite(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
...directorySites[0]!,
|
||||||
|
default_social_image_id: 1,
|
||||||
|
site_name: 'Renew',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoJournal() {
|
||||||
|
return {
|
||||||
|
...directorySites[1]!,
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoArticle(overrides: Record<string, unknown> = {}, body = seoBody) {
|
||||||
|
return {
|
||||||
|
...editorArticle,
|
||||||
|
body,
|
||||||
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
|
excerpt: '',
|
||||||
|
seo_title: '',
|
||||||
|
seo_description: '',
|
||||||
|
cover_image_id: null,
|
||||||
|
cover_image_alt: '',
|
||||||
|
social_title: '',
|
||||||
|
social_description: '',
|
||||||
|
social_image_id: null,
|
||||||
|
canonical_url: '',
|
||||||
|
noindex: false,
|
||||||
|
seo_dismissed: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoApiResponse = {
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: seoDescription,
|
||||||
|
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||||
|
robots: 'index, follow',
|
||||||
|
og: {
|
||||||
|
type: 'article',
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: seoDescription,
|
||||||
|
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||||
|
site_name: 'Renew',
|
||||||
|
image: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
image_alt: seoCoverAlt,
|
||||||
|
locale: 'en',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'How we price a bathroom renovation | Renew',
|
||||||
|
description: seoDescription,
|
||||||
|
image: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
image_alt: seoCoverAlt,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
url: 'https://renew.example/media/north-wall.jpg',
|
||||||
|
alt: seoCoverAlt,
|
||||||
|
source: 'cover_image',
|
||||||
|
},
|
||||||
|
authors: [
|
||||||
|
{
|
||||||
|
name: 'Inès Duarte',
|
||||||
|
url: 'https://ines-duarte.example',
|
||||||
|
same_as: ['https://www.linkedin.com/in/ines-duarte'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
published_at: '2026-03-12T09:14:00Z',
|
||||||
|
modified_at: '2026-10-15T11:02:00Z',
|
||||||
|
fallbacks: { title: 'title', description: 'body', image: 'cover', canonical: 'site' },
|
||||||
|
json_ld: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: 'How we price a bathroom renovation',
|
||||||
|
description: seoDescription,
|
||||||
|
image: ['https://renew.example/media/north-wall.jpg'],
|
||||||
|
author: [
|
||||||
|
{
|
||||||
|
'@type': 'Person',
|
||||||
|
name: 'Inès Duarte',
|
||||||
|
url: 'https://ines-duarte.example',
|
||||||
|
sameAs: ['https://www.linkedin.com/in/ines-duarte'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
datePublished: '2026-03-12T09:14:00Z',
|
||||||
|
dateModified: '2026-10-15T11:02:00Z',
|
||||||
|
mainEntityOfPage: 'https://renew.example/how-we-price-a-bathroom-renovation',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeoOptions {
|
||||||
|
article?: Record<string, unknown>
|
||||||
|
body?: string
|
||||||
|
check?: ApiMock
|
||||||
|
sites?: unknown[]
|
||||||
|
overrides?: Record<string, ApiMock>
|
||||||
|
library?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoApi(options: SeoOptions = {}): Record<string, ApiMock> {
|
||||||
|
return editorApi({
|
||||||
|
'GET /v1/article/12': {
|
||||||
|
status: 200,
|
||||||
|
body: seoArticle(options.article, options.body),
|
||||||
|
},
|
||||||
|
'GET /v1/site/': { status: 200, body: options.sites ?? [seoSite()] },
|
||||||
|
'POST /v1/article/12/seo/check': options.check ?? { status: 200, body: seoReady },
|
||||||
|
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
|
||||||
|
'GET /v1/image-variants': { status: 403 },
|
||||||
|
'GET /v1/article/12/seo': { status: 200, body: seoApiResponse },
|
||||||
|
...(options.overrides ?? {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoOpenSection(page: Page) {
|
||||||
|
if (isNarrow(page)) {
|
||||||
|
await page.getByRole('button', { name: 'Details', exact: true }).click()
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
}
|
||||||
|
const toggle = page.locator('[data-testid="seo-toggle"]:visible')
|
||||||
|
await toggle.waitFor()
|
||||||
|
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||||
|
await toggle.click()
|
||||||
|
}
|
||||||
|
await page.locator('[data-testid="seo-body"]:visible').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoOpenPanel(page: Page) {
|
||||||
|
await seoOpenSection(page)
|
||||||
|
await page.locator('[data-testid="seo-edit"]:visible').click()
|
||||||
|
await page.locator('[data-testid="seo-panel-suggestions"]').waitFor()
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoScrollPanel(page: Page, top: number) {
|
||||||
|
await page.evaluate((offset) => {
|
||||||
|
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
||||||
|
body.scrollTop = offset
|
||||||
|
})
|
||||||
|
}, top)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoOpenDisclosure(page: Page, title: string) {
|
||||||
|
await page.locator('[data-testid="seo-disclosure-toggle"]', { hasText: title }).first().click()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoSidebarState(options: SeoOptions, open: boolean): VisualState {
|
||||||
|
return {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi(options),
|
||||||
|
steps: async (page) => {
|
||||||
|
if (open) {
|
||||||
|
await seoOpenSection(page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isNarrow(page)) {
|
||||||
|
await page.getByRole('button', { name: 'Details', exact: true }).click()
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seoPanelState(
|
||||||
|
options: SeoOptions,
|
||||||
|
steps: (page: Page) => Promise<void> = async () => {},
|
||||||
|
scrollY = 0,
|
||||||
|
): VisualState {
|
||||||
|
return {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi(options),
|
||||||
|
steps: async (page) => {
|
||||||
|
await seoOpenPanel(page)
|
||||||
|
await steps(page)
|
||||||
|
await seoScrollPanel(page, scrollY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoTypeInPanel(page: Page, label: string, value: string) {
|
||||||
|
await page.getByRole('dialog').getByLabel(label).first().fill(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoFilled = {
|
||||||
|
seo_title: seoFilledTitle,
|
||||||
|
seo_description: seoFilledDescription,
|
||||||
|
excerpt: seoFilledExcerpt,
|
||||||
|
cover_image_id: 1,
|
||||||
|
cover_image_alt: seoCoverAlt,
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoCoverNoAlt = { cover_image_id: 1, cover_image_alt: '' }
|
||||||
|
|
||||||
|
const seoSocialCustom = {
|
||||||
|
cover_image_id: 1,
|
||||||
|
cover_image_alt: seoCoverAlt,
|
||||||
|
social_title: 'Bathroom costs, explained',
|
||||||
|
social_description: 'A plain guide to what drives the price.',
|
||||||
|
social_image_id: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoOpenApiDialog(page: Page) {
|
||||||
|
await seoOpenSection(page)
|
||||||
|
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||||
|
await page.locator('[data-testid="api-preview"]').waitFor()
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seoSaveFromPanel(page: Page) {
|
||||||
|
await seoTypeInPanel(page, 'Meta title', seoFilledTitle)
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'Save changes', exact: true })
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorSeoStates: Record<string, VisualState> = {
|
||||||
|
'Collapsed: ready': seoSidebarState({}, false),
|
||||||
|
'Collapsed: suggestions': seoSidebarState(
|
||||||
|
{ check: seoCheck(['title_long', 'cover_alt']) },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
'Collapsed: hidden': seoSidebarState(
|
||||||
|
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
'Open: ready': seoSidebarState({}, true),
|
||||||
|
'Open: suggestions': seoSidebarState(
|
||||||
|
{ check: seoCheck(['title_long', 'cover_alt', 'no_h', 'short']) },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
'Open: checking': seoSidebarState({ check: { status: 200, hang: true } }, true),
|
||||||
|
'Open: hidden': seoSidebarState(
|
||||||
|
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
||||||
|
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||||
|
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
||||||
|
'Panel: outside the soft ranges': seoPanelState({
|
||||||
|
article: { seo_title: seoLongTitle, seo_description: 'What a bathroom renovation costs.' },
|
||||||
|
check: seoCheck(['title_long', 'desc_short']),
|
||||||
|
}),
|
||||||
|
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
|
||||||
|
'Panel: slug taken': seoPanelState(
|
||||||
|
{
|
||||||
|
check: {
|
||||||
|
status: 200,
|
||||||
|
bodyFor: (_url, call) =>
|
||||||
|
seoCheck([], 'ready', call >= 3 ? seoTakenSlug : undefined).body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (page) => {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Edit slug' }).click()
|
||||||
|
const slug = page.getByRole('dialog').getByLabel('URL slug')
|
||||||
|
await slug.fill('bathroom-costs')
|
||||||
|
await slug.blur()
|
||||||
|
await page.getByText('is already used on Renew.').waitFor()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Panel: cover without alt': seoPanelState({
|
||||||
|
article: seoCoverNoAlt,
|
||||||
|
check: seoCheck(['cover_alt']),
|
||||||
|
}),
|
||||||
|
'Panel: choose cover': seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'Choose image', exact: true })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await waitForPickerTiles(page)
|
||||||
|
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}),
|
||||||
|
'Panel: social sharing open': seoPanelState(
|
||||||
|
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
||||||
|
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||||
|
560,
|
||||||
|
),
|
||||||
|
'Panel: social customised': seoPanelState(
|
||||||
|
{ article: seoSocialCustom },
|
||||||
|
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||||
|
560,
|
||||||
|
),
|
||||||
|
'Panel: advanced, invalid canonical': seoPanelState(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await seoOpenDisclosure(page, 'Advanced')
|
||||||
|
const canonical = page.getByRole('dialog').getByLabel('Canonical URL')
|
||||||
|
await canonical.fill('renew.example/costs')
|
||||||
|
await canonical.blur()
|
||||||
|
await page.getByText('Enter a full address starting with https://').waitFor()
|
||||||
|
},
|
||||||
|
640,
|
||||||
|
),
|
||||||
|
'Panel: hidden from search': seoPanelState(
|
||||||
|
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
|
||||||
|
async () => {},
|
||||||
|
700,
|
||||||
|
),
|
||||||
|
'Panel: mobile search preview': seoPanelState(
|
||||||
|
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
||||||
|
async (page) => {
|
||||||
|
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
|
||||||
|
'Panel: saving': seoPanelState(
|
||||||
|
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
|
||||||
|
async (page) => {
|
||||||
|
await seoSaveFromPanel(page)
|
||||||
|
await page.getByRole('dialog').getByText('Saving…').first().waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Panel: save failed': seoPanelState(
|
||||||
|
{ overrides: { 'PATCH /v1/article/12': { status: 500 } } },
|
||||||
|
async (page) => {
|
||||||
|
await seoSaveFromPanel(page)
|
||||||
|
await page.getByText("Couldn't save search and sharing.").waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Saved (toast)': {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({ overrides: { 'PATCH /v1/article/12': { status: 200, body: seoArticle() } } }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await seoOpenPanel(page)
|
||||||
|
await seoSaveFromPanel(page)
|
||||||
|
await page.getByText('Search and sharing saved').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Formatted: {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({ article: { is_online: true } }),
|
||||||
|
steps: seoOpenApiDialog,
|
||||||
|
},
|
||||||
|
'Raw JSON': {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({ article: { is_online: true } }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await seoOpenApiDialog(page)
|
||||||
|
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Copied: {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
origin: integrationOrigin,
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({ article: { is_online: true } }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: async () => undefined },
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await seoOpenApiDialog(page)
|
||||||
|
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Copy JSON', exact: true })
|
||||||
|
.click({ force: true })
|
||||||
|
await page.getByRole('button', { name: 'Copied', exact: true }).waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Draft article': {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({ article: { is_online: false } }),
|
||||||
|
steps: seoOpenApiDialog,
|
||||||
|
},
|
||||||
|
'Dialog: loading': {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({
|
||||||
|
article: { is_online: true },
|
||||||
|
overrides: { 'GET /v1/article/12/seo': { status: 200, hang: true } },
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await seoOpenSection(page)
|
||||||
|
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||||
|
await page.locator('[data-testid="api-preview-loading"]').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Dialog: error': {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
now: editorNow,
|
||||||
|
api: seoApi({
|
||||||
|
article: { is_online: true },
|
||||||
|
overrides: { 'GET /v1/article/12/seo': { status: 500 } },
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await seoOpenSection(page)
|
||||||
|
await page.locator('[data-testid="seo-preview"]:visible').click()
|
||||||
|
await page.locator('[data-testid="api-preview-error"]').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||||
sites: sitesStates,
|
sites: sitesStates,
|
||||||
authors: authorsStates,
|
authors: authorsStates,
|
||||||
@@ -6273,6 +6740,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
|
...editorSeoStates,
|
||||||
'Open: one site, one author': metaState({}, async () => {}),
|
'Open: one site, one author': metaState({}, async () => {}),
|
||||||
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
|
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
|
||||||
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),
|
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),
|
||||||
|
|||||||
Reference in new issue
Block a user