@letterncms/sdk 0.0.0-stage → 0.1.0

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 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
- "name": "@letterncms/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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.0",
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
+ }
@@ -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
+ }
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.