mechanica-shared 2.0.0-alpha.9 → 2.0.2

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/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
- }
@@ -1,212 +0,0 @@
1
- import type { Block } from './types'
2
- import type { LocalesConfig } from './locale'
3
- import { passDefaultValue, walkTree, walkSchema, getValueByPath } from './schema'
4
-
5
- const HTML_ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
6
-
7
- /** Escape a templated value so it's safe in element text and attribute values. */
8
- function escapeHtml(value: string): string {
9
- return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]!)
10
- }
11
-
12
- /**
13
- * Substitute `{{ a.b }}` placeholders in an HTML string with data values. Used to
14
- * template the `<head>` (title, meta, Open Graph, …) from `defineData` values and
15
- * the current page. Resolved values are HTML-escaped. The triple-brace form
16
- * `{{{ a.b }}}` serializes the value as raw JSON instead (script-safe, see
17
- * {@link serializeState}) — for JSON-LD structured data inside a
18
- * `<script type="application/ld+json">`, where HTML-escaping would corrupt it.
19
- */
20
- export function passDataToHTML(html: string, data: any): string {
21
- return html.replace(/\{\{\{(.+?)\}\}\}|\{\{(.+?)\}\}/g, (_match, rawExpr, escapedExpr) => {
22
- if (rawExpr !== undefined) {
23
- const value = getValueByPath(data, rawExpr.trim())
24
- return value === undefined ? '' : serializeState(value)
25
- }
26
- return escapeHtml(String(getValueByPath(data, escapedExpr.trim()) ?? ''))
27
- })
28
- }
29
-
30
- export interface PageState {
31
- content: any[]
32
- data: Record<string, any>
33
- page?: {
34
- title?: string
35
- path?: string
36
- meta?: Record<string, unknown>
37
- /** Set on paginated variants: which chunk of the page's paginated query this is. */
38
- pagination?: { page: number; pageCount?: number }
39
- /** The locale this page renders in (multi-language sites). */
40
- locale?: string
41
- /** Which locales this logical page has a translation for. */
42
- locales?: string[]
43
- }
44
- }
45
-
46
- /** What `render` may return: bare HTML, or HTML plus the queries it resolved. */
47
- export type RenderResult = string | { html: string; query?: Record<string, unknown> }
48
-
49
- export interface GeneratePageOptions {
50
- /** The index.html template. */
51
- index: string
52
- /** Block metadata keyed by blockId. */
53
- blocksMap: Map<string, Block>
54
- /** The page to render. */
55
- state: PageState
56
- /** Declared data entries (with unfolded schemas) for default-filling. */
57
- dataEntries: { id: string; props: any }[]
58
- /** Site-level data merged under page data. */
59
- projectData?: Record<string, any>
60
- /** Site identity, exposed to `{{ site.url }}` / `{{ site.name }}` templating. */
61
- site?: { url?: string; name?: string }
62
- /** The site's locale config — baked into `state.locales` so the runtime can
63
- * prefix internal links for the page's locale. Omitted when i18n is off. */
64
- locales?: LocalesConfig
65
- baseUrl?: string
66
- path?: string
67
- /**
68
- * Serve build assets from this base URL (a CDN origin like
69
- * `https://cdn.example.com`). Root-relative `/assets/…` references in the
70
- * HTML are prefixed with it — `/assets/x.js` → `<assetsUrl>/assets/x.js` — so
71
- * the built `assets/` folder can live on a CDN. A trailing slash is ignored,
72
- * and an already-absolute `https://…/assets/` URL is left untouched. Uploaded
73
- * media (`/media/…`) is rewritten by the export's `onFile`, not here.
74
- */
75
- assetsUrl?: string
76
- /**
77
- * Extra `<link>` tags for this page's content, injected before `</head>` —
78
- * used by the export to preload the block chunks/CSS the page uses (blocks
79
- * are code-split out of the client entry).
80
- */
81
- pageLinks?: (content: any[]) => string[]
82
- /**
83
- * Render the page state to HTML (provided by the SSR bundle). May also
84
- * return the query results resolved during the render — they're baked into
85
- * `window.state.query` so the client hydrates them synchronously.
86
- */
87
- render: (state: any, path: string) => Promise<RenderResult> | RenderResult
88
- }
89
-
90
- /** Render a single page into the index template with serialized state. */
91
- export async function generatePage(
92
- options: GeneratePageOptions,
93
- ): Promise<{ html: string; query: Record<string, unknown> }> {
94
- // Fill block-data defaults from each block's schema.
95
- walkTree(options.state.content, (block: any) => {
96
- const meta = options.blocksMap.get(block.blockId)
97
- if (meta) passDefaultValue(block.data, meta.props)
98
- })
99
-
100
- // Merge and default the shared data entries.
101
- const merged = { ...options.projectData, ...options.state.data }
102
- const data = Object.fromEntries(
103
- options.dataEntries.map((entry) => [entry.id, passDefaultValue(merged[entry.id] ?? {}, entry.props)]),
104
- )
105
-
106
- const state: Record<string, unknown> = {
107
- content: options.state.content,
108
- data,
109
- baseUrl: options.baseUrl,
110
- // The page's own path rides along (pagination pathFor, `{{ page.path }}`).
111
- page: { path: options.path, ...options.state.page },
112
- }
113
- // The locale config rides the state so the runtime prefixes internal links
114
- // for `page.locale` and language switchers can enumerate translations.
115
- if (options.locales) state.locales = options.locales
116
-
117
- const result = await options.render(state, options.path ?? '')
118
- const rendered = typeof result === 'string' ? result : result.html
119
- const query = (typeof result === 'string' ? undefined : result.query) ?? {}
120
- // Bake resolved queries into the hydration state — the client reads them
121
- // synchronously, so hydration matches the server markup with no refetch.
122
- if (Object.keys(query).length) state.query = query
123
-
124
- // Template against data plus the page identity and site config, so
125
- // `{{ page.meta.title }}`, `{{ page.path }}` and `{{ site.url }}` all work.
126
- // Uses `state.page` (not `options.state.page`) so `path` is present — the
127
- // same shape dev's transformIndexHtml templates against.
128
- let index = passDataToHTML(options.index, { ...data, site: options.site, page: state.page })
129
-
130
- // Inject the rendered markup into the #app container. A template without it
131
- // would export empty pages — fail loudly instead of silently shipping shells.
132
- const appMatch = index.match(/(<div[^>]*\bid="app"[^>]*>)([\s\S]*?)<\/div>/)
133
- if (!appMatch) {
134
- throw new Error(
135
- 'index.html has no <div id="app"> container — the rendered page has nowhere to go. ' +
136
- 'Add <div id="app"></div> to the template body.',
137
- )
138
- }
139
- const start = appMatch.index! + appMatch[1]!.length
140
- const end = appMatch.index! + appMatch[0].length - '</div>'.length
141
- index = index.slice(0, start) + rendered + index.slice(end)
142
-
143
- const links = options.pageLinks?.(options.state.content) ?? []
144
- if (links.length) index = index.replace('</head>', `${links.join('\n')}\n</head>`)
145
-
146
- if (options.assetsUrl) {
147
- const base = options.assetsUrl.replace(/\/+$/, '')
148
- // Prefix only genuine root-relative refs (right after a quote/paren/equals),
149
- // so an already-absolute `https://…/assets/` URL is never double-prefixed.
150
- index = index.replace(/(["'(=])\/assets\//g, (_, edge) => `${edge}${base}/assets/`)
151
- }
152
-
153
- const stateScript = `<script>window.state=${serializeState(state)}</script>`
154
- return { html: index.replace('</body>', `${stateScript}\n</body>`), query }
155
- }
156
-
157
- // Characters that can break out of an inline <script>: `<` (closes the tag via
158
- // `</script>`, or opens `<script`/`<!--`) and the line separators U+2028 / U+2029
159
- // (invalid in JS string literals). Built from char codes so the source stays
160
- // plain ASCII.
161
- const UNSAFE_IN_SCRIPT = new RegExp(`[${[0x3c, 0x2028, 0x2029].map((c) => '\\u' + c.toString(16).padStart(4, '0')).join('')}]`, 'g')
162
-
163
- /**
164
- * Serialize runtime state for embedding in an inline `<script>`. Plain JSON is
165
- * unsafe (a `</script>` in the data would close the tag early), so the few
166
- * dangerous characters are escaped to their `\uXXXX` form — valid JSON/JS that
167
- * `window.state` and the router's regex read back unchanged.
168
- */
169
- export function serializeState(state: unknown): string {
170
- return JSON.stringify(state).replace(UNSAFE_IN_SCRIPT, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'))
171
- }
172
-
173
- export interface GenerateProjectOptions extends Omit<GeneratePageOptions, 'state' | 'path'> {
174
- pages: Array<{ content: any[]; data: Record<string, any>; path: string; page?: PageState['page'] }>
175
- /** Map an asset path to its emitted path (and copy it). */
176
- onFile?: (path: string) => string
177
- }
178
-
179
- /** Render every page of a project, yielding the html, path and resolved queries. */
180
- export async function* generateProject(
181
- options: GenerateProjectOptions,
182
- ): AsyncGenerator<{ html: string; path: string; query: Record<string, unknown> }> {
183
- for (const page of options.pages) {
184
- page.content = page.content ?? []
185
- if (options.onFile) collectFiles(page.content, options.blocksMap, options.onFile)
186
- const { html, query } = await generatePage({ ...options, state: page, path: page.path })
187
- yield { html, path: page.path, query }
188
- }
189
- }
190
-
191
- /** Rewrite asset references (image/file/richText) through `onFile`. */
192
- function collectFiles(content: any[], blocksMap: Map<string, Block>, onFile: (path: string) => string): void {
193
- walkTree(content, (block) => {
194
- const meta = blocksMap.get(block.blockId)
195
- if (!meta) return
196
- walkSchema(block.data, meta.props, (value: any, schema: any) => {
197
- if (!value) return
198
- if (schema.format === 'image' || schema.format === 'file') {
199
- if (value.src) value.src = onFile(value.src)
200
- if (value.previewSrc) value.previewSrc = onFile(value.previewSrc)
201
- if (value.croppedSrc) value.croppedSrc = onFile(value.croppedSrc)
202
- }
203
- if (schema.format === 'richText' && schema.type === 'array') {
204
- for (const row of value) {
205
- if (row.image?.src) row.image.src = onFile(row.image.src)
206
- if (row.image?.previewSrc) row.image.previewSrc = onFile(row.image.previewSrc)
207
- if (row.image?.croppedSrc) row.image.croppedSrc = onFile(row.image.croppedSrc)
208
- }
209
- }
210
- })
211
- })
212
- }
package/src/index.ts DELETED
@@ -1,119 +0,0 @@
1
- export type {
2
- Block,
3
- ContentBlock,
4
- ComposedBlockDefinition,
5
- ComposerComponentDefinition,
6
- ComposerComponentEntry,
7
- ComposerManifest,
8
- ComposerClassDefinition,
9
- ComposerClassEntry,
10
- ComposerClassDef,
11
- ComposerElementKind,
12
- ComposerBreakpoints,
13
- PropBinding,
14
- DataEntry,
15
- DataScope,
16
- PageMeta,
17
- State,
18
- PageLink,
19
- VirtualPage,
20
- } from './types'
21
-
22
- export { normalizeClassManifest } from './composer-manifest'
23
-
24
- export {
25
- normalizeLocales,
26
- isLocale,
27
- parseLocalePath,
28
- localePath,
29
- localeLabel,
30
- type LocalesConfig,
31
- type LocalesOption,
32
- } from './locale'
33
-
34
- export {
35
- resolveComposedTemplate,
36
- resolveBindings,
37
- templateBlockIds,
38
- isBinding,
39
- } from './compose'
40
-
41
- export {
42
- type FieldType,
43
- type ImageCropConfig,
44
- type RegisterAlias,
45
- builtinFields,
46
- registerFieldSchemas,
47
- getFieldDefault,
48
- areFieldSchemasRegistered,
49
- } from './fields'
50
-
51
- export {
52
- getDefaultValue,
53
- passDefaultValue,
54
- buildPreviewData,
55
- mergePreviewData,
56
- walkTree,
57
- walkSchema,
58
- getValueByPath,
59
- } from './schema'
60
-
61
- export {
62
- generatePage,
63
- generateProject,
64
- passDataToHTML,
65
- serializeState,
66
- type GeneratePageOptions,
67
- type GenerateProjectOptions,
68
- type PageState,
69
- type RenderResult,
70
- } from './generate-page'
71
-
72
- export {
73
- validateLinks,
74
- collectInternalLinks,
75
- normalizeInternalUrl,
76
- type LinkIssue,
77
- } from './validate-links'
78
-
79
- export {
80
- pageUrl,
81
- paginationVariantPath,
82
- applySeoTags,
83
- auditPageHtml,
84
- buildSitemap,
85
- buildRobotsTxt,
86
- type SeoTagOptions,
87
- type SitemapEntry,
88
- } from './seo'
89
-
90
- export { migrateContent, findUnknownBlocks } from './migrate'
91
-
92
- export {
93
- mergeTranslation,
94
- diffTranslation,
95
- mergeValue,
96
- diffValue,
97
- mergeBlocks,
98
- diffBlocks,
99
- deepEqual,
100
- type TranslationDoc,
101
- } from './translation'
102
-
103
- // The `.page.md` codec is deliberately NOT re-exported here: it pulls in the
104
- // YAML parser, and this barrel is imported by the client runtime — nothing in
105
- // a production page needs to parse pages. Server-side callers (dev store,
106
- // CLI, rich-text codec) import from 'mechanica-shared/page-format'.
107
-
108
- export {
109
- parseQueryKey,
110
- isPaginatedQuery,
111
- resolvePagesQuery,
112
- resolveQueryKey,
113
- type QuerySource,
114
- type QueryContext,
115
- type PageQueryItem,
116
- type PagesQueryArgs,
117
- type PaginatedPagesResult,
118
- } from './query-engine'
119
-
package/src/locale.ts DELETED
@@ -1,88 +0,0 @@
1
- /**
2
- * Multi-language (i18n) helpers — pure, DOM-free path math shared by the dev
3
- * server, the runtime and the static export. A logical page has one canonical
4
- * path (`/about`); each non-default locale is served under a `/<code>` prefix
5
- * (`/ru/about`). The default locale is always unprefixed.
6
- */
7
-
8
- /** The site's locale configuration (from the plugin's `locales` option). */
9
- export interface LocalesConfig {
10
- /** The default locale code — served at unprefixed URLs. */
11
- default: string
12
- /** Every locale the site publishes, the default included. */
13
- all: string[]
14
- /** Optional human labels for the editor UI, keyed by code (`{ ru: 'Русский' }`). */
15
- labels?: Record<string, string>
16
- }
17
-
18
- /** A raw `locales` plugin option: the full config or a bare list of codes. */
19
- export type LocalesOption = LocalesConfig | string[] | undefined
20
-
21
- /**
22
- * Normalize a raw `locales` option into a config, or `null` when i18n is off:
23
- * the option is omitted/empty, or it resolves to a single locale (nothing to
24
- * prefix or translate). The default is forced into `all`; duplicates drop.
25
- */
26
- export function normalizeLocales(option: LocalesOption): LocalesConfig | null {
27
- if (!option) return null
28
- const raw = Array.isArray(option) ? { default: option[0] ?? '', all: option } : option
29
- const all = raw.all.filter((code, i) => !!code && raw.all.indexOf(code) === i)
30
- const fallbackDefault = raw.default || all[0] || ''
31
- if (!fallbackDefault) return null
32
- if (!all.includes(fallbackDefault)) all.unshift(fallbackDefault)
33
- // A single-locale site needs no prefixing or variants.
34
- if (all.length < 2) return null
35
- return { default: fallbackDefault, all, labels: (raw as LocalesConfig).labels }
36
- }
37
-
38
- /** Whether `code` is a locale this config knows about. */
39
- export function isLocale(config: LocalesConfig | null | undefined, code: string): boolean {
40
- return !!config && config.all.includes(code)
41
- }
42
-
43
- /**
44
- * Split a URL path into its locale and logical path. A leading `/<code>`
45
- * segment naming a non-default locale is stripped; everything else stays as-is
46
- * under the default locale.
47
- *
48
- * parseLocalePath('/ru/blog', cfg) → { locale: 'ru', path: '/blog' }
49
- * parseLocalePath('/blog', cfg) → { locale: 'en', path: '/blog' } // en = default
50
- * parseLocalePath('/ru', cfg) → { locale: 'ru', path: '/' }
51
- */
52
- export function parseLocalePath(
53
- urlPath: string,
54
- config: LocalesConfig | null | undefined,
55
- ): { locale: string; path: string } {
56
- if (!config) return { locale: '', path: urlPath }
57
- const match = urlPath.match(/^\/([^/]+)(\/.*)?$/)
58
- const head = match?.[1]
59
- if (head && head !== config.default && config.all.includes(head)) {
60
- return { locale: head, path: match![2] || '/' }
61
- }
62
- return { locale: config.default, path: urlPath }
63
- }
64
-
65
- /**
66
- * Prefix a logical path for a locale. The default locale (or no config, or an
67
- * unknown code) returns the path unchanged; other locales get a `/<code>`
68
- * prefix.
69
- *
70
- * localePath('/blog', 'ru', cfg) → '/ru/blog'
71
- * localePath('/', 'ru', cfg) → '/ru'
72
- * localePath('/blog', 'en', cfg) → '/blog' // en = default
73
- */
74
- export function localePath(
75
- path: string,
76
- locale: string | undefined,
77
- config: LocalesConfig | null | undefined,
78
- ): string {
79
- if (!config || !locale || locale === config.default) return path
80
- if (!config.all.includes(locale)) return path
81
- const clean = path === '/' ? '' : path
82
- return `/${locale}${clean}`
83
- }
84
-
85
- /** The human label for a locale — the config's `labels`, else the code itself. */
86
- export function localeLabel(config: LocalesConfig | null | undefined, code: string): string {
87
- return config?.labels?.[code] ?? code
88
- }
package/src/migrate.ts DELETED
@@ -1,44 +0,0 @@
1
- import type { Block, ContentBlock } from './types'
2
- import { walkTree } from './schema'
3
-
4
- /**
5
- * Upgrade placed blocks whose data was written with an older schema version.
6
- *
7
- * Each placed block records the schema version it was saved with (`v`, absent
8
- * = 1). When a block type declares a newer `version`, its `migrate` hook runs
9
- * with the stored data and the version it came from, then the block is
10
- * stamped with the current version. Runs on load (dev state, export) so pages
11
- * never render stale-shaped data; the upgrade persists with the next save.
12
- *
13
- * Returns whether anything changed.
14
- */
15
- export function migrateContent(content: ContentBlock[], blocksMap: Map<string, Block>): boolean {
16
- let changed = false
17
- walkTree(content, (block) => {
18
- const meta = blocksMap.get(block.blockId)
19
- const version = meta?.version
20
- if (!version) return
21
- const from = block.v ?? 1
22
- if (from >= version) return
23
- if (meta!.migrate) {
24
- const result = meta!.migrate(block.data ?? {}, from)
25
- if (result) block.data = result
26
- }
27
- block.v = version
28
- changed = true
29
- })
30
- return changed
31
- }
32
-
33
- /**
34
- * Block ids referenced by the content tree that the block registry doesn't
35
- * know (deleted or renamed block types). These render as nothing — callers
36
- * should surface them (export warning, editor badge).
37
- */
38
- export function findUnknownBlocks(content: ContentBlock[], blocksMap: Map<string, Block>): string[] {
39
- const unknown = new Set<string>()
40
- walkTree(content, (block) => {
41
- if (!blocksMap.has(block.blockId)) unknown.add(block.blockId)
42
- })
43
- return [...unknown]
44
- }