feat: updated integration page with missing API routes

This commit is contained in:
Florian Sylvain committed 2026-10-26 02:18:35 +01:00
1 parent 25d9c30b8a
commit 7c0ee21906
25 files changed
+2402 -350

No files matched your search

+66 -4
View File
@@ -51,18 +51,27 @@ describe('index', () => {
'auth',
'articles',
'seo',
'ctypes',
'entries',
'builtin',
'media',
'sites',
'images',
'users',
'settings',
'sites',
'authors',
'images',
'session',
'errors',
'notes',
])
})
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(85)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37)
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
})
it('never indexes code samples or response examples', () => {
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
expect(search('"total"').endpoints).toEqual([])
@@ -164,12 +173,13 @@ describe('matching', () => {
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' })
expect(search('media').sections[0]!.entry.sectionId).toBe('media')
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',
description: '37 routes, not available to API keys',
})
})
@@ -189,6 +199,58 @@ describe('matching', () => {
})
})
describe('custom content types', () => {
it.each([
['media', 16, 1],
['404', 23, 1],
['login', 1, 1],
['entry', 7, 1],
['capacity', 2, 0],
['schema_incompatible', 1, 0],
['content type', 10, 2],
])('counts "%s" as %i endpoints and %i sections', (query, endpoints, sections) => {
const result = search(query)
expect(result.endpoints).toHaveLength(endpoints)
expect(result.sections).toHaveLength(sections)
})
it('also matches "entries" for the term entry, in text and in highlights', () => {
const keys = search('entry').endpoints.map((hit) => hit.entry.key)
expect(keys).toContain('ep:ct-delete')
expect(keys).toContain('ep:ct-update')
expect(routes(search('entry').sections)).toEqual(['section Entries'])
expect(highlightSegments('Entries and entry', ['entry'])).toEqual([
{ text: 'Entries', marked: true },
{ text: ' and ', marked: false },
{ text: 'entry', marked: true },
])
})
it('labels the events keys as body fields and a code as an error', () => {
const capacity = search('capacity').endpoints
expect(capacity.map((hit) => hit.via?.kind)).toEqual(['body', 'body'])
expect(search('starts_on').endpoints.map((hit) => hit.via)).toEqual([
{ kind: 'body', text: 'starts_on' },
{ kind: 'body', text: 'starts_on' },
])
const [schema] = search('schema_incompatible').endpoints
expect(schema!.via!.kind).toBe('error')
expect(schema!.via!.text.startsWith('409 schema_incompatible')).toBe(true)
expect(routes(search('schema_incompatible').endpoints)).toEqual([
'PUT /v1/content-type/{key}',
])
})
it('finds the new sections by their keywords', () => {
expect(routes(search('schema').sections)).toEqual([
'section Content types',
'section Built-in types',
])
expect(routes(search('author').sections)).toContain('section Sites and authors')
expect(routes(search('purge').sections)).toEqual(['section Media'])
})
})
describe('suggestions', () => {
it('lists the five suggested rows in order', () => {
expect(routes(suggestedHits(index))).toEqual([
+10 -2
View File
@@ -1,4 +1,4 @@
import type { HttpMethod } from '@/data/apiCatalogue'
import { IMAGES_AFTER_GROUP, type ApiGroup, type HttpMethod } from '@/data/apiCatalogue'
export const SECTION_TITLE = 'text-[1.125rem]/6.5 font-semibold text-highlighted'
@@ -16,10 +16,13 @@ const METHOD_BASE =
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
const PATCH_CLASS = `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`
export const METHOD_CLASS: Record<HttpMethod, string> = {
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
PATCH: `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`,
PATCH: PATCH_CLASS,
PUT: PATCH_CLASS,
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
}
@@ -30,3 +33,8 @@ export function splitInlineCode(text: string) {
export function endpointAnchor(id: string) {
return `ep-${id}`
}
export function groupsAroundImages(groups: ApiGroup[]) {
const split = groups.findIndex((group) => group.key === IMAGES_AFTER_GROUP) + 1
return { before: groups.slice(0, split), after: groups.slice(split) }
}
+57 -11
View File
@@ -1,10 +1,14 @@
import { t, translate } from '@/i18n'
import { groupsAroundImages } from '@/lib/apiReference'
import {
API_GROUPS,
ERROR_ROWS,
LOGIN_FAMILY_CAPTION,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
type ApiEndpoint,
type ApiError,
type ApiGroup,
type HttpMethod,
} from '@/data/apiCatalogue'
@@ -63,6 +67,18 @@ export interface SearchIndex {
sections: SectionEntry[]
}
const ENTRY_TERM = 'entry'
const ENTRY_PLURAL = 'entries'
const GROUP_KEYWORDS: Record<string, string> = {
seo: 'seo sitemap',
ctypes: 'custom fields field types schema',
entries: 'content type custom data',
builtin: 'builtin fields schema',
media: 'purge',
sites: 'site author',
}
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
export function firstSentence(text: string) {
@@ -74,6 +90,17 @@ export function queryTerms(query: string) {
return query.toLowerCase().split(/\s+/).filter(Boolean)
}
function errorText(error: ApiError) {
const caption = translate(error.caption)
return error.tail
? `${error.status} ${caption} ${translate(error.tail)}`
: `${error.status} ${caption}`
}
function bodyKeys(endpoint: ApiEndpoint) {
return [...new Set([...endpoint.body.map((field) => field.name), ...endpoint.searchKeys])]
}
function endpointEntries(): EndpointEntry[] {
const documented = API_GROUPS.flatMap((group) =>
group.endpoints.map<EndpointEntry>((endpoint) => ({
@@ -91,11 +118,11 @@ function endpointEntries(): EndpointEntry[] {
kind: 'query',
text: field.name,
})),
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })),
...bodyKeys(endpoint).map<SearchMatch>((text) => ({ kind: 'body', text })),
],
errors: endpoint.errors.map<SearchMatch>((error) => ({
kind: 'error',
text: `${error.status} ${translate(error.caption)}`,
text: errorText(error),
})),
})),
)
@@ -154,6 +181,14 @@ function sectionEntries(): SectionEntry[] {
locked,
keywords,
})
const { before, after } = groupsAroundImages(API_GROUPS)
const groupSection = (group: ApiGroup) =>
section(
group.key,
translate(group.title),
routeCount(group.endpoints.length),
GROUP_KEYWORDS[group.key],
)
return [
section(
'auth',
@@ -161,15 +196,14 @@ function sectionEntries(): SectionEntry[] {
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)),
),
...before.map(groupSection),
section(
'images',
t('Images and variants'),
t('The ?v= parameter'),
'variant image width height format quality',
),
...after.map(groupSection),
section(
'session',
t('Session only'),
@@ -196,19 +230,23 @@ export function buildSearchIndex(): SearchIndex {
return { endpoints: endpointEntries(), sections: sectionEntries() }
}
function includesTerm(text: string, term: string) {
return text.includes(term) || (term === ENTRY_TERM && text.includes(ENTRY_PLURAL))
}
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)) {
if (includesTerm(primaryText(entry), term)) {
return null
}
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term))
const field = entry.fields.find((item) => includesTerm(item.text.toLowerCase(), term))
if (field) {
return field
}
return entry.errors.find((item) => item.text.toLowerCase().includes(term))
return entry.errors.find((item) => includesTerm(item.text.toLowerCase(), term))
}
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
@@ -225,7 +263,7 @@ function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null
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
return terms.every((term) => includesTerm(text, term)) ? { entry, via: null } : null
}
export function suggestedHits(index: SearchIndex): SearchHit[] {
@@ -254,14 +292,22 @@ function escapePattern(term: string) {
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function termPattern(term: string) {
return term === ENTRY_TERM ? 'entr(?:y|ies)' : escapePattern(term)
}
function isMarked(part: string, terms: Set<string>) {
return terms.has(part) || (part === ENTRY_PLURAL && terms.has(ENTRY_TERM))
}
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 pattern = new RegExp(`(${terms.map(termPattern).join('|')})`, 'gi')
const lowered = new Set(terms)
return text
.split(pattern)
.filter((part) => part !== '')
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) }))
.map((part) => ({ text: part, marked: isMarked(part.toLowerCase(), lowered) }))
}