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/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/index.js +476 -0
- package/dist/page-format.js +301 -0
- package/dist/types/fields.d.ts +34 -0
- package/dist/types/generate-page.d.ts +85 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/migrate.d.ts +19 -0
- package/dist/types/page-format.d.ts +43 -0
- package/dist/types/query-engine.d.ts +83 -0
- package/dist/types/schema.d.ts +29 -0
- package/dist/types/types.d.ts +122 -0
- package/dist/types/validate-links.d.ts +25 -0
- package/package.json +46 -0
- package/src/fields.ts +106 -0
- package/src/generate-page.ts +175 -0
- package/src/index.ts +66 -0
- package/src/migrate.ts +44 -0
- package/src/page-format.ts +413 -0
- package/src/query-engine.ts +162 -0
- package/src/schema.ts +137 -0
- package/src/types.ts +126 -0
- package/src/validate-links.ts +56 -0
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
|
+
}
|