mechanica-shared 2.0.0-alpha.11 → 2.0.0-alpha.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica-shared",
3
- "version": "2.0.0-alpha.11",
3
+ "version": "2.0.0-alpha.12",
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
  },
@@ -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
- }
@@ -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
- }