@letterncms/sdk 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/package.json +20 -5
- package/src/api.ts +154 -0
- package/src/events.ts +14 -0
- package/src/i18n.ts +49 -0
- package/src/index.ts +29 -0
- package/src/manifest.ts +96 -0
- package/src/query.ts +57 -0
- package/src/registry.ts +159 -0
- package/src/router.tsx +201 -0
- package/src/types.ts +337 -0
- package/README.md +0 -3
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Falko Joseph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
2
|
+
"name": "@letterncms/sdk",
|
|
3
|
+
"description": "The admin's API client, ability hooks, router and extension registry. The admin provides the code at runtime.",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": "./src/index.ts"
|
|
6
|
+
},
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"react": "^19.0.0",
|
|
9
|
+
"@tanstack/react-query": "^5.0.0"
|
|
10
|
+
},
|
|
11
|
+
"version": "0.1.1",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/letterncms/cms.git"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { t } from './i18n'
|
|
2
|
+
import type { Boot } from './types'
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
__CMS__: Boot
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const boot: Boot = (typeof window !== 'undefined' && window.__CMS__) || {
|
|
11
|
+
base: '/admin',
|
|
12
|
+
api: '/api/admin',
|
|
13
|
+
version: 'dev',
|
|
14
|
+
brand: { name: '', domain: '', product_url: '', docs_url: '', github_url: '', support_email: '', mark: '', wordmark: '' },
|
|
15
|
+
site: { name: '', url: '' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Error thrown for any failed request; mirrors the server's AbilityException. */
|
|
19
|
+
export class CmsError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
public readonly type: string = 'failed',
|
|
23
|
+
public readonly status: number = 500,
|
|
24
|
+
public readonly details: Record<string, string> = {},
|
|
25
|
+
) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.name = 'CmsError'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The token to send with a request that changes something. The cookie's name is the site's own, as cookies don't tell ports apart. */
|
|
32
|
+
export function xsrfToken(): string | null {
|
|
33
|
+
const name = boot.xsrfCookie ?? 'XSRF-TOKEN'
|
|
34
|
+
const cookie = document.cookie.split(/;\s*/).find((part) => part.startsWith(`${name}=`))
|
|
35
|
+
return cookie ? decodeURIComponent(cookie.slice(name.length + 1)) : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let unauthorizedHandler: (() => void) | null = null
|
|
39
|
+
|
|
40
|
+
/** Called whenever the session expires (401) so the theme can show the sign-in screen. */
|
|
41
|
+
export function onUnauthorized(handler: () => void) {
|
|
42
|
+
unauthorizedHandler = handler
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headers(accept: string, json: boolean): Record<string, string> {
|
|
46
|
+
const h: Record<string, string> = { Accept: accept, 'X-Requested-With': 'XMLHttpRequest' }
|
|
47
|
+
const token = xsrfToken()
|
|
48
|
+
if (token) h['X-XSRF-TOKEN'] = token
|
|
49
|
+
if (json) h['Content-Type'] = 'application/json'
|
|
50
|
+
// The language this page was opened in (it may differ from the account's: `?lang=nl`).
|
|
51
|
+
if (boot.locale) h['X-Cms-Locale'] = boot.locale
|
|
52
|
+
return h
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function failure(response: Response, json: any): CmsError {
|
|
56
|
+
if (response.status === 401 && unauthorizedHandler) unauthorizedHandler()
|
|
57
|
+
const error = json?.error
|
|
58
|
+
const laravelErrors = json?.errors ? Object.fromEntries(Object.entries(json.errors).map(([k, v]) => [k, (v as string[])[0]])) : {}
|
|
59
|
+
return new CmsError(
|
|
60
|
+
error?.message ?? json?.message ?? (response.status === 419 ? t('Your session expired. Reload the page.') : t('Request failed ({status})', { status: response.status })),
|
|
61
|
+
error?.type ?? (response.status === 422 ? 'invalid_input' : 'failed'),
|
|
62
|
+
response.status,
|
|
63
|
+
error?.details ?? laravelErrors,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const apiUrl = (path: string) => (path.startsWith('/') ? path : `${boot.api}/${path}`)
|
|
68
|
+
|
|
69
|
+
export async function request<T = unknown>(method: string, path: string, body?: unknown, init: RequestInit = {}): Promise<T> {
|
|
70
|
+
const response = await fetch(apiUrl(path), {
|
|
71
|
+
method,
|
|
72
|
+
credentials: 'same-origin',
|
|
73
|
+
headers: { ...headers('application/json', body !== undefined && !(body instanceof FormData)), ...(init.headers as Record<string, string> | undefined) },
|
|
74
|
+
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
|
75
|
+
...init,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const text = await response.text()
|
|
79
|
+
const json = text ? safeJson(text) : null
|
|
80
|
+
|
|
81
|
+
if (!response.ok) throw failure(response, json)
|
|
82
|
+
|
|
83
|
+
return json as T
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST that answers with server-sent events. Calls onEvent for every event as
|
|
88
|
+
* it arrives and resolves when the stream ends; abort with the signal.
|
|
89
|
+
*/
|
|
90
|
+
export async function eventStream(path: string, body: unknown, onEvent: (event: string, data: any) => void, signal?: AbortSignal): Promise<void> {
|
|
91
|
+
const response = await fetch(apiUrl(path), {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
credentials: 'same-origin',
|
|
94
|
+
headers: headers('text/event-stream', true),
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
signal,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (!response.ok || !response.body || !(response.headers.get('Content-Type') ?? '').startsWith('text/event-stream')) {
|
|
100
|
+
const text = await response.text()
|
|
101
|
+
throw failure(response.ok ? new Response(null, { status: 500 }) : response, text ? safeJson(text) : null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
|
105
|
+
let buffer = ''
|
|
106
|
+
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { value, done } = await reader.read()
|
|
109
|
+
if (done) break
|
|
110
|
+
buffer += value.replace(/\r\n/g, '\n')
|
|
111
|
+
|
|
112
|
+
let end: number
|
|
113
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
114
|
+
const chunk = buffer.slice(0, end)
|
|
115
|
+
buffer = buffer.slice(end + 2)
|
|
116
|
+
let event = 'message'
|
|
117
|
+
const data: string[] = []
|
|
118
|
+
for (const line of chunk.split('\n')) {
|
|
119
|
+
if (line.startsWith('event:')) event = line.slice(6).trim()
|
|
120
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
|
|
121
|
+
}
|
|
122
|
+
if (data.length) onEvent(event, safeJson(data.join('\n')))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function safeJson(text: string): any {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text)
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Run an ability as the signed-in user. Resolves with the ability's result. */
|
|
136
|
+
export async function run<T = any>(ability: string, input: Record<string, unknown> = {}): Promise<T> {
|
|
137
|
+
const res = await request<{ data: T }>('POST', `abilities/${ability}`, input)
|
|
138
|
+
return res.data
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface BatchCall {
|
|
142
|
+
ability: string
|
|
143
|
+
input?: Record<string, unknown>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function batch(calls: BatchCall[], stopOnError = true): Promise<({ data: any } | { error: { type: string; message: string } })[]> {
|
|
147
|
+
const res = await request<{ data: any[] }>('POST', 'batch', { calls, stopOnError })
|
|
148
|
+
return res.data
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Absolute admin URL for a path inside the admin. */
|
|
152
|
+
export function adminUrl(path = '/'): string {
|
|
153
|
+
return boot.base + (path === '/' ? '' : path.startsWith('/') ? path : `/${path}`)
|
|
154
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type Handler<T = any> = (payload: T) => void
|
|
2
|
+
const handlers = new Map<string, Set<Handler>>()
|
|
3
|
+
|
|
4
|
+
/** Tiny event bus for loose coupling between the theme and plugin modules. */
|
|
5
|
+
export const events = {
|
|
6
|
+
on<T = any>(name: string, handler: Handler<T>): () => void {
|
|
7
|
+
if (!handlers.has(name)) handlers.set(name, new Set())
|
|
8
|
+
handlers.get(name)!.add(handler)
|
|
9
|
+
return () => handlers.get(name)?.delete(handler)
|
|
10
|
+
},
|
|
11
|
+
emit<T = any>(name: string, payload?: T) {
|
|
12
|
+
handlers.get(name)?.forEach((h) => h(payload))
|
|
13
|
+
},
|
|
14
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { boot } from './api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The admin speaks the user's language. Labels are written in English and are their
|
|
5
|
+
* own key; core and every package ship `lang/<code>.json` dictionaries, merged by the
|
|
6
|
+
* server and handed over at boot. A missing translation shows the English text.
|
|
7
|
+
* Write English labels, headings and actions in title case ("Design System");
|
|
8
|
+
* descriptions, instructions and messages use sentence case. Translations follow
|
|
9
|
+
* the capitalization conventions of their language.
|
|
10
|
+
*
|
|
11
|
+
* t('Publish') → "Publiceren"
|
|
12
|
+
* t('Delete “{title}”?', { title }) → placeholders in braces
|
|
13
|
+
* plural(count, '{count} entry', '{count} entries')
|
|
14
|
+
* {brand} in any string → the product's name, without passing it
|
|
15
|
+
*/
|
|
16
|
+
// Read from boot on first use: api.ts translates its own errors, so the two modules import each other.
|
|
17
|
+
let messages: Record<string, string> | null = null
|
|
18
|
+
let current: string | null = null
|
|
19
|
+
const dictionary = () => (messages ??= (boot.messages as Record<string, string> | undefined) ?? {})
|
|
20
|
+
|
|
21
|
+
export function t(text: string, params?: Record<string, string | number | null | undefined>): string {
|
|
22
|
+
const translated = dictionary()[text] || text
|
|
23
|
+
if (!translated.includes('{')) return translated
|
|
24
|
+
const values: Record<string, string | number | null | undefined> = { brand: boot.brand?.name, ...params }
|
|
25
|
+
return translated.replace(/\{(\w+)\}/g, (match, key: string) => (values[key] === undefined || values[key] === null ? match : String(values[key])))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The singular or plural form for a count, translated, with `{count}` filled in. */
|
|
29
|
+
export function plural(count: number, one: string, other: string, params?: Record<string, string | number | null | undefined>): string {
|
|
30
|
+
const form = new Intl.PluralRules(locale()).select(count) === 'one' ? one : other
|
|
31
|
+
return t(form, { count: count.toLocaleString(locale()), ...params })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The admin's language (a code like `nl`). Dates and numbers follow it. */
|
|
35
|
+
export function locale(): string {
|
|
36
|
+
return (current ??= boot.locale ?? 'en')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The languages the admin speaks: code → name in that language. */
|
|
40
|
+
export function languages(): Record<string, string> {
|
|
41
|
+
return boot.languages ?? { en: 'English' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Switch the dictionary (after the user picks another language). */
|
|
45
|
+
export function setLocale(code: string, dictionary: Record<string, string>) {
|
|
46
|
+
current = code
|
|
47
|
+
messages = dictionary
|
|
48
|
+
document.documentElement.lang = code
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { locale } from './i18n'
|
|
2
|
+
|
|
3
|
+
export * from './types'
|
|
4
|
+
export { boot, request, eventStream, run, batch, adminUrl, CmsError, onUnauthorized, xsrfToken } from './api'
|
|
5
|
+
export type { BatchCall } from './api'
|
|
6
|
+
export { navigate, useLocation, useScreenLocation, ScreenLocation, useTab, matchPath, setQuery, Link, blockNavigation, prefetch, onLinkIntent } from './router'
|
|
7
|
+
export type { LinkProps, Location } from './router'
|
|
8
|
+
export { queryClient, useAbility, useAction, invalidate, abilityKey, prefetchAbility } from './query'
|
|
9
|
+
export { loadManifest, setManifest, onManifest, getManifest, useManifest, useManifestIfLoaded, useConfig, isActive, can, brand } from './manifest'
|
|
10
|
+
export { registry, panelsFor, registerAs, unregister, reregister } from './registry'
|
|
11
|
+
export type { RouteDef, PanelDef, CommandDef, Registrations } from './registry'
|
|
12
|
+
export { events } from './events'
|
|
13
|
+
export { t, plural, locale, languages, setLocale } from './i18n'
|
|
14
|
+
|
|
15
|
+
/** Human-readable relative time ("3 min ago"). */
|
|
16
|
+
export function timeAgo(iso: string | null | undefined): string {
|
|
17
|
+
if (!iso) return ''
|
|
18
|
+
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000)
|
|
19
|
+
const rtf = new Intl.RelativeTimeFormat(locale(), { numeric: 'auto', style: 'short' })
|
|
20
|
+
const steps: [number, Intl.RelativeTimeFormatUnit][] = [[60, 'second'], [3600, 'minute'], [86400, 'hour'], [604800, 'day'], [2629800, 'week'], [31557600, 'month'], [Infinity, 'year']]
|
|
21
|
+
// A moment ago is "now"; past that, whole units only (23 hours, not a rounded-up "24 hr.", then "yesterday").
|
|
22
|
+
if (Math.abs(seconds) < 45) return rtf.format(0, 'second')
|
|
23
|
+
let previous = 1
|
|
24
|
+
for (const [limit, unit] of steps) {
|
|
25
|
+
if (Math.abs(seconds) < limit) return rtf.format(-Math.trunc(seconds / previous), unit)
|
|
26
|
+
previous = limit
|
|
27
|
+
}
|
|
28
|
+
return ''
|
|
29
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { boot, request } from './api'
|
|
3
|
+
import type { BrandInfo, Manifest } from './types'
|
|
4
|
+
|
|
5
|
+
let manifest: Manifest | null = null
|
|
6
|
+
const listeners = new Set<() => void>()
|
|
7
|
+
|
|
8
|
+
function publish(next: Manifest) {
|
|
9
|
+
manifest = next
|
|
10
|
+
listeners.forEach((l) => l())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetch the manifest again. Call it after changing anything it describes (packages,
|
|
15
|
+
* collections, the sidebar…): the admin follows along — new modules load, the
|
|
16
|
+
* modules of packages turned off leave, the sidebar redraws — without a reload.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadManifest(): Promise<Manifest> {
|
|
19
|
+
const res = await request<{ data: Manifest }>('GET', 'manifest')
|
|
20
|
+
publish(res.data)
|
|
21
|
+
return res.data
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Use a manifest the server already sent along (the admin page embeds it at boot). */
|
|
25
|
+
export function setManifest(next: Manifest) {
|
|
26
|
+
publish(next)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Be told whenever the manifest changes (admin themes follow it to load and unload modules). */
|
|
30
|
+
export function onManifest(listener: (manifest: Manifest) => void): () => void {
|
|
31
|
+
const l = () => manifest && listener(manifest)
|
|
32
|
+
listeners.add(l)
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(l)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The product's display name, links and images: never write the name in a string. In a translated string use `{brand}`. */
|
|
39
|
+
export function brand(): BrandInfo {
|
|
40
|
+
return manifest?.brand ?? boot.brand
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getManifest(): Manifest {
|
|
44
|
+
if (!manifest) throw new Error('Manifest not loaded yet.')
|
|
45
|
+
return manifest
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The manifest, or null before sign-in (forms like sign-in render without it). */
|
|
49
|
+
export function useManifestIfLoaded(): Manifest | null {
|
|
50
|
+
return useSyncExternalStore(
|
|
51
|
+
(l) => {
|
|
52
|
+
listeners.add(l)
|
|
53
|
+
return () => listeners.delete(l)
|
|
54
|
+
},
|
|
55
|
+
() => manifest,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useManifest(): Manifest {
|
|
60
|
+
const m = useSyncExternalStore(
|
|
61
|
+
(l) => {
|
|
62
|
+
listeners.add(l)
|
|
63
|
+
return () => listeners.delete(l)
|
|
64
|
+
},
|
|
65
|
+
() => manifest,
|
|
66
|
+
)
|
|
67
|
+
if (!m) throw new Error('Manifest not loaded yet.')
|
|
68
|
+
return m
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A module's values that every admin user may read (e.g. letterncms/content's page size),
|
|
73
|
+
* as the module put them in the manifest under `config.<namespace>`.
|
|
74
|
+
*/
|
|
75
|
+
export function useConfig<T extends Record<string, unknown> = Record<string, any>>(namespace: string): Partial<T> {
|
|
76
|
+
return (useManifest().config?.[namespace] ?? {}) as Partial<T>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when a package (module, plugin or theme) is active on this site. */
|
|
80
|
+
export function isActive(pkg: string): boolean {
|
|
81
|
+
return manifest?.packages.active.includes(pkg) ?? false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Wildcard-aware permission check against the signed-in user's grants. */
|
|
85
|
+
export function can(permission: string, any = false): boolean {
|
|
86
|
+
if (!manifest) return false
|
|
87
|
+
const { user } = manifest
|
|
88
|
+
if (user.super) return true
|
|
89
|
+
return user.grants.some((grant) => grant === '*' || glob(grant, permission) || (any && glob(permission, grant)))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function glob(pattern: string, value: string): boolean {
|
|
93
|
+
if (pattern === value) return true
|
|
94
|
+
const regex = new RegExp('^' + pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')
|
|
95
|
+
return regex.test(value)
|
|
96
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { QueryClient, useMutation, useQuery, type UseQueryOptions } from '@tanstack/react-query'
|
|
2
|
+
import { CmsError, run } from './api'
|
|
3
|
+
|
|
4
|
+
export const queryClient = new QueryClient({
|
|
5
|
+
defaultOptions: {
|
|
6
|
+
queries: {
|
|
7
|
+
staleTime: 15_000,
|
|
8
|
+
retry: (count, error) => !(error instanceof CmsError && [401, 403, 404, 422].includes(error.status)) && count < 2,
|
|
9
|
+
refetchOnWindowFocus: true,
|
|
10
|
+
},
|
|
11
|
+
mutations: { retry: false },
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** Query keys are [ability, input] so mutations can invalidate by ability prefix. */
|
|
16
|
+
export function abilityKey(ability: string, input?: Record<string, unknown>) {
|
|
17
|
+
return input === undefined ? [ability] : [ability, input]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Prepare a screen's data using the same cache as useAbility; cached data opens immediately. */
|
|
21
|
+
export function prefetchAbility<T = any>(ability: string, input: Record<string, unknown> = {}): Promise<T> {
|
|
22
|
+
return queryClient.ensureQueryData<T>({ queryKey: abilityKey(ability, input), queryFn: () => run<T>(ability, input) })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read data through a (read-only) ability. */
|
|
26
|
+
export function useAbility<T = any>(
|
|
27
|
+
ability: string,
|
|
28
|
+
input: Record<string, unknown> = {},
|
|
29
|
+
options: Omit<UseQueryOptions<T, CmsError>, 'queryKey' | 'queryFn'> = {},
|
|
30
|
+
) {
|
|
31
|
+
return useQuery<T, CmsError>({ queryKey: abilityKey(ability, input), queryFn: () => run<T>(ability, input), ...options })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Change data through an ability. `invalidates` lists ability prefixes whose
|
|
36
|
+
* cached results become stale, e.g. ['entries/'].
|
|
37
|
+
*/
|
|
38
|
+
export function useAction<T = any, I extends Record<string, unknown> = Record<string, unknown>>(
|
|
39
|
+
ability: string,
|
|
40
|
+
options: { invalidates?: string[]; onSuccess?: (data: T, input: I) => void; onError?: (error: CmsError) => void } = {},
|
|
41
|
+
) {
|
|
42
|
+
return useMutation<T, CmsError, I>({
|
|
43
|
+
mutationFn: (input: I) => run<T>(ability, input),
|
|
44
|
+
onSuccess: (data, input) => {
|
|
45
|
+
invalidate(...(options.invalidates ?? []))
|
|
46
|
+
options.onSuccess?.(data, input)
|
|
47
|
+
},
|
|
48
|
+
onError: options.onError,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function invalidate(...prefixes: string[]) {
|
|
53
|
+
if (prefixes.length === 0) return
|
|
54
|
+
void queryClient.invalidateQueries({
|
|
55
|
+
predicate: (q) => typeof q.queryKey[0] === 'string' && prefixes.some((p) => (q.queryKey[0] as string).startsWith(p)),
|
|
56
|
+
})
|
|
57
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import type { FieldProps, SurfaceComponentProps } from './types'
|
|
3
|
+
import type { Location } from './router'
|
|
4
|
+
|
|
5
|
+
export interface RouteDef {
|
|
6
|
+
path: string
|
|
7
|
+
component: ComponentType<{ params: Record<string, string> }>
|
|
8
|
+
/** Prime required queries before showing this screen. Also called on link intent. */
|
|
9
|
+
load?: (params: Record<string, string>, location: Location) => Promise<unknown>
|
|
10
|
+
/** "default" renders inside the shell; "full" takes the whole window (editors). */
|
|
11
|
+
layout?: 'default' | 'full'
|
|
12
|
+
title?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PanelDef<P = any> {
|
|
16
|
+
id: string
|
|
17
|
+
slot: string
|
|
18
|
+
/** Tab label for sidebar panels. */
|
|
19
|
+
label?: string
|
|
20
|
+
icon?: string
|
|
21
|
+
component: ComponentType<P>
|
|
22
|
+
order?: number
|
|
23
|
+
when?: (props: P) => boolean
|
|
24
|
+
/** Entry field handles edited by this applicable panel. Unclaimed fields use the standard form. */
|
|
25
|
+
fieldHandles?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommandDef {
|
|
29
|
+
id: string
|
|
30
|
+
label: string
|
|
31
|
+
icon?: string
|
|
32
|
+
group?: string
|
|
33
|
+
keywords?: string[]
|
|
34
|
+
shortcut?: string
|
|
35
|
+
/** When true the palette offers this command for any typed text and passes it in (e.g. "Ask the CMS …"). */
|
|
36
|
+
acceptsQuery?: boolean
|
|
37
|
+
perform: (query?: string) => void | Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Listener = () => void
|
|
41
|
+
|
|
42
|
+
/** The module whose code is running right now (see `registerAs`); what it registers is attributed to it. */
|
|
43
|
+
let owner: string | null = null
|
|
44
|
+
|
|
45
|
+
interface Layer<T> {
|
|
46
|
+
owner: string | null
|
|
47
|
+
value: T
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A keyed store where a later registration of the same key wins, and taking a
|
|
52
|
+
* module's registrations back reveals what it had replaced.
|
|
53
|
+
*/
|
|
54
|
+
function store<T>() {
|
|
55
|
+
const layers = new Map<string, Layer<T>[]>()
|
|
56
|
+
const listeners = new Set<Listener>()
|
|
57
|
+
let version = 0
|
|
58
|
+
const changed = () => {
|
|
59
|
+
version++
|
|
60
|
+
listeners.forEach((l) => l())
|
|
61
|
+
}
|
|
62
|
+
const top = (key: string) => {
|
|
63
|
+
const stack = layers.get(key)
|
|
64
|
+
return stack?.[stack.length - 1]
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
set(key: string, value: T) {
|
|
68
|
+
const stack = (layers.get(key) ?? []).filter((layer) => layer.owner !== owner)
|
|
69
|
+
stack.push({ owner, value })
|
|
70
|
+
layers.set(key, stack)
|
|
71
|
+
changed()
|
|
72
|
+
},
|
|
73
|
+
delete(key: string) {
|
|
74
|
+
layers.delete(key)
|
|
75
|
+
changed()
|
|
76
|
+
},
|
|
77
|
+
get: (key: string) => top(key)?.value,
|
|
78
|
+
all: () => [...layers.keys()].map((key) => top(key)!.value),
|
|
79
|
+
entries: () => [...layers.keys()].map((key) => [key, top(key)!.value] as [string, T]),
|
|
80
|
+
subscribe(l: Listener) {
|
|
81
|
+
listeners.add(l)
|
|
82
|
+
return () => listeners.delete(l)
|
|
83
|
+
},
|
|
84
|
+
version: () => version,
|
|
85
|
+
/** @internal Remove what a module registered; returns it so it can be put back. */
|
|
86
|
+
withdraw(id: string): [string, T][] {
|
|
87
|
+
const taken: [string, T][] = []
|
|
88
|
+
for (const [key, stack] of layers) {
|
|
89
|
+
const kept = stack.filter((layer) => {
|
|
90
|
+
if (layer.owner !== id) return true
|
|
91
|
+
taken.push([key, layer.value])
|
|
92
|
+
return false
|
|
93
|
+
})
|
|
94
|
+
if (kept.length === stack.length) continue
|
|
95
|
+
if (kept.length) layers.set(key, kept)
|
|
96
|
+
else layers.delete(key)
|
|
97
|
+
}
|
|
98
|
+
if (taken.length) changed()
|
|
99
|
+
return taken
|
|
100
|
+
},
|
|
101
|
+
/** @internal Put back what `withdraw` took. */
|
|
102
|
+
restore(id: string, entries: [string, T][]) {
|
|
103
|
+
for (const [key, value] of entries) layers.set(key, [...(layers.get(key) ?? []).filter((layer) => layer.owner !== id), { owner: id, value }])
|
|
104
|
+
if (entries.length) changed()
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extension points for plugins. Admin themes read from here; plugins write.
|
|
111
|
+
*
|
|
112
|
+
* registry.routes.set('/media', { path: '/media', component: MediaLibrary })
|
|
113
|
+
* registry.fields.set('media', MediaField)
|
|
114
|
+
* registry.panels.set('seo', { id: 'seo', slot: 'entry.sidebar', component: SeoPanel })
|
|
115
|
+
*/
|
|
116
|
+
export const registry = {
|
|
117
|
+
routes: store<RouteDef>(),
|
|
118
|
+
fields: store<ComponentType<FieldProps>>(),
|
|
119
|
+
panels: store<PanelDef>(),
|
|
120
|
+
widgets: store<ComponentType<{ id: string }>>(),
|
|
121
|
+
components: store<ComponentType<SurfaceComponentProps<any>>>(),
|
|
122
|
+
commands: store<CommandDef>(),
|
|
123
|
+
icons: store<string>(),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Stores = typeof registry
|
|
127
|
+
export type Registrations = { [K in keyof Stores]: ReturnType<Stores[K]['withdraw']> }
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a module's code (its import and setup) with everything it registers
|
|
131
|
+
* attributed to it, so the admin can take it back when its package is turned
|
|
132
|
+
* off. Admin themes load modules through this, one at a time.
|
|
133
|
+
*/
|
|
134
|
+
export async function registerAs(id: string, load: () => unknown): Promise<void> {
|
|
135
|
+
const previous = owner
|
|
136
|
+
owner = id
|
|
137
|
+
try {
|
|
138
|
+
await load()
|
|
139
|
+
} finally {
|
|
140
|
+
owner = previous
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Take back everything a module registered. Returns it, for `reregister` when the module comes back. */
|
|
145
|
+
export function unregister(id: string): Registrations {
|
|
146
|
+
return Object.fromEntries(Object.entries(registry).map(([name, s]) => [name, s.withdraw(id)])) as Registrations
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Put a module's registrations back (an ES module runs once per page, so turning it on again restores what it registered). */
|
|
150
|
+
export function reregister(id: string, registrations: Registrations) {
|
|
151
|
+
for (const [name, entries] of Object.entries(registrations)) registry[name as keyof Stores].restore(id, entries as never)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Panels for a slot, ordered. */
|
|
155
|
+
export function panelsFor<P>(slot: string, props: P): PanelDef<P>[] {
|
|
156
|
+
return (registry.panels.all() as PanelDef<P>[])
|
|
157
|
+
.filter((p) => p.slot === slot && (!p.when || p.when(props)))
|
|
158
|
+
.sort((a, b) => (a.order ?? 50) - (b.order ?? 50))
|
|
159
|
+
}
|
package/src/router.tsx
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useDeferredValue, useEffect, useMemo, useState, useSyncExternalStore, type AnchorHTMLAttributes, type FocusEvent, type MouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import { boot } from './api'
|
|
3
|
+
|
|
4
|
+
type Listener = () => void
|
|
5
|
+
const listeners = new Set<Listener>()
|
|
6
|
+
let blockers: (() => string | null)[] = []
|
|
7
|
+
|
|
8
|
+
/** Where the admin is. `action` says how it got there: a link (push), a redirect (replace) or back/forward (pop). */
|
|
9
|
+
export interface Location {
|
|
10
|
+
path: string
|
|
11
|
+
search: string
|
|
12
|
+
query: Record<string, string>
|
|
13
|
+
action: 'push' | 'replace' | 'pop'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Snapshot {
|
|
17
|
+
path: string
|
|
18
|
+
search: string
|
|
19
|
+
action: Location['action']
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function currentPath(): string {
|
|
23
|
+
const path = window.location.pathname.startsWith(boot.base) ? window.location.pathname.slice(boot.base.length) : window.location.pathname
|
|
24
|
+
return path === '' ? '/' : path
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let snapshot: Snapshot = { path: currentPath(), search: window.location.search, action: 'pop' }
|
|
28
|
+
|
|
29
|
+
function update(action: Snapshot['action']) {
|
|
30
|
+
snapshot = { path: currentPath(), search: window.location.search, action }
|
|
31
|
+
listeners.forEach((l) => l())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
window.addEventListener('popstate', () => update('pop'))
|
|
35
|
+
|
|
36
|
+
const subscribe = (l: Listener) => {
|
|
37
|
+
listeners.add(l)
|
|
38
|
+
return () => {
|
|
39
|
+
listeners.delete(l)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const useSnapshot = () => useSyncExternalStore(subscribe, () => snapshot)
|
|
44
|
+
|
|
45
|
+
/** The location screens see: the one they were drawn for, while the next screen gets ready. */
|
|
46
|
+
const ScreenContext = createContext<Location | null>(null)
|
|
47
|
+
|
|
48
|
+
/** Register a guard (e.g. unsaved changes). Return a message to block navigation. */
|
|
49
|
+
export function blockNavigation(check: () => string | null): () => void {
|
|
50
|
+
blockers.push(check)
|
|
51
|
+
return () => {
|
|
52
|
+
blockers = blockers.filter((b) => b !== check)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function blocked(): boolean {
|
|
57
|
+
for (const check of blockers) {
|
|
58
|
+
const message = check()
|
|
59
|
+
if (message && !window.confirm(message)) return true
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function navigate(to: string, options: { replace?: boolean } = {}) {
|
|
65
|
+
if (/^https?:\/\//.test(to)) {
|
|
66
|
+
window.location.href = to
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (blocked()) return
|
|
70
|
+
const url = boot.base + (to === '/' ? '' : to)
|
|
71
|
+
if (options.replace) window.history.replaceState(null, '', url)
|
|
72
|
+
else window.history.pushState(null, '', url)
|
|
73
|
+
update(options.replace ? 'replace' : 'push')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toLocation(s: Snapshot): Location {
|
|
77
|
+
return { path: s.path, search: s.search, query: Object.fromEntries(new URLSearchParams(s.search)), action: s.action }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function useLocation(): Location {
|
|
81
|
+
const screen = useContext(ScreenContext)
|
|
82
|
+
const live = useSnapshot()
|
|
83
|
+
const location = useMemo(() => toLocation(live), [live])
|
|
84
|
+
return screen ?? location
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* For admin themes: the location to draw the screen for. Going to another path
|
|
89
|
+
* keeps the current screen on view until the next one is ready (code and data),
|
|
90
|
+
* so navigating never flashes a loading state; changes to the query apply at once.
|
|
91
|
+
* Wrap the screen in `<ScreenLocation>` with it, so the screen reads it too.
|
|
92
|
+
*/
|
|
93
|
+
export function useScreenLocation(prepare?: (location: Location) => Promise<unknown>): { location: Location; pending: boolean } {
|
|
94
|
+
const live = useSnapshot()
|
|
95
|
+
const [ready, setReady] = useState(live)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!prepare) return
|
|
98
|
+
if (live.path === ready.path) {
|
|
99
|
+
setReady(live)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
let canceled = false
|
|
103
|
+
// A later navigation wins, even if an earlier request finishes last. Failures
|
|
104
|
+
// still open the destination so its normal error/retry UI can handle them.
|
|
105
|
+
void Promise.resolve().then(() => prepare(toLocation(live))).catch(() => {}).then(() => {
|
|
106
|
+
if (!canceled) setReady(live)
|
|
107
|
+
})
|
|
108
|
+
return () => { canceled = true }
|
|
109
|
+
}, [live, ready.path, prepare])
|
|
110
|
+
const target = !prepare || live.path === ready.path ? live : ready
|
|
111
|
+
const deferred = useDeferredValue(target)
|
|
112
|
+
const s = deferred.path === live.path ? live : deferred
|
|
113
|
+
const location = useMemo(() => toLocation(s), [s])
|
|
114
|
+
return { location, pending: s !== live }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function ScreenLocation({ location, children }: { location: Location; children: ReactNode }) {
|
|
118
|
+
return createElement(ScreenContext.Provider, { value: location }, children)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let prefetcher: ((path: string) => void) | null = null
|
|
122
|
+
|
|
123
|
+
/** For admin themes: called when a link is about to be followed (hovered or focused), to get its screen ready. */
|
|
124
|
+
export function onLinkIntent(handler: (path: string) => void) {
|
|
125
|
+
prefetcher = handler
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get a screen's code and required data ready, e.g. when a menu item is highlighted. */
|
|
129
|
+
export function prefetch(path: string) {
|
|
130
|
+
if (!/^https?:\/\//.test(path)) prefetcher?.(path)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Match "/collections/:handle/*" against a path. */
|
|
134
|
+
export function matchPath(pattern: string, path: string): Record<string, string> | null {
|
|
135
|
+
const p = pattern.split('/').filter(Boolean)
|
|
136
|
+
const s = path.split('/').filter(Boolean)
|
|
137
|
+
const params: Record<string, string> = {}
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < p.length; i++) {
|
|
140
|
+
const part = p[i]
|
|
141
|
+
if (part === '*') {
|
|
142
|
+
params['*'] = s.slice(i).map(decodeURIComponent).join('/')
|
|
143
|
+
return params
|
|
144
|
+
}
|
|
145
|
+
if (i >= s.length) return null
|
|
146
|
+
if (part.startsWith(':')) params[part.slice(1)] = decodeURIComponent(s[i])
|
|
147
|
+
else if (part !== s[i]) return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return s.length === p.length ? params : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function setQuery(values: Record<string, string | number | null | undefined>, replace = true) {
|
|
154
|
+
const params = new URLSearchParams(window.location.search)
|
|
155
|
+
for (const [k, v] of Object.entries(values)) {
|
|
156
|
+
if (v === null || v === undefined || v === '') params.delete(k)
|
|
157
|
+
else params.set(k, String(v))
|
|
158
|
+
}
|
|
159
|
+
const qs = params.toString()
|
|
160
|
+
const url = window.location.pathname + (qs ? `?${qs}` : '')
|
|
161
|
+
replace ? window.history.replaceState(null, '', url) : window.history.pushState(null, '', url)
|
|
162
|
+
update(replace ? 'replace' : 'push')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A tab kept in the URL (`?tab=…`), so a refresh or a shared link opens the same
|
|
167
|
+
* tab. The first value is the default and stays out of the URL.
|
|
168
|
+
*
|
|
169
|
+
* const [tab, setTab] = useTab(['fields', 'settings', 'submissions'])
|
|
170
|
+
*/
|
|
171
|
+
export function useTab<T extends string>(values: readonly T[], key = 'tab'): [T, (value: T) => void] {
|
|
172
|
+
const { query } = useLocation()
|
|
173
|
+
const current = values.includes(query[key] as T) ? (query[key] as T) : values[0]
|
|
174
|
+
return [current, (value: T) => setQuery({ [key]: value === values[0] ? null : value })]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
178
|
+
to: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Link({ to, onClick, onMouseEnter, onFocus, ...props }: LinkProps) {
|
|
182
|
+
const external = /^https?:\/\//.test(to)
|
|
183
|
+
return createElement('a', {
|
|
184
|
+
...props,
|
|
185
|
+
href: external ? to : boot.base + (to === '/' ? '' : to),
|
|
186
|
+
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
187
|
+
onMouseEnter?.(event)
|
|
188
|
+
prefetch(to)
|
|
189
|
+
},
|
|
190
|
+
onFocus: (event: FocusEvent<HTMLAnchorElement>) => {
|
|
191
|
+
onFocus?.(event)
|
|
192
|
+
prefetch(to)
|
|
193
|
+
},
|
|
194
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
195
|
+
onClick?.(event)
|
|
196
|
+
if (event.defaultPrevented || external || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0 || props.target === '_blank') return
|
|
197
|
+
event.preventDefault()
|
|
198
|
+
navigate(to)
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SurfaceComponentInfo {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
description: string
|
|
7
|
+
props: Record<string, unknown>
|
|
8
|
+
children: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Versioned data, never generated JSX, HTML, CSS or executable code. */
|
|
12
|
+
export interface SurfaceDocument {
|
|
13
|
+
version: 1
|
|
14
|
+
id: string
|
|
15
|
+
title: string
|
|
16
|
+
spec: { root: string; elements: Record<string, SurfaceElement> }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SurfaceElement {
|
|
20
|
+
type: string
|
|
21
|
+
props: Record<string, unknown>
|
|
22
|
+
children?: string[]
|
|
23
|
+
on?: Record<string, { action: string; params?: Record<string, unknown> }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SurfaceAction {
|
|
27
|
+
node: string
|
|
28
|
+
name: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Renderers own their interactions; the host sends action identifiers to the server. */
|
|
32
|
+
export interface SurfaceComponentProps<P = Record<string, unknown>> {
|
|
33
|
+
props: P
|
|
34
|
+
children?: ReactNode
|
|
35
|
+
disabled: boolean
|
|
36
|
+
onAction: (name: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The product's display name, links and images, from brand/brand.json. */
|
|
40
|
+
export interface BrandInfo {
|
|
41
|
+
name: string
|
|
42
|
+
domain: string
|
|
43
|
+
product_url: string
|
|
44
|
+
docs_url: string
|
|
45
|
+
github_url: string
|
|
46
|
+
support_email: string
|
|
47
|
+
mark: string
|
|
48
|
+
wordmark: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Boot {
|
|
52
|
+
base: string
|
|
53
|
+
api: string
|
|
54
|
+
version: string
|
|
55
|
+
brand: BrandInfo
|
|
56
|
+
site: { name: string; url: string }
|
|
57
|
+
/** The name of this site's XSRF cookie. */
|
|
58
|
+
xsrfCookie?: string
|
|
59
|
+
/** The admin's language, the languages it speaks (code → name) and its strings in that language. */
|
|
60
|
+
locale?: string
|
|
61
|
+
languages?: Record<string, string>
|
|
62
|
+
messages?: Record<string, string>
|
|
63
|
+
/** The signed-in user's manifest, sent along with the page so the admin starts without waiting for it. */
|
|
64
|
+
manifest?: Manifest
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FieldDef {
|
|
68
|
+
handle: string
|
|
69
|
+
/** Control identity assigned by Fields; nested controls include their value path. */
|
|
70
|
+
id?: string
|
|
71
|
+
describedBy?: string
|
|
72
|
+
type: string
|
|
73
|
+
label: string
|
|
74
|
+
instructions?: string
|
|
75
|
+
required?: boolean
|
|
76
|
+
default?: unknown
|
|
77
|
+
width?: 'full' | 'half' | 'third' | 'two-thirds'
|
|
78
|
+
section?: string
|
|
79
|
+
/** Render with this admin component instead of the type's own (e.g. a json value with a custom editor). */
|
|
80
|
+
component?: string
|
|
81
|
+
/** Presentation options the core components understand: an icon in the input, its size, autofill, monospace, `display` (segmented, checkbox). */
|
|
82
|
+
icon?: string
|
|
83
|
+
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
84
|
+
placeholder?: string
|
|
85
|
+
autofocus?: boolean
|
|
86
|
+
autocomplete?: string
|
|
87
|
+
inputmode?: 'numeric' | 'text'
|
|
88
|
+
mono?: boolean
|
|
89
|
+
display?: string
|
|
90
|
+
/** text: `blur` saves on blur/Enter instead of on every keystroke. */
|
|
91
|
+
commit?: 'change' | 'blur'
|
|
92
|
+
/** select segmented: fill the row. */
|
|
93
|
+
block?: boolean
|
|
94
|
+
/** Inline rows: where the value comes from (`own` here, or `inherited`) and how to clear it. */
|
|
95
|
+
status?: 'own' | 'inherited'
|
|
96
|
+
reset?: () => void
|
|
97
|
+
/** select: hand values back as numbers. */
|
|
98
|
+
numeric?: boolean
|
|
99
|
+
/** Extra classes for the field's wrapper (e.g. a width in a toolbar). */
|
|
100
|
+
className?: string
|
|
101
|
+
[option: string]: unknown
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface FieldTypeDef {
|
|
105
|
+
key: string
|
|
106
|
+
label: string
|
|
107
|
+
icon: string
|
|
108
|
+
description: string
|
|
109
|
+
group: string
|
|
110
|
+
component: string
|
|
111
|
+
config: FieldDef[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CollectionSummary {
|
|
115
|
+
handle: string
|
|
116
|
+
title: string
|
|
117
|
+
singular: string
|
|
118
|
+
/** The create button's label in the user's language ("New Page", "Nieuw bericht"). */
|
|
119
|
+
newLabel: string
|
|
120
|
+
icon: string
|
|
121
|
+
structure: 'list' | 'tree' | 'dated'
|
|
122
|
+
defaultView?: 'table' | 'grid' | 'calendar'
|
|
123
|
+
routable: boolean
|
|
124
|
+
driver: 'database' | 'files'
|
|
125
|
+
can: { create: boolean; edit: boolean; publish: boolean; delete: boolean }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface NavItem {
|
|
129
|
+
id: string
|
|
130
|
+
label: string
|
|
131
|
+
path: string
|
|
132
|
+
icon: string
|
|
133
|
+
/** The section it is listed in: of the sidebar, or of the settings sidebar. */
|
|
134
|
+
section: string
|
|
135
|
+
/** A keyboard shortcut that opens it, shown with its label (e.g. `mod+j`). */
|
|
136
|
+
shortcut?: string | null
|
|
137
|
+
badge?: string | number | null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The main sidebar as arranged for this user: items in sections; hidden ones are under More. `personal` when they pinned their own. */
|
|
141
|
+
export interface Sidebar extends Arrangement {
|
|
142
|
+
personal: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A group of an arranged part of the admin (a sidebar section, a home column): item ids in order. An empty label has no heading. */
|
|
146
|
+
export interface ArrangedSection {
|
|
147
|
+
id: string
|
|
148
|
+
label: string
|
|
149
|
+
items: string[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** How the sidebar or the home screen is arranged — by a person (a preference) or as the site's default (the `sidebar` and `dashboard` values of the `admin` settings). */
|
|
153
|
+
export interface Arrangement {
|
|
154
|
+
sections: ArrangedSection[]
|
|
155
|
+
hidden: string[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A home-screen widget as registered in PHP (`$admin->widget()`); its component is `registry.widgets.get(id)`. */
|
|
159
|
+
export interface WidgetInfo {
|
|
160
|
+
id: string
|
|
161
|
+
label: string
|
|
162
|
+
description: string
|
|
163
|
+
icon: string
|
|
164
|
+
column: 'main' | 'side'
|
|
165
|
+
/** On everyone's home screen until they rearrange it. */
|
|
166
|
+
enabled: boolean
|
|
167
|
+
/** The package it comes from (its label). */
|
|
168
|
+
source: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface Manifest {
|
|
172
|
+
/** Trusted components for declarative, contextual admin surfaces. */
|
|
173
|
+
components?: SurfaceComponentInfo[]
|
|
174
|
+
cms: { version: string; lockedDown: boolean; debug: boolean }
|
|
175
|
+
brand: BrandInfo
|
|
176
|
+
site: { name: string; url: string; locales: string[]; defaultLocale: string; timezone: string }
|
|
177
|
+
user: User & { grants: string[]; preferences: Record<string, unknown> }
|
|
178
|
+
collections: CollectionSummary[]
|
|
179
|
+
globals: ({ handle: string; title: string; icon: string } & RecordTimestamps)[]
|
|
180
|
+
fieldTypes: FieldTypeDef[]
|
|
181
|
+
settings: { namespace: string; label: string; icon: string; description: string; package: string | null }[]
|
|
182
|
+
packages: { active: string[]; pending: Record<string, string> }
|
|
183
|
+
/** Every screen the user may open: the sidebar's in its order (those under More included), then the settings sidebar's. */
|
|
184
|
+
nav: NavItem[]
|
|
185
|
+
sidebar: Sidebar
|
|
186
|
+
/** The settings sidebar: its sections with the ids of their items. */
|
|
187
|
+
settingsNav: ArrangedSection[]
|
|
188
|
+
modules: { id: string; url: string; package: string | null }[]
|
|
189
|
+
styles: string[]
|
|
190
|
+
/** Home-screen widgets the user may add: shown in the gallery with their description and the package they come from. */
|
|
191
|
+
widgets: WidgetInfo[]
|
|
192
|
+
/** The home screen's columns with the ids of the widgets the user sees. */
|
|
193
|
+
dashboard: ArrangedSection[]
|
|
194
|
+
/** The home screen as arranged for this user (hidden = in the gallery); `personal` when they arranged their own. */
|
|
195
|
+
home?: { layout: Arrangement; personal: boolean; problems?: Record<string, string> }
|
|
196
|
+
commands: { id: string; label: string; icon: string; group: string; path?: string; ability?: string; input?: Record<string, unknown>; keywords?: string[] }[]
|
|
197
|
+
/** The language the manifest's labels are in. */
|
|
198
|
+
locale?: string
|
|
199
|
+
/** Values modules share with every admin user, by settings namespace (see useConfig). */
|
|
200
|
+
config?: Record<string, Record<string, unknown>>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Standard overview metadata. Unknown or inapplicable dates are null. */
|
|
204
|
+
export interface RecordTimestamps {
|
|
205
|
+
publishedAt: string | null
|
|
206
|
+
updatedAt: string | null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface User extends RecordTimestamps {
|
|
210
|
+
id: string
|
|
211
|
+
name: string
|
|
212
|
+
email: string
|
|
213
|
+
avatar: string | null
|
|
214
|
+
initials: string
|
|
215
|
+
roles: string[]
|
|
216
|
+
super: boolean
|
|
217
|
+
locale: string | null
|
|
218
|
+
/** `active`, `invited` (made by a plugin, no password yet) or `suspended`. */
|
|
219
|
+
status: string
|
|
220
|
+
/** No way into the admin: an account of the public site. */
|
|
221
|
+
visitor: boolean
|
|
222
|
+
twoFactor: boolean
|
|
223
|
+
lastLoginAt: string | null
|
|
224
|
+
createdAt: string | null
|
|
225
|
+
/** Profile values for the fields in content/users.yaml. */
|
|
226
|
+
data: Record<string, any>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type EntryStatus = 'draft' | 'published'
|
|
230
|
+
export type DisplayStatus = 'draft' | 'published' | 'changed' | 'scheduled'
|
|
231
|
+
|
|
232
|
+
/** Safe identity used by content, history and people pickers. */
|
|
233
|
+
export interface PersonSummary {
|
|
234
|
+
id: string
|
|
235
|
+
name: string
|
|
236
|
+
initials: string
|
|
237
|
+
avatar: string | null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type EntryCapabilities = CollectionSummary['can']
|
|
241
|
+
|
|
242
|
+
/** Editorial values; publication timing and structure use explicit commands. */
|
|
243
|
+
export interface EntryContent {
|
|
244
|
+
title: string
|
|
245
|
+
slug: string
|
|
246
|
+
data: Record<string, any>
|
|
247
|
+
authorId: string | null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface EntrySummary extends RecordTimestamps {
|
|
251
|
+
id: string
|
|
252
|
+
collection: string
|
|
253
|
+
locale: string
|
|
254
|
+
title: string
|
|
255
|
+
slug: string
|
|
256
|
+
uri: string | null
|
|
257
|
+
status: EntryStatus
|
|
258
|
+
displayStatus: DisplayStatus
|
|
259
|
+
parentId: string | null
|
|
260
|
+
position: number
|
|
261
|
+
authorId: string | null
|
|
262
|
+
author: PersonSummary | null
|
|
263
|
+
/** Opaque persisted content version for mutation preconditions. */
|
|
264
|
+
version: string
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface Entry extends EntrySummary {
|
|
268
|
+
hasDraft: boolean
|
|
269
|
+
data: Record<string, any>
|
|
270
|
+
originId: string | null
|
|
271
|
+
createdAt: string | null
|
|
272
|
+
translations?: Record<string, string>
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface Page<T> {
|
|
276
|
+
items: T[]
|
|
277
|
+
total: number
|
|
278
|
+
page: number
|
|
279
|
+
perPage?: number
|
|
280
|
+
pages: number
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Props every field component receives. */
|
|
284
|
+
export interface FieldProps<T = any> {
|
|
285
|
+
field: FieldDef
|
|
286
|
+
value: T
|
|
287
|
+
onChange: (value: T) => void
|
|
288
|
+
error?: string
|
|
289
|
+
disabled?: boolean
|
|
290
|
+
context: FieldContext
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface FieldContext {
|
|
294
|
+
/** Path to the containing group/repeater value; the field handle is appended by Fields. */
|
|
295
|
+
path?: string[]
|
|
296
|
+
errors?: Record<string, string>
|
|
297
|
+
entry?: Entry
|
|
298
|
+
collection?: string
|
|
299
|
+
locale?: string
|
|
300
|
+
/** Current (unsaved) entry title. */
|
|
301
|
+
title?: string
|
|
302
|
+
/** All current values of the form (for conditional UIs). */
|
|
303
|
+
values?: Record<string, any>
|
|
304
|
+
/** Imported fields from the saved blueprint, when configuring a blueprint. */
|
|
305
|
+
blueprintFields?: FieldDef[]
|
|
306
|
+
/** Nesting level when fields are edited inside other fields (blueprint sub-fields). */
|
|
307
|
+
depth?: number
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Shared content and mutation capabilities available to entry extensions. */
|
|
311
|
+
export interface EntryPanelProps {
|
|
312
|
+
/** Complete working snapshot, including editorial metadata, for previews. */
|
|
313
|
+
content: EntryContent
|
|
314
|
+
entry: Entry | null
|
|
315
|
+
collection: { handle: string; title: string; singular: string; body?: string; route?: string; structure?: 'list' | 'tree' | 'dated'; resolvedFields: FieldDef[] }
|
|
316
|
+
values: Record<string, any>
|
|
317
|
+
title: string
|
|
318
|
+
setValue: (handle: string, value: any) => void
|
|
319
|
+
save: () => Promise<void>
|
|
320
|
+
reload: () => void | Promise<void>
|
|
321
|
+
editable: boolean
|
|
322
|
+
can: EntryCapabilities
|
|
323
|
+
errors: Record<string, string>
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** One body editor workspace. Selection never changes the content or persistence model. */
|
|
327
|
+
export interface EntryEditorProps extends EntryPanelProps {
|
|
328
|
+
setTitle: (title: string) => void
|
|
329
|
+
/** The explicitly configured main-content field. */
|
|
330
|
+
bodyField: FieldDef
|
|
331
|
+
/** Additional entry.body panels after the main content. */
|
|
332
|
+
footer: ReactNode
|
|
333
|
+
/** Host-owned record details, history and plugin tabs. */
|
|
334
|
+
sidebar: ReactNode
|
|
335
|
+
sidebarOpen: boolean
|
|
336
|
+
setSidebarOpen: (open: boolean) => void
|
|
337
|
+
}
|
package/README.md
DELETED