@apleasantview/eleventy-plugin-baseline 0.1.0-next.32 → 0.1.0-next.39

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 (65) 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 +6 -1
  5. package/core/filters/markdown.js +6 -0
  6. package/core/filters/related-posts.js +7 -1
  7. package/core/global-functions/index.js +6 -0
  8. package/core/logging.js +25 -25
  9. package/core/page-context.js +310 -0
  10. package/core/registry.js +110 -0
  11. package/core/schema.js +37 -0
  12. package/core/shortcodes/image.js +167 -144
  13. package/core/shortcodes/index.js +2 -0
  14. package/core/slug-index.js +61 -0
  15. package/core/translation-map-store.js +46 -0
  16. package/core/types.js +73 -0
  17. package/core/utils/helpers.js +75 -0
  18. package/core/utils/pick.js +7 -0
  19. package/core/virtual-dir.js +111 -0
  20. package/core/wikilinks.js +152 -0
  21. package/index.js +364 -0
  22. package/modules/assets/index.js +162 -0
  23. package/modules/assets/processors/esbuild-process.js +35 -0
  24. package/modules/assets/processors/postcss-process.js +52 -0
  25. package/modules/assets/schema.js +14 -0
  26. package/modules/head/drivers/capo-adapter.js +72 -0
  27. package/modules/head/drivers/posthtml-head-elements.js +140 -0
  28. package/modules/head/index.js +106 -0
  29. package/modules/head/schema.js +42 -0
  30. package/modules/head/utils/alternates.js +11 -0
  31. package/modules/head/utils/dedupe.js +47 -0
  32. package/modules/multilang/index.js +149 -0
  33. package/modules/navigator/index.js +140 -0
  34. package/modules/navigator/schema.js +13 -0
  35. package/modules/{navigator-core → navigator}/templates/navigator-core.html +14 -8
  36. package/modules/navigator/utils/debug.js +41 -0
  37. package/modules/sitemap/index.js +121 -0
  38. package/modules/sitemap/templates/sitemap-core.html +34 -0
  39. package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
  40. package/modules.js +6 -0
  41. package/package.json +15 -6
  42. package/core/debug.js +0 -20
  43. package/core/filters.js +0 -9
  44. package/core/globals.js +0 -6
  45. package/core/helpers.js +0 -127
  46. package/core/modules.js +0 -22
  47. package/core/shortcodes.js +0 -3
  48. package/eleventy.config.js +0 -157
  49. package/modules/assets-core/plugins/assets-core.js +0 -84
  50. package/modules/assets-esbuild/filters/inline-esbuild.js +0 -24
  51. package/modules/assets-esbuild/plugins/assets-esbuild.js +0 -71
  52. package/modules/assets-postcss/filters/inline-postcss.js +0 -38
  53. package/modules/assets-postcss/plugins/assets-postcss.js +0 -75
  54. package/modules/head-core/drivers/posthtml-head-elements.js +0 -132
  55. package/modules/head-core/plugins/head-core.js +0 -57
  56. package/modules/head-core/utils/head-utils.js +0 -183
  57. package/modules/multilang-core/plugins/multilang-core.js +0 -101
  58. package/modules/navigator-core/plugins/navigator-core.js +0 -39
  59. package/modules/sitemap-core/plugins/sitemap-core.js +0 -65
  60. package/modules/sitemap-core/templates/sitemap-core.html +0 -25
  61. /package/core/{globals → global-functions}/date.js +0 -0
  62. /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
  63. /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
  64. /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
  65. /package/modules/{multilang-core → multilang}/filters/i18n-translations-for.js +0 -0
