mechanica-shared 2.0.0-alpha.11 → 2.0.0-alpha.13
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/package.json +1 -5
- package/src/block-format.ts +0 -105
- package/src/compose.ts +0 -155
- package/src/composer-manifest.ts +0 -45
- package/src/fields.ts +0 -155
- package/src/generate-page.ts +0 -214
- package/src/index.ts +0 -120
- package/src/locale.ts +0 -88
- package/src/migrate.ts +0 -44
- package/src/page-format.ts +0 -429
- package/src/query-engine.ts +0 -175
- package/src/schema.ts +0 -137
- package/src/seo.ts +0 -228
- package/src/translation.ts +0 -169
- package/src/types.ts +0 -370
- package/src/validate-links.ts +0 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mechanica-shared",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.13",
|
|
4
4
|
"description": "DOM-free types, schema helpers and page-generation core shared across Mechanica",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,23 +13,19 @@
|
|
|
13
13
|
"tag": "next"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"src",
|
|
17
16
|
"dist"
|
|
18
17
|
],
|
|
19
18
|
"exports": {
|
|
20
19
|
".": {
|
|
21
20
|
"types": "./dist/types/index.d.ts",
|
|
22
|
-
"bun": "./src/index.ts",
|
|
23
21
|
"import": "./dist/index.js"
|
|
24
22
|
},
|
|
25
23
|
"./page-format": {
|
|
26
24
|
"types": "./dist/types/page-format.d.ts",
|
|
27
|
-
"bun": "./src/page-format.ts",
|
|
28
25
|
"import": "./dist/page-format.js"
|
|
29
26
|
},
|
|
30
27
|
"./block-format": {
|
|
31
28
|
"types": "./dist/types/block-format.d.ts",
|
|
32
|
-
"bun": "./src/block-format.ts",
|
|
33
29
|
"import": "./dist/block-format.js"
|
|
34
30
|
}
|
|
35
31
|
},
|
package/src/block-format.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
2
|
-
import type { ComposedBlockDefinition, ContentBlock } from './types'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Codec for the composed-block file format (`.mech/blocks/<id>.block.yml`) — a
|
|
6
|
-
* single YAML document holding a {@link ComposedBlockDefinition}. Kept a
|
|
7
|
-
* separate entry point (`mechanica-shared/block-format`), deliberately NOT
|
|
8
|
-
* re-exported from the barrel: it pulls in the YAML parser, and the barrel is
|
|
9
|
-
* imported by the client runtime, which never parses these files (only the dev
|
|
10
|
-
* store, the plugin's collect step, and the CLI do). Mirrors the `page-format`
|
|
11
|
-
* rule. See PLAN.md § 3.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/** Thrown by {@link parseComposedBlock} with a human-readable reason. */
|
|
15
|
-
export class ComposedBlockParseError extends Error {
|
|
16
|
-
constructor(message: string) {
|
|
17
|
-
super(message)
|
|
18
|
-
this.name = 'ComposedBlockParseError'
|
|
19
|
-
Object.setPrototypeOf(this, ComposedBlockParseError.prototype)
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
24
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Validate the minimal shape of a content node (recurses into children). */
|
|
28
|
-
function assertContentBlock(node: unknown, where: string): asserts node is ContentBlock {
|
|
29
|
-
if (!isPlainObject(node)) throw new ComposedBlockParseError(`${where}: expected a mapping`)
|
|
30
|
-
if (typeof node.blockId !== 'string' || node.blockId === '') {
|
|
31
|
-
throw new ComposedBlockParseError(`${where}: missing "blockId"`)
|
|
32
|
-
}
|
|
33
|
-
if (node.data !== undefined && !isPlainObject(node.data)) {
|
|
34
|
-
throw new ComposedBlockParseError(`${where}: "data" must be a mapping`)
|
|
35
|
-
}
|
|
36
|
-
const children = node.children
|
|
37
|
-
if (children === undefined) return
|
|
38
|
-
if (Array.isArray(children)) {
|
|
39
|
-
children.forEach((child, i) => assertContentBlock(child, `${where} › child ${i}`))
|
|
40
|
-
} else if (isPlainObject(children)) {
|
|
41
|
-
for (const [slot, list] of Object.entries(children)) {
|
|
42
|
-
if (!Array.isArray(list)) throw new ComposedBlockParseError(`${where}: slot "${slot}" must be a list`)
|
|
43
|
-
list.forEach((child, i) => assertContentBlock(child, `${where} › ${slot}[${i}]`))
|
|
44
|
-
}
|
|
45
|
-
} else {
|
|
46
|
-
throw new ComposedBlockParseError(`${where}: "children" must be a list or a slot mapping`)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Parse a `.block.yml` document into a {@link ComposedBlockDefinition}.
|
|
52
|
-
*
|
|
53
|
-
* @param fallbackId Used as the id when the document omits one — the store and
|
|
54
|
-
* plugin pass the filename base, so a hand-authored file may
|
|
55
|
-
* omit `id` and be identified by its filename.
|
|
56
|
-
*/
|
|
57
|
-
export function parseComposedBlock(text: string, fallbackId?: string): ComposedBlockDefinition {
|
|
58
|
-
let doc: unknown
|
|
59
|
-
try {
|
|
60
|
-
doc = parseYaml(text)
|
|
61
|
-
} catch (error) {
|
|
62
|
-
throw new ComposedBlockParseError(`invalid YAML: ${error instanceof Error ? error.message : error}`)
|
|
63
|
-
}
|
|
64
|
-
if (doc == null) doc = {}
|
|
65
|
-
if (!isPlainObject(doc)) throw new ComposedBlockParseError('expected a top-level mapping')
|
|
66
|
-
|
|
67
|
-
const id = typeof doc.id === 'string' && doc.id !== '' ? doc.id : fallbackId
|
|
68
|
-
if (!id) throw new ComposedBlockParseError('missing "id"')
|
|
69
|
-
if (typeof doc.name !== 'string' || doc.name === '') throw new ComposedBlockParseError('missing "name"')
|
|
70
|
-
|
|
71
|
-
const template = doc.template ?? []
|
|
72
|
-
if (!Array.isArray(template)) throw new ComposedBlockParseError('"template" must be a list')
|
|
73
|
-
template.forEach((node, i) => assertContentBlock(node, `template[${i}]`))
|
|
74
|
-
|
|
75
|
-
if (doc.props !== undefined && !isPlainObject(doc.props)) {
|
|
76
|
-
throw new ComposedBlockParseError('"props" must be a mapping')
|
|
77
|
-
}
|
|
78
|
-
if (doc.previewData !== undefined && !isPlainObject(doc.previewData)) {
|
|
79
|
-
throw new ComposedBlockParseError('"previewData" must be a mapping')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const def: ComposedBlockDefinition = { id, name: doc.name, template: template as ContentBlock[] }
|
|
83
|
-
if (typeof doc.icon === 'string') def.icon = doc.icon
|
|
84
|
-
if (typeof doc.category === 'string') def.category = doc.category
|
|
85
|
-
if (doc.hidden === true) def.hidden = true
|
|
86
|
-
if (doc.standalone === true) def.standalone = true
|
|
87
|
-
if (isPlainObject(doc.props)) def.props = doc.props
|
|
88
|
-
if (isPlainObject(doc.previewData)) def.previewData = doc.previewData
|
|
89
|
-
return def
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Serialize a {@link ComposedBlockDefinition} to a `.block.yml` document. */
|
|
93
|
-
export function serializeComposedBlock(def: ComposedBlockDefinition): string {
|
|
94
|
-
// Build an ordered object so the file reads header-first, tree-last.
|
|
95
|
-
const ordered: Record<string, unknown> = { id: def.id, name: def.name }
|
|
96
|
-
if (def.icon) ordered.icon = def.icon
|
|
97
|
-
if (def.category) ordered.category = def.category
|
|
98
|
-
// Only emit `hidden`/`standalone` when true — typical blocks stay clean.
|
|
99
|
-
if (def.hidden) ordered.hidden = true
|
|
100
|
-
if (def.standalone) ordered.standalone = true
|
|
101
|
-
if (def.props && Object.keys(def.props).length) ordered.props = def.props
|
|
102
|
-
if (def.previewData && Object.keys(def.previewData).length) ordered.previewData = def.previewData
|
|
103
|
-
ordered.template = def.template ?? []
|
|
104
|
-
return stringifyYaml(ordered, { lineWidth: 0 })
|
|
105
|
-
}
|
package/src/compose.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import type { ComposedBlockDefinition, ContentBlock, PropBinding } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Expansion of composed blocks into a concrete content tree. DOM- and
|
|
5
|
-
* framework-free so the render side (client, SSR, export) shares one
|
|
6
|
-
* implementation. See PLAN.md § 2.3 / § 4.4.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Reserved data keys the composer engine consumes (never passed as props). */
|
|
10
|
-
const IF_KEY = '$if'
|
|
11
|
-
const EACH_KEY = '$each'
|
|
12
|
-
|
|
13
|
-
/** Whether a value is a prop binding (`{ $bind: 'name' }`). */
|
|
14
|
-
export function isBinding(value: unknown): value is PropBinding {
|
|
15
|
-
return (
|
|
16
|
-
typeof value === 'object' &&
|
|
17
|
-
value !== null &&
|
|
18
|
-
!Array.isArray(value) &&
|
|
19
|
-
typeof (value as { $bind?: unknown }).$bind === 'string'
|
|
20
|
-
)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Look up a binding name in a prop scope. Supports dot paths (`$item.title`,
|
|
25
|
-
* `cta.url`) so a repeated subtree can bind fields of the current `$item` and a
|
|
26
|
-
* binding can reach into an object prop without exposing each leaf separately.
|
|
27
|
-
*/
|
|
28
|
-
export function lookupBinding(name: string, props: Record<string, unknown>): unknown {
|
|
29
|
-
if (name in props) return props[name]
|
|
30
|
-
if (!name.includes('.')) return undefined
|
|
31
|
-
let value: unknown = props
|
|
32
|
-
for (const part of name.split('.')) {
|
|
33
|
-
if (value === null || typeof value !== 'object') return undefined
|
|
34
|
-
value = (value as Record<string, unknown>)[part]
|
|
35
|
-
}
|
|
36
|
-
return value
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Deep-resolve a data value: substitute every `{ $bind: name }` with the named
|
|
41
|
-
* prop, recursing through arrays and plain objects (so a bound value nested in
|
|
42
|
-
* e.g. a `smartLink` object resolves too). Non-binding scalars pass through;
|
|
43
|
-
* everything is cloned, so the returned value never aliases the template.
|
|
44
|
-
*/
|
|
45
|
-
export function resolveBindings(value: unknown, props: Record<string, unknown>): unknown {
|
|
46
|
-
if (isBinding(value)) return lookupBinding(value.$bind, props)
|
|
47
|
-
if (Array.isArray(value)) return value.map((item) => resolveBindings(item, props))
|
|
48
|
-
if (value !== null && typeof value === 'object') {
|
|
49
|
-
const out: Record<string, unknown> = {}
|
|
50
|
-
for (const [key, item] of Object.entries(value)) out[key] = resolveBindings(item, props)
|
|
51
|
-
return out
|
|
52
|
-
}
|
|
53
|
-
return value
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Resolve one template node, or `null` when a falsy `$if` binding drops it. */
|
|
57
|
-
function resolveNode(
|
|
58
|
-
node: ContentBlock,
|
|
59
|
-
props: Record<string, unknown>,
|
|
60
|
-
prefix: string,
|
|
61
|
-
index: number,
|
|
62
|
-
idSuffix = '',
|
|
63
|
-
): ContentBlock | null {
|
|
64
|
-
const raw = node.data ?? {}
|
|
65
|
-
// `$if` gates the node's presence — resolve it before anything else so a
|
|
66
|
-
// hidden element costs nothing downstream.
|
|
67
|
-
if (IF_KEY in raw && !resolveBindings(raw[IF_KEY], props)) return null
|
|
68
|
-
|
|
69
|
-
const data: Record<string, unknown> = {}
|
|
70
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
71
|
-
if (key === IF_KEY || key === EACH_KEY) continue
|
|
72
|
-
data[key] = resolveBindings(value, props)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Namespace ids under the placed instance so two placements of the same
|
|
76
|
-
// composed block produce distinct, stable vnode keys.
|
|
77
|
-
const id = `${prefix}:${node.id ?? index}${idSuffix}`
|
|
78
|
-
const resolved: ContentBlock = { id, blockId: node.blockId, data }
|
|
79
|
-
if (node.v != null) resolved.v = node.v
|
|
80
|
-
if (node.children) resolved.children = resolveChildren(node.children, props, id)
|
|
81
|
-
return resolved
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function resolveList(list: ContentBlock[], props: Record<string, unknown>, prefix: string): ContentBlock[] {
|
|
85
|
-
const out: ContentBlock[] = []
|
|
86
|
-
list.forEach((child, index) => {
|
|
87
|
-
const each = child.data?.[EACH_KEY]
|
|
88
|
-
if (typeof each === 'string') {
|
|
89
|
-
// `$each: 'items'` repeats this node (and its subtree) once per array
|
|
90
|
-
// item. Inside the subtree, `$bind: '$item'` / `'$item.field'` resolve
|
|
91
|
-
// from the current item and `$bind: '$index'` from its position; other
|
|
92
|
-
// names still resolve from the block's props. A missing/non-array prop
|
|
93
|
-
// renders nothing — an empty list is empty, not broken.
|
|
94
|
-
const items = lookupBinding(each, props)
|
|
95
|
-
if (!Array.isArray(items)) return
|
|
96
|
-
items.forEach((item, i) => {
|
|
97
|
-
const scope = { ...props, $item: item, $index: i }
|
|
98
|
-
const resolved = resolveNode(child, scope, prefix, index, `@${i}`)
|
|
99
|
-
if (resolved) out.push(resolved)
|
|
100
|
-
})
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
const resolved = resolveNode(child, props, prefix, index)
|
|
104
|
-
if (resolved) out.push(resolved)
|
|
105
|
-
})
|
|
106
|
-
return out
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function resolveChildren(
|
|
110
|
-
children: NonNullable<ContentBlock['children']>,
|
|
111
|
-
props: Record<string, unknown>,
|
|
112
|
-
prefix: string,
|
|
113
|
-
): ContentBlock['children'] {
|
|
114
|
-
if (Array.isArray(children)) return resolveList(children, props, `${prefix}/d`)
|
|
115
|
-
const map: Record<string, ContentBlock[]> = {}
|
|
116
|
-
for (const [name, list] of Object.entries(children)) {
|
|
117
|
-
map[name] = resolveList(list, props, `${prefix}/${name}`)
|
|
118
|
-
}
|
|
119
|
-
return map
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Expand a composed block's template into a concrete content tree: substitute
|
|
124
|
-
* `$bind` values from `props`, repeat `$each` nodes per array item, drop
|
|
125
|
-
* `$if`-hidden nodes, and namespace every node id under `instanceId` (the
|
|
126
|
-
* placed block's id — stable across renders). The result is rendered by the
|
|
127
|
-
* normal block-render pipeline.
|
|
128
|
-
*/
|
|
129
|
-
export function resolveComposedTemplate(
|
|
130
|
-
def: ComposedBlockDefinition,
|
|
131
|
-
props: Record<string, unknown> | undefined,
|
|
132
|
-
instanceId: string,
|
|
133
|
-
): ContentBlock[] {
|
|
134
|
-
return resolveList(def.template ?? [], props ?? {}, instanceId)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Block ids referenced anywhere in a composed template (recursing into slot
|
|
139
|
-
* children). Used to expand a page's used-block set so the underlying compiled
|
|
140
|
-
* blocks' chunks load before a composed block mounts. Element blocks (`mech:*`)
|
|
141
|
-
* ship with the runtime; they need no loader but are harmless to include.
|
|
142
|
-
*/
|
|
143
|
-
export function templateBlockIds(def: ComposedBlockDefinition): Set<string> {
|
|
144
|
-
const ids = new Set<string>()
|
|
145
|
-
const walk = (list: ContentBlock[]): void => {
|
|
146
|
-
for (const node of list) {
|
|
147
|
-
ids.add(node.blockId)
|
|
148
|
-
if (!node.children) continue
|
|
149
|
-
if (Array.isArray(node.children)) walk(node.children)
|
|
150
|
-
else for (const inner of Object.values(node.children)) walk(inner)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
walk(def.template ?? [])
|
|
154
|
-
return ids
|
|
155
|
-
}
|
package/src/composer-manifest.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ComposerClassDef,
|
|
3
|
-
ComposerClassEntry,
|
|
4
|
-
ComposerElementKind,
|
|
5
|
-
} from './types'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Normalize the Block Composer manifest's `classes` record into the flat list
|
|
9
|
-
* the composer consumes — unfolding the string / array shorthands and defaulting
|
|
10
|
-
* each title to its class name. Pure and DOM-free (the barrel is client-safe);
|
|
11
|
-
* called from the generated `virtual:mechanica/components` module at runtime.
|
|
12
|
-
* See COMPOSER-MANIFEST.md § 2.
|
|
13
|
-
*
|
|
14
|
-
* Shorthands:
|
|
15
|
-
* - `'text'` → `{ on: ['text'] }`
|
|
16
|
-
* - `['frame', 'image']` → `{ on: ['frame', 'image'] }`
|
|
17
|
-
* - `{ title?, on, group? }` → as written (only the full form carries a group)
|
|
18
|
-
*/
|
|
19
|
-
export function normalizeClassManifest(
|
|
20
|
-
classes: Record<string, ComposerClassEntry> | undefined,
|
|
21
|
-
): ComposerClassDef[] {
|
|
22
|
-
if (!classes) return []
|
|
23
|
-
const out: ComposerClassDef[] = []
|
|
24
|
-
for (const cls in classes) {
|
|
25
|
-
const entry = classes[cls]
|
|
26
|
-
if (entry == null) continue
|
|
27
|
-
const kinds = classKinds(entry)
|
|
28
|
-
if (!kinds.length) continue
|
|
29
|
-
const full = typeof entry === 'object' && !Array.isArray(entry) ? entry : null
|
|
30
|
-
const def: ComposerClassDef = { cls, title: full?.title || cls, kinds }
|
|
31
|
-
if (full?.group) def.group = full.group
|
|
32
|
-
out.push(def)
|
|
33
|
-
}
|
|
34
|
-
return out
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const KINDS: readonly ComposerElementKind[] = ['frame', 'text', 'image']
|
|
38
|
-
const isKind = (v: unknown): v is ComposerElementKind => KINDS.includes(v as ComposerElementKind)
|
|
39
|
-
|
|
40
|
-
/** The element kinds a `classes` entry applies to (any shorthand), deduped. */
|
|
41
|
-
function classKinds(entry: ComposerClassEntry): ComposerElementKind[] {
|
|
42
|
-
const raw = isKind(entry) ? [entry] : Array.isArray(entry) ? entry : entry.on
|
|
43
|
-
const list = Array.isArray(raw) ? raw : [raw]
|
|
44
|
-
return [...new Set(list.filter(isKind))]
|
|
45
|
-
}
|
package/src/fields.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { registerAlias, type SchemaItem } from 'compact-json-schema'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Per-field cropping config for the `image` field, declared as an annotation on
|
|
5
|
-
* a block/data schema — e.g. `{ type: 'image', crop: { width: 1200, height: 630 } }`.
|
|
6
|
-
* `true` enables a free crop frame; `{ width, height }` locks the frame's aspect
|
|
7
|
-
* to `width/height` and downscales the derivative to that box; `{ aspect }` locks
|
|
8
|
-
* the ratio without a size cap. The editor's crop dialog reads this off the
|
|
9
|
-
* unfolded schema; the runtime ignores it. See `ImageValue` for what a crop
|
|
10
|
-
* produces on the value (`crop` rect + `croppedSrc`).
|
|
11
|
-
*/
|
|
12
|
-
export type ImageCropConfig =
|
|
13
|
-
| boolean
|
|
14
|
-
| {
|
|
15
|
-
/** Target output width in px (locks the crop aspect to width/height). */
|
|
16
|
-
width?: number
|
|
17
|
-
/** Target output height in px. */
|
|
18
|
-
height?: number
|
|
19
|
-
/** Lock the crop ratio without a size cap; ignored when width & height are set. */
|
|
20
|
-
aspect?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Mechanica uses compact-json-schema's `format` keyword for its field aliases
|
|
24
|
-
// (`image`, `smartLink`, …). The library keeps `SchemaAnnotations` minimal
|
|
25
|
-
// (`default` only), so declare `format` here — that's what lets the schemas
|
|
26
|
-
// below (and any block/data schema) carry `format` without an `as SchemaItem`
|
|
27
|
-
// cast. `crop` rides the same channel: an extra keyword on an `image` field that
|
|
28
|
-
// survives unfolding onto the field editor's `schema` prop. The output types for
|
|
29
|
-
// the alias shorthands live in mechanica's `core/field-types.ts` (SchemaTypesMap).
|
|
30
|
-
declare module 'compact-json-schema' {
|
|
31
|
-
interface SchemaAnnotations {
|
|
32
|
-
format?: string
|
|
33
|
-
crop?: ImageCropConfig
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A built-in or user-defined editable field type. The *runtime* half lives here
|
|
39
|
-
* (the compact-json-schema alias + a default value); the editor component half
|
|
40
|
-
* is attached separately in the plugin via `defineFieldType`.
|
|
41
|
-
*/
|
|
42
|
-
export interface FieldType {
|
|
43
|
-
/** Format name, e.g. `'image'`. Used as the compact-json-schema alias. */
|
|
44
|
-
name: string
|
|
45
|
-
/** The compact-json-schema definition this alias expands to. */
|
|
46
|
-
schema: SchemaItem
|
|
47
|
-
/** Default value, or a factory returning a fresh one. */
|
|
48
|
-
default?: unknown | (() => unknown)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The field types registered by default. */
|
|
52
|
-
export const builtinFields: FieldType[] = [
|
|
53
|
-
{
|
|
54
|
-
name: 'image',
|
|
55
|
-
// `alt` is authored in the editor (image SEO + accessibility); `width` /
|
|
56
|
-
// `height` are the intrinsic pixel size of the *original*, captured when the
|
|
57
|
-
// image is chosen, so blocks can render dimension attributes and avoid
|
|
58
|
-
// layout shift. `focalX`/`focalY` are a 0..1 focal point (drives
|
|
59
|
-
// `object-position` / `background-position`); `crop` is a normalized crop
|
|
60
|
-
// rectangle over the original whose baked-down result is `croppedSrc`
|
|
61
|
-
// (`croppedWidth`/`croppedHeight` its intrinsic size). Non-destructive: the
|
|
62
|
-
// original `src` + `crop` stay on the value, so a crop is always re-editable.
|
|
63
|
-
schema: {
|
|
64
|
-
type: 'object',
|
|
65
|
-
format: 'image',
|
|
66
|
-
properties: {
|
|
67
|
-
src: 'string',
|
|
68
|
-
previewSrc: 'string?',
|
|
69
|
-
alt: 'string?',
|
|
70
|
-
width: 'number?',
|
|
71
|
-
height: 'number?',
|
|
72
|
-
focalX: 'number?',
|
|
73
|
-
focalY: 'number?',
|
|
74
|
-
crop: {
|
|
75
|
-
type: 'object?',
|
|
76
|
-
properties: { x: 'number', y: 'number', width: 'number', height: 'number' },
|
|
77
|
-
},
|
|
78
|
-
croppedSrc: 'string?',
|
|
79
|
-
croppedWidth: 'number?',
|
|
80
|
-
croppedHeight: 'number?',
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
// Start empty so the editor shows its upload/pick affordance rather than a
|
|
84
|
-
// placeholder image (and pages render nothing until an image is chosen).
|
|
85
|
-
default: () => ({ src: '' }),
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: 'file',
|
|
89
|
-
schema: { type: 'object', format: 'file', properties: { src: 'string' } },
|
|
90
|
-
default: () => ({ src: '' }),
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
name: 'text',
|
|
94
|
-
schema: { type: 'string', format: 'text' },
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
name: 'color',
|
|
98
|
-
schema: { type: 'string', format: 'color' },
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
name: 'smartLink',
|
|
102
|
-
schema: {
|
|
103
|
-
type: 'object',
|
|
104
|
-
format: 'smartLink',
|
|
105
|
-
properties: { url: 'string', title: 'string', external: 'boolean', openNewTab: 'boolean' },
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: 'multiselect',
|
|
110
|
-
schema: { type: 'array', format: 'multiselect', items: 'string' },
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
name: 'richText',
|
|
114
|
-
schema: {
|
|
115
|
-
type: 'array',
|
|
116
|
-
format: 'richText',
|
|
117
|
-
items: { type: 'object', properties: { text: 'string', type: 'string?', styles: 'object?' } },
|
|
118
|
-
},
|
|
119
|
-
default: () => [{ text: '' }],
|
|
120
|
-
},
|
|
121
|
-
]
|
|
122
|
-
|
|
123
|
-
export type RegisterAlias = typeof registerAlias
|
|
124
|
-
|
|
125
|
-
const fieldDefaults = new Map<string, unknown | (() => unknown)>()
|
|
126
|
-
let registered = false
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Register field types as compact-json-schema aliases and record their default
|
|
130
|
-
* values. Call once per runtime before unfolding any block/data schema.
|
|
131
|
-
*
|
|
132
|
-
* @param register Override the alias registrar (defaults to compact-json-schema's).
|
|
133
|
-
* @param fields Field set to register (defaults to {@link builtinFields}).
|
|
134
|
-
*/
|
|
135
|
-
export function registerFieldSchemas(
|
|
136
|
-
register: RegisterAlias = registerAlias,
|
|
137
|
-
fields: FieldType[] = builtinFields,
|
|
138
|
-
): void {
|
|
139
|
-
for (const field of fields) {
|
|
140
|
-
register(field.name as never, field.schema as never)
|
|
141
|
-
if (field.default !== undefined) fieldDefaults.set(field.name, field.default)
|
|
142
|
-
}
|
|
143
|
-
registered = true
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Resolve the default value for a registered field format, or `undefined`. */
|
|
147
|
-
export function getFieldDefault(format: string): unknown {
|
|
148
|
-
const value = fieldDefaults.get(format)
|
|
149
|
-
return typeof value === 'function' ? (value as () => unknown)() : value
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Whether {@link registerFieldSchemas} has run in this runtime. */
|
|
153
|
-
export function areFieldSchemasRegistered(): boolean {
|
|
154
|
-
return registered
|
|
155
|
-
}
|