mechanica-shared 2.0.0-alpha.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/src/schema.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type { Block, ContentBlock } from './types'
2
+ import { getFieldDefault } from './fields'
3
+
4
+ /**
5
+ * Compute the default value for a (compact-unfolded) schema node, consulting the
6
+ * field registry for format-specific defaults (e.g. `image`, `richText`).
7
+ */
8
+ export function getDefaultValue(schema: any): any {
9
+ if (schema.default !== undefined) return schema.default
10
+ if (schema.nullable) return null
11
+
12
+ if (schema.format) {
13
+ const fieldDefault = getFieldDefault(schema.format)
14
+ if (fieldDefault !== undefined) return fieldDefault
15
+ }
16
+
17
+ if (schema.type === 'number' || schema.type === 'integer') return 0
18
+ if (schema.type === 'object') {
19
+ if (!schema.properties) return {}
20
+ return Object.fromEntries(
21
+ Object.entries(schema.properties).map(([key, value]) => {
22
+ if (!schema.required?.includes(key)) return [key, undefined]
23
+ return [key, getDefaultValue(value)]
24
+ }),
25
+ )
26
+ }
27
+ if (schema.type === 'array') return []
28
+ if (schema.type === 'boolean') return false
29
+ return ''
30
+ }
31
+
32
+ /**
33
+ * Fill missing values in `state` with schema defaults, recursing into objects.
34
+ * Returns `state` when present, otherwise a freshly generated default.
35
+ */
36
+ export function passDefaultValue(state: any, schema: any): any {
37
+ if (!schema) return state
38
+ if (schema.type === 'object' && state) {
39
+ for (const key in schema.properties) {
40
+ if (!(key in state) && !schema.required?.includes(key)) continue
41
+ state[key] = passDefaultValue(state[key], schema.properties[key])
42
+ }
43
+ }
44
+ return state ?? getDefaultValue(schema)
45
+ }
46
+
47
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
49
+ }
50
+
51
+ /** Deep-merge `patch` over `base`: plain objects merge, arrays and scalars replace. */
52
+ export function mergePreviewData(
53
+ base: Record<string, unknown>,
54
+ patch: Record<string, unknown>,
55
+ ): Record<string, unknown> {
56
+ const out: Record<string, unknown> = { ...base }
57
+ for (const [key, value] of Object.entries(patch)) {
58
+ const current = out[key]
59
+ out[key] =
60
+ isPlainObject(current) && isPlainObject(value) ? mergePreviewData(current, value) : value
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * Resolve the data a block should render with outside a page: schema defaults,
67
+ * overlaid with the block's authored `previewData`, overlaid with per-call
68
+ * overrides (e.g. the `?data=` payload of the preview route).
69
+ *
70
+ * @param props Unfolded (JSON-schema shaped) props schema, as on `Block.props`.
71
+ */
72
+ export function buildPreviewData(
73
+ props: Record<string, unknown> | undefined,
74
+ previewData?: Record<string, unknown>,
75
+ overrides?: Record<string, unknown>,
76
+ ): Record<string, unknown> {
77
+ const defaults = props ? getDefaultValue(props) : {}
78
+ const base = isPlainObject(defaults) ? defaults : {}
79
+ return mergePreviewData(mergePreviewData(base, previewData ?? {}), overrides ?? {})
80
+ }
81
+
82
+ /** Depth-first walk over a content tree, including array and named-slot children. */
83
+ export function walkTree(blocks: ContentBlock[], callback: (block: ContentBlock) => void): void {
84
+ for (const block of blocks) {
85
+ callback(block)
86
+ if (!block.children) continue
87
+ if (Array.isArray(block.children)) {
88
+ walkTree(block.children, callback)
89
+ } else {
90
+ for (const list of Object.values(block.children)) {
91
+ walkTree(list, callback)
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ type WalkSchemaCallback = (
98
+ value: any,
99
+ schema: any,
100
+ key?: string,
101
+ parent?: any,
102
+ isRequired?: boolean,
103
+ ) => void
104
+
105
+ /** Walk a value alongside its schema, invoking `callback` for each described node. */
106
+ export function walkSchema(obj: any, schema: Block['props'] | any, callback: WalkSchemaCallback): void {
107
+ if (schema.type === 'object' && schema.properties && obj) {
108
+ for (const [key, childSchema] of Object.entries(schema.properties)) {
109
+ const isRequired = schema.required?.includes(key) ?? false
110
+ callback(obj[key], childSchema, key, obj, isRequired)
111
+
112
+ const childType = (childSchema as any).type
113
+ if (childType === 'array' || childType === 'object') {
114
+ walkSchema(obj[key], childSchema, callback)
115
+ }
116
+ }
117
+ }
118
+
119
+ if (schema.type === 'array' && schema.items && obj) {
120
+ for (const value of obj) {
121
+ callback(value, schema.items)
122
+ if (schema.items.type === 'array' || schema.items.type === 'object') {
123
+ walkSchema(value, schema.items, callback)
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Resolve a dotted path within a data object (`'postMeta.date'`). */
130
+ export function getValueByPath(data: any, path: string): unknown {
131
+ let value = data
132
+ for (const key of path.split('.')) {
133
+ if (value == null) return value
134
+ value = value[key]
135
+ }
136
+ return value
137
+ }
package/src/types.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Core data types shared between the plugin runtime, the editor, and the
3
+ * (future) render service. This module is intentionally DOM- and
4
+ * framework-free so the render side can import it cleanly.
5
+ */
6
+
7
+ /** Scope at which a {@link DataEntry} lives. */
8
+ export type DataScope = 'site' | 'folder' | 'page'
9
+
10
+ /** Metadata about the current page, exposed via `usePageData`. */
11
+ export interface PageMeta {
12
+ title?: string
13
+ path?: string
14
+ meta?: Record<string, unknown>
15
+ /**
16
+ * Set on paginated variants of a page (`/blog/2`, …): which chunk of its
17
+ * paginated query this URL shows. Page 1 is the base path and carries none.
18
+ */
19
+ pagination?: { page: number; pageCount?: number }
20
+ }
21
+
22
+ /**
23
+ * Editor-facing metadata describing a *block type* — its schema and palette
24
+ * presentation, not its placed content. Produced by the block compiler from a
25
+ * `defineBlock(...)` descriptor.
26
+ */
27
+ export interface Block {
28
+ id: string
29
+ name: string
30
+ /** Palette grouping (was `group` in v1). */
31
+ category?: string
32
+ /** Icon key shown in the block palette. */
33
+ icon?: string
34
+ description?: string
35
+ /** Sort order within a category. */
36
+ order?: number
37
+ /** Hidden from the palette. */
38
+ hidden?: boolean
39
+ /** Available only in dev, stripped from production output. */
40
+ devOnly?: boolean
41
+ /**
42
+ * Restrict the block to pages under these folders (folder paths relative to
43
+ * `pages/`, e.g. `'docs'`; nested folders match by prefix). Omitted = offered
44
+ * everywhere. Placed blocks always keep rendering — this only filters what
45
+ * the palette offers.
46
+ */
47
+ folders?: string[]
48
+ /**
49
+ * Schema version of this block (defaults to 1). Bump it together with a
50
+ * `migrate` function whenever a saved page's data needs reshaping (renamed
51
+ * prop, changed type); placed blocks record the version they were written
52
+ * with and migrate on load.
53
+ */
54
+ version?: number
55
+ /**
56
+ * Upgrade a placed block's data from an older schema version. Receives the
57
+ * stored data and the version it was written with; mutate it in place or
58
+ * return the replacement. Must handle every `from < version`.
59
+ */
60
+ migrate?: (data: Record<string, unknown>, from: number) => Record<string, unknown> | undefined | void
61
+ /**
62
+ * Example prop values used when the block renders outside a page — the
63
+ * palette hover preview and the `/@mechanica/preview` route (`mechanica shot`).
64
+ * Merged over schema defaults, so it only needs the props that matter visually.
65
+ * A `$slots` key fills the block's slots with child blocks (see
66
+ * `PreviewSlotEntry` in `mechanica`); unfilled slots preview as placeholders.
67
+ */
68
+ previewData?: Record<string, unknown>
69
+ /** compact-json-schema describing the editable props. */
70
+ props?: Record<string, unknown>
71
+ /** Slot name → slot metadata (currently `true`). */
72
+ slots?: Record<string, unknown>
73
+ }
74
+
75
+ /** A block placed in a page's content tree. */
76
+ export interface ContentBlock {
77
+ id: string
78
+ blockId: string
79
+ data: Record<string, unknown>
80
+ /** Schema version the data was written with (absent = 1); see `Block.version`. */
81
+ v?: number
82
+ /** Either a single default-slot list or a map of named-slot lists. */
83
+ children?: ContentBlock[] | Record<string, ContentBlock[]>
84
+ }
85
+
86
+ /** A shared data entry declared with `defineData`. */
87
+ export interface DataEntry {
88
+ id: string
89
+ title?: string
90
+ /** compact-json-schema describing the data shape. */
91
+ props?: Record<string, unknown>
92
+ }
93
+
94
+ /**
95
+ * The runtime state serialized onto the page (`window.state`) and read back by
96
+ * the runtime to hydrate.
97
+ */
98
+ export interface State {
99
+ content: ContentBlock[]
100
+ /** Effective data: site < folder < page merged, with schema defaults filled. */
101
+ data: Record<string, unknown>
102
+ query?: Record<string, unknown>
103
+ /**
104
+ * Scope buckets for the editor, so it can edit each level and tell which
105
+ * entries a page overrides. Only injected in dev (the runtime renders `data`).
106
+ */
107
+ siteData?: Record<string, unknown>
108
+ folderData?: Record<string, unknown>
109
+ pageData?: Record<string, unknown>
110
+ /** The folder this page lives in (null at the root), so the editor can offer folder scope. */
111
+ folder?: string | null
112
+ /** Base URL the page is served under (for routing/link resolution). */
113
+ baseUrl?: string
114
+ /** Current page metadata. */
115
+ page?: PageMeta
116
+ }
117
+
118
+ /** A link target produced by the `smartLink` field type. */
119
+ export interface PageLink {
120
+ id: string
121
+ url?: string
122
+ query?: string
123
+ title: string
124
+ external?: boolean
125
+ openNewTab?: boolean
126
+ }
@@ -0,0 +1,56 @@
1
+ import type { Block } from './types'
2
+ import { walkTree, walkSchema } from './schema'
3
+
4
+ /** A link on a page pointing at an internal path no page is exported for. */
5
+ export interface LinkIssue {
6
+ /** The page the broken link lives on. */
7
+ page: string
8
+ /** The internal URL with no matching page. */
9
+ url: string
10
+ }
11
+
12
+ /** Normalize an internal URL for page-path comparison (drop query/hash/trailing slash). */
13
+ export function normalizeInternalUrl(url: string): string {
14
+ const bare = url.split(/[?#]/)[0]!
15
+ const trimmed = bare.replace(/\/+$/, '')
16
+ return trimmed === '' ? '/' : trimmed
17
+ }
18
+
19
+ /**
20
+ * Collect the internal link targets on a page: every `smartLink`-formatted
21
+ * field whose URL is site-relative (starts with `/`) and not marked external.
22
+ */
23
+ export function collectInternalLinks(content: unknown[], blocksMap: Map<string, Block>): string[] {
24
+ const found: string[] = []
25
+ walkTree(content as never, (block: any) => {
26
+ const meta = blocksMap.get(block.blockId)
27
+ if (!meta) return
28
+ walkSchema(block.data, meta.props, (value: any, schema: any) => {
29
+ if (schema?.format !== 'smartLink' || value == null) return
30
+ const url = typeof value === 'string' ? value : value.url
31
+ const external = typeof value === 'object' && value.external === true
32
+ if (typeof url === 'string' && url.startsWith('/') && !external) found.push(url)
33
+ })
34
+ })
35
+ return found
36
+ }
37
+
38
+ /**
39
+ * Cross-check every page's internal `smartLink` targets against the set of
40
+ * page paths that actually exist. Returns the dead links (empty = all good).
41
+ * Meant for export/deploy time — a warning, not a hard failure, since a target
42
+ * may be intentionally served by something else (redirects, external hosting).
43
+ */
44
+ export function validateLinks(
45
+ pages: Array<{ path: string; content?: unknown[] }>,
46
+ blocksMap: Map<string, Block>,
47
+ ): LinkIssue[] {
48
+ const known = new Set(pages.map((page) => normalizeInternalUrl(page.path)))
49
+ const issues: LinkIssue[] = []
50
+ for (const page of pages) {
51
+ for (const url of collectInternalLinks(page.content ?? [], blocksMap)) {
52
+ if (!known.has(normalizeInternalUrl(url))) issues.push({ page: page.path, url })
53
+ }
54
+ }
55
+ return issues
56
+ }