Files
RenewCMS/web/src/views/IntegrationView.vue
T

726 lines
22 KiB
Vue

<script setup lang="ts">
import { t, translate } from '@/i18n'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance,
} from 'vue'
import { useRoute } from 'vue-router'
import ApiSearchPalette from '@/components/ApiSearchPalette.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationImages from '@/components/IntegrationImages.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import {
API_GROUPS,
ERROR_ROWS,
FIELD_TYPES_ANCHOR,
ERROR_SAMPLE,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
authExamples,
type ApiGroup,
type ErrorRow,
} from '@/data/apiCatalogue'
import { SECTION_TITLE, endpointAnchor, groupsAroundImages } from '@/lib/apiReference'
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
const SPY_OFFSET_PX = 120
const route = useRoute()
const origin = computed(() => window.location.origin)
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const baseUrlTabs = computed(() => [{ k: 'base', label: t('Base URL'), code: origin.value }])
const errorSampleTabs = computed(() => [
{ k: 'error', label: t('Error response · text/plain'), code: ERROR_SAMPLE },
])
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
function groupSection(group: ApiGroup) {
return { id: group.key, label: translate(group.title) }
}
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...groupsBeforeImages.map(groupSection),
{ id: 'images', label: t('Images and variants') },
...groupsAfterImages.map(groupSection),
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
])
const authKeyCopy = computed(() =>
t(
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
),
)
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ k: 'curl', label: 'curl', code: examples.curl },
{ k: 'fetch', label: 'fetch', code: examples.fetch },
]
})
const authNotes = computed(() => [
t(
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.',
),
t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
),
])
const notes = computed(() => [
t(
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
),
t(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
),
t(
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.',
),
])
const errorColumns = computed(() => [
{
accessorKey: 'status',
get header() {
return t('Status')
},
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
},
{
accessorKey: 'meaning',
get header() {
return t('Meaning')
},
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
},
{
accessorKey: 'messages',
get header() {
return t('Message')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
interface MessagePart {
text: string
code: boolean
}
function messageParts(row: ErrorRow) {
const parts: MessagePart[] = []
const push = (text: string, code: boolean) => {
if (parts.length > 0) {
parts.push({ text: ' / ', code: false })
}
parts.push({ text, code })
}
row.messages.forEach((message) => push(`"${message}"`, false))
row.codes?.forEach((code) => push(code, true))
return parts
}
const fieldTypeColumns = computed(() => [
{
accessorKey: 'type',
get header() {
return t('Type')
},
meta: { class: { th: 'w-22', td: 'w-22' } },
},
{
accessorKey: 'value',
get header() {
return t('Value in an entry')
},
meta: { class: { th: 'lg:w-1/2', td: 'lg:w-1/2' } },
},
{
accessorKey: 'rules',
get header() {
return t('Rules')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
const pageBlocks = [
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
{ kind: 'images' as const, key: 'images', group: null },
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
]
function snippetTabs(group: ApiGroup) {
return group.snippet
? [
{
k: group.snippet.key,
label: translate(group.snippet.label),
code: group.snippet.code,
},
]
: []
}
function routeCount(count: number) {
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
}
function scrollParent(element: HTMLElement) {
let current = element.parentElement
while (current) {
const overflow = getComputedStyle(current).overflowY
if (overflow === 'auto' || overflow === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function sectionElement(id: string) {
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
}
function scrollToElement(element: HTMLElement | null) {
const parent = element ? scrollParent(element) : null
if (!element || !parent) {
element?.scrollIntoView?.({ block: 'start' })
return
}
const margin = parseFloat(getComputedStyle(element).scrollMarginTop) || 0
const offset = element.getBoundingClientRect().top - parent.getBoundingClientRect().top
parent.scrollTo({ top: parent.scrollTop + offset - margin })
}
function goTo(id: string) {
activeSection.value = id
scrollToElement(sectionElement(id))
}
function updateActive() {
const element = root.value
const parent = element ? scrollParent(element) : null
if (!parent) {
return
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
let currentTop = Number.NEGATIVE_INFINITY
for (const section of sections.value) {
const top = sectionElement(section.id)?.getBoundingClientRect().top
if (top !== undefined && top <= limit && top > currentTop) {
current = section.id
currentTop = top
}
}
activeSection.value = current
}
interface RevealableCard {
reveal: () => Promise<void>
}
const FLASH_MS = 1500
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
const searchOpen = ref(false)
const searchData = computed(() => buildSearchIndex())
const flashedCard = ref<string | null>(null)
const flashMode = ref<'animated' | 'static'>('animated')
const cards = new Map<string, RevealableCard>()
let searchOpener: HTMLElement | null = null
let skipFocusRestore = false
let flashTimer: ReturnType<typeof setTimeout> | undefined
function setCard(id: string, card: Element | ComponentPublicInstance | null) {
if (card) {
cards.set(id, card as unknown as RevealableCard)
} else {
cards.delete(id)
}
}
function openSearch(trigger?: HTMLElement | null) {
const active = document.activeElement
searchOpener = trigger ?? (active instanceof HTMLElement ? active : null)
searchOpen.value = true
}
watch(searchOpen, async (value) => {
if (value) {
return
}
await nextTick()
if (skipFocusRestore) {
skipFocusRestore = false
return
}
searchOpener?.focus()
})
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
const field = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
return (
field ||
target.isContentEditable === true ||
target.closest('[contenteditable=""], [contenteditable="true"]') !== null
)
}
function onShortcut(event: KeyboardEvent) {
if (searchOpen.value || event.defaultPrevented || event.isComposing) {
return
}
const withoutAlt = !event.altKey
const chord =
(event.ctrlKey || event.metaKey) &&
withoutAlt &&
!event.shiftKey &&
event.key.toLowerCase() === 'k'
const slash =
event.key === '/' &&
withoutAlt &&
!event.ctrlKey &&
!event.metaKey &&
!isEditable(event.target)
if (chord || slash) {
event.preventDefault()
openSearch()
}
}
function writeHash(anchor: string) {
const { pathname, search } = window.location
window.history.replaceState(window.history.state, '', `${pathname}${search}#${anchor}`)
}
function startFlash(id: string) {
clearTimeout(flashTimer)
flashMode.value = window.matchMedia?.(REDUCED_MOTION_QUERY).matches ? 'static' : 'animated'
flashedCard.value = id
flashTimer = setTimeout(() => {
flashedCard.value = null
}, FLASH_MS)
}
function revealCard(id: string, sectionId: string) {
activeSection.value = sectionId
void cards.get(id)?.reveal()
scrollToElement(sectionElement(endpointAnchor(id)))
writeHash(endpointAnchor(id))
startFlash(id)
}
function focusSection(id: string) {
goTo(id)
writeHash(id)
const heading = sectionElement(id)?.querySelector<HTMLElement>('h2')
heading?.setAttribute('tabindex', '-1')
heading?.focus({ preventScroll: true })
}
async function onSearchSelect(entry: SearchEntry) {
skipFocusRestore = true
await nextTick()
if (entry.kind === 'endpoint' && entry.cardId) {
revealCard(entry.cardId, entry.sectionId)
return
}
focusSection(entry.sectionId)
}
function revealSharedCard(anchor: string) {
const group = API_GROUPS.find((item) =>
item.endpoints.some((endpoint) => endpointAnchor(endpoint.id) === anchor),
)
if (group) {
revealCard(anchor.slice('ep-'.length), group.key)
}
}
let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
document.addEventListener('keydown', onShortcut)
const target = route.hash.slice(1)
if (sections.value.some((section) => section.id === target)) {
nextTick(() => goTo(target))
} else if (target.startsWith('ep-')) {
nextTick(() => revealSharedCard(target))
}
})
onBeforeUnmount(() => {
scroller?.removeEventListener('scroll', updateActive)
document.removeEventListener('keydown', onShortcut)
clearTimeout(flashTimer)
})
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Integration')">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
</div>
<UButton
color="neutral"
variant="outline"
:aria-label="t('Search the API reference')"
aria-keyshortcuts="/ Control+K Meta+K"
aria-haspopup="dialog"
class="hidden h-9 w-80 shrink-0 justify-start gap-2 rounded-none border border-accented bg-default py-0 pr-2 pl-3 text-base font-normal text-muted ring-0 hover:border-muted hover:bg-default focus-visible:outline-primary lg:inline-flex"
@click="openSearch($event.currentTarget as HTMLElement)"
>
<UIcon name="i-lucide-search" class="size-4 shrink-0 text-muted" />
<span class="min-w-0 flex-1 truncate text-left">{{
t('Search the API reference…')
}}</span>
<UKbd
value="/"
class="h-5 min-w-5 justify-center rounded-none border border-accented bg-elevated px-1.5 font-mono normal-case text-xs font-medium text-toned ring-0"
/>
</UButton>
</div>
<p class="max-w-180 text-base text-pretty text-toned">
{{
t(
'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, and manage content types and entries, without the back-office.',
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<CodeSnippet :tabs="baseUrlTabs" />
</div>
<section
data-anchor="auth"
aria-labelledby="h-auth"
:class="[SECTION_CLASS, 'gap-3.5']"
>
<h2 id="h-auth" :class="SECTION_TITLE">
{{ t('Authentication') }}
</h2>
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div :class="AUTH_CARD">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('Public') }}</span>
<UBadge
:label="t('No key')"
color="neutral"
variant="outline"
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
/>
</div>
<span class="text-pretty text-toned">{{
t(
'No key needed. Two read-only routes, and only online articles are returned.',
)
}}</span>
<div
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
>
<span>GET /v1/article/</span>
<span>GET /v1/article/{id}</span>
</div>
</div>
<div :class="[AUTH_CARD, 'min-w-0']">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('API key') }}</span>
<UBadge
:label="t('Full access')"
color="success"
variant="soft"
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
/>
</div>
<span class="text-pretty text-toned"
><CodeText :text="authKeyCopy"
/></span>
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
<span class="text-pretty text-muted"
><CodeText
:text="
t(
'Keys start with `rcms_` and are shown in full once, when you create them.',
)
"
/></span>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="min-h-11 self-start px-0 lg:min-h-7"
>
{{ t('Manage API keys') }}
</UButton>
</div>
</div>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
<template v-for="block in pageBlocks" :key="block.key">
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
<section
v-else
:data-anchor="block.group.key"
:aria-labelledby="`h-${block.group.key}`"
:class="SECTION_CLASS"
>
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 :id="`h-${block.group.key}`" :class="SECTION_TITLE">
{{ translate(block.group.title) }}
</h2>
<span class="text-muted">{{
routeCount(block.group.endpoints.length)
}}</span>
</div>
<span class="text-pretty text-toned">{{
translate(block.group.description)
}}</span>
<div v-if="block.group.note" class="flex gap-2.5 text-base text-toned">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"
><CodeText :text="translate(block.group.note)"
/></span>
</div>
<div
v-if="block.group.fieldTypes"
:data-anchor="FIELD_TYPES_ANCHOR"
class="flex scroll-mt-16 flex-col gap-1.5 lg:scroll-mt-8"
>
<span class="font-semibold text-highlighted">{{
t('Field types')
}}</span>
<UTable
:data="block.group.fieldTypes"
:columns="fieldTypeColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #type-cell="{ row }">
<span class="font-mono text-xs font-medium text-highlighted">{{
row.original.type
}}</span>
</template>
<template #value-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty"
><CodeText :text="translate(row.original.value)"
/></span>
<span class="text-pretty lg:hidden"
><CodeText :text="translate(row.original.rules)"
/></span>
</div>
</template>
<template #rules-cell="{ row }">
<span class="text-pretty"
><CodeText :text="translate(row.original.rules)"
/></span>
</template>
</UTable>
</div>
<CodeSnippet v-if="block.group.snippet" :tabs="snippetTabs(block.group)" />
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in block.group.endpoints"
:key="endpoint.id"
:ref="(card) => setCard(endpoint.id, card)"
:endpoint="endpoint"
:origin="origin"
:flash="flashedCard === endpoint.id ? flashMode : null"
/>
</div>
</section>
</template>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">
{{ t('Session only') }}
</h2>
<span class="text-muted">{{
t('{0} routes, not available to API keys', [sessionRouteCount])
}}</span>
</div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
</section>
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
<h2 id="h-errors" :class="SECTION_TITLE">
{{ t('Errors') }}
</h2>
<span class="text-pretty text-toned">{{
t(
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.',
)
}}</span>
<UTable
:data="ERROR_ROWS"
:columns="errorColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-3 py-2 text-sm font-medium text-muted',
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #status-cell="{ row }">
<span class="font-mono text-sm font-medium text-highlighted">{{
row.original.status
}}</span>
</template>
<template #meaning-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-highlighted">{{
translate(row.original.meaning)
}}</span>
<div class="flex flex-col gap-0.5 lg:hidden">
<span class="text-pretty text-toned"
><template
v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
><CodeText :text="translate(row.original.note)"
/></span>
</div>
</div>
</template>
<template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty"
><template
v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span
v-if="row.original.note"
class="text-pretty text-muted"
data-testid="error-note"
><CodeText :text="translate(row.original.note)"
/></span>
</div>
</template>
</UTable>
<CodeSnippet :tabs="errorSampleTabs" />
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
<h2 id="h-notes" :class="SECTION_TITLE">
{{ t('Notes') }}
</h2>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
</div>
<SectionIndex
:sections="sections"
:active="activeSection"
@select="goTo"
@search="openSearch"
/>
<ApiSearchPalette
v-model:open="searchOpen"
:index="searchData"
@select="onSearchSelect"
/>
</div>
</DashboardShell>
</template>