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

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 (59) hide show
  1. package/README.md +48 -23
  2. package/core/content-map-store.js +51 -0
  3. package/core/filters/index.js +4 -0
  4. package/core/filters/isString.js +1 -1
  5. package/core/filters/related-posts.js +1 -1
  6. package/core/global-functions/index.js +6 -0
  7. package/core/logging.js +54 -23
  8. package/core/page-context.js +310 -0
  9. package/core/registry.js +110 -0
  10. package/core/schema.js +70 -0
  11. package/core/shortcodes/image.js +8 -3
  12. package/core/shortcodes/index.js +2 -0
  13. package/core/slug-index.js +61 -0
  14. package/core/translation-map-store.js +46 -0
  15. package/core/types.js +73 -0
  16. package/core/utils/helpers.js +75 -0
  17. package/core/utils/pick.js +7 -0
  18. package/core/virtual-dir.js +111 -0
  19. package/core/wikilinks.js +152 -0
  20. package/index.js +364 -0
  21. package/modules/assets/index.js +162 -0
  22. package/modules/assets/processors/esbuild-process.js +68 -0
  23. package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +39 -2
  24. package/modules/assets/schema.js +14 -0
  25. package/modules/head/drivers/capo-adapter.js +94 -0
  26. package/modules/head/drivers/posthtml-head-elements.js +140 -0
  27. package/modules/head/index.js +106 -0
  28. package/modules/head/schema.js +42 -0
  29. package/modules/head/utils/alternates.js +11 -0
  30. package/modules/head/utils/dedupe.js +47 -0
  31. package/modules/multilang/index.js +149 -0
  32. package/modules/navigator/index.js +140 -0
  33. package/modules/navigator/schema.js +13 -0
  34. package/modules/{navigator-core → navigator}/templates/navigator-core.html +10 -4
  35. package/{core → modules/navigator/utils}/debug.js +7 -1
  36. package/modules/sitemap/index.js +121 -0
  37. package/modules/{sitemap-core → sitemap}/templates/sitemap-core.html +2 -2
  38. package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
  39. package/modules.js +6 -0
  40. package/package.json +15 -6
  41. package/core/filters.js +0 -9
  42. package/core/globals.js +0 -6
  43. package/core/helpers.js +0 -36
  44. package/core/modules.js +0 -18
  45. package/core/shortcodes.js +0 -3
  46. package/eleventy.config.js +0 -169
  47. package/modules/assets-core/plugins/assets-core.js +0 -197
  48. package/modules/assets-esbuild/process.js +0 -33
  49. package/modules/head-core/drivers/posthtml-head-elements.js +0 -127
  50. package/modules/head-core/plugins/head-core.js +0 -75
  51. package/modules/head-core/utils/head-utils.js +0 -249
  52. package/modules/multilang-core/plugins/multilang-core.js +0 -118
  53. package/modules/navigator-core/plugins/navigator-core.js +0 -57
  54. package/modules/sitemap-core/plugins/sitemap-core.js +0 -88
  55. /package/core/{globals → global-functions}/date.js +0 -0
  56. /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
  57. /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
  58. /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
  59. /package/modules/{multilang-core → multilang}/filters/i18n-translations-for.js +0 -0
