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/README.md +7 -4
- package/dist/block-format.js +4 -0
- package/dist/index.js +39 -8
- package/dist/page-format.js +2 -0
- package/dist/types/compose.d.ts +10 -3
- package/dist/types/generate-page.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/page-format.d.ts +5 -0
- package/dist/types/types.d.ts +45 -1
- package/package.json +4 -8
- package/src/block-format.ts +0 -100
- package/src/compose.ts +0 -120
- package/src/composer-manifest.ts +0 -45
- package/src/fields.ts +0 -155
- package/src/generate-page.ts +0 -212
- package/src/index.ts +0 -119
- package/src/locale.ts +0 -88
- package/src/migrate.ts +0 -44
- package/src/page-format.ts +0 -422
- package/src/query-engine.ts +0 -175
- package/src/schema.ts +0 -137
- package/src/seo.ts +0 -228
- package/src/translation.ts +0 -169
- package/src/types.ts +0 -334
- package/src/validate-links.ts +0 -56
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
|
-
}
|
package/src/generate-page.ts
DELETED
|
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
|
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
|
-
}
|