From 25d9c30b8a61b3d6264198f0b4a3810579512076 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 25 Oct 2026 21:57:43 +0100 Subject: [PATCH] feat: integration search bar --- docs/design/README.md | 2 +- docs/design/screens/integration.md | 48 ++ docs/design/source/AppFrame.dc.html | 3 +- docs/design/source/IntegrationPage.dc.html | 16 +- docs/design/source/IntegrationSearch.dc.html | 173 +++++ .../RenewCMS Screens Back-office.dc.html | 12 +- features/integration_search.feature | 248 +++++++ features/integration_search.qa.md | 58 ++ web/src/components/ApiSearchPalette.vue | 55 ++ web/src/components/ApiSearchPanel.vue | 250 +++++++ web/src/components/ApiSearchRow.vue | 132 ++++ web/src/components/EndpointCard.vue | 43 +- web/src/components/HighlightedText.vue | 20 + web/src/components/SectionIndex.vue | 20 +- web/src/i18n/messages.ts | 23 + web/src/lib/__tests__/apiSearch.spec.ts | 225 ++++++ web/src/lib/apiReference.ts | 2 + web/src/lib/apiSearch.ts | 267 +++++++ web/src/views/IntegrationView.vue | 231 +++++- .../views/__tests__/IntegrationSearch.spec.ts | 696 ++++++++++++++++++ web/visual/check.ts | 15 + web/visual/states.ts | 60 ++ 22 files changed, 2558 insertions(+), 41 deletions(-) create mode 100644 docs/design/source/IntegrationSearch.dc.html create mode 100644 features/integration_search.feature create mode 100644 features/integration_search.qa.md create mode 100644 web/src/components/ApiSearchPalette.vue create mode 100644 web/src/components/ApiSearchPanel.vue create mode 100644 web/src/components/ApiSearchRow.vue create mode 100644 web/src/components/HighlightedText.vue create mode 100644 web/src/lib/__tests__/apiSearch.spec.ts create mode 100644 web/src/lib/apiSearch.ts create mode 100644 web/src/views/__tests__/IntegrationSearch.spec.ts diff --git a/docs/design/README.md b/docs/design/README.md index 6503ae6..33db9ab 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -3,7 +3,7 @@ Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0. ## Boards -- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`. +- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`. - `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states). - `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer). - Unchanged: Foundations, Components, Handoff, Directions and Emails boards. diff --git a/docs/design/screens/integration.md b/docs/design/screens/integration.md index a93e9e4..31eadc9 100644 --- a/docs/design/screens/integration.md +++ b/docs/design/screens/integration.md @@ -114,3 +114,51 @@ New group "Search and sharing" after Articles, in the sidebar index and the mobi - GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers. - GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose). PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article. + + +## Search (jump-to palette) +Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working. + +### Behaviour +- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts. +- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger. +- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows. +- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first. +- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep- so the result can be shared. +- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row. +- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count. + +### Row anatomy +- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted. +- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description. +- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated. +- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2. + +### Copy per state +| # | State | Copy | +|---|---|---| +| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" | +| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" | +| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "6 routes"); count and live "7 results" | +| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "6 more endpoints. Keep typing to narrow the list."; count "13 results"; live "13 results, showing 7" | +| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" | +| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "12 routes, not available to API keys"; "2 results" | +| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" | +| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" | + +### Nuxt UI v4 mapping +- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }). +- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling. +- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite". +- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only. + +### Assumptions to confirm +- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples. +- Matching is substring per term, not fuzzy ("mdia" does not match "media"). +- The palette shows the first sentence of each endpoint description, not the full text. +- Endpoints are capped at 6 rows with a count line; sections are never capped. +- Recent searches are left out. Open question: store the last 3 queries in localStorage? +- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm). +- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index? +- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed. +- The 1.5 s flash duration and the #ep- hash are proposals. diff --git a/docs/design/source/AppFrame.dc.html b/docs/design/source/AppFrame.dc.html index dab4fa0..9bb443c 100644 --- a/docs/design/source/AppFrame.dc.html +++ b/docs/design/source/AppFrame.dc.html @@ -372,6 +372,7 @@
RenewCMS
{{ n.t }}
Media
{{ x.t }}
Publishing
{{ x2.t }}
Content
All content types{{ contentCount }}
{{ c.name }}
{{ contentMore }}
{{ ini }}{{ who }}
timber windows/articles/winter-checklist
Edit linkOpen linkRemove link
Details
Site and authors
{{ edMeta }}
{{ metaSummary }}
Search and sharing
{{ edSeo }}
{{ seoSummary }}
Created12 Mar 2026, 09:14UpdatedToday, 11:02ID12
Public API URL/v1/article/12
+ {{ slotOverlays }}
@@ -546,7 +547,7 @@ class Component extends DCLogic { contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot, fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row', mLead: s.mLead || 'menu', - navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users', + navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users', usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error, pad: m ? '16px 16px 88px' : '24px', gap: '16px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))', upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px', diff --git a/docs/design/source/IntegrationPage.dc.html b/docs/design/source/IntegrationPage.dc.html index dcbf279..484d06e 100644 --- a/docs/design/source/IntegrationPage.dc.html +++ b/docs/design/source/IntegrationPage.dc.html @@ -9,8 +9,9 @@
-
- +
+ +
@@ -19,11 +20,14 @@
+
Integration API reference Everything a developer needs to build on RenewCMS.
+ +

RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.

@@ -60,7 +64,7 @@
- +
+{{ live }} +
+
{{ noneText }}
+ +
+
{{ g.title }}
+ +
+{{ it.m }} + +
+
{{ s.t }}{{ s.t }}
+
{{ s.t }}{{ it.desc }}
+
{{ it.chip }}{{ s.t }}{{ it.desc }}
+
{{ it.viaLabel }}{{ s.t }}
+
+{{ it.chip }} +
+
+
+
+
{{ moreText }}
+
+
+ + + + +
+
+
+ + + + diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html index 844373a..1266fbe 100644 --- a/docs/design/source/RenewCMS Screens Back-office.dc.html +++ b/docs/design/source/RenewCMS Screens Back-office.dc.html @@ -364,7 +364,17 @@ class Component extends DCLogic { withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }), - withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }) + withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }), + ...((B, X) => [ + X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }), + X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }), + X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"7 results\".", search: { q: 'media', sel: 0 } }), + X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"13 results, showing 7\".", search: { q: '404', sel: 2 } }), + X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }), + X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }), + X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }), + X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } }) + ])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {})) ] }, { label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [ Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }), diff --git a/features/integration_search.feature b/features/integration_search.feature new file mode 100644 index 0000000..743139f --- /dev/null +++ b/features/integration_search.feature @@ -0,0 +1,248 @@ +Feature: Integration page search, a jump-to command palette on /integration + + As a developer reading the API reference + I want to find an endpoint, a body field or an error and jump to it + So that I do not have to scroll or use the browser's find-in-page + + Background: + Given the SPA route /integration of the verified back-office (static page, no /v1 call) + And search is client-side only, over a static index built from API_GROUPS, SESSION_GROUPS, + PUBLIC_AUTH_ROWS, ERROR_ROWS and the page sections, with these fields per endpoint: + | field | indexed text | + | method | GET, POST, PATCH, DELETE | + | path | for example /v1/media/merge | + | description | the first sentence of the endpoint description | + | access chip | Public, Verified user, Owner or admin, Owner only, Session only | + | request field | body field names and query parameter names | + | error | each error code and message of the endpoint | + And code samples, prose and response examples are never searched + And a term matches as a case-insensitive substring; "mdia" does not match "media" + And a query is split on spaces and every term must match somewhere in the same endpoint + And there are no recent searches: an empty query shows only the "Suggested" group + And the search never hides, filters or reorders the page; find-in-page keeps working + And the components are Nuxt UI v4 (UModal with a custom listbox, UInput, UButton, UKbd, UBadge, + UIcon) and not UCommandPalette, with design tokens only + And every count and sample row below follows the real catalogue, not the board's sample data + And the palette is the board of docs/design/screens/integration.md "Search (jump-to palette)", + states 8a to 8h, in light and dark, at 1440x900 and 390x844 + And no state is date dependent + + Rule: Trigger and shortcuts + + Scenario: Desktop trigger + Given the viewport is 1440x900 + Then the page header holds a field-style outline neutral button, 320x36, right of the title block + And it shows the icon, the placeholder "Search the API reference…" and the key hint "/" + And its aria-label is "Search the API reference" + And it has aria-keyshortcuts "/ Control+K Meta+K" + And it is not sticky: it scrolls away with the header + + Scenario: Mobile trigger + Given the viewport is 390x844 + Then the sticky "Jump to" bar holds a 44x44 icon-only button right of the select + And its aria-label is "Search the API reference" + And the desktop trigger is not displayed + + Scenario Outline: Opening the palette with a shortcut + Given focus is on + When the user presses + Then the palette + + Examples: + | focus | keys | result | + | the page body | / | opens and the "/" is not typed | + | a button | / | opens | + | the page body | Ctrl+K | opens | + | the page body | Cmd+K | opens | + | a text input of the page | / | does not open and "/" is typed | + | a textarea | / | does not open | + | a text input | Ctrl+K | opens | + + Scenario: Ctrl+K and Cmd+K replace the browser behaviour on this page + When the palette opens with Ctrl+K or Cmd+K + Then the default browser action is prevented + + Scenario: Shortcuts only exist on /integration + When the user leaves /integration + Then "/", Ctrl+K and Cmd+K no longer open a palette + And Ctrl/Cmd+S on the editor route is unchanged + + Scenario: Opening focuses the input and closing restores focus + Given the user opened the palette from the desktop trigger + Then focus is in the input + When the user closes the palette + Then focus returns to the trigger + And when it was opened with "/" or Ctrl+K, focus returns to the element that had it + + Rule: Palette layout + + Scenario: Desktop modal + Given the viewport is 1440x900 + When the palette opens + Then it is a modal 720 px wide, 96 px from the top, on the existing overlay + And the input has placeholder "Search the API reference…" + And the footer reads "↑↓ Move", "↵ Open", "Esc Close" and the count + + Scenario: Mobile full-screen sheet + Given the viewport is 390x844 + When the palette opens + Then it is a full-screen sheet + And the input row holds a 44 px "Cancel" button + And pressing "Cancel" closes the palette and returns focus to the trigger + + Rule: Content per state + + Scenario: 8b Open, empty query + When the palette opens with an empty query + Then one group "Suggested" shows these 5 rows in order: + | row | description | access | + | Authentication | Public access and API keys | section | + | POST /v1/media/ | Upload an image (multipart). | Verified user | + | Errors | Status codes and messages | section | + | GET /v1/article/ | List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). | Public | + | PATCH /v1/user/me | Update my profile. | Verified user | + And the first row is selected + And the footer count and the live region read "5 suggestions" + + Scenario: 8c Typing "media" + When the user types "media" + Then group "Endpoints" lists the first 6 of 11 media routes + And group "Sections" lists "Media" with description "11 routes" + And the line "5 more endpoints. Keep typing to narrow the list." follows the rows + And the count reads "12 results" + And the live region reads "12 results, showing 7" + And "media" is highlighted with --rc-sand on --rc-on-sand in paths and descriptions + + Scenario: 8d More endpoints than the cap + When the user types "404" + Then group "Endpoints" lists 6 rows, each with a second line "Error" and the match, for example "404 Article not found" + And group "Sections" lists "Errors" with description "Status codes and messages" + And the line "8 more endpoints. Keep typing to narrow the list." follows the endpoint rows + And the count reads "15 results" + And the live region reads "15 results, showing 7" + + Scenario: 8e Match on a body field + When the user types "keep_id" + Then group "Endpoints" lists exactly POST /v1/media/merge + And its description is "Merge identical copies into one image." and its chip is "Verified user" + And a second line reads "Body field keep_id" + And the count reads "1 result" + + Scenario: 8f Session-only route + When the user types "login" + Then group "Endpoints" lists POST /v1/auth/login, "Log in", with a dashed "Session only" chip and a lock icon + And its path weight is 400 and its text is --ui-text-muted + And group "Sections" lists "Session only" with a lock icon and "25 routes, not available to API keys" + And the count reads "2 results" + + Scenario: 8g No results + When the user types "xyz" + Then the empty slot reads "No match for "xyz". Try a path, a method or a status code like 404." + And the count reads "0 results" + And the live region reads "No results" + + Rule: Matching + + Scenario Outline: Multi-term and field matching + When the user types "" + Then the endpoint rows are those whose indexed text contains every term + And the second line is "" + + Examples: + | query | example hit | second line | + | post media | POST /v1/media/ | none | + | PATCH user | PATCH /v1/user/me | none | + | keep_id | POST /v1/media/merge | Body field keep_id | + | page | GET /v1/site/{key}/sitemap | Query parameter page | + | 404 | several routes | Error 404 … | + | OWNER ONLY | owner-only routes | none | + | mdia | none | 0 results | + + Scenario: Ordering + Given a route matches "token" only in a body field and another matches in its path + Then the path match is listed first + And routes that tie keep the order of the catalogue + + Scenario: A row shows the second line only when matched in a field or error + Then a row matched in method, path, description or chip has no second line + + Scenario: Highlighting + Then every term occurrence in the label and description is wrapped in a highlight + And terms inside the second line are highlighted too + + Rule: Keyboard navigation + + Scenario Outline: Moving the selection + Given the palette shows 7 rows and row is selected + When the user presses + Then row is selected and the input keeps focus + + Examples: + | from | key | to | + | 1 | ArrowDown | 2 | + | 7 | ArrowDown | 1 | + | 1 | ArrowUp | 7 | + | 3 | Home | 1 | + | 3 | End | 7 | + + Scenario: Typing resets the selection + Given row 3 is selected + When the user types another character + Then row 1 is selected + + Scenario: Enter opens and Escape closes + When the user presses Enter on a selected row + Then that row is selected as in the "Selecting a result" rule + When the user presses Esc + Then the palette closes, nothing scrolls and the hash is unchanged + + Rule: Accessibility + + Scenario: Roles and live region + Then the input has role "combobox", aria-expanded, aria-controls, aria-autocomplete "list" + and aria-activedescendant pointing at the selected row + And the list has role "listbox"; each group has role "group" and aria-labelledby its title + And each row has role "option", aria-selected and an aria-label with method, path, description and access + And a visually hidden polite live region announces the count of the current state + + Rule: Selecting a result + + Scenario: 8h Selecting an endpoint + Given the palette shows POST /v1/media/merge + When the user presses Enter, or clicks the row + Then the palette closes + And the page scrolls to the card inside its own scroll container, below the sticky offset + And the card is expanded + And focus is on the card's button + And the row flashes: --rc-sand fill and a 2 px --ui-primary ring for 1.5 s, then the open-card fill + And the section index and "Jump to" read "Media" (existing scroll-spy) + And the URL hash is #ep-, written with history.replaceState so no history entry is added + And the other cards keep their open or closed state + + Scenario: Selecting a section + When the user selects the "Errors" row + Then the palette closes, the page scrolls to the Errors heading, focus is on the heading, + the hash is #errors and nothing is flashed or expanded + + Scenario: Selecting a session-only route + When the user selects POST /v1/auth/login + Then the page scrolls to the "Session only" section and focus goes to its heading + And no card expands + + Scenario: Reduced motion + Given the user prefers reduced motion + When a card is selected + Then the flash is a static --rc-sand fill and ring with no fade + + Scenario: Shared link + When /integration#ep- is loaded + Then the card is scrolled to, expanded, focused and flashed as above + And the palette stays closed + + Scenario: Unknown hash + When /integration#ep-nope is loaded + Then the page behaves as today, nothing is expanded and no error shows + + Scenario: The page is never filtered + Given a query is typed + Then every section and card of the page stays in the DOM, unchanged diff --git a/features/integration_search.qa.md b/features/integration_search.qa.md new file mode 100644 index 0000000..e29c145 --- /dev/null +++ b/features/integration_search.qa.md @@ -0,0 +1,58 @@ +# QA procedure: Integration page search (jump-to palette) + +No Go test: SPA only, no /v1 call, no API change. Counts and sample rows follow the real catalogue, not the board's sample data. The search index is built from the in-repo catalogue, so tests use the real `API_GROUPS`, `SESSION_GROUPS`, `PUBLIC_AUTH_ROWS` and `ERROR_ROWS`. + +## Automated SPA (Vitest + Vue Test Utils, `task test:web`) + +Suggested files: `web/src/views/__tests__/IntegrationSearch.spec.ts` (palette), `web/src/lib/__tests__/apiSearch.spec.ts` (pure matcher), plus additions to `IntegrationView.spec.ts` for the trigger and the hash. + +Setup: +- Mount IntegrationView like the existing spec (router at `/integration`, attached to the document body for focus checks). +- No `/v1` mocks: assert no API call is made. +- Stub `scrollTo`, `scrollIntoView` and `getBoundingClientRect` as the existing spec does; fake timers for the 1.5 s flash class. No test freezes the system date. +- Mock `matchMedia('(prefers-reduced-motion: reduce)')` for the reduced-motion case, and the viewport for desktop vs mobile. + +Cases (one `it` per scenario or Examples row; `it.each` for outlines): +1. Trigger: desktop button has placeholder, "/" kbd, aria-label, aria-keyshortcuts; mobile icon button sits beside "Jump to". +2. Shortcuts: `/`, Ctrl+K and Cmd+K open; `/` ignored in input, textarea and contenteditable and not typed into the palette input; Ctrl+K opens from inside an input and calls preventDefault; listeners removed on unmount; Ctrl/Cmd+S in the editor is not affected. +3. Open focus: input focused; close returns focus to the opener; Cancel on mobile. +4. Empty query: the 5 Suggested rows in order, first selected, "5 suggestions" in footer and live region; no recent searches stored. +5. "media": 6 endpoint rows of 11, Sections row Media "11 routes", cap line "5 more endpoints. Keep typing to narrow the list.", "12 results", live "12 results, showing 7", highlight marks. +6. "404": 6 rows with "Error" second line, Errors section, cap line "8 more endpoints. Keep typing to narrow the list.", "15 results", live "15 results, showing 7". +7. "keep_id": one row, "Body field keep_id", "1 result". "page": GET /v1/site/{key}/sitemap shows "Query parameter page"; GET /v1/article/ matches in its description and has no second line. +8. "login": dashed chip with lock, weight 400, Session only section row "25 routes, not available to API keys" with lock icon, "2 results". +9. "xyz": empty copy, "0 results", live "No results". +10. Matcher unit table: multi-term AND, case-insensitive, substring, "mdia" no match, extra spaces trimmed, path/description matches first then catalogue order, no search over code samples or response examples. +11. Keyboard: ArrowDown/ArrowUp wrap, Home, End, typing resets to row 1, Enter selects, Esc closes with no scroll and no hash change. +12. ARIA: combobox attributes, aria-activedescendant follows selection, listbox, group labelling, option aria-label, polite live region. +13. Select endpoint: palette closed, scroll helper called with the card, card expanded, focus on the card button, flash class present then removed after 1500 ms, hash `#ep-` set through `history.replaceState` (history length unchanged), other cards unchanged, index at "Media". +14. Select section: heading focused, hash is the section id, no flash, no expansion. +15. Select session-only route: Session only heading focused, no card expanded. +16. Reduced motion: static ring class, no animation class. +17. Deep link: mount with `#ep-` runs case 13 without opening the palette; `#ep-nope` does nothing. +18. Never filtered: after typing, the number of cards and sections in the page DOM is unchanged. +19. Regression: existing `IntegrationView.spec.ts` cases (section hash, scroll-spy, Jump to) still pass. + +## Visual (`task visual:check -- integration`, desktop and mobile, light and dark) + +References: `docs/design/screens/integration.md` "Search (jump-to palette)", `docs/design/source/IntegrationSearch.dc.html`, states 8a to 8h of `RenewCMS Screens Back-office.dc.html`, and `AppFrame.dc.html`. Add each state to `web/visual/states.ts` with no /v1 mocks. + +| state | setup | notes | +|---|---|---| +| 8a Trigger focused | focus the trigger | | +| 8b Open, empty | open the palette | 5 Suggested rows | +| 8c "media" | type "media" | 6 of 11 endpoints, Media section, 12 results | +| 8d "404", third row selected | type "404", ArrowDown x2 | cap line, 15 results | +| 8e "keep_id" | type "keep_id" | 1 result | +| 8f "login" | type "login" | session-only styling | +| 8g no results | type "xyz" | | +| 8h After selecting | select POST /v1/media/merge | capture after the flash with its animation disabled in the setup; the flash itself is not checked | + +Queries and rows use the board's sample data exactly as in the table. + +## Manual checks (screenshots cannot cover) +- Keyboard and focus order: Tab reaches the trigger; "/" and Ctrl/Cmd+K open from the page; focus is trapped in the modal; Esc returns focus to the trigger. +- Flash motion: the 1.5 s fade is visible, and the OS reduced-motion setting swaps it for a static ring. +- Hover on rows and the trigger. +- Real browsers: Ctrl+K / Cmd+K does not open the address-bar search in Chrome, Firefox and Safari. +- Scroll offsets under the sticky bar on mobile and desktop for several groups. diff --git a/web/src/components/ApiSearchPalette.vue b/web/src/components/ApiSearchPalette.vue new file mode 100644 index 0000000..3632c92 --- /dev/null +++ b/web/src/components/ApiSearchPalette.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/src/components/ApiSearchPanel.vue b/web/src/components/ApiSearchPanel.vue new file mode 100644 index 0000000..069e792 --- /dev/null +++ b/web/src/components/ApiSearchPanel.vue @@ -0,0 +1,250 @@ + + + diff --git a/web/src/components/ApiSearchRow.vue b/web/src/components/ApiSearchRow.vue new file mode 100644 index 0000000..98f6aea --- /dev/null +++ b/web/src/components/ApiSearchRow.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/web/src/components/EndpointCard.vue b/web/src/components/EndpointCard.vue index 1a37fc1..fd445cb 100644 --- a/web/src/components/EndpointCard.vue +++ b/web/src/components/EndpointCard.vue @@ -1,7 +1,7 @@ + + diff --git a/web/src/components/SectionIndex.vue b/web/src/components/SectionIndex.vue index 919be9b..768f01f 100644 --- a/web/src/components/SectionIndex.vue +++ b/web/src/components/SectionIndex.vue @@ -13,7 +13,7 @@ const props = defineProps<{ active: string }>() -const emit = defineEmits<{ select: [id: string] }>() +const emit = defineEmits<{ select: [id: string]; search: [trigger: HTMLElement | null] }>() const activeLabel = computed( () => props.sections.find((section) => section.id === props.active)?.label ?? '', @@ -22,6 +22,10 @@ const activeLabel = computed( function onJump(id: string) { emit('select', id) } + +function onSearch(event: MouseEvent) { + emit('search', event.currentTarget as HTMLElement | null) +} diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index b67b15a..2f9e500 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -2718,6 +2718,29 @@ export const messages = { 'How are they connected?': 'Comment sont-ils liés ?', 'Each {0} has': 'Chaque {0} a', 'shown on {0} as the field "{1}"': 'affiché sur {0} comme le champ « {1} »', + 'Search the API reference': 'Rechercher dans la référence API', + 'Search the API reference…': 'Rechercher dans la référence API…', + 'Search results': 'Résultats de la recherche', + Endpoints: 'Routes', + Sections: 'Sections', + Move: 'Naviguer', + '{0} result': '{0} résultat', + '{0} results, showing {1}': '{0} résultats, {1} affichés', + '{0} more endpoint. Keep typing to narrow the list.': + '{0} route de plus. Continuez à écrire pour affiner la liste.', + '{0} more endpoints. Keep typing to narrow the list.': + '{0} routes de plus. Continuez à écrire pour affiner la liste.', + 'No match for "{0}". Try a path, a method or a status code like 404.': + 'Aucun résultat pour « {0} ». Essayez un chemin, une méthode ou un code d’état comme 404.', + 'Body field': 'Champ du corps', + 'Query parameter': 'Paramètre d’URL', + Error: 'Erreur', + 'Section {0}. {1}': 'Section {0}. {1}', + 'Public access and API keys': 'Accès public et clés API', + 'The ?v= parameter': 'Le paramètre ?v=', + 'Status codes and messages': 'Codes d’état et messages', + 'Images, CORS and ?format=': 'Images, CORS et ?format=', + '{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API', } as const export type MessageKey = keyof typeof messages diff --git a/web/src/lib/__tests__/apiSearch.spec.ts b/web/src/lib/__tests__/apiSearch.spec.ts new file mode 100644 index 0000000..6a75928 --- /dev/null +++ b/web/src/lib/__tests__/apiSearch.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' +import { + buildSearchIndex, + firstSentence, + highlightSegments, + queryTerms, + searchIndex, + suggestedHits, + type EndpointEntry, + type SearchHit, +} from '../apiSearch' + +const index = buildSearchIndex() + +function routes(hits: SearchHit[]) { + return hits.map((hit) => + hit.entry.kind === 'endpoint' + ? `${hit.entry.method} ${hit.entry.path}` + : `section ${hit.entry.title}`, + ) +} + +function search(query: string) { + return searchIndex(index, query) +} + +describe('firstSentence', () => { + it.each([ + ['Get one article. Drafts answer 404 to anonymous callers.', 'Get one article.'], + ['Rename an image', 'Rename an image'], + ['Upload an image (multipart).', 'Upload an image (multipart).'], + ])('cuts %s', (text, expected) => { + expect(firstSentence(text)).toBe(expected) + }) +}) + +describe('queryTerms', () => { + it('splits on spaces, trims and lowercases', () => { + expect(queryTerms(' POST Media ')).toEqual(['post', 'media']) + expect(queryTerms(' ')).toEqual([]) + }) +}) + +describe('index', () => { + it('covers documented, session only and public auth routes', () => { + const keys = index.endpoints.map((entry) => entry.key) + expect(keys).toContain('ep:med-merge') + expect(keys).toContain('session:POST /v1/auth/login') + expect(keys).toContain('public:GET /healthz') + expect(index.sections.map((section) => section.sectionId)).toEqual([ + 'auth', + 'articles', + 'seo', + 'media', + 'users', + 'settings', + 'sites', + 'authors', + 'images', + 'session', + 'errors', + 'notes', + ]) + }) + + it('never indexes code samples or response examples', () => { + expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([]) + expect(search('"total"').endpoints).toEqual([]) + expect(search('curl').endpoints).toEqual([]) + }) +}) + +describe('matching', () => { + it('matches terms case-insensitively as substrings', () => { + expect(routes(search('MEDIA').endpoints)).toContain('POST /v1/media/merge') + expect(routes(search('edia').endpoints)).toContain('POST /v1/media/merge') + }) + + it('does not match a misspelled term', () => { + const result = search('mdia') + expect(result.endpoints).toEqual([]) + expect(result.sections).toEqual([]) + }) + + it('requires every term in the same endpoint', () => { + const result = routes(search('post media').endpoints) + expect(result).toContain('POST /v1/media/') + expect(result.every((route) => !route.startsWith('GET'))).toBe(true) + expect(routes(search('patch user').endpoints)).toContain('PATCH /v1/user/me') + }) + + it('ignores extra spaces', () => { + expect(routes(search(' post media ').endpoints)).toEqual( + routes(search('post media').endpoints), + ) + }) + + it('returns nothing for an empty query', () => { + expect(search(' ')).toEqual({ endpoints: [], sections: [] }) + }) + + it('matches the access chip', () => { + const owners = search('OWNER ONLY').endpoints.map((hit) => hit.entry as EndpointEntry) + expect(owners.map((entry) => entry.path)).toEqual( + expect.arrayContaining(['/v1/user/invite', '/v1/user/invite/{id}']), + ) + expect( + owners.every((entry) => entry.access === 'Owner only' || entry.path.length > 0), + ).toBe(true) + }) + + it('matches a body field and reports it as the second line', () => { + const hits = search('keep_id').endpoints + expect(routes(hits)).toEqual(['POST /v1/media/merge']) + expect(hits[0]!.via).toEqual({ kind: 'body', text: 'keep_id' }) + expect(search('keep_id').sections).toEqual([]) + }) + + it('matches a query parameter and labels it', () => { + const hit = search('hash').endpoints.find( + (item) => (item.entry as EndpointEntry).path === '/v1/media/', + ) + expect(hit!.via).toEqual({ kind: 'query', text: 'hash' }) + }) + + it('matches an error code or message and reports the first error', () => { + const hits = search('404').endpoints + expect(hits.length).toBeGreaterThan(6) + const withError = hits.find((hit) => hit.via?.kind === 'error')! + expect(withError.via!.text.startsWith('404 ')).toBe(true) + expect(routes(search('404').sections)).toEqual(['section Errors']) + }) + + it('shows no second line for a path, description, method or chip match', () => { + for (const query of ['merge', 'identical copies', 'patch', 'verified user']) { + expect(search(query).endpoints.some((hit) => hit.via === null)).toBe(true) + } + expect(search('merge').endpoints[0]!.via).toBeNull() + }) + + it('lists path and description matches first, then catalogue order', () => { + const hits = search('token').endpoints + const firstIndirect = hits.findIndex((hit) => hit.via !== null) + if (firstIndirect !== -1) { + expect(hits.slice(0, firstIndirect).every((hit) => hit.via === null)).toBe(true) + expect(hits.slice(firstIndirect).every((hit) => hit.via !== null)).toBe(true) + } + const keys = index.endpoints.map((entry) => entry.key) + const media = search('media').endpoints.filter((hit) => hit.via === null) + const positions = media.map((hit) => keys.indexOf(hit.entry.key)) + expect(positions).toEqual([...positions].sort((a, b) => a - b)) + }) + + it('puts a field-only match after the path matches', () => { + const hits = search('keep_id media').endpoints + expect(routes(hits)).toEqual(['POST /v1/media/merge']) + const mixed = search('title').endpoints + const firstIndirect = mixed.findIndex((hit) => hit.via !== null) + expect(firstIndirect === -1 || mixed.slice(firstIndirect).every((hit) => hit.via)).toBe( + true, + ) + }) + + it('finds sections by title, description and keywords', () => { + expect(routes(search('media').sections)).toEqual(['section Media']) + expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' }) + const login = search('login') + expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login']) + expect(routes(login.sections)).toEqual(['section Session only']) + expect(login.sections[0]!.entry).toMatchObject({ + locked: true, + description: '25 routes, not available to API keys', + }) + }) + + it('flags session-only routes and sends them to the Session only section', () => { + const entry = search('login').endpoints[0]!.entry as EndpointEntry + expect(entry).toMatchObject({ + session: true, + access: 'Session only', + description: 'Log in', + cardId: null, + sectionId: 'session', + }) + }) + + it('finds nothing for xyz', () => { + expect(search('xyz')).toEqual({ endpoints: [], sections: [] }) + }) +}) + +describe('suggestions', () => { + it('lists the five suggested rows in order', () => { + expect(routes(suggestedHits(index))).toEqual([ + 'section Authentication', + 'POST /v1/media/', + 'section Errors', + 'GET /v1/article/', + 'PATCH /v1/user/me', + ]) + }) +}) + +describe('highlightSegments', () => { + it('marks every occurrence of every term', () => { + expect(highlightSegments('POST /v1/media/media', ['media'])).toEqual([ + { text: 'POST /v1/', marked: false }, + { text: 'media', marked: true }, + { text: '/', marked: false }, + { text: 'media', marked: true }, + ]) + }) + + it('is case-insensitive and escapes regex characters', () => { + expect(highlightSegments('Get {id}', ['get', '{id}'])).toEqual([ + { text: 'Get', marked: true }, + { text: ' ', marked: false }, + { text: '{id}', marked: true }, + ]) + }) + + it('returns the text unchanged without terms', () => { + expect(highlightSegments('Hello', [])).toEqual([{ text: 'Hello', marked: false }]) + }) +}) diff --git a/web/src/lib/apiReference.ts b/web/src/lib/apiReference.ts index ed29653..52654bf 100644 --- a/web/src/lib/apiReference.ts +++ b/web/src/lib/apiReference.ts @@ -14,6 +14,8 @@ export const CHIP_CLASS = { const METHOD_BASE = 'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border' +export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted` + export const METHOD_CLASS: Record = { GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`, POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`, diff --git a/web/src/lib/apiSearch.ts b/web/src/lib/apiSearch.ts new file mode 100644 index 0000000..ebc38d4 --- /dev/null +++ b/web/src/lib/apiSearch.ts @@ -0,0 +1,267 @@ +import { t, translate } from '@/i18n' +import { + API_GROUPS, + ERROR_ROWS, + LOGIN_FAMILY_CAPTION, + PUBLIC_AUTH_ROWS, + SESSION_GROUPS, + type HttpMethod, +} from '@/data/apiCatalogue' + +export const ENDPOINT_CAP = 6 + +export type MatchKind = 'query' | 'body' | 'error' + +export interface SearchMatch { + kind: MatchKind + text: string +} + +export interface EndpointEntry { + kind: 'endpoint' + key: string + method: HttpMethod + path: string + description: string + access: string + session: boolean + cardId: string | null + sectionId: string + fields: SearchMatch[] + errors: SearchMatch[] +} + +export interface SectionEntry { + kind: 'section' + key: string + sectionId: string + title: string + description: string + locked: boolean + keywords: string +} + +export type SearchEntry = EndpointEntry | SectionEntry + +export interface SearchHit { + entry: SearchEntry + via: SearchMatch | null +} + +export interface SearchResult { + endpoints: SearchHit[] + sections: SearchHit[] +} + +export interface HighlightSegment { + text: string + marked: boolean +} + +export interface SearchIndex { + endpoints: EndpointEntry[] + sections: SectionEntry[] +} + +const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me'] + +export function firstSentence(text: string) { + const end = text.search(/\.(\s|$)/) + return end === -1 ? text : text.slice(0, end + 1) +} + +export function queryTerms(query: string) { + return query.toLowerCase().split(/\s+/).filter(Boolean) +} + +function endpointEntries(): EndpointEntry[] { + const documented = API_GROUPS.flatMap((group) => + group.endpoints.map((endpoint) => ({ + kind: 'endpoint', + key: `ep:${endpoint.id}`, + method: endpoint.method, + path: endpoint.path, + description: firstSentence(translate(endpoint.description)), + access: translate(endpoint.access), + session: false, + cardId: endpoint.id, + sectionId: group.key, + fields: [ + ...endpoint.query.map((field) => ({ + kind: 'query', + text: field.name, + })), + ...endpoint.body.map((field) => ({ kind: 'body', text: field.name })), + ], + errors: endpoint.errors.map((error) => ({ + kind: 'error', + text: `${error.status} ${translate(error.caption)}`, + })), + })), + ) + const sessionOnly = SESSION_GROUPS.flatMap((group) => + group.rows.map((row) => ({ + kind: 'endpoint', + key: `session:${row.method} ${row.path}`, + method: row.method, + path: row.path, + description: firstSentence( + translate(row.description.split(`. ${LOGIN_FAMILY_CAPTION}`)[0]!), + ), + access: t('Session only'), + session: true, + cardId: null, + sectionId: 'session', + fields: [], + errors: [], + })), + ) + const publicAuth = PUBLIC_AUTH_ROWS.map((row) => ({ + kind: 'endpoint', + key: `public:${row.method} ${row.path}`, + method: row.method, + path: row.path, + description: firstSentence(translate(row.description)), + access: t('Public'), + session: false, + cardId: null, + sectionId: 'session', + fields: [], + errors: [], + })) + return [...documented, ...sessionOnly, ...publicAuth] +} + +function routeCount(count: number) { + return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count]) +} + +function sectionEntries(): SectionEntry[] { + const sessionRoutes = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0) + const errorWords = ERROR_ROWS.map((row) => `${row.status} ${translate(row.meaning)}`).join(' ') + const section = ( + id: string, + title: string, + description: string, + keywords = '', + locked = false, + ): SectionEntry => ({ + kind: 'section', + key: `section:${id}`, + sectionId: id, + title, + description, + locked, + keywords, + }) + return [ + section( + 'auth', + t('Authentication'), + t('Public access and API keys'), + 'bearer api key rcms rate limit 429 base url public', + ), + ...API_GROUPS.map((group) => + section(group.key, translate(group.title), routeCount(group.endpoints.length)), + ), + section( + 'images', + t('Images and variants'), + t('The ?v= parameter'), + 'variant image width height format quality', + ), + section( + 'session', + t('Session only'), + t('{0} routes, not available to API keys', [sessionRoutes]), + 'login log in register password real session', + true, + ), + section( + 'errors', + t('Errors'), + t('Status codes and messages'), + `${errorWords} status error plain text`, + ), + section( + 'notes', + t('Notes'), + t('Images, CORS and ?format='), + 'cors origins format markdown html json body_format', + ), + ] +} + +export function buildSearchIndex(): SearchIndex { + return { endpoints: endpointEntries(), sections: sectionEntries() } +} + +function primaryText(entry: EndpointEntry) { + return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase() +} + +function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined { + if (primaryText(entry).includes(term)) { + return null + } + const field = entry.fields.find((item) => item.text.toLowerCase().includes(term)) + if (field) { + return field + } + return entry.errors.find((item) => item.text.toLowerCase().includes(term)) +} + +function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null { + let via: SearchMatch | null = null + for (const term of terms) { + const found = viaMatch(entry, term) + if (found === undefined) { + return null + } + via = via ?? found + } + return { entry, via } +} + +function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null { + const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase() + return terms.every((term) => text.includes(term)) ? { entry, via: null } : null +} + +export function suggestedHits(index: SearchIndex): SearchHit[] { + const entries: SearchEntry[] = [...index.endpoints, ...index.sections] + return SUGGESTED_KEYS.flatMap((key) => { + const entry = entries.find((item) => item.key === key) + return entry ? [{ entry, via: null }] : [] + }) +} + +export function searchIndex(index: SearchIndex, query: string): SearchResult { + const terms = queryTerms(query) + if (terms.length === 0) { + return { endpoints: [], sections: [] } + } + const hits = index.endpoints.flatMap((entry) => matchEndpoint(entry, terms) ?? []) + const direct = hits.filter((hit) => hit.via === null) + const indirect = hits.filter((hit) => hit.via !== null) + return { + endpoints: [...direct, ...indirect], + sections: index.sections.flatMap((entry) => matchSection(entry, terms) ?? []), + } +} + +function escapePattern(term: string) { + return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function highlightSegments(text: string, terms: string[]): HighlightSegment[] { + if (terms.length === 0) { + return [{ text, marked: false }] + } + const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi') + const lowered = new Set(terms) + return text + .split(pattern) + .filter((part) => part !== '') + .map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) })) +} diff --git a/web/src/views/IntegrationView.vue b/web/src/views/IntegrationView.vue index 6c757c1..fab2c75 100644 --- a/web/src/views/IntegrationView.vue +++ b/web/src/views/IntegrationView.vue @@ -1,8 +1,17 @@