@@ -0,0 +1,68 @@
1
+ import * as esbuild from 'esbuild';
2
+ import { createLogger } from '../../../core/logging.js';
3
+
4
+ /**
5
+ * esbuild processor (processor)
6
+ *
7
+ * Bundles a single JS entrypoint with esbuild and returns the text. Used
8
+ * both by the `js` template format compile guard and by the
9
+ * `inlineESbuild` filter in the assets module.
10
+ *
11
+ * Architecture layer:
12
+ * module
13
+ *
14
+ * System role:
15
+ * Stateless bundler called by `modules/assets/index.js`. The compile
16
+ * guard decides which files reach this processor; this file owns the
17
+ * esbuild call itself.
18
+ *
19
+ * Lifecycle:
20
+ * build-time → invoked per matching entrypoint during template compile,
21
+ * or per-call from the inline filter
22
+ *
23
+ * Why this exists:
24
+ * Eleventy treats every `.js` file as a template. A dedicated processor
25
+ * keeps esbuild configuration out of the template format wiring and lets
26
+ * the inline filter reuse the same defaults.
27
+ *
28
+ * Scope:
29
+ * Owns esbuild option defaults and the bundle call. Does not own the
30
+ * compile guard, the watch target, or markup wrapping; the assets
31
+ * module owns those.
32
+ *
33
+ * Data flow:
34
+ * entrypoint path + options → esbuild.build → bundled JS text
35
+ */
36
+
37
+ const log = createLogger('assets-esbuild');
38
+ const defaultOptions = { minify: true, target: 'es2020' };
39
+
40
+ /**
41
+ * Bundle a JS file with esbuild.
42
+ *
43
+ * @param {string} jsFilePath - Absolute path to the entry file.
44
+ * @param {Object} [options] - esbuild options (merged with defaults).
45
+ * @param {boolean} [options.minify=true] - Minify output.
46
+ * @param {string} [options.target='es2020'] - esbuild target.
47
+ * @returns {Promise<string>} Bundled JS text, or an error comment on failure.
48
+ */
49
+ export default async function assetsESbuild(jsFilePath, options = {}) {
50
+ const userOptions = { ...defaultOptions, ...options };
51
+
52
+ try {
53
+ let result = await esbuild.build({
54
+ entryPoints: [jsFilePath],
55
+ bundle: true,
56
+ minify: userOptions.minify,
57
+ target: userOptions.target,
58
+ write: false
59
+ });
60
+
61
+ // Return raw JS; markup wrapping is handled by the plugin registration.
62
+ return result.outputFiles[0].text;
63
+ } catch (error) {
64
+ log.error('esbuild failed:', error);
65
+ // Surface a safe JS comment so the caller can decide how to wrap it.
66
+ return '/* Error processing JS */';
67
+ }
68
+ }
@@ -1,7 +1,44 @@
1
1
  import fs from 'fs/promises';
2
2
  import postcss from 'postcss';
3
3
  import loadPostCSSConfig from 'postcss-load-config';
4
- import fallbackPostCSSConfig from './fallback/postcss.config.js';
4
+ import fallbackPostCSSConfig from '../configs/postcss.config.js';
5
+ import { createLogger } from '../../../core/logging.js';
6
+
7
+ /**
8
+ * PostCSS processor (processor)
9
+ *
10
+ * Processes a single CSS entrypoint through PostCSS and returns the text.
11
+ * Used both by the `css` template format compile guard and by the
12
+ * `inlinePostCSS` filter in the assets module.
13
+ *
14
+ * Architecture layer:
15
+ * module
16
+ *
17
+ * System role:
18
+ * Stateless processor called by `modules/assets/index.js`. Resolves the
19
+ * user's PostCSS config from the project root, falling back to the
20
+ * bundled Baseline config when none is found. Cached for the lifetime
21
+ * of the process.
22
+ *
23
+ * Lifecycle:
24
+ * build-time → invoked per matching entrypoint during template compile,
25
+ * or per-call from the inline filter
26
+ *
27
+ * Why this exists:
28
+ * Eleventy has no PostCSS hook of its own. A dedicated processor lets
29
+ * user configs win when present and keeps the bundled fallback out of
30
+ * the consumer's `node_modules` resolution path.
31
+ *
32
+ * Scope:
33
+ * Owns config resolution, caching, and the PostCSS call. Does not own
34
+ * the compile guard, the watch target, or markup wrapping; the assets
35
+ * module owns those.
36
+ *
37
+ * Data flow:
38
+ * entrypoint path → PostCSS pipeline → processed CSS text
39
+ */
40
+
41
+ const log = createLogger('assets-postcss');
5
42
 
6
43
  // Resolve user PostCSS config from the project root (cwd), not the Eleventy input dir.
7
44
  const configRoot = process.cwd();
