@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.
- package/README.md +48 -23
- package/core/content-map-store.js +51 -0
- package/core/filters/index.js +4 -0
- package/core/filters/isString.js +1 -1
- package/core/filters/related-posts.js +1 -1
- package/core/global-functions/index.js +6 -0
- package/core/logging.js +54 -23
- package/core/page-context.js +310 -0
- package/core/registry.js +110 -0
- package/core/schema.js +70 -0
- package/core/shortcodes/image.js +8 -3
- package/core/shortcodes/index.js +2 -0
- package/core/slug-index.js +61 -0
- package/core/translation-map-store.js +46 -0
- package/core/types.js +73 -0
- package/core/utils/helpers.js +75 -0
- package/core/utils/pick.js +7 -0
- package/core/virtual-dir.js +111 -0
- package/core/wikilinks.js +152 -0
- package/index.js +364 -0
- package/modules/assets/index.js +162 -0
- package/modules/assets/processors/esbuild-process.js +68 -0
- package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +39 -2
- package/modules/assets/schema.js +14 -0
- package/modules/head/drivers/capo-adapter.js +94 -0
- package/modules/head/drivers/posthtml-head-elements.js +140 -0
- package/modules/head/index.js +106 -0
- package/modules/head/schema.js +42 -0
- package/modules/head/utils/alternates.js +11 -0
- package/modules/head/utils/dedupe.js +47 -0
- package/modules/multilang/index.js +149 -0
- package/modules/navigator/index.js +140 -0
- package/modules/navigator/schema.js +13 -0
- package/modules/{navigator-core → navigator}/templates/navigator-core.html +10 -4
- package/{core → modules/navigator/utils}/debug.js +7 -1
- package/modules/sitemap/index.js +121 -0
- package/modules/{sitemap-core → sitemap}/templates/sitemap-core.html +2 -2
- package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
- package/modules.js +6 -0
- package/package.json +15 -6
- package/core/filters.js +0 -9
- package/core/globals.js +0 -6
- package/core/helpers.js +0 -36
- package/core/modules.js +0 -18
- package/core/shortcodes.js +0 -3
- package/eleventy.config.js +0 -169
- package/modules/assets-core/plugins/assets-core.js +0 -197
- package/modules/assets-esbuild/process.js +0 -33
- package/modules/head-core/drivers/posthtml-head-elements.js +0 -127
- package/modules/head-core/plugins/head-core.js +0 -75
- package/modules/head-core/utils/head-utils.js +0 -249
- package/modules/multilang-core/plugins/multilang-core.js +0 -118
- package/modules/navigator-core/plugins/navigator-core.js +0 -57
- package/modules/sitemap-core/plugins/sitemap-core.js +0 -88
- /package/core/{globals → global-functions}/date.js +0 -0
- /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
- /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
- /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
- /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 '
|
|
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
|
-
|
|
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
|
+
};
|