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/schema.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import type { Block, ContentBlock } from './types'
|
|
2
|
-
import { getFieldDefault } from './fields'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Compute the default value for a (compact-unfolded) schema node, consulting the
|
|
6
|
-
* field registry for format-specific defaults (e.g. `image`, `richText`).
|
|
7
|
-
*/
|
|
8
|
-
export function getDefaultValue(schema: any): any {
|
|
9
|
-
if (schema.default !== undefined) return schema.default
|
|
10
|
-
if (schema.nullable) return null
|
|
11
|
-
|
|
12
|
-
if (schema.format) {
|
|
13
|
-
const fieldDefault = getFieldDefault(schema.format)
|
|
14
|
-
if (fieldDefault !== undefined) return fieldDefault
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (schema.type === 'number' || schema.type === 'integer') return 0
|
|
18
|
-
if (schema.type === 'object') {
|
|
19
|
-
if (!schema.properties) return {}
|
|
20
|
-
return Object.fromEntries(
|
|
21
|
-
Object.entries(schema.properties).map(([key, value]) => {
|
|
22
|
-
if (!schema.required?.includes(key)) return [key, undefined]
|
|
23
|
-
return [key, getDefaultValue(value)]
|
|
24
|
-
}),
|
|
25
|
-
)
|
|
26
|
-
}
|
|
27
|
-
if (schema.type === 'array') return []
|
|
28
|
-
if (schema.type === 'boolean') return false
|
|
29
|
-
return ''
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Fill missing values in `state` with schema defaults, recursing into objects.
|
|
34
|
-
* Returns `state` when present, otherwise a freshly generated default.
|
|
35
|
-
*/
|
|
36
|
-
export function passDefaultValue(state: any, schema: any): any {
|
|
37
|
-
if (!schema) return state
|
|
38
|
-
if (schema.type === 'object' && state) {
|
|
39
|
-
for (const key in schema.properties) {
|
|
40
|
-
if (!(key in state) && !schema.required?.includes(key)) continue
|
|
41
|
-
state[key] = passDefaultValue(state[key], schema.properties[key])
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return state ?? getDefaultValue(schema)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Deep-merge `patch` over `base`: plain objects merge, arrays and scalars replace. */
|
|
52
|
-
export function mergePreviewData(
|
|
53
|
-
base: Record<string, unknown>,
|
|
54
|
-
patch: Record<string, unknown>,
|
|
55
|
-
): Record<string, unknown> {
|
|
56
|
-
const out: Record<string, unknown> = { ...base }
|
|
57
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
58
|
-
const current = out[key]
|
|
59
|
-
out[key] =
|
|
60
|
-
isPlainObject(current) && isPlainObject(value) ? mergePreviewData(current, value) : value
|
|
61
|
-
}
|
|
62
|
-
return out
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Resolve the data a block should render with outside a page: schema defaults,
|
|
67
|
-
* overlaid with the block's authored `previewData`, overlaid with per-call
|
|
68
|
-
* overrides (e.g. the `?data=` payload of the preview route).
|
|
69
|
-
*
|
|
70
|
-
* @param props Unfolded (JSON-schema shaped) props schema, as on `Block.props`.
|
|
71
|
-
*/
|
|
72
|
-
export function buildPreviewData(
|
|
73
|
-
props: Record<string, unknown> | undefined,
|
|
74
|
-
previewData?: Record<string, unknown>,
|
|
75
|
-
overrides?: Record<string, unknown>,
|
|
76
|
-
): Record<string, unknown> {
|
|
77
|
-
const defaults = props ? getDefaultValue(props) : {}
|
|
78
|
-
const base = isPlainObject(defaults) ? defaults : {}
|
|
79
|
-
return mergePreviewData(mergePreviewData(base, previewData ?? {}), overrides ?? {})
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Depth-first walk over a content tree, including array and named-slot children. */
|
|
83
|
-
export function walkTree(blocks: ContentBlock[], callback: (block: ContentBlock) => void): void {
|
|
84
|
-
for (const block of blocks) {
|
|
85
|
-
callback(block)
|
|
86
|
-
if (!block.children) continue
|
|
87
|
-
if (Array.isArray(block.children)) {
|
|
88
|
-
walkTree(block.children, callback)
|
|
89
|
-
} else {
|
|
90
|
-
for (const list of Object.values(block.children)) {
|
|
91
|
-
walkTree(list, callback)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
type WalkSchemaCallback = (
|
|
98
|
-
value: any,
|
|
99
|
-
schema: any,
|
|
100
|
-
key?: string,
|
|
101
|
-
parent?: any,
|
|
102
|
-
isRequired?: boolean,
|
|
103
|
-
) => void
|
|
104
|
-
|
|
105
|
-
/** Walk a value alongside its schema, invoking `callback` for each described node. */
|
|
106
|
-
export function walkSchema(obj: any, schema: Block['props'] | any, callback: WalkSchemaCallback): void {
|
|
107
|
-
if (schema.type === 'object' && schema.properties && obj) {
|
|
108
|
-
for (const [key, childSchema] of Object.entries(schema.properties)) {
|
|
109
|
-
const isRequired = schema.required?.includes(key) ?? false
|
|
110
|
-
callback(obj[key], childSchema, key, obj, isRequired)
|
|
111
|
-
|
|
112
|
-
const childType = (childSchema as any).type
|
|
113
|
-
if (childType === 'array' || childType === 'object') {
|
|
114
|
-
walkSchema(obj[key], childSchema, callback)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (schema.type === 'array' && schema.items && obj) {
|
|
120
|
-
for (const value of obj) {
|
|
121
|
-
callback(value, schema.items)
|
|
122
|
-
if (schema.items.type === 'array' || schema.items.type === 'object') {
|
|
123
|
-
walkSchema(value, schema.items, callback)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Resolve a dotted path within a data object (`'postMeta.date'`). */
|
|
130
|
-
export function getValueByPath(data: any, path: string): unknown {
|
|
131
|
-
let value = data
|
|
132
|
-
for (const key of path.split('.')) {
|
|
133
|
-
if (value == null) return value
|
|
134
|
-
value = value[key]
|
|
135
|
-
}
|
|
136
|
-
return value
|
|
137
|
-
}
|
package/src/seo.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import { serializeState } from './generate-page'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* SEO helpers shared by `mechanica export` and the future render backend.
|
|
5
|
-
* Everything here is pure string work over already-rendered HTML — DOM-free,
|
|
6
|
-
* like the rest of this package.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** The public URL of an exported page: origin + directory-style path. */
|
|
10
|
-
export function pageUrl(siteUrl: string, path: string): string {
|
|
11
|
-
const base = siteUrl.replace(/\/+$/, '')
|
|
12
|
-
return path === '/' ? `${base}/` : `${base}${path}/`
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** The path of the n-th chunk of a paginated page (`/blog` → `/blog/2`). */
|
|
16
|
-
export function paginationVariantPath(basePath: string, page: number): string {
|
|
17
|
-
if (page <= 1) return basePath
|
|
18
|
-
return `${basePath === '/' ? '' : basePath}/${page}`
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface SeoTagOptions {
|
|
22
|
-
/** Absolute site origin (`https://example.com`). Enables all URL-based tags. */
|
|
23
|
-
siteUrl?: string
|
|
24
|
-
/** Site display name — emits WebSite JSON-LD on the root page. */
|
|
25
|
-
siteName?: string
|
|
26
|
-
/** The page's exported path (`/`, `/blog`, `/blog/2`). */
|
|
27
|
-
path: string
|
|
28
|
-
/** Inject `<meta name="robots" content="noindex">`. */
|
|
29
|
-
noindex?: boolean
|
|
30
|
-
/** Set for paginated pages (page 1 included) — emits prev/next + title suffix. */
|
|
31
|
-
pagination?: { page: number; pageCount: number; basePath: string }
|
|
32
|
-
/**
|
|
33
|
-
* True when the index template references `page.pagination` itself — the
|
|
34
|
-
* author handles variant titles, so no automatic "— Page N" suffix.
|
|
35
|
-
*/
|
|
36
|
-
templateHandlesPagination?: boolean
|
|
37
|
-
/** Breadcrumb trail (root first) — emits BreadcrumbList JSON-LD. */
|
|
38
|
-
breadcrumbs?: { name: string; path: string }[]
|
|
39
|
-
/**
|
|
40
|
-
* Language alternates for a translated page — emits `<link rel="alternate"
|
|
41
|
-
* hreflang="…">` per locale plus an `x-default`. `path` is each locale's
|
|
42
|
-
* exported path (already locale-prefixed); `hreflang` its code (`x-default`
|
|
43
|
-
* for the default). Skipped when the page has only one locale.
|
|
44
|
-
*/
|
|
45
|
-
alternates?: { hreflang: string; path: string }[]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Meta properties whose `content` must be an absolute URL per the OG/Twitter specs. */
|
|
49
|
-
const ABSOLUTE_URL_METAS =
|
|
50
|
-
/^(?:og:url|og:image(?::url|:secure_url)?|og:video(?::url|:secure_url)?|og:audio(?::url|:secure_url)?|twitter:image(?::src)?)$/
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Rewrite root-relative `content` values (`/media/x.jpg`) of Open Graph /
|
|
54
|
-
* Twitter URL metas to absolute URLs — crawlers reject relative ones, so a
|
|
55
|
-
* templated `{{ head.image.src }}` would otherwise silently break link previews.
|
|
56
|
-
*/
|
|
57
|
-
function absolutizeSocialUrls(html: string, siteUrl: string): string {
|
|
58
|
-
const base = siteUrl.replace(/\/+$/, '')
|
|
59
|
-
return html.replace(/<meta\b[^>]*>/gi, (tag) => {
|
|
60
|
-
const name = tag.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i)?.[1]
|
|
61
|
-
if (!name || !ABSOLUTE_URL_METAS.test(name)) return tag
|
|
62
|
-
// Root-relative only; `//host/…` protocol-relative URLs are left alone.
|
|
63
|
-
return tag.replace(/(content\s*=\s*["'])\/(?!\/)/i, `$1${base}/`)
|
|
64
|
-
})
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** An inline JSON-LD script (script-safe serialization — see serializeState). */
|
|
68
|
-
function jsonLdScript(value: unknown): string {
|
|
69
|
-
return `<script type="application/ld+json">${serializeState(value)}</script>`
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Inject the SEO tags a page can't reasonably be asked to author by hand:
|
|
74
|
-
* canonical URL, `og:url`, absolute social-image URLs, robots noindex,
|
|
75
|
-
* pagination prev/next + "— Page N" titles, and WebSite / BreadcrumbList
|
|
76
|
-
* JSON-LD. Tags the template already contains are never duplicated, so a
|
|
77
|
-
* hand-authored `<head>` always wins.
|
|
78
|
-
*/
|
|
79
|
-
export function applySeoTags(html: string, options: SeoTagOptions): string {
|
|
80
|
-
if (!html.includes('</head>')) return html
|
|
81
|
-
const { siteUrl, pagination } = options
|
|
82
|
-
const tags: string[] = []
|
|
83
|
-
|
|
84
|
-
if (siteUrl) {
|
|
85
|
-
html = absolutizeSocialUrls(html, siteUrl)
|
|
86
|
-
const url = pageUrl(siteUrl, options.path)
|
|
87
|
-
if (!/rel\s*=\s*["']canonical["']/i.test(html)) tags.push(`<link rel="canonical" href="${url}">`)
|
|
88
|
-
if (!/(?:property|name)\s*=\s*["']og:url["']/i.test(html)) {
|
|
89
|
-
tags.push(`<meta property="og:url" content="${url}">`)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (options.noindex && !/name\s*=\s*["']robots["']/i.test(html)) {
|
|
94
|
-
tags.push('<meta name="robots" content="noindex">')
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (pagination) {
|
|
98
|
-
// Variants otherwise ship the base page's exact <title> — duplicate titles
|
|
99
|
-
// across /blog, /blog/2, … — unless the template handles pagination itself.
|
|
100
|
-
if (pagination.page > 1 && !options.templateHandlesPagination) {
|
|
101
|
-
html = html.replace(
|
|
102
|
-
/<title>([\s\S]*?)<\/title>/i,
|
|
103
|
-
(_match, title) => `<title>${title} — Page ${pagination.page}</title>`,
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
if (siteUrl) {
|
|
107
|
-
if (pagination.page > 1) {
|
|
108
|
-
const prev = paginationVariantPath(pagination.basePath, pagination.page - 1)
|
|
109
|
-
tags.push(`<link rel="prev" href="${pageUrl(siteUrl, prev)}">`)
|
|
110
|
-
}
|
|
111
|
-
if (pagination.page < pagination.pageCount) {
|
|
112
|
-
const next = paginationVariantPath(pagination.basePath, pagination.page + 1)
|
|
113
|
-
tags.push(`<link rel="next" href="${pageUrl(siteUrl, next)}">`)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// hreflang alternates for a translated page (each locale + x-default). Emitted
|
|
119
|
-
// only with a site url — the hrefs must be absolute for crawlers.
|
|
120
|
-
if (siteUrl && options.alternates && options.alternates.length > 1) {
|
|
121
|
-
for (const alt of options.alternates) {
|
|
122
|
-
const href = pageUrl(siteUrl, alt.path)
|
|
123
|
-
if (!new RegExp(`hreflang\\s*=\\s*["']${alt.hreflang}["']`, 'i').test(html)) {
|
|
124
|
-
tags.push(`<link rel="alternate" hreflang="${alt.hreflang}" href="${href}">`)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (siteUrl && options.siteName && options.path === '/' && !/"@type"\s*:\s*"WebSite"/.test(html)) {
|
|
130
|
-
tags.push(
|
|
131
|
-
jsonLdScript({
|
|
132
|
-
'@context': 'https://schema.org',
|
|
133
|
-
'@type': 'WebSite',
|
|
134
|
-
name: options.siteName,
|
|
135
|
-
url: pageUrl(siteUrl, '/'),
|
|
136
|
-
}),
|
|
137
|
-
)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const crumbs = options.breadcrumbs
|
|
141
|
-
if (siteUrl && crumbs && crumbs.length > 1 && !/"@type"\s*:\s*"BreadcrumbList"/.test(html)) {
|
|
142
|
-
tags.push(
|
|
143
|
-
jsonLdScript({
|
|
144
|
-
'@context': 'https://schema.org',
|
|
145
|
-
'@type': 'BreadcrumbList',
|
|
146
|
-
itemListElement: crumbs.map((crumb, i) => ({
|
|
147
|
-
'@type': 'ListItem',
|
|
148
|
-
position: i + 1,
|
|
149
|
-
name: crumb.name,
|
|
150
|
-
item: pageUrl(siteUrl, crumb.path),
|
|
151
|
-
})),
|
|
152
|
-
}),
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (tags.length) html = html.replace('</head>', `${tags.join('\n')}\n</head>`)
|
|
157
|
-
return html
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Cheap regex-level SEO audit of a rendered page. Returns human-readable
|
|
162
|
-
* issues (empty title, missing description, h1 problems, images without alt)
|
|
163
|
-
* — the export surfaces them as warnings, same philosophy as the link validator.
|
|
164
|
-
*/
|
|
165
|
-
export function auditPageHtml(html: string): string[] {
|
|
166
|
-
const issues: string[] = []
|
|
167
|
-
|
|
168
|
-
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
|
|
169
|
-
if (!title) issues.push('no <title> tag')
|
|
170
|
-
else if (!title[1]!.trim()) issues.push('empty <title>')
|
|
171
|
-
|
|
172
|
-
let description: string | undefined
|
|
173
|
-
for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
174
|
-
if (!/name\s*=\s*["']description["']/i.test(tag)) continue
|
|
175
|
-
description = tag.match(/content\s*=\s*["']([^"']*)["']/i)?.[1] ?? ''
|
|
176
|
-
}
|
|
177
|
-
if (description === undefined) issues.push('no meta description')
|
|
178
|
-
else if (!description.trim()) issues.push('empty meta description')
|
|
179
|
-
|
|
180
|
-
const h1Count = (html.match(/<h1[\s>]/gi) ?? []).length
|
|
181
|
-
if (h1Count === 0) issues.push('no <h1> heading')
|
|
182
|
-
else if (h1Count > 1) issues.push(`${h1Count} <h1> headings — search engines expect one`)
|
|
183
|
-
|
|
184
|
-
const missingAlt = (html.match(/<img\b[^>]*>/gi) ?? []).filter((tag) => !/\balt\s*=/i.test(tag)).length
|
|
185
|
-
if (missingAlt > 0) issues.push(`${missingAlt} <img> without alt text`)
|
|
186
|
-
|
|
187
|
-
return issues
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface SitemapEntry {
|
|
191
|
-
path: string
|
|
192
|
-
/** ISO date (`2026-07-04`) — emitted as `<lastmod>`. */
|
|
193
|
-
lastmod?: string
|
|
194
|
-
/**
|
|
195
|
-
* Language alternates for a translated page — each locale's exported path
|
|
196
|
-
* plus `x-default` — emitted as `<xhtml:link rel="alternate" hreflang="…">`.
|
|
197
|
-
*/
|
|
198
|
-
alternates?: { hreflang: string; path: string }[]
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Build a sitemap.xml for the exported pages (directory-style URLs). */
|
|
202
|
-
export function buildSitemap(siteUrl: string, entries: SitemapEntry[]): string {
|
|
203
|
-
const hasAlternates = entries.some((entry) => entry.alternates && entry.alternates.length > 1)
|
|
204
|
-
const urls = [...entries]
|
|
205
|
-
.sort((a, b) => a.path.localeCompare(b.path))
|
|
206
|
-
.map(({ path, lastmod, alternates }) => {
|
|
207
|
-
const suffix = lastmod ? `<lastmod>${lastmod}</lastmod>` : ''
|
|
208
|
-
const links =
|
|
209
|
-
alternates && alternates.length > 1
|
|
210
|
-
? alternates
|
|
211
|
-
.map(
|
|
212
|
-
(alt) =>
|
|
213
|
-
`<xhtml:link rel="alternate" hreflang="${alt.hreflang}" href="${pageUrl(siteUrl, alt.path)}"/>`,
|
|
214
|
-
)
|
|
215
|
-
.join('')
|
|
216
|
-
: ''
|
|
217
|
-
return ` <url><loc>${pageUrl(siteUrl, path)}</loc>${suffix}${links}</url>`
|
|
218
|
-
})
|
|
219
|
-
const urlsetOpen = hasAlternates
|
|
220
|
-
? '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
|
|
221
|
-
: '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
|
222
|
-
return ['<?xml version="1.0" encoding="UTF-8"?>', urlsetOpen, ...urls, '</urlset>', ''].join('\n')
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** The default robots.txt emitted alongside a sitemap (unless the site ships its own). */
|
|
226
|
-
export function buildRobotsTxt(siteUrl: string): string {
|
|
227
|
-
return `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl.replace(/\/+$/, '')}/sitemap.xml\n`
|
|
228
|
-
}
|
package/src/translation.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Translation overlays — pure, DOM-free helpers that let a page's `@locale`
|
|
3
|
-
* file store *only* what it changes and inherit everything else from the
|
|
4
|
-
* default-locale page. The mechanism mirrors the localized shared-data pattern
|
|
5
|
-
* (diff-against-base), one level deeper: inside a page's block tree.
|
|
6
|
-
*
|
|
7
|
-
* Two directions:
|
|
8
|
-
* - {@link mergeTranslation} — read time (dev `buildPageState` + static export):
|
|
9
|
-
* overlay the sparse translation on the base so shared fields (images, links,
|
|
10
|
-
* colors, layout) resolve to the default-locale value automatically.
|
|
11
|
-
* - {@link diffTranslation} — save time: strip everything a translation shares
|
|
12
|
-
* with the base, so the file on disk carries only the genuinely translated
|
|
13
|
-
* fields (and stays a readable, reviewable diff).
|
|
14
|
-
*
|
|
15
|
-
* Two structural rules, both following the "**base owns structure**" model:
|
|
16
|
-
* - **Blocks** are matched by their stable `id`. The base owns which blocks
|
|
17
|
-
* exist and their order; a translation only fills fields on the blocks it
|
|
18
|
-
* shares. A block added to the base appears in every locale (untranslated).
|
|
19
|
-
* - Within a block's `data`, **objects deep-merge** (so an image's `alt` can be
|
|
20
|
-
* translated while its `src` inherits), while **arrays and scalars replace
|
|
21
|
-
* wholesale** — an array (rich text, a list of cards) is an atomic unit: a
|
|
22
|
-
* translation either inherits it or overrides it entirely, never a mix (which
|
|
23
|
-
* would interleave translated and untranslated entries).
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import type { ContentBlock } from './types'
|
|
27
|
-
|
|
28
|
-
type Data = Record<string, unknown>
|
|
29
|
-
type Children = ContentBlock['children']
|
|
30
|
-
|
|
31
|
-
function isPlainObject(value: unknown): value is Data {
|
|
32
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Structural deep-equality (objects key-insensitive to order, arrays ordered). */
|
|
36
|
-
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
37
|
-
if (a === b) return true
|
|
38
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
39
|
-
return a.length === b.length && a.every((value, i) => deepEqual(value, b[i]))
|
|
40
|
-
}
|
|
41
|
-
if (isPlainObject(a) && isPlainObject(b)) {
|
|
42
|
-
const keys = Object.keys(a)
|
|
43
|
-
if (keys.length !== Object.keys(b).length) return false
|
|
44
|
-
return keys.every((key) => key in b && deepEqual(a[key], b[key]))
|
|
45
|
-
}
|
|
46
|
-
return false
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Overlay a translation `overlay` value on its `base`: objects deep-merge
|
|
51
|
-
* (per-key inheritance), arrays and scalars are taken from the overlay when it
|
|
52
|
-
* provides one, and an absent overlay (`undefined`) inherits the base.
|
|
53
|
-
*/
|
|
54
|
-
export function mergeValue(base: unknown, overlay: unknown): unknown {
|
|
55
|
-
if (overlay === undefined) return base
|
|
56
|
-
if (isPlainObject(base) && isPlainObject(overlay)) {
|
|
57
|
-
const out: Data = { ...base }
|
|
58
|
-
for (const key of Object.keys(overlay)) out[key] = mergeValue(base[key], overlay[key])
|
|
59
|
-
return out
|
|
60
|
-
}
|
|
61
|
-
return overlay
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The sparse diff of a `full` value against its `base`: equal values collapse to
|
|
66
|
-
* `undefined` (inherit), objects keep only their differing keys (recursively),
|
|
67
|
-
* and arrays/scalars are kept whole when changed.
|
|
68
|
-
*/
|
|
69
|
-
export function diffValue(base: unknown, full: unknown): unknown {
|
|
70
|
-
if (deepEqual(base, full)) return undefined
|
|
71
|
-
if (isPlainObject(base) && isPlainObject(full)) {
|
|
72
|
-
const out: Data = {}
|
|
73
|
-
for (const key of Object.keys(full)) {
|
|
74
|
-
const diff = diffValue(base[key], full[key])
|
|
75
|
-
if (diff !== undefined) out[key] = diff
|
|
76
|
-
}
|
|
77
|
-
return Object.keys(out).length ? out : undefined
|
|
78
|
-
}
|
|
79
|
-
return full
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Overlay translated blocks on the base list, matched by `id` (base owns order). */
|
|
83
|
-
export function mergeBlocks(base: ContentBlock[], overlay: ContentBlock[] | undefined): ContentBlock[] {
|
|
84
|
-
if (!overlay?.length) return base
|
|
85
|
-
const byId = new Map(overlay.map((block) => [block.id, block]))
|
|
86
|
-
return base.map((block) => mergeBlock(block, byId.get(block.id)))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function mergeBlock(base: ContentBlock, overlay: ContentBlock | undefined): ContentBlock {
|
|
90
|
-
if (!overlay) return base
|
|
91
|
-
const merged: ContentBlock = {
|
|
92
|
-
...base,
|
|
93
|
-
data: mergeValue(base.data ?? {}, overlay.data ?? {}) as Data,
|
|
94
|
-
}
|
|
95
|
-
const children = mergeChildren(base.children, overlay.children)
|
|
96
|
-
if (children !== undefined) merged.children = children
|
|
97
|
-
return merged
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function mergeChildren(base: Children, overlay: Children): Children {
|
|
101
|
-
if (base === undefined) return undefined
|
|
102
|
-
if (Array.isArray(base)) {
|
|
103
|
-
return mergeBlocks(base, Array.isArray(overlay) ? overlay : undefined)
|
|
104
|
-
}
|
|
105
|
-
const overlaySlots = overlay && !Array.isArray(overlay) ? overlay : undefined
|
|
106
|
-
const out: Record<string, ContentBlock[]> = {}
|
|
107
|
-
for (const slot of Object.keys(base)) out[slot] = mergeBlocks(base[slot] ?? [], overlaySlots?.[slot])
|
|
108
|
-
return out
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Keep only the blocks (and fields) a `full` list changes vs `base` (base owns structure). */
|
|
112
|
-
export function diffBlocks(base: ContentBlock[], full: ContentBlock[]): ContentBlock[] {
|
|
113
|
-
const byId = new Map(base.map((block) => [block.id, block]))
|
|
114
|
-
const out: ContentBlock[] = []
|
|
115
|
-
for (const block of full) {
|
|
116
|
-
const baseBlock = byId.get(block.id)
|
|
117
|
-
// Base owns structure: an overlay-only block never renders, so drop it.
|
|
118
|
-
if (!baseBlock) continue
|
|
119
|
-
const data = diffValue(baseBlock.data ?? {}, block.data ?? {}) as Data | undefined
|
|
120
|
-
const children = diffChildren(baseBlock.children, block.children)
|
|
121
|
-
if (data === undefined && children === undefined) continue
|
|
122
|
-
const sparse: ContentBlock = { id: block.id, blockId: block.blockId, data: data ?? {} }
|
|
123
|
-
if (block.v !== undefined) sparse.v = block.v
|
|
124
|
-
if (children !== undefined) sparse.children = children
|
|
125
|
-
out.push(sparse)
|
|
126
|
-
}
|
|
127
|
-
return out
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function diffChildren(base: Children, full: Children): Children | undefined {
|
|
131
|
-
if (full === undefined) return undefined
|
|
132
|
-
if (!Array.isArray(full)) {
|
|
133
|
-
// Named slots.
|
|
134
|
-
const baseSlots = base && !Array.isArray(base) ? base : undefined
|
|
135
|
-
const out: Record<string, ContentBlock[]> = {}
|
|
136
|
-
for (const slot of Object.keys(full)) {
|
|
137
|
-
const diff = diffBlocks(baseSlots?.[slot] ?? [], full[slot] ?? [])
|
|
138
|
-
if (diff.length) out[slot] = diff
|
|
139
|
-
}
|
|
140
|
-
return Object.keys(out).length ? out : undefined
|
|
141
|
-
}
|
|
142
|
-
const diff = diffBlocks(Array.isArray(base) ? base : [], full)
|
|
143
|
-
return diff.length ? diff : undefined
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** The overlay-able projection of a page: its content tree, page data and frontmatter meta. */
|
|
147
|
-
export interface TranslationDoc {
|
|
148
|
-
content?: ContentBlock[]
|
|
149
|
-
data?: Data
|
|
150
|
-
meta?: Data
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Read-time: resolve a sparse translation against the default-locale page. */
|
|
154
|
-
export function mergeTranslation(base: TranslationDoc, overlay: TranslationDoc): Required<TranslationDoc> {
|
|
155
|
-
return {
|
|
156
|
-
content: mergeBlocks(base.content ?? [], overlay.content),
|
|
157
|
-
data: mergeValue(base.data ?? {}, overlay.data ?? {}) as Data,
|
|
158
|
-
meta: mergeValue(base.meta ?? {}, overlay.meta ?? {}) as Data,
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Save-time: reduce a full translation to only what differs from the base. */
|
|
163
|
-
export function diffTranslation(base: TranslationDoc, full: TranslationDoc): Required<TranslationDoc> {
|
|
164
|
-
return {
|
|
165
|
-
content: diffBlocks(base.content ?? [], full.content ?? []),
|
|
166
|
-
data: (diffValue(base.data ?? {}, full.data ?? {}) as Data) ?? {},
|
|
167
|
-
meta: (diffValue(base.meta ?? {}, full.meta ?? {}) as Data) ?? {},
|
|
168
|
-
}
|
|
169
|
-
}
|