@@ -42,7 +79,7 @@ export default async function assetsPostCSS(cssFilePath) {
42
79
  // Return raw CSS; markup wrapping is handled in the plugin registration.
43
80
  return result.css;
44
81
  } catch (error) {
45
- console.error(error);
82
+ log.error('PostCSS failed:', error);
46
83
  // Surface a safe CSS string so the caller can decide how to wrap it.
47
84
  return '/* Error processing CSS */';
48
85
  }
@@ -0,0 +1,14 @@
1
+ import * as z from 'zod';
2
+
3
+ // Structural schema for the `options.assets` slice. Permissive on unknown
4
+ // keys (esbuild accepts many options we don't touch); strict on the keys the
5
+ // plugin itself reads.
6
+
7
+ export const esbuildOptionsSchema = z.looseObject({
8
+ minify: z.boolean().optional(),
9
+ target: z.string().optional()
10
+ });
11
+
12
+ export const optionsSchema = z.looseObject({
13
+ esbuild: esbuildOptionsSchema.optional()
14
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Capo PostHTML adapter (driver)
3
+ *
4
+ * Implements the `HTMLAdapter` interface capo.js v2 expects, against
5
+ * PostHTML's `{ tag, attrs, content }` node shape. Only `getWeight` is
6
+ * exercised downstream; the rest are shimmed to satisfy the contract.
7
+ *
8
+ * Architecture layer:
9
+ * module
10
+ *
11
+ * System role:
12
+ * Translation shim between the head driver's PostHTML tree and the
13
+ * capo.js sort. Used by `posthtml-head-elements.js` when ordering the
14
+ * composed `<head>` element list.
15
+ *
16
+ * Lifecycle:
17
+ * transform-time → invoked per node while capo.js scores element weights
18
+ *
19
+ * Why this exists:
20
+ * capo.js is DOM-shaped; PostHTML is not. Without an adapter the driver
21
+ * would have to walk the tree twice or hand-roll element weighting.
22
+ *
23
+ * Scope:
24
+ * Owns attribute lookup, tag name resolution, and text extraction over
25
+ * PostHTML nodes. Does not own weighting logic; capo.js owns that.
26
+ *
27
+ * Data flow:
28
+ * PostHTML node → adapter accessor → capo.js getWeight
29
+ *
30
+ * A PostHTML element node looks like `{ tag, attrs, content }` where attrs
31
+ * is either undefined or a plain object. Boolean attributes appear with an
32
+ * empty-string value (`{ async: '' }`) or as `true`.
33
+ */
34
+
35
+ const hasAttr = (node, name) => {
36
+ if (!node || !node.attrs) return false;
37
+ const key = name.toLowerCase();
38
+ for (const k of Object.keys(node.attrs)) {
39
+ if (k.toLowerCase() === key) return true;
40
+ }
41
+ return false;
42
+ };
43
+
44
+ const getAttr = (node, name) => {
45
+ if (!node || !node.attrs) return null;
46
+ const key = name.toLowerCase();
47
+ for (const k of Object.keys(node.attrs)) {
48
+ if (k.toLowerCase() === key) {
49
+ const v = node.attrs[k];
50
+ return v === true ? '' : v == null ? null : String(v);
51
+ }
52
+ }
53
+ return null;
54
+ };
55
+
56
+ const getText = (node) => {
57
+ if (!node || node.content == null) return '';
58
+ if (typeof node.content === 'string') return node.content;
59
+ if (Array.isArray(node.content)) return node.content.filter((c) => typeof c === 'string').join('');
60
+ return '';
61
+ };
62
+
63
+ export const capoPosthtmlAdapter = {
64
+ isElement(node) {
65
+ return !!(node && typeof node === 'object' && typeof node.tag === 'string');
66
+ },
67
+ getTagName(node) {
68
+ return node && typeof node.tag === 'string' ? node.tag.toLowerCase() : '';
69
+ },
70
+ getAttribute(node, name) {
71
+ return getAttr(node, name);
72
+ },
73
+ hasAttribute(node, name) {
74
+ return hasAttr(node, name);
75
+ },
76
+ getAttributeNames(node) {
77
+ return node && node.attrs ? Object.keys(node.attrs) : [];
78
+ },
79
+ getTextContent(node) {
80
+ return getText(node);
81
+ },
82
+ getChildren() {
83
+ return [];
84
+ },
85
+ getParent() {
86
+ return null;
87
+ },
88
+ getSiblings() {
89
+ return [];
90
+ },
91
+ stringify(node) {
92
+ return node && node.tag ? `<${node.tag}>` : '';
93
+ }
94
+ };
@@ -0,0 +1,140 @@
1
+ import { getWeight, ElementWeights } from '@rviscomi/capo.js';
2
+ import { capoPosthtmlAdapter as adapter } from './capo-adapter.js';
3
+ import { dedupeMeta, dedupeLink } from '../utils/dedupe.js';
4
+
5
+ /**
6
+ * PostHTML head driver (driver)
7
+ *
8
+ * Default head renderer. Emits standard meta tags, layers user extras and
9
+ * hreflang alternates on top, dedupes, capo-sorts, and replaces the
10
+ * <baseline-head> placeholder with the result.
11
+ *
12
+ * Architecture layer:
13
+ * module
14
+ *
15
+ * System role:
16
+ * The seam between head's pipeline and the renderer choice. Alternate
17
+ * drivers can be substituted at the import site without changing the
18
+ * cascade-time seed builder.
19
+ *
20
+ * Lifecycle:
21
+ * transform-time → emit, dedupe, sort, mutate the PostHTML tree
22
+ *
23
+ * Why this exists:
24
+ * Splitting the renderer from the seed builder lets head swap rendering
25
+ * strategies (e.g. a future direct-DOM driver) without touching cascade
26
+ * wiring or the page-context shape.
27
+ *
28
+ * Scope:
29
+ * Owns node emission, dedupe orchestration, capo sort, and placeholder
30
+ * replacement.
31
+ * Does not own seed shape (page context), hreflang building
32
+ * (head/utils/alternates.js), or capo's element weights (capo.js).
33
+ *
34
+ * Data flow:
35
+ * seeds + alternates + options → emit → dedupe → capo-sort → PostHTML
36
+ * tree mutation
37
+ *
38
+ * @param {Object} args
39
+ * @param {Object} args.seeds - Page context for the current page.
40
+ * @param {Array<Object>} args.alternates - hreflang link descriptors.
41
+ * @param {Object} args.options - Head options (titleSeparator, showGenerator).
42
+ * @param {string} args.placeholderTag - Placeholder element to replace.
43
+ * @param {string} args.eol - End-of-line separator interleaved between nodes.
44
+ * @param {Object} args.log - Scoped logger.
45
+ * @returns {(tree: Object) => Object} PostHTML plugin function.
46
+ */
47
+ export function renderHead({ seeds, alternates, options, placeholderTag, eol, log }) {
48
+ const defaults = emitMeta(seeds.meta, seeds.render, options);
49
+ const extras = emitExtras(seeds.head, alternates);
50
+
51
+ const deduped = dedupeAll([...defaults, ...extras]);
52
+ const sorted = capoSort(deduped);
53
+
54
+ return function rendererPlugin(tree) {
55
+ // log.info('injecting head for', seeds.page.inputPath || seeds.page.url);
56
+ tree.match({ tag: placeholderTag }, () => ({
57
+ tag: 'head',
58
+ content: interleaveEOL(sorted, eol)
59
+ }));
60
+ return tree;
61
+ };
62
+ }
63
+
64
+ function emitMeta(meta, render, options) {
65
+ const nodes = [];
66
+ nodes.push(mkMeta({ charset: 'UTF-8' }));
67
+ nodes.push(mkMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' }));
68
+ if (meta.title) nodes.push({ tag: 'title', content: [meta.title] });
69
+ if (meta.description) nodes.push(mkMeta({ name: 'description', content: meta.description }));
70
+ nodes.push(mkMeta({ name: 'robots', content: meta.robots }));
71
+ if (meta.canonical) nodes.push(mkLink({ rel: 'canonical', href: meta.canonical }));
72
+ if (options.showGenerator && render.generator) {
73
+ nodes.push(mkMeta({ name: 'generator', content: render.generator }));
74
+ }
75
+
76
+ return nodes;
77
+ }
78
+
79
+ function emitExtras(head, alternates = []) {
80
+ const nodes = [];
81
+ for (const m of asArray(head?.meta)) nodes.push(mkMeta(m));
82
+ for (const l of asArray(head?.link)) {
83
+ if (l?.rel === 'canonical') continue; // 🚨 remove duplication source
84
+ nodes.push(mkLink(l));
85
+ }
86
+ for (const s of asArray(head?.script)) nodes.push(mkScript(s));
87
+ for (const s of asArray(head?.style)) nodes.push(mkStyle(s));
88
+ for (const a of alternates) nodes.push(mkLink(a));
89
+
90
+ return nodes;
91
+ }
92
+
93
+ function dedupeAll(nodes) {
94
+ const metas = [];
95
+ const links = [];
96
+ const others = [];
97
+ for (const n of nodes) {
98
+ if (n.tag === 'meta') metas.push(n.attrs || {});
99
+ else if (n.tag === 'link') links.push(n.attrs || {});
100
+ else others.push(n);
101
+ }
102
+ const dedupedMetas = dedupeMeta(metas).map(mkMeta);
103
+ const dedupedLinks = dedupeLink(links).map(mkLink);
104
+
105
+ return [...dedupedMetas, ...dedupedLinks, ...others];
106
+ }
107
+
108
+ function capoSort(nodes) {
109
+ const weighted = nodes.map((node, i) => ({
110
+ node,
111
+ i,
112
+ weight: adapter.isElement(node) ? getWeight(node, adapter) : ElementWeights.OTHER
113
+ }));
114
+ weighted.sort((a, b) => b.weight - a.weight || a.i - b.i);
115
+
116
+ return weighted.map((w) => w.node);
117
+ }
118
+
119
+ function mkMeta(attrs) {
120
+ return { tag: 'meta', attrs };
121
+ }
122
+ function mkLink(attrs) {
123
+ return { tag: 'link', attrs };
124
+ }
125
+ function mkScript(entry) {
126
+ const { content, ...attrs } = entry || {};
127
+ return content !== undefined ? { tag: 'script', attrs, content: [content] } : { tag: 'script', attrs };
128
+ }
129
+ function mkStyle(entry) {
130
+ const { content, ...attrs } = entry || {};
131
+ return content !== undefined ? { tag: 'style', attrs, content: [content] } : { tag: 'style', attrs };
132
+ }
133
+ function asArray(v) {
134
+ return Array.isArray(v) ? v : [];
135
+ }
136
+ function interleaveEOL(nodes, eol) {
137
+ const out = [];
138
+ for (const n of nodes) out.push(n, eol);
139
+ return out;
140
+ }
@@ -0,0 +1,106 @@
1
+ import { renderHead } from './drivers/posthtml-head-elements.js';
2
+ import { buildAlternates } from './utils/alternates.js';
3
+ import { optionsSchema } from './schema.js';
4
+
5
+ // Internal constants — not user-facing.
6
+ const PLACEHOLDER_TAG = 'baseline-head';
7
+ const EOL = '\n';
8
+
9
+ /**
10
+ * Head (module)
11
+ *
12
+ * Render-time <head> composer. Turns the normalised page context into a
13
+ * sorted, deduped element list and replaces <baseline-head> in the output.
14
+ *
15
+ * Architecture layer:
16
+ * module
17
+ *
18
+ * System role:
19
+ * Consumes the page context (built at cascade-time) and the translation
20
+ * map (written at cascade-time) to produce the final <head> at
21
+ * transform-time.
22
+ *
23
+ * Lifecycle:
24
+ * cascade-time → upstream page-context registry builds the per-page seeds
25
+ * transform-time → PostHTML plugin reads seeds, emits nodes, capo-sorts,
26
+ * replaces <baseline-head>
27
+ *
28
+ * Why this exists:
29
+ * Eleventy's htmlTransformer context exposes only page metadata, not the
30
+ * full data cascade. Pre-built seeds in the page-context registry carry
31
+ * every field the composer needs from cascade-time into transform-time.
32
+ *
33
+ * Scope:
34
+ * Owns transform-time composition and placeholder replacement.
35
+ * Pass 1 covers bucket 1 only: charset, viewport, title, description,
36
+ * robots, canonical, optional generator, plus user extras from
37
+ * settings.head and hreflang alternates. SEO and JSON-LD are later passes.
38
+ * Does not own seed shape (page context) or driver internals.
39
+ *
40
+ * Data flow:
41
+ * page context + translation-map store + settings.head → driver →
42
+ * PostHTML tree mutation (replaces <baseline-head>)
43
+ *
44
+ * @param {import("@11ty/eleventy").UserConfig} eleventyConfig
45
+ * @param {Object} moduleContext
46
+ */
47
+ export function headCore(eleventyConfig, moduleContext) {
48
+ const { state, runtime, log } = moduleContext;
49
+ const { settings, options } = state;
50
+
51
+ // Structural-only options check: log on mismatch, do not throw.
52
+ const parsed = optionsSchema.safeParse(options.head);
53
+ if (!parsed.success) {
54
+ for (const issue of parsed.error.issues) {
55
+ log.info('options:', `${issue.path.join('.')} — ${issue.message}`);
56
+ }
57
+ }
58
+
59
+ const pageContextRegistry = moduleContext.resolvePageContext;
60
+
61
+ // Resolved plugin options with defaults.
62
+ const headOptions = {
63
+ titleSeparator: options.head?.titleSeparator ?? ' – ',
64
+ showGenerator: options.head?.showGenerator ?? false
65
+ };
66
+
67
+ // Per-build stats (cleared on eleventy.after for watch-mode reruns).
68
+ const headStats = { pages: new Set() };
69
+
70
+ eleventyConfig.on('eleventy.after', () => {
71
+ log.info({
72
+ message: 'Head injection summary',
73
+ totalPages: headStats.pages.size,
74
+ sample: Array.from(headStats.pages).slice(0, 10)
75
+ });
76
+ headStats.pages.clear();
77
+ });
78
+
79
+ // --- Transform-time: compose and inject. ---
80
+ log.info('Injecting heads to pages');
81
+ eleventyConfig.htmlTransformer.addPosthtmlPlugin('html', function (context) {
82
+ headStats.pages.add(context?.page?.inputPath || context?.outputPath);
83
+
84
+ const key = context?.page?.url ?? context?.page?.inputPath;
85
+ const seeds = pageContextRegistry?.getByKey(key);
86
+ if (!seeds) {
87
+ log.warn('no head seeds for', context?.page?.inputPath || context?.outputPath);
88
+ return (tree) => tree;
89
+ }
90
+
91
+ const translationKey = seeds.page?.locale?.translationKey;
92
+
93
+ const alternates = translationKey
94
+ ? buildAlternates(seeds.page?.locale?.translationKey, runtime.translationMap.get(), seeds.site?.url)
95
+ : [];
96
+
97
+ return renderHead({
98
+ seeds,
99
+ alternates,
100
+ options: headOptions,
101
+ placeholderTag: PLACEHOLDER_TAG,
102
+ eol: EOL,
103
+ log
104
+ });
105
+ });
106
+ }
@@ -0,0 +1,42 @@
1
+ import * as z from 'zod';
2
+
3
+ // Structural schemas for head-core. Permissive on unknown keys, typed on
4
+ // keys the driver reads. Non-throwing at the call site — safeParse only.
5
+
6
+ // `options.head` slice: render-behaviour knobs.
7
+ export const optionsSchema = z.looseObject({
8
+ titleSeparator: z.string().optional(),
9
+ showGenerator: z.boolean().optional()
10
+ });
11
+
12
+ // `settings.head` extras slot: additive link/script/meta/style arrays.
13
+ export const settingsHeadSchema = z.looseObject({
14
+ link: z.array(z.looseObject({})).optional(),
15
+ script: z.array(z.looseObject({})).optional(),
16
+ meta: z.array(z.looseObject({})).optional(),
17
+ style: z.array(z.looseObject({})).optional()
18
+ });
19
+
20
+ // `settings.seo` site-default SEO scalars, page-overridable.
21
+ export const settingsSeoSchema = z.looseObject({
22
+ ogImage: z.string().optional(),
23
+ twitterSite: z.string().optional()
24
+ });
25
+
26
+ // Page-level `seo:` block. Same scalar set as bare front matter, namespaced.
27
+ // `seo.foo` wins over bare `foo`.
28
+ export const pageSeoSchema = z.looseObject({
29
+ title: z.string().optional(),
30
+ description: z.string().optional(),
31
+ noindex: z.boolean().optional(),
32
+ canonical: z.string().optional(),
33
+ ogTitle: z.string().optional(),
34
+ ogDescription: z.string().optional(),
35
+ ogType: z.string().optional(),
36
+ ogImage: z.string().optional(),
37
+ twitterCard: z.string().optional(),
38
+ twitterSite: z.string().optional(),
39
+ twitterTitle: z.string().optional(),
40
+ twitterDescription: z.string().optional(),
41
+ twitterImage: z.string().optional()
42
+ });
@@ -0,0 +1,11 @@
1
+ export function buildAlternates(translationKey, translationsMap, siteUrl) {
2
+ if (!translationKey || !translationsMap) return [];
3
+ const variants = translationsMap[translationKey];
4
+ if (!variants) return [];
5
+ return Object.values(variants).flatMap((entry) => {
6
+ if (!entry?.url) return [];
7
+ const href = siteUrl ? new URL(entry.url, siteUrl).href : entry.url;
8
+ const link = { rel: 'alternate', hreflang: entry.lang, href };
9
+ return entry.isDefaultLang ? [link, { ...link, hreflang: 'x-default' }] : [link];
10
+ });
11
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Deduplicate meta tags. Last-wins by key (charset, name, property, http-equiv).
3
+ * Preserves insertion order after dedup.
4
+ * @param {Array<Object>} [arr=[]] - Array of meta tag objects.
5
+ * @returns {Array<Object>}
6
+ */
7
+ function metaKey(meta) {
8
+ if (meta.charset) return 'charset';
9
+ if (meta.name) return `name:${meta.name}`;
10
+ if (meta.property) return `prop:${meta.property}`;
11
+ if (meta['http-equiv']) return `http:${meta['http-equiv']}`;
12
+ return null;
13
+ }
14
+
15
+ export const dedupeMeta = (arr = []) => {
16
+ const seen = new Set();
17
+ const out = [];
18
+
19
+ for (let i = arr.length - 1; i >= 0; i--) {
20
+ const key = metaKey(arr[i]);
21
+ if (!key || seen.has(key)) continue;
22
+ seen.add(key);
23
+ out.push(arr[i]);
24
+ }
25
+
26
+ return out.reverse();
27
+ };
28
+
29
+ /**
30
+ * Deduplicate link tags by rel+hreflang+href. Last-wins, preserves insertion order.
31
+ * @param {Array<Object>} [links=[]] - Array of link tag objects.
32
+ * @returns {Array<Object>}
33
+ */
34
+ export const dedupeLink = (links = []) => {
35
+ const seen = new Set();
36
+ const out = [];
37
+
38
+ for (let i = links.length - 1; i >= 0; i--) {
39
+ const link = links[i];
40
+ const key = link.rel && link.href ? `rel:${link.rel}|hreflang:${link.hreflang ?? ''}|${link.href}` : null;
41
+ if (!key || seen.has(key)) continue;
42
+ seen.add(key);
43
+ out.push(link);
44
+ }
45
+
46
+ return out.reverse();
47
+ };