@@ -0,0 +1,162 @@
1
+ import path from 'node:path';
2
+ import { TemplatePath } from '@11ty/eleventy-utils';
3
+
4
+ import { optionsSchema } from './schema.js';
5
+ import assetsESbuild from './processors/esbuild-process.js';
6
+ import assetsPostCSS from './processors/postcss-process.js';
7
+
8
+ /**
9
+ * Assets (module)
10
+ *
11
+ * Asset pipeline integration. Wires Eleventy’s template formats to esbuild
12
+ * and PostCSS through compile guards that allow only declared entrypoints,
13
+ * and exposes inline filters for critical-path assets.
14
+ *
15
+ * Architecture layer:
16
+ * module
17
+ *
18
+ * System role:
19
+ * Bridge between Eleventy’s template system and the external asset
20
+ * processors. Reads `directories.assets` from the virtual-dir substrate.
21
+ *
22
+ * Lifecycle:
23
+ * build-time → register js/css formats, compile guards, watch target, and
24
+ * inline filters; guards run per-entrypoint during compile
25
+ *
26
+ * Why this exists:
27
+ * Eleventy treats every .js and .css file as a template. Without compile
28
+ * guards, 11tydata.js files and non-entry assets would either pollute the
29
+ * template graph or trigger the wrong processor.
30
+ *
31
+ * Scope:
32
+ * Owns template format registration, compile guards, watch wiring, and the
33
+ * inline filters (inlinePostCSS, inlineESbuild).
34
+ * Does not own the processors themselves (assets/processors/) or
35
+ * `directories.assets` resolution (core/virtual-dir.js).
36
+ *
37
+ * Data flow:
38
+ * assets/{js,css}/index.{js,css} entrypoints → compile guard →
39
+ * esbuild/PostCSS processor → output
40
+ *
41
+ * @param {import("@11ty/eleventy").UserConfig} eleventyConfig
42
+ * @param {Object} moduleContext
43
+ */
44
+ export function assetsCore(eleventyConfig, moduleContext) {
45
+ const { state, directories, log } = moduleContext;
46
+ const { settings, options } = state;
47
+
48
+ // Structural-only options check: log on mismatch, do not throw.
49
+ const parsed = optionsSchema.safeParse(options.assets);
50
+ if (!parsed.success) {
51
+ for (const issue of parsed.error.issues) {
52
+ log.info('options:', `${issue.path.join('.')} — ${issue.message}`);
53
+ }
54
+ }
55
+
56
+ const inputDirectory = directories.input;
57
+ const assetsDirectory = directories.assets;
58
+ const jsDirectory = `${assetsDirectory}js/`;
59
+ const cssDirectory = `${assetsDirectory}css/`;
60
+
61
+ const esbuildOptions = options.assets.esbuild || {};
62
+ const dataFiles = `${inputDirectory}**/*.11tydata.js`;
63
+ const watchGlob = TemplatePath.join(assetsDirectory, '**/*.{css,js,svg,png,jpeg,jpg,webp,gif,avif}');
64
+
65
+ if (!assetsDirectory) {
66
+ log.warn('eleventyConfig.directories.assets is unset; registerVirtualDir must run before this plugin.');
67
+ return;
68
+ }
69
+
70
+ // Watch common asset formats so edits trigger reloads during --serve.
71
+ eleventyConfig.addWatchTarget(watchGlob);
72
+
73
+ // --- JS (esbuild) ---
74
+ // Register js as a template format. Only index.js files under assets/js/
75
+ // are compiled; everything else (11tydata.js, non-entry scripts) is skipped
76
+ // by the compile guard. The inline filter wraps the same process function.
77
+ // Defaults (minify, target) live in assets-esbuild/process.js.
78
+
79
+ eleventyConfig.addTemplateFormats('js');
80
+
81
+ // Prevent Eleventy from processing 11tydata.js files as templates.
82
+ // The compile guard below also filters these, but without this ignore
83
+ // Eleventy still enters them into the template graph (data cascade,
84
+ // permalink computation) before compile gets a chance to reject them.
85
+ eleventyConfig.ignores.add(dataFiles);
86
+
87
+ eleventyConfig.addExtension('js', {
88
+ outputFileExtension: 'js',
89
+ useLayouts: false,
90
+ read: false,
91
+ compileOptions: {
92
+ permalink: true,
93
+ cache: true
94
+ },
95
+ // Compile guard: only process index.js files under the assets js directory.
96
+ // Returning undefined skips the file without error.
97
+ compile: async function (_inputContent, inputPath) {
98
+ if (
99
+ inputPath.includes('11tydata.js') ||
100
+ !inputPath.startsWith(jsDirectory) ||
101
+ path.basename(inputPath) !== 'index.js'
102
+ ) {
103
+ return;
104
+ }
105
+
106
+ return async () => assetsESbuild(inputPath, esbuildOptions);
107
+ }
108
+ });
109
+
110
+ // Inline filter: bundle a JS file and wrap in <script> tags.
111
+ // Accepts per-call esbuild options (merged with defaults in process.js).
112
+ // Eleventy's addAsyncFilter handles the Nunjucks callback bridge,
113
+ // so this is a plain async function.
114
+ eleventyConfig.addAsyncFilter('inlineESbuild', async function (inputPath, opts = {}) {
115
+ try {
116
+ const js = await assetsESbuild(inputPath, opts);
117
+ return `<script>${js}</script>`;
118
+ } catch {
119
+ // Non-fatal: return an error comment so the build doesn't break.
120
+ return `<script>/* Error processing JS */</script>`;
121
+ }
122
+ });
123
+
124
+ // --- CSS (PostCSS) ---
125
+ // Register css as a template format. Only index.css files under assets/css/
126
+ // are compiled; non-entry CSS is skipped. Reads from disk (read: false) —
127
+ // the process function owns its own I/O. Config loading and caching live
128
+ // in assets-postcss/process.js.
129
+
130
+ eleventyConfig.addTemplateFormats('css');
131
+
132
+ eleventyConfig.addExtension('css', {
133
+ outputFileExtension: 'css',
134
+ useLayouts: false,
135
+ read: false,
136
+ compileOptions: {
137
+ permalink: true,
138
+ cache: true
139
+ },
140
+ // Compile guard: only process index.css files under the assets css directory.
141
+ compile: async function (_inputContent, inputPath) {
142
+ if (!inputPath.startsWith(cssDirectory) || path.basename(inputPath) !== 'index.css') {
143
+ return;
144
+ }
145
+
146
+ return async () => assetsPostCSS(inputPath);
147
+ }
148
+ });
149
+
150
+ // Inline filter: process a CSS file through PostCSS and wrap in <style> tags.
151
+ // Eleventy's addAsyncFilter handles the Nunjucks callback bridge,
152
+ // so this is a plain async function.
153
+ eleventyConfig.addAsyncFilter('inlinePostCSS', async function (inputPath) {
154
+ try {
155
+ const css = await assetsPostCSS(inputPath);
156
+ return `<style>${css}</style>`;
157
+ } catch {
158
+ // Non-fatal: return an error comment so the build doesn't break.
159
+ return `<style>/* Error processing CSS */</style>`;
160
+ }
161
+ });
162
+ }
@@ -0,0 +1,35 @@
1
+ import * as esbuild from 'esbuild';
2
+ import { createLogger } from '../../../core/logging.js';
3
+
4
+ const log = createLogger('assets-esbuild');
5
+ const defaultOptions = { minify: true, target: 'es2020' };
6
+
7
+ /**
8
+ * Bundle a JS file with esbuild.
9
+ *
10
+ * @param {string} jsFilePath - Absolute path to the entry file.
11
+ * @param {Object} [options] - esbuild options (merged with defaults).
12
+ * @param {boolean} [options.minify=true] - Minify output.
13
+ * @param {string} [options.target='es2020'] - esbuild target.
14
+ * @returns {Promise<string>} Bundled JS text, or an error comment on failure.
15
+ */
16
+ export default async function assetsESbuild(jsFilePath, options = {}) {
17
+ const userOptions = { ...defaultOptions, ...options };
18
+
19
+ try {
20
+ let result = await esbuild.build({
21
+ entryPoints: [jsFilePath],
22
+ bundle: true,
23
+ minify: userOptions.minify,
24
+ target: userOptions.target,
25
+ write: false
26
+ });
27
+
28
+ // Return raw JS; markup wrapping is handled by the plugin registration.
29
+ return result.outputFiles[0].text;
30
+ } catch (error) {
31
+ log.error('esbuild failed:', error);
32
+ // Surface a safe JS comment so the caller can decide how to wrap it.
33
+ return '/* Error processing JS */';
34
+ }
35
+ }
@@ -0,0 +1,52 @@
1
+ import fs from 'fs/promises';
2
+ import postcss from 'postcss';
3
+ import loadPostCSSConfig from 'postcss-load-config';
4
+ import fallbackPostCSSConfig from '../configs/postcss.config.js';
5
+ import { createLogger } from '../../../core/logging.js';
6
+
7
+ const log = createLogger('assets-postcss');
8
+
9
+ // Resolve user PostCSS config from the project root (cwd), not the Eleventy input dir.
10
+ const configRoot = process.cwd();
11
+ let cachedConfig = null;
12
+
13
+ async function getPostCSSConfig() {
14
+ if (cachedConfig) return cachedConfig;
15
+
16
+ try {
17
+ // Prefer the consuming project's PostCSS config (postcss.config.* or package.json#postcss).
18
+ cachedConfig = await loadPostCSSConfig({}, configRoot);
19
+ } catch {
20
+ // If none is found, fall back to the bundled Baseline config to keep builds working.
21
+ const { plugins, ...options } = fallbackPostCSSConfig;
22
+ cachedConfig = { plugins, options };
23
+ }
24
+ return cachedConfig;
25
+ }
26
+
27
+ /**
28
+ * Process a CSS file through PostCSS.
29
+ * Reads from disk, uses project postcss.config.js or bundled fallback.
30
+ * Config is cached for the lifetime of the process.
31
+ *
32
+ * @param {string} cssFilePath - Absolute path to the entry file.
33
+ * @returns {Promise<string>} Processed CSS text, or an error comment on failure.
34
+ */
35
+ export default async function assetsPostCSS(cssFilePath) {
36
+ try {
37
+ const cssContent = await fs.readFile(cssFilePath, 'utf8');
38
+ const { plugins, options } = await getPostCSSConfig();
39
+
40
+ const result = await postcss(plugins).process(cssContent, {
41
+ ...options,
42
+ from: cssFilePath
43
+ });
44
+
45
+ // Return raw CSS; markup wrapping is handled in the plugin registration.
46
+ return result.css;
47
+ } catch (error) {
48
+ log.error('PostCSS failed:', error);
49
+ // Surface a safe CSS string so the caller can decide how to wrap it.
50
+ return '/* Error processing CSS */';
51
+ }
52
+ }
@@ -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,72 @@
1
+ /**
2
+ * capo.js adapter for PostHTML AST nodes.
3
+ *
4
+ * Implements the HTMLAdapter interface capo.js v2 uses to compute element
5
+ * weights (src/adapters/adapter.js in @rviscomi/capo.js). Only getWeight is
6
+ * consumed downstream; the rest are shimmed to satisfy the shape.
7
+ *
8
+ * A PostHTML element node looks like `{ tag, attrs, content }` where attrs
9
+ * is either undefined or a plain object. Boolean attributes appear with an
10
+ * empty-string value (`{ async: '' }`) or as `true`.
11
+ */
12
+
13
+ const hasAttr = (node, name) => {
14
+ if (!node || !node.attrs) return false;
15
+ const key = name.toLowerCase();
16
+ for (const k of Object.keys(node.attrs)) {
17
+ if (k.toLowerCase() === key) return true;
18
+ }
19
+ return false;
20
+ };
21
+
22
+ const getAttr = (node, name) => {
23
+ if (!node || !node.attrs) return null;
24
+ const key = name.toLowerCase();
25
+ for (const k of Object.keys(node.attrs)) {
26
+ if (k.toLowerCase() === key) {
27
+ const v = node.attrs[k];
28
+ return v === true ? '' : v == null ? null : String(v);
29
+ }
30
+ }
31
+ return null;
32
+ };
33
+
34
+ const getText = (node) => {
35
+ if (!node || node.content == null) return '';
36
+ if (typeof node.content === 'string') return node.content;
37
+ if (Array.isArray(node.content)) return node.content.filter((c) => typeof c === 'string').join('');
38
+ return '';
39
+ };
40
+
41
+ export const capoPosthtmlAdapter = {
42
+ isElement(node) {
43
+ return !!(node && typeof node === 'object' && typeof node.tag === 'string');
44
+ },
45
+ getTagName(node) {
46
+ return node && typeof node.tag === 'string' ? node.tag.toLowerCase() : '';
47
+ },
48
+ getAttribute(node, name) {
49
+ return getAttr(node, name);
50
+ },
51
+ hasAttribute(node, name) {
52
+ return hasAttr(node, name);
53
+ },
54
+ getAttributeNames(node) {
55
+ return node && node.attrs ? Object.keys(node.attrs) : [];
56
+ },
57
+ getTextContent(node) {
58
+ return getText(node);
59
+ },
60
+ getChildren() {
61
+ return [];
62
+ },
63
+ getParent() {
64
+ return null;
65
+ },
66
+ getSiblings() {
67
+ return [];
68
+ },
69
+ stringify(node) {
70
+ return node && node.tag ? `<${node.tag}>` : '';
71
+ }
72
+ };
@@ -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 (driver inside head)
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
+ };