mechanica-shared 2.0.0-alpha.9 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
- }
@@ -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
- }