@apleasantview/eleventy-plugin-baseline 0.1.0-next.40 → 0.1.0-next.42

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.
Files changed (73) hide show
  1. package/README.md +30 -32
  2. package/core/back-compat/options.js +69 -0
  3. package/core/content-graph/backlinks.js +65 -0
  4. package/core/content-graph/extractors.js +185 -0
  5. package/core/content-graph/graph.js +121 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/dates/git-date.js +71 -0
  9. package/core/dates/index.js +55 -0
  10. package/core/locale/derive-lang.js +19 -0
  11. package/core/locale/index.js +6 -0
  12. package/core/locale/normalize-lang.js +13 -0
  13. package/core/locale/normalize-locale.js +20 -0
  14. package/core/locale/open-graph-locale.js +14 -0
  15. package/core/locale/resolve-default.js +27 -0
  16. package/core/locale/resolve-locale.js +16 -0
  17. package/core/logging/banner.js +49 -0
  18. package/core/{logging.js → logging/index.js} +19 -2
  19. package/core/logging/quips.js +30 -0
  20. package/core/markdown/auto-heading-ids.js +86 -0
  21. package/core/markdown/index.js +5 -0
  22. package/core/markdown/safe-use.js +42 -0
  23. package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
  24. package/core/page-context/build.js +336 -0
  25. package/core/page-context/index.js +1 -0
  26. package/core/page-context/register.js +73 -0
  27. package/core/page-context/seo-helpers.js +56 -0
  28. package/core/schema.js +22 -2
  29. package/core/seo-graph/adapter.js +246 -0
  30. package/core/seo-graph/build.js +87 -0
  31. package/core/seo-graph/index.js +1 -0
  32. package/core/seo-graph/open-graph.js +130 -0
  33. package/core/seo-graph/register.js +42 -0
  34. package/core/seo-graph/schema.js +18 -0
  35. package/core/slug-index.js +2 -2
  36. package/core/state.js +75 -0
  37. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  38. package/core/surface/index.js +22 -0
  39. package/core/types.js +3 -0
  40. package/core/utils/add-trailing-slash.js +11 -0
  41. package/core/utils/ensure-dot-slash-dir.js +13 -0
  42. package/core/utils/normalize-language-map.js +37 -0
  43. package/core/utils/resolve-field.js +9 -0
  44. package/core/utils/resolve-subdir.js +20 -0
  45. package/core/utils/slugify.js +15 -0
  46. package/core/utils/title-case-slug.js +15 -0
  47. package/core/utils/unique-by.js +25 -0
  48. package/core/virtual-dir.js +11 -10
  49. package/index.js +161 -118
  50. package/modules/assets/index.js +4 -2
  51. package/modules/assets/processors/esbuild-process.js +2 -2
  52. package/modules/assets/processors/postcss-process.js +2 -2
  53. package/modules/head/drivers/posthtml-head-elements.js +92 -12
  54. package/modules/head/index.js +23 -19
  55. package/modules/head/schema.js +7 -3
  56. package/modules/multilang/filters/i18n-default-translation.js +2 -4
  57. package/modules/multilang/filters/i18n-translation-in.js +2 -2
  58. package/modules/multilang/filters/i18n-translations-for.js +2 -2
  59. package/modules/multilang/index.js +80 -39
  60. package/modules/navigator/index.js +39 -25
  61. package/modules/navigator/templates/navigator-core.html +1 -1
  62. package/modules/sitemap/index.js +8 -4
  63. package/modules/sitemap/templates/sitemap-core.html +1 -1
  64. package/package.json +5 -2
  65. package/core/filters/index.js +0 -4
  66. package/core/global-functions/index.js +0 -6
  67. package/core/page-context.js +0 -310
  68. package/core/shortcodes/index.js +0 -2
  69. package/core/utils/helpers.js +0 -75
  70. /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
  71. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  72. /package/core/{filters → surface/filters}/isString.js +0 -0
  73. /package/core/{filters → surface/filters}/related-posts.js +0 -0
