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.
@@ -0,0 +1,122 @@
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
+ /** Scope at which a {@link DataEntry} lives. */
7
+ export type DataScope = 'site' | 'folder' | 'page';
8
+ /** Metadata about the current page, exposed via `usePageData`. */
9
+ export interface PageMeta {
10
+ title?: string;
11
+ path?: string;
12
+ meta?: Record<string, unknown>;
13
+ /**
14
+ * Set on paginated variants of a page (`/blog/2`, …): which chunk of its
15
+ * paginated query this URL shows. Page 1 is the base path and carries none.
16
+ */
17
+ pagination?: {
18
+ page: number;
19
+ 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
+ /** A block placed in a page's content tree. */
75
+ export interface ContentBlock {
76
+ id: string;
77
+ blockId: string;
78
+ data: Record<string, unknown>;
79
+ /** Schema version the data was written with (absent = 1); see `Block.version`. */
80
+ v?: number;
81
+ /** Either a single default-slot list or a map of named-slot lists. */
82
+ children?: ContentBlock[] | Record<string, ContentBlock[]>;
83
+ }
84
+ /** A shared data entry declared with `defineData`. */
85
+ export interface DataEntry {
86
+ id: string;
87
+ title?: string;
88
+ /** compact-json-schema describing the data shape. */
89
+ props?: Record<string, unknown>;
90
+ }
91
+ /**
92
+ * The runtime state serialized onto the page (`window.state`) and read back by
93
+ * the runtime to hydrate.
94
+ */
95
+ export interface State {
96
+ content: ContentBlock[];
97
+ /** Effective data: site < folder < page merged, with schema defaults filled. */
98
+ data: Record<string, unknown>;
99
+ query?: Record<string, unknown>;
100
+ /**
101
+ * Scope buckets for the editor, so it can edit each level and tell which
102
+ * entries a page overrides. Only injected in dev (the runtime renders `data`).
103
+ */
104
+ siteData?: Record<string, unknown>;
105
+ folderData?: Record<string, unknown>;
106
+ pageData?: Record<string, unknown>;
107
+ /** The folder this page lives in (null at the root), so the editor can offer folder scope. */
108
+ folder?: string | null;
109
+ /** Base URL the page is served under (for routing/link resolution). */
110
+ baseUrl?: string;
111
+ /** Current page metadata. */
112
+ page?: PageMeta;
113
+ }
114
+ /** A link target produced by the `smartLink` field type. */
115
+ export interface PageLink {
116
+ id: string;
117
+ url?: string;
118
+ query?: string;
119
+ title: string;
120
+ external?: boolean;
121
+ openNewTab?: boolean;
122
+ }
@@ -0,0 +1,25 @@
1
+ import type { Block } from './types';
2
+ /** A link on a page pointing at an internal path no page is exported for. */
3
+ export interface LinkIssue {
4
+ /** The page the broken link lives on. */
5
+ page: string;
6
+ /** The internal URL with no matching page. */
7
+ url: string;
8
+ }
9
+ /** Normalize an internal URL for page-path comparison (drop query/hash/trailing slash). */
10
+ export declare function normalizeInternalUrl(url: string): string;
11
+ /**
12
+ * Collect the internal link targets on a page: every `smartLink`-formatted
13
+ * field whose URL is site-relative (starts with `/`) and not marked external.
14
+ */
15
+ export declare function collectInternalLinks(content: unknown[], blocksMap: Map<string, Block>): string[];
16
+ /**
17
+ * Cross-check every page's internal `smartLink` targets against the set of
18
+ * page paths that actually exist. Returns the dead links (empty = all good).
19
+ * Meant for export/deploy time — a warning, not a hard failure, since a target
20
+ * may be intentionally served by something else (redirects, external hosting).
21
+ */
22
+ export declare function validateLinks(pages: Array<{
23
+ path: string;
24
+ content?: unknown[];
25
+ }>, blocksMap: Map<string, Block>): LinkIssue[];
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "mechanica-shared",
3
+ "version": "2.0.0-alpha.0",
4
+ "description": "DOM-free types, schema helpers and page-generation core shared across Mechanica",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://gitlab.letary.ru/den59k/mechanica.git"
9
+ },
10
+ "type": "module",
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "tag": "next"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/types/index.d.ts",
22
+ "bun": "./src/index.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./page-format": {
26
+ "types": "./dist/types/page-format.d.ts",
27
+ "bun": "./src/page-format.ts",
28
+ "import": "./dist/page-format.js"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "vite build && tsc -p tsconfig.build.json",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "typecheck": "tsc --noEmit",
36
+ "prepublishOnly": "bun run build"
37
+ },
38
+ "dependencies": {
39
+ "compact-json-schema": "^0.1.5",
40
+ "yaml": "^2.6.1"
41
+ },
42
+ "devDependencies": {
43
+ "vite": "^8.0.16",
44
+ "vitest": "^4.1.9"
45
+ }
46
+ }
package/src/fields.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { registerAlias, type SchemaItem } from 'compact-json-schema'
2
+
3
+ // Mechanica uses compact-json-schema's `format` keyword for its field aliases
4
+ // (`image`, `smartLink`, …). The library keeps `SchemaAnnotations` minimal
5
+ // (`default` only), so declare `format` here — that's what lets the schemas
6
+ // below (and any block/data schema) carry `format` without an `as SchemaItem`
7
+ // cast. The output types for the alias shorthands live in mechanica's
8
+ // `core/field-types.ts` (SchemaTypesMap).
9
+ declare module 'compact-json-schema' {
10
+ interface SchemaAnnotations {
11
+ format?: string
12
+ }
13
+ }
14
+
15
+ /**
16
+ * A built-in or user-defined editable field type. The *runtime* half lives here
17
+ * (the compact-json-schema alias + a default value); the editor component half
18
+ * is attached separately in the plugin via `defineFieldType`.
19
+ */
20
+ export interface FieldType {
21
+ /** Format name, e.g. `'image'`. Used as the compact-json-schema alias. */
22
+ name: string
23
+ /** The compact-json-schema definition this alias expands to. */
24
+ schema: SchemaItem
25
+ /** Default value, or a factory returning a fresh one. */
26
+ default?: unknown | (() => unknown)
27
+ }
28
+
29
+ /** The field types registered by default. */
30
+ export const builtinFields: FieldType[] = [
31
+ {
32
+ name: 'image',
33
+ schema: { type: 'object', format: 'image', properties: { src: 'string', previewSrc: 'string?' } },
34
+ // Start empty so the editor shows its upload/pick affordance rather than a
35
+ // placeholder image (and pages render nothing until an image is chosen).
36
+ default: () => ({ src: '' }),
37
+ },
38
+ {
39
+ name: 'file',
40
+ schema: { type: 'object', format: 'file', properties: { src: 'string' } },
41
+ default: () => ({ src: '' }),
42
+ },
43
+ {
44
+ name: 'text',
45
+ schema: { type: 'string', format: 'text' },
46
+ },
47
+ {
48
+ name: 'color',
49
+ schema: { type: 'string', format: 'color' },
50
+ },
51
+ {
52
+ name: 'smartLink',
53
+ schema: {
54
+ type: 'object',
55
+ format: 'smartLink',
56
+ properties: { url: 'string', title: 'string', external: 'boolean', openNewTab: 'boolean' },
57
+ },
58
+ },
59
+ {
60
+ name: 'multiselect',
61
+ schema: { type: 'array', format: 'multiselect', items: 'string' },
62
+ },
63
+ {
64
+ name: 'richText',
65
+ schema: {
66
+ type: 'array',
67
+ format: 'richText',
68
+ items: { type: 'object', properties: { text: 'string', type: 'string?', styles: 'object?' } },
69
+ },
70
+ default: () => [{ text: '' }],
71
+ },
72
+ ]
73
+
74
+ export type RegisterAlias = typeof registerAlias
75
+
76
+ const fieldDefaults = new Map<string, unknown | (() => unknown)>()
77
+ let registered = false
78
+
79
+ /**
80
+ * Register field types as compact-json-schema aliases and record their default
81
+ * values. Call once per runtime before unfolding any block/data schema.
82
+ *
83
+ * @param register Override the alias registrar (defaults to compact-json-schema's).
84
+ * @param fields Field set to register (defaults to {@link builtinFields}).
85
+ */
86
+ export function registerFieldSchemas(
87
+ register: RegisterAlias = registerAlias,
88
+ fields: FieldType[] = builtinFields,
89
+ ): void {
90
+ for (const field of fields) {
91
+ register(field.name as never, field.schema as never)
92
+ if (field.default !== undefined) fieldDefaults.set(field.name, field.default)
93
+ }
94
+ registered = true
95
+ }
96
+
97
+ /** Resolve the default value for a registered field format, or `undefined`. */
98
+ export function getFieldDefault(format: string): unknown {
99
+ const value = fieldDefaults.get(format)
100
+ return typeof value === 'function' ? (value as () => unknown)() : value
101
+ }
102
+
103
+ /** Whether {@link registerFieldSchemas} has run in this runtime. */
104
+ export function areFieldSchemasRegistered(): boolean {
105
+ return registered
106
+ }
@@ -0,0 +1,175 @@
1
+ import type { Block } from './types'
2
+ import { passDefaultValue, walkTree, walkSchema, getValueByPath } from './schema'
3
+
4
+ const HTML_ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
5
+
6
+ /** Escape a templated value so it's safe in element text and attribute values. */
7
+ function escapeHtml(value: string): string {
8
+ return value.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]!)
9
+ }
10
+
11
+ /**
12
+ * Substitute `{{ a.b }}` placeholders in an HTML string with data values. Used to
13
+ * template the `<head>` (title, meta, Open Graph, …) from `defineData` values and
14
+ * the current page. Resolved values are HTML-escaped.
15
+ */
16
+ export function passDataToHTML(html: string, data: any): string {
17
+ return html.replace(/\{\{(.+?)\}\}/g, (_match, expr) =>
18
+ escapeHtml(String(getValueByPath(data, expr.trim()) ?? '')),
19
+ )
20
+ }
21
+
22
+ export interface PageState {
23
+ content: any[]
24
+ data: Record<string, any>
25
+ page?: {
26
+ title?: string
27
+ path?: string
28
+ meta?: Record<string, unknown>
29
+ /** Set on paginated variants: which chunk of the page's paginated query this is. */
30
+ pagination?: { page: number; pageCount?: number }
31
+ }
32
+ }
33
+
34
+ /** What `render` may return: bare HTML, or HTML plus the queries it resolved. */
35
+ export type RenderResult = string | { html: string; query?: Record<string, unknown> }
36
+
37
+ export interface GeneratePageOptions {
38
+ /** The index.html template. */
39
+ index: string
40
+ /** Block metadata keyed by blockId. */
41
+ blocksMap: Map<string, Block>
42
+ /** The page to render. */
43
+ state: PageState
44
+ /** Declared data entries (with unfolded schemas) for default-filling. */
45
+ dataEntries: { id: string; props: any }[]
46
+ /** Site-level data merged under page data. */
47
+ projectData?: Record<string, any>
48
+ baseUrl?: string
49
+ path?: string
50
+ /** Rewrite `/assets/` to this base when set. */
51
+ assetsUrl?: string
52
+ /**
53
+ * Extra `<link>` tags for this page's content, injected before `</head>` —
54
+ * used by the export to preload the block chunks/CSS the page uses (blocks
55
+ * are code-split out of the client entry).
56
+ */
57
+ pageLinks?: (content: any[]) => string[]
58
+ /**
59
+ * Render the page state to HTML (provided by the SSR bundle). May also
60
+ * return the query results resolved during the render — they're baked into
61
+ * `window.state.query` so the client hydrates them synchronously.
62
+ */
63
+ render: (state: any, path: string) => Promise<RenderResult> | RenderResult
64
+ }
65
+
66
+ /** Render a single page into the index template with serialized state. */
67
+ export async function generatePage(
68
+ options: GeneratePageOptions,
69
+ ): Promise<{ html: string; query: Record<string, unknown> }> {
70
+ // Fill block-data defaults from each block's schema.
71
+ walkTree(options.state.content, (block: any) => {
72
+ const meta = options.blocksMap.get(block.blockId)
73
+ if (meta) passDefaultValue(block.data, meta.props)
74
+ })
75
+
76
+ // Merge and default the shared data entries.
77
+ const merged = { ...options.projectData, ...options.state.data }
78
+ const data = Object.fromEntries(
79
+ options.dataEntries.map((entry) => [entry.id, passDefaultValue(merged[entry.id] ?? {}, entry.props)]),
80
+ )
81
+
82
+ const state: Record<string, unknown> = {
83
+ content: options.state.content,
84
+ data,
85
+ baseUrl: options.baseUrl,
86
+ // The page's own path rides along (pagination pathFor, `{{ page.path }}`).
87
+ page: { path: options.path, ...options.state.page },
88
+ }
89
+
90
+ const result = await options.render(state, options.path ?? '')
91
+ const rendered = typeof result === 'string' ? result : result.html
92
+ const query = (typeof result === 'string' ? undefined : result.query) ?? {}
93
+ // Bake resolved queries into the hydration state — the client reads them
94
+ // synchronously, so hydration matches the server markup with no refetch.
95
+ if (Object.keys(query).length) state.query = query
96
+
97
+ // Template against data plus the page meta, so `{{ page.meta.title }}` works.
98
+ let index = passDataToHTML(options.index, { ...data, page: options.state.page })
99
+
100
+ // Inject the rendered markup into the #app container. A template without it
101
+ // would export empty pages — fail loudly instead of silently shipping shells.
102
+ const appMatch = index.match(/(<div[^>]*\bid="app"[^>]*>)([\s\S]*?)<\/div>/)
103
+ if (!appMatch) {
104
+ throw new Error(
105
+ 'index.html has no <div id="app"> container — the rendered page has nowhere to go. ' +
106
+ 'Add <div id="app"></div> to the template body.',
107
+ )
108
+ }
109
+ const start = appMatch.index! + appMatch[1]!.length
110
+ const end = appMatch.index! + appMatch[0].length - '</div>'.length
111
+ index = index.slice(0, start) + rendered + index.slice(end)
112
+
113
+ const links = options.pageLinks?.(options.state.content) ?? []
114
+ if (links.length) index = index.replace('</head>', `${links.join('\n')}\n</head>`)
115
+
116
+ if (options.assetsUrl) index = index.replace(/\/assets\//g, options.assetsUrl)
117
+
118
+ const stateScript = `<script>window.state=${serializeState(state)}</script>`
119
+ return { html: index.replace('</body>', `${stateScript}\n</body>`), query }
120
+ }
121
+
122
+ // Characters that can break out of an inline <script>: `<` (closes the tag via
123
+ // `</script>`, or opens `<script`/`<!--`) and the line separators U+2028 / U+2029
124
+ // (invalid in JS string literals). Built from char codes so the source stays
125
+ // plain ASCII.
126
+ const UNSAFE_IN_SCRIPT = new RegExp(`[${[0x3c, 0x2028, 0x2029].map((c) => '\\u' + c.toString(16).padStart(4, '0')).join('')}]`, 'g')
127
+
128
+ /**
129
+ * Serialize runtime state for embedding in an inline `<script>`. Plain JSON is
130
+ * unsafe (a `</script>` in the data would close the tag early), so the few
131
+ * dangerous characters are escaped to their `\uXXXX` form — valid JSON/JS that
132
+ * `window.state` and the router's regex read back unchanged.
133
+ */
134
+ export function serializeState(state: unknown): string {
135
+ return JSON.stringify(state).replace(UNSAFE_IN_SCRIPT, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'))
136
+ }
137
+
138
+ export interface GenerateProjectOptions extends Omit<GeneratePageOptions, 'state' | 'path'> {
139
+ pages: Array<{ content: any[]; data: Record<string, any>; path: string; page?: PageState['page'] }>
140
+ /** Map an asset path to its emitted path (and copy it). */
141
+ onFile?: (path: string) => string
142
+ }
143
+
144
+ /** Render every page of a project, yielding the html, path and resolved queries. */
145
+ export async function* generateProject(
146
+ options: GenerateProjectOptions,
147
+ ): AsyncGenerator<{ html: string; path: string; query: Record<string, unknown> }> {
148
+ for (const page of options.pages) {
149
+ page.content = page.content ?? []
150
+ if (options.onFile) collectFiles(page.content, options.blocksMap, options.onFile)
151
+ const { html, query } = await generatePage({ ...options, state: page, path: page.path })
152
+ yield { html, path: page.path, query }
153
+ }
154
+ }
155
+
156
+ /** Rewrite asset references (image/file/richText) through `onFile`. */
157
+ function collectFiles(content: any[], blocksMap: Map<string, Block>, onFile: (path: string) => string): void {
158
+ walkTree(content, (block) => {
159
+ const meta = blocksMap.get(block.blockId)
160
+ if (!meta) return
161
+ walkSchema(block.data, meta.props, (value: any, schema: any) => {
162
+ if (!value) return
163
+ if (schema.format === 'image' || schema.format === 'file') {
164
+ if (value.src) value.src = onFile(value.src)
165
+ if (value.previewSrc) value.previewSrc = onFile(value.previewSrc)
166
+ }
167
+ if (schema.format === 'richText' && schema.type === 'array') {
168
+ for (const row of value) {
169
+ if (row.image?.src) row.image.src = onFile(row.image.src)
170
+ if (row.image?.previewSrc) row.image.previewSrc = onFile(row.image.previewSrc)
171
+ }
172
+ }
173
+ })
174
+ })
175
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ export type {
2
+ Block,
3
+ ContentBlock,
4
+ DataEntry,
5
+ DataScope,
6
+ PageMeta,
7
+ State,
8
+ PageLink,
9
+ } from './types'
10
+
11
+ export {
12
+ type FieldType,
13
+ type RegisterAlias,
14
+ builtinFields,
15
+ registerFieldSchemas,
16
+ getFieldDefault,
17
+ areFieldSchemasRegistered,
18
+ } from './fields'
19
+
20
+ export {
21
+ getDefaultValue,
22
+ passDefaultValue,
23
+ buildPreviewData,
24
+ mergePreviewData,
25
+ walkTree,
26
+ walkSchema,
27
+ getValueByPath,
28
+ } from './schema'
29
+
30
+ export {
31
+ generatePage,
32
+ generateProject,
33
+ passDataToHTML,
34
+ serializeState,
35
+ type GeneratePageOptions,
36
+ type GenerateProjectOptions,
37
+ type PageState,
38
+ type RenderResult,
39
+ } from './generate-page'
40
+
41
+ export {
42
+ validateLinks,
43
+ collectInternalLinks,
44
+ normalizeInternalUrl,
45
+ type LinkIssue,
46
+ } from './validate-links'
47
+
48
+ export { migrateContent, findUnknownBlocks } from './migrate'
49
+
50
+ // The `.page.md` codec is deliberately NOT re-exported here: it pulls in the
51
+ // YAML parser, and this barrel is imported by the client runtime — nothing in
52
+ // a production page needs to parse pages. Server-side callers (dev store,
53
+ // CLI, rich-text codec) import from 'mechanica-shared/page-format'.
54
+
55
+ export {
56
+ parseQueryKey,
57
+ isPaginatedQuery,
58
+ resolvePagesQuery,
59
+ resolveQueryKey,
60
+ type QuerySource,
61
+ type QueryContext,
62
+ type PageQueryItem,
63
+ type PagesQueryArgs,
64
+ type PaginatedPagesResult,
65
+ } from './query-engine'
66
+
package/src/migrate.ts ADDED
@@ -0,0 +1,44 @@
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
+ }