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
|
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
|
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
|
+
}
|