@@ -0,0 +1,336 @@
1
+ import { setEntry } from '../registry.js';
2
+ import { slugify } from '../utils/slugify.js';
3
+ import { titleCaseSlug } from '../utils/title-case-slug.js';
4
+ import { uniqueBy } from '../utils/unique-by.js';
5
+ import { resolveField } from '../utils/resolve-field.js';
6
+ import { extractFirstParagraph, normalizeCanonical } from './seo-helpers.js';
7
+
8
+ /**
9
+ * Apply a title template, replacing tokens with resolved values. Tokens:
10
+ * `%s` (page title), `%siteTitle%`, `%tagline%`. One regex with the longer
11
+ * tokens first so `%s` does not eat the `%s` inside `%siteTitle%`.
12
+ * Replacement is literal: an empty value leaves the surrounding template
13
+ * text as the author wrote it.
14
+ *
15
+ * @param {string} template
16
+ * @param {{ title?: string, siteTitle?: string, tagline?: string }} tokens
17
+ * @returns {string}
18
+ */
19
+ export function applyTitleTemplate(template, { title, siteTitle, tagline }) {
20
+ const values = { '%s': title, '%siteTitle%': siteTitle, '%tagline%': tagline };
21
+ return template.replace(/%siteTitle%|%tagline%|%s/g, (token) => values[token] ?? '');
22
+ }
23
+
24
+ /**
25
+ * Resolve the final `<title>` text.
26
+ *
27
+ * Precedence: per-page bare opt-out (`titleTemplate: null`) → per-page
28
+ * template → titleless home (baked-in `siteTitle` + `tagline`) → global
29
+ * template → default (`page – site`, guarded so a page named like the site
30
+ * stays bare). With no template set anywhere this reproduces the legacy
31
+ * separator composition exactly.
32
+ *
33
+ * @returns {string}
34
+ */
35
+ export function resolveTitle({ data, isHome, pageTitle, siteTitle, tagline, separator, globalTemplate }) {
36
+ const pageTemplate = data?.titleTemplate;
37
+ const base = pageTitle ?? siteTitle;
38
+ const tokens = { title: pageTitle, siteTitle, tagline };
39
+
40
+ if (pageTemplate === null) return base;
41
+ if (typeof pageTemplate === 'string') return applyTitleTemplate(pageTemplate, tokens);
42
+ if (isHome && !data?.seo?.title) return tagline ? `${siteTitle}${separator}${tagline}` : base;
43
+ if (typeof globalTemplate === 'string') return applyTitleTemplate(globalTemplate, tokens);
44
+ if (!isHome && pageTitle && siteTitle && pageTitle !== siteTitle) return `${pageTitle}${separator}${siteTitle}`;
45
+ return base;
46
+ }
47
+
48
+ /**
49
+ * Resolve a breadcrumb trail from the page's ancestor section path.
50
+ *
51
+ * `section` is the containing-directory chain, not a path that ends at the
52
+ * page: a leaf (/docs/module/head/) and its section index (/docs/module/)
53
+ * share the same section. The tell is the URL. When the page IS its section
54
+ * index, the last segment names it (relabelled with the page title); otherwise
55
+ * the page is a leaf and gets appended as its own crumb. The final crumb keeps
56
+ * its URL so the schema renderer can wire its @id, and is flagged `current` so
57
+ * the visible renderer knows not to link it. In multilang, a deliberately
58
+ * non-default language prefixes every URL with `/{lang}`.
59
+ *
60
+ * @param {{ section?: string[], url?: string, title?: string, lang?: string, isDefaultLang?: boolean }} input
61
+ * @returns {Array<{ label: string, url: string, current?: boolean }>}
62
+ */
63
+ export function buildBreadcrumbs({ section = [], url, title, lang, isDefaultLang } = {}) {
64
+ if (!section?.length || !url) return [];
65
+
66
+ // Only a deliberately non-default language prefixes the path; absence
67
+ // (no multilang) keeps the root, never a spurious `/{lang}`.
68
+ const base = isDefaultLang === false && lang ? `/${lang}` : '';
69
+
70
+ const crumbs = [{ label: 'Home', url: `${base}/` }];
71
+ let acc = base;
72
+ for (const seg of section) {
73
+ acc += `/${seg}`;
74
+ crumbs.push({ label: titleCaseSlug(seg), url: `${acc}/` });
75
+ }
76
+
77
+ const sectionUrl = `${base}/${section.join('/')}/`;
78
+ if (url === sectionUrl) {
79
+ crumbs[crumbs.length - 1].label = title ?? crumbs[crumbs.length - 1].label;
80
+ } else {
81
+ crumbs.push({ label: title ?? titleCaseSlug(section[section.length - 1]), url });
82
+ }
83
+
84
+ crumbs[crumbs.length - 1].current = true;
85
+ return crumbs;
86
+ }
87
+
88
+ /**
89
+ * Page context — builder factory
90
+ *
91
+ * Returns a `buildPageContext` function bound to the runtime dependencies
92
+ * it needs (scope, slug index, resolved settings, runtime substrate handles,
93
+ * options). Each top-level key in the page context (`site`, `page`, `entry`,
94
+ * `query`, `meta`, `render`, `head`) has its own builder inside the closure.
95
+ *
96
+ * Architecture layer:
97
+ * runtime substrate (page-context internal)
98
+ *
99
+ * System role:
100
+ * Pure transformation of Eleventy data → normalised page context. The
101
+ * factory keeps cross-builder dependencies (separator, site.url, contentMap)
102
+ * in one place without threading them through every builder signature.
103
+ *
104
+ * @param {{
105
+ * scope: { values: Map },
106
+ * slugIndex: { set: (slug: string, url: string, inputPath: string) => void } | null,
107
+ * settings: import('../types.js').BaselineSettings,
108
+ * runtime: { contentMap: any },
109
+ * options: import('../types.js').BaselineOptions,
110
+ * log?: { warn: (...args: unknown[]) => void }
111
+ * }} deps
112
+ * @returns {(data: any) => object}
113
+ */
114
+ export function createPageContext({ scope, slugIndex, settings, runtime, options, log }) {
115
+ const separator = options.head?.titleSeparator ?? ' – ';
116
+
117
+ function buildSite(lang, userSettings) {
118
+ const langEntry = lang ? userSettings.languages?.[lang] : undefined;
119
+ return {
120
+ title: langEntry?.title ?? userSettings.title ?? '',
121
+ tagline: langEntry?.tagline ?? userSettings.tagline ?? '',
122
+ description: langEntry?.description ?? userSettings.description ?? '',
123
+ url: userSettings.url ?? '',
124
+ noindex: userSettings.noindex === true
125
+ };
126
+ }
127
+
128
+ function buildPage(pageInput) {
129
+ return {
130
+ inputPath: pageInput?.inputPath,
131
+ fileSlug: pageInput?.fileSlug,
132
+ filePathStem: pageInput?.filePathStem,
133
+ outputFileExtension: pageInput?.outputFileExtension,
134
+ templateSyntax: pageInput?.templateSyntax,
135
+ date: pageInput?.date,
136
+ url: pageInput?.url,
137
+ outputPath: pageInput?.outputPath,
138
+ lang: pageInput?.lang,
139
+ locale: pageInput?.locale,
140
+ translationKey: pageInput?.translationKey,
141
+ isDefaultLang: pageInput?.isDefaultLang,
142
+ sitemap: pageInput?.sitemap
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Build the `entry` branch — the author's view of the page.
148
+ *
149
+ * Holds the content's self-description (title, description, excerpt), its
150
+ * identity (slug), structural classification (section as a hierarchical
151
+ * path array, type as a free-form classifier), and per-page head extras.
152
+ * Values pass through raw; consumers normalise.
153
+ */
154
+ function buildEntry(data) {
155
+ const rawSlug = data?.slug ?? data?.page?.fileSlug;
156
+
157
+ // Coerce a string section to a single-element array, with a dev warning.
158
+ // Strict contract is "section is always an array"; runtime stays forgiving.
159
+ let section = data?.section;
160
+ if (typeof section === 'string') {
161
+ if (process.env.NODE_ENV !== 'production') {
162
+ log?.warn(
163
+ `entry.section should be an array, got string "${section}" at ${data?.page?.url ?? data?.page?.inputPath}. Use ['${section}'] instead.`
164
+ );
165
+ }
166
+ section = [section];
167
+ }
168
+
169
+ return {
170
+ title: data?.seo?.title ?? data?.title,
171
+ description: data?.seo?.description ?? data?.description,
172
+ excerpt: data?.excerpt,
173
+ slug: slugify(rawSlug),
174
+ section,
175
+ type: data?.type,
176
+ head: data?.head,
177
+ breadcrumbs: buildBreadcrumbs({
178
+ section,
179
+ url: data?.page?.url,
180
+ title: data?.seo?.title ?? data?.title,
181
+ lang: data?.page?.lang,
182
+ isDefaultLang: data?.page?.isDefaultLang
183
+ })
184
+ };
185
+ }
186
+
187
+ function buildQuery({ page }) {
188
+ return {
189
+ isHome: page.url === '/'
190
+ };
191
+ }
192
+
193
+ function buildMeta({ data, site, page, query }) {
194
+ const noindex = site.noindex || data?.noindex === true;
195
+
196
+ const robots = noindex
197
+ ? 'noindex, nofollow'
198
+ : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
199
+
200
+ const contentMap = runtime.contentMap;
201
+
202
+ const siteTitle = site.title;
203
+ const siteDescription = site.description;
204
+ const tagline = site.tagline;
205
+
206
+ const pageTitle = data?.seo?.title ?? data?.title ?? siteTitle;
207
+ const pageDescription = data?.seo?.description ?? data?.description ?? data?.excerpt ?? extractFirstParagraph(data);
208
+
209
+ // ---- DESCRIPTION ----
210
+ const description = resolveField({
211
+ pageValue: pageDescription,
212
+ siteValue: siteDescription,
213
+ isHome: query.isHome
214
+ });
215
+
216
+ // ---- TITLE ----
217
+ const title = resolveTitle({
218
+ data,
219
+ isHome: query.isHome,
220
+ pageTitle,
221
+ siteTitle,
222
+ tagline,
223
+ separator,
224
+ globalTemplate: options.head?.titleTemplate
225
+ });
226
+
227
+ // ---- CANONICAL ----
228
+ let canonical;
229
+
230
+ if (!noindex) {
231
+ const rawCanonical =
232
+ data?.canonical ?? page.url ?? (page.inputPath && contentMap?.inputPathToUrl?.[page.inputPath]?.[0]);
233
+
234
+ canonical = normalizeCanonical(rawCanonical, site.url);
235
+ }
236
+
237
+ return {
238
+ title,
239
+ description,
240
+ canonical,
241
+ robots,
242
+ noindex
243
+ };
244
+ }
245
+
246
+ function buildRender(data) {
247
+ return {
248
+ generator: data?.eleventy?.generator
249
+ };
250
+ }
251
+
252
+ // --- HEAD (global + page-level merge + dedupe) ---
253
+ function buildHead({ userSettings, data, siteUrl }) {
254
+ const userHead = userSettings.head ?? {};
255
+ const pageHead = data?.head ?? {};
256
+
257
+ // Keys must distinguish tags that legitimately differ: links by rel + href
258
+ // (so preconnect and dns-prefetch to one host both survive), metas by their
259
+ // identifying attribute (name / property / charset / http-equiv) so og:*
260
+ // property tags are not collapsed. The head driver runs the authoritative
261
+ // final pass; this just merges settings + front-matter without losing tags.
262
+ // Links key on rel + href so tags that share a host but differ in rel
263
+ // (preconnect vs dns-prefetch) are not collapsed. Metas key on their
264
+ // identifying attribute so two tags with the same key (e.g. a repeated
265
+ // og:title) collapse to the last, matching the driver's authoritative
266
+ // pass instead of leaning on uniqueBy's by-shape fallback.
267
+ const link = uniqueBy([...(userHead.link ?? []), ...(pageHead.link ?? [])], (item) => {
268
+ if (item?.rel === 'canonical') {
269
+ try {
270
+ return `canonical|${normalizeCanonical(item.href, siteUrl)}`;
271
+ } catch {
272
+ return `canonical|${item?.href}`;
273
+ }
274
+ }
275
+ return item?.href ? `${item.rel ?? ''}|${item.href}` : undefined;
276
+ });
277
+
278
+ const script = uniqueBy([...(userHead.script ?? []), ...(pageHead.script ?? [])], 'src');
279
+
280
+ const style = uniqueBy([...(userHead.style ?? []), ...(pageHead.style ?? [])], 'href');
281
+
282
+ const meta = uniqueBy([...(userHead.meta ?? []), ...(pageHead.meta ?? [])], (item) => {
283
+ if (item?.charset) return 'charset';
284
+ if (item?.name) return `name:${item.name}`;
285
+ if (item?.property) return `prop:${item.property}`;
286
+ if (item?.['http-equiv']) return `http:${item['http-equiv']}`;
287
+ return undefined;
288
+ });
289
+
290
+ return {
291
+ link,
292
+ script,
293
+ style,
294
+ meta
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Main context builder.
300
+ * Pure transformation: Eleventy data → normalised page context.
301
+ */
302
+ return function buildPageContext(data) {
303
+ const pageInput = data.page ?? {};
304
+ const userSettings = data.settings ?? settings;
305
+
306
+ const page = buildPage(pageInput);
307
+ const site = buildSite(page.lang, userSettings);
308
+ const entry = buildEntry(data);
309
+ const query = buildQuery({ entry, page });
310
+ const meta = buildMeta({ data, site, page, query });
311
+ const render = buildRender(data);
312
+ const head = buildHead({ userSettings, data, siteUrl: site.url });
313
+
314
+ const context = {
315
+ site,
316
+ page,
317
+ entry,
318
+ query,
319
+ meta,
320
+ render,
321
+ head
322
+ };
323
+
324
+ const inspectionKey = context.page.url ?? context.page.inputPath;
325
+ if (inspectionKey) setEntry(scope, inspectionKey, context);
326
+
327
+ if (slugIndex && entry.slug && page.url) {
328
+ const eligible = page.isDefaultLang === true;
329
+ if (eligible) {
330
+ slugIndex.set(entry.slug, page.url, page.inputPath);
331
+ }
332
+ }
333
+
334
+ return context;
335
+ };
336
+ }
@@ -0,0 +1 @@
1
+ export { registerPageContext } from './register.js';
@@ -0,0 +1,73 @@
1
+ import { createLogger } from '../logging/index.js';
2
+ import { getScope, memoize } from '../registry.js';
3
+ import { createPageContext } from './build.js';
4
+
5
+ const SCOPE_NAME = 'core:page-context';
6
+ const LOG_NAME = 'page-context';
7
+ const COMPUTED_KEY = 'eleventyComputed._pageContext';
8
+
9
+ /**
10
+ * Page context (runtime substrate)
11
+ *
12
+ * A normalised per-page object built once at cascade-time and cached for
13
+ * transform-time consumers. The shape downstream modules read instead of
14
+ * re-deriving from raw cascade data.
15
+ *
16
+ * Architecture layer:
17
+ * runtime substrate
18
+ *
19
+ * System role:
20
+ * Lifecycle bridge between Eleventy's data cascade and the htmlTransformer.
21
+ * Head reads it via `getByKey`; navigator snapshots it for inspection.
22
+ *
23
+ * Lifecycle:
24
+ * cascade-time → eleventyComputed._pageContext builds and caches the context
25
+ * transform-time → consumers retrieve the cached context by page.url
26
+ *
27
+ * Why this exists:
28
+ * Eleventy's htmlTransformer context exposes only page metadata, not the
29
+ * data cascade. The cache lets transform-time consumers read the same
30
+ * normalised view that cascade-time produced.
31
+ *
32
+ * Scope:
33
+ * Owns the page-context shape, memoisation, key-based lookup, and snapshot.
34
+ * Does not own the meaning of any field; modules consume them as they see fit.
35
+ * Templates with `_internal: true` are skipped (synthetic sitemap pages, etc.).
36
+ *
37
+ * Data flow:
38
+ * data cascade → buildPageContext → registry scope → head, navigator
39
+ *
40
+ * @param {import("@11ty/eleventy").UserConfig} eleventyConfig
41
+ * @param {Object} coreContext - Resolved baseline core context (state, runtime, helpers).
42
+ */
43
+ export function registerPageContext(eleventyConfig, coreContext) {
44
+ const { state, runtime } = coreContext;
45
+ const { slugIndex } = runtime;
46
+ const { settings, options } = state;
47
+
48
+ const log = createLogger(LOG_NAME, { verbose: options.verbose });
49
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
50
+
51
+ const buildPageContext = createPageContext({ scope, slugIndex, settings, runtime, options, log });
52
+
53
+ function shouldSkip(data) {
54
+ if (data._internal) return true;
55
+ if (data.page?.outputFileExtension !== 'html') return true;
56
+ return false;
57
+ }
58
+
59
+ eleventyConfig.addGlobalData(COMPUTED_KEY, () => {
60
+ return (data) => {
61
+ if (shouldSkip(data)) return null;
62
+ return memoize(scope, data, buildPageContext);
63
+ };
64
+ });
65
+
66
+ log.info('Page context registered');
67
+
68
+ return {
69
+ get: (data) => scope.cache.get(data),
70
+ getByKey: (key) => scope.values.get(key),
71
+ snapshot: () => Object.fromEntries(scope.values)
72
+ };
73
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Page context — SEO helpers
3
+ *
4
+ * Pure URL/content normalisation used when building the `meta` slice of
5
+ * the page context. No Eleventy, no registry.
6
+ *
7
+ * Architecture layer:
8
+ * runtime substrate (page-context internal)
9
+ */
10
+
11
+ /**
12
+ * Strip common tracking query params and the URL fragment.
13
+ *
14
+ * @param {URL} urlObj
15
+ * @returns {URL}
16
+ */
17
+ export function stripTrackingParams(urlObj) {
18
+ ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid'].forEach((p) =>
19
+ urlObj.searchParams.delete(p)
20
+ );
21
+
22
+ urlObj.hash = '';
23
+ return urlObj;
24
+ }
25
+
26
+ /**
27
+ * Pull the first paragraph's inner HTML out of a rendered page's content.
28
+ * Used as the last-resort source for meta descriptions.
29
+ *
30
+ * @param {{ content?: string }} data
31
+ * @returns {string | undefined}
32
+ */
33
+ export function extractFirstParagraph(data) {
34
+ const html = data?.content;
35
+ if (!html) return;
36
+ const match = html.match(/<p>(.*?)<\/p>/i);
37
+ return match?.[1];
38
+ }
39
+
40
+ /**
41
+ * Resolve a path against the site URL, strip the fragment, and remove
42
+ * tracking params. Returns undefined when inputs are missing or invalid.
43
+ *
44
+ * @param {string | undefined} path
45
+ * @param {string | undefined} siteUrl
46
+ * @returns {string | undefined}
47
+ */
48
+ export function normalizeCanonical(path, siteUrl) {
49
+ if (!path || !siteUrl) return;
50
+
51
+ const url = new URL(path, siteUrl);
52
+
53
+ url.hash = '';
54
+
55
+ return stripTrackingParams(url).href;
56
+ }
package/core/schema.js CHANGED
@@ -58,7 +58,26 @@ export const settingsSchema = z.object({
58
58
  url: z.string().optional(),
59
59
  noindex: z.boolean().optional(),
60
60
  defaultLanguage: z.string().optional(),
61
- languages: z.record(z.string(), z.looseObject({})).optional(),
61
+ defaultLocale: z.string().optional(),
62
+ languages: z
63
+ .unknown()
64
+ .optional()
65
+ .superRefine((value, ctx) => {
66
+ if (value === undefined) return;
67
+
68
+ if (Array.isArray(value)) {
69
+ const arrayResult = z.array(z.string().min(1)).safeParse(value);
70
+ if (!arrayResult.success) {
71
+ for (const issue of arrayResult.error.issues) ctx.addIssue(issue);
72
+ }
73
+ return;
74
+ }
75
+
76
+ const recordResult = z.record(z.string(), z.looseObject({})).safeParse(value);
77
+ if (!recordResult.success) {
78
+ for (const issue of recordResult.error.issues) ctx.addIssue(issue);
79
+ }
80
+ }),
62
81
  head: z
63
82
  .object({
64
83
  link: z.array(z.looseObject({})).optional(),
@@ -66,5 +85,6 @@ export const settingsSchema = z.object({
66
85
  meta: z.array(z.looseObject({})).optional(),
67
86
  style: z.array(z.looseObject({})).optional()
68
87
  })
69
- .optional()
88
+ .optional(),
89
+ seo: z.looseObject({}).optional()
70
90
  });