@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.
- 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 +6 -1
- package/core/filters/markdown.js +6 -0
- package/core/filters/related-posts.js +7 -1
- package/core/global-functions/index.js +6 -0
- package/core/logging.js +25 -25
- package/core/page-context.js +310 -0
- package/core/registry.js +110 -0
- package/core/schema.js +37 -0
- package/core/shortcodes/image.js +167 -144
- 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 +35 -0
- package/modules/assets/processors/postcss-process.js +52 -0
- package/modules/assets/schema.js +14 -0
- package/modules/head/drivers/capo-adapter.js +72 -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 +14 -8
- package/modules/navigator/utils/debug.js +41 -0
- package/modules/sitemap/index.js +121 -0
- package/modules/sitemap/templates/sitemap-core.html +34 -0
- package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
- package/modules.js +6 -0
- package/package.json +15 -6
- package/core/debug.js +0 -20
- package/core/filters.js +0 -9
- package/core/globals.js +0 -6
- package/core/helpers.js +0 -127
- package/core/modules.js +0 -22
- package/core/shortcodes.js +0 -3
- package/eleventy.config.js +0 -157
- package/modules/assets-core/plugins/assets-core.js +0 -84
- package/modules/assets-esbuild/filters/inline-esbuild.js +0 -24
- package/modules/assets-esbuild/plugins/assets-esbuild.js +0 -71
- package/modules/assets-postcss/filters/inline-postcss.js +0 -38
- package/modules/assets-postcss/plugins/assets-postcss.js +0 -75
- package/modules/head-core/drivers/posthtml-head-elements.js +0 -132
- package/modules/head-core/plugins/head-core.js +0 -57
- package/modules/head-core/utils/head-utils.js +0 -183
- package/modules/multilang-core/plugins/multilang-core.js +0 -101
- package/modules/navigator-core/plugins/navigator-core.js +0 -39
- package/modules/sitemap-core/plugins/sitemap-core.js +0 -65
- package/modules/sitemap-core/templates/sitemap-core.html +0 -25
- /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,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
|
+
};
|