@apleasantview/eleventy-plugin-baseline 0.1.0-next.33 → 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 +1 -1
- package/core/filters/related-posts.js +1 -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 +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-esbuild/process.js → assets/processors/esbuild-process.js} +3 -1
- package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +5 -2
- 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 +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/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,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
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { I18nPlugin } from '@11ty/eleventy';
|
|
2
|
+
import { DeepCopy } from '@11ty/eleventy-utils';
|
|
3
|
+
import { normalizeLanguages } from '../../core/utils/helpers.js';
|
|
4
|
+
import i18nTranslationsFor from './filters/i18n-translations-for.js';
|
|
5
|
+
import i18nTranslationIn from './filters/i18n-translation-in.js';
|
|
6
|
+
import i18nDefaultTranslation from './filters/i18n-default-translation.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Multilang (module)
|
|
10
|
+
*
|
|
11
|
+
* Language infrastructure. Normalises language config, builds translation
|
|
12
|
+
* relationships, attaches per-page locale data, and exposes cross-language
|
|
13
|
+
* lookup filters. Active only when options.multilingual is true and both
|
|
14
|
+
* defaultLanguage and at least one languages entry are set; otherwise the
|
|
15
|
+
* module exits early.
|
|
16
|
+
*
|
|
17
|
+
* Architecture layer:
|
|
18
|
+
* module
|
|
19
|
+
*
|
|
20
|
+
* System role:
|
|
21
|
+
* Wraps Eleventy's I18nPlugin and feeds the translation-map store that
|
|
22
|
+
* head reads at transform-time. Sitemap reuses the same normalised
|
|
23
|
+
* language map.
|
|
24
|
+
*
|
|
25
|
+
* Lifecycle:
|
|
26
|
+
* build-time → normalise languages, attach I18nPlugin, register filters
|
|
27
|
+
* and computed page.locale
|
|
28
|
+
* cascade-time → translationsMap and translations collections build the
|
|
29
|
+
* per-translationKey map and write it to the store
|
|
30
|
+
*
|
|
31
|
+
* Why this exists:
|
|
32
|
+
* I18nPlugin handles locale-aware routing but not translation
|
|
33
|
+
* relationships. Head needs a transform-time-readable hreflang map; the
|
|
34
|
+
* collection populates it once and the store carries it across the
|
|
35
|
+
* lifecycle boundary.
|
|
36
|
+
*
|
|
37
|
+
* Scope:
|
|
38
|
+
* Owns language normalisation, page.locale computation, the translations
|
|
39
|
+
* and translationsMap collections, and the i18n filters
|
|
40
|
+
* (i18nTranslationsFor, i18nTranslationIn, i18nDefaultTranslation).
|
|
41
|
+
* Does not own URL routing (I18nPlugin) or hreflang rendering (head).
|
|
42
|
+
*
|
|
43
|
+
* Data flow:
|
|
44
|
+
* settings.languages + page.lang/translationKey → normalisation +
|
|
45
|
+
* I18nPlugin → collections + computed page.locale + translation-map
|
|
46
|
+
* store → head, sitemap
|
|
47
|
+
*
|
|
48
|
+
* @param {import("@11ty/eleventy/src/UserConfig.js").default} eleventyConfig
|
|
49
|
+
* @param {Object} moduleContext
|
|
50
|
+
*/
|
|
51
|
+
export function multilangCore(eleventyConfig, moduleContext) {
|
|
52
|
+
const { state, runtime, log } = moduleContext;
|
|
53
|
+
const { settings, options } = state;
|
|
54
|
+
|
|
55
|
+
// --- Language normalization ---
|
|
56
|
+
// Accept languages as array or object; normalize to object map.
|
|
57
|
+
// Drives collection building, locale data, and sitemap-core language config.
|
|
58
|
+
const normalizeLanguageCode = (lang) => (lang || '').toLowerCase().trim();
|
|
59
|
+
const defaultLanguage = normalizeLanguageCode(settings.defaultLanguage);
|
|
60
|
+
const languages = normalizeLanguages(settings, log);
|
|
61
|
+
const hasLanguages = languages && Object.keys(languages).length > 0;
|
|
62
|
+
|
|
63
|
+
const isMultilingual = options.multilang === true && defaultLanguage && hasLanguages;
|
|
64
|
+
|
|
65
|
+
if (!isMultilingual) {
|
|
66
|
+
log.info('inactive: requires options.multilingual + settings.defaultLanguage + languages');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Register Eleventy's built-in I18nPlugin for locale-aware URL resolution.
|
|
71
|
+
eleventyConfig.addPlugin(I18nPlugin, {
|
|
72
|
+
defaultLanguage: defaultLanguage,
|
|
73
|
+
errorMode: 'allow-fallback'
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Computed locale data: every page gets a page.locale object with its
|
|
77
|
+
// resolved lang, translationKey, and whether it's the default language.
|
|
78
|
+
eleventyConfig.addGlobalData('eleventyComputed.page.locale', () => {
|
|
79
|
+
return (data) => {
|
|
80
|
+
const translationKey = data.translationKey;
|
|
81
|
+
const lang = normalizeLanguageCode(data.lang || data.language || defaultLanguage);
|
|
82
|
+
const isDefaultLang = lang === defaultLanguage;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
translationKey,
|
|
86
|
+
lang,
|
|
87
|
+
isDefaultLang
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Build a set of allowed language codes for validation during collection building.
|
|
93
|
+
const allowedLanguages = new Set(Object.keys(languages).map(normalizeLanguageCode));
|
|
94
|
+
|
|
95
|
+
// Build both the map (keyed by translationKey → lang) and the flat list.
|
|
96
|
+
// Shared logic for both collections — called once per collection registration.
|
|
97
|
+
const buildTranslations = (collection) => {
|
|
98
|
+
const map = {};
|
|
99
|
+
const list = [];
|
|
100
|
+
|
|
101
|
+
for (const page of collection.getAll()) {
|
|
102
|
+
const translationKey = page.data.translationKey;
|
|
103
|
+
if (!translationKey) continue;
|
|
104
|
+
|
|
105
|
+
const lang = page.data.lang || page.data.language || defaultLanguage;
|
|
106
|
+
if (!lang) continue;
|
|
107
|
+
|
|
108
|
+
if (allowedLanguages.size && !allowedLanguages.has(lang)) {
|
|
109
|
+
log.info(`Unknown lang "${lang}" in ${page.inputPath}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const locale = { locale: { translationKey, lang, isDefaultLang: lang === defaultLanguage } };
|
|
114
|
+
const safeCopy = DeepCopy(page, locale);
|
|
115
|
+
list.push(safeCopy);
|
|
116
|
+
|
|
117
|
+
if (!map[translationKey]) map[translationKey] = {};
|
|
118
|
+
map[translationKey][lang] = {
|
|
119
|
+
title: page.data.title,
|
|
120
|
+
url: page.url,
|
|
121
|
+
lang,
|
|
122
|
+
isDefaultLang: lang === defaultLanguage,
|
|
123
|
+
data: page.data
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { map, list };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// --- Collections ---
|
|
131
|
+
|
|
132
|
+
// Map form: translationsMap[translationKey][lang] → page metadata.
|
|
133
|
+
eleventyConfig.addCollection('translationsMap', (collection) => {
|
|
134
|
+
const map = buildTranslations(collection).map;
|
|
135
|
+
runtime.translationMap.set(map);
|
|
136
|
+
return map;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Flat list: all translatable pages with locale data attached.
|
|
140
|
+
eleventyConfig.addCollection('translations', (collection) => {
|
|
141
|
+
return buildTranslations(collection).list;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- Filters ---
|
|
145
|
+
// Relational helpers for cross-language lookups in templates.
|
|
146
|
+
eleventyConfig.addFilter('i18nTranslationsFor', i18nTranslationsFor);
|
|
147
|
+
eleventyConfig.addFilter('i18nTranslationIn', i18nTranslationIn);
|
|
148
|
+
eleventyConfig.addFilter('i18nDefaultTranslation', i18nDefaultTranslation);
|
|
149
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import debug from './utils/debug.js';
|
|
5
|
+
import { optionsSchema } from './schema.js';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Navigator (module)
|
|
12
|
+
*
|
|
13
|
+
* Debug surface. Exposes Eleventy and Baseline runtime state to templates so
|
|
14
|
+
* developers can inspect data shape, scope contents, and lifecycle output
|
|
15
|
+
* without leaving the page.
|
|
16
|
+
*
|
|
17
|
+
* Architecture layer:
|
|
18
|
+
* module
|
|
19
|
+
*
|
|
20
|
+
* System role:
|
|
21
|
+
* Read-only window into the runtime substrate. Pulls snapshots from the
|
|
22
|
+
* page-context registry and content-map store via the module context;
|
|
23
|
+
* does not write back.
|
|
24
|
+
*
|
|
25
|
+
* Lifecycle:
|
|
26
|
+
* build-time → register Nunjucks globals, debug filters, and the
|
|
27
|
+
* optional virtual debug page
|
|
28
|
+
* cascade-time → eleventyComputed `_snapshot` resolves contentMap and
|
|
29
|
+
* pageContext on each page
|
|
30
|
+
*
|
|
31
|
+
* Why this exists:
|
|
32
|
+
* Render-time inspection of cascade state has no built-in surface.
|
|
33
|
+
* Centralising globals and filters under a debug-only module keeps the
|
|
34
|
+
* inspection vocabulary stable and out of feature modules.
|
|
35
|
+
*
|
|
36
|
+
* Scope:
|
|
37
|
+
* Owns the `_runtime` and `_ctx` Nunjucks globals, computed `_snapshot`,
|
|
38
|
+
* debug filters (`_inspect`, `_json`, `_keys`), and the optional virtual
|
|
39
|
+
* page at /navigator-core.html.
|
|
40
|
+
* Does not own the data it surfaces (page-context registry, content-map
|
|
41
|
+
* store).
|
|
42
|
+
*
|
|
43
|
+
* Data flow:
|
|
44
|
+
* snapshots (contentMap, pageContext) + this.ctx → globals + computed
|
|
45
|
+
* `_snapshot` + virtual page → developer
|
|
46
|
+
*
|
|
47
|
+
* Note: `_snapshot.contentMap` is null on the navigator template itself
|
|
48
|
+
* because it renders before `eleventy.contentMap` fires. Read `_snapshot`
|
|
49
|
+
* from any ordinary page for a populated contentMap.
|
|
50
|
+
*
|
|
51
|
+
* @param {import("@11ty/eleventy").UserConfig} eleventyConfig
|
|
52
|
+
* @param {Object} moduleContext
|
|
53
|
+
* @param {Object} moduleContext.state - Resolved plugin state.
|
|
54
|
+
* @param {Object} moduleContext.snapshots - Thunks: { contentMap, pageContext }.
|
|
55
|
+
*/
|
|
56
|
+
export function navigatorCore(eleventyConfig, moduleContext) {
|
|
57
|
+
const { state, snapshots, log, env } = moduleContext;
|
|
58
|
+
const { settings, options } = state;
|
|
59
|
+
|
|
60
|
+
// Structural-only options check: log on mismatch, do not throw.
|
|
61
|
+
const parsed = optionsSchema.safeParse(options.navigator);
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
for (const issue of parsed.error.issues) {
|
|
64
|
+
log.info('options:', `${issue.path.join('.')} — ${issue.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Boolean shorthand activates the virtual page; object form lets users tune.
|
|
69
|
+
const navigatorOpts = options.navigator && typeof options.navigator === 'object' ? options.navigator : {};
|
|
70
|
+
const renderTemplate = env.mode === 'development' ?? navigatorOpts.template ?? Boolean(options.navigator);
|
|
71
|
+
const inspectorDepth = navigatorOpts.inspectorDepth ?? 4;
|
|
72
|
+
|
|
73
|
+
eleventyConfig.addGlobalData('eleventyComputed._snapshot', () => {
|
|
74
|
+
return () => ({
|
|
75
|
+
contentMap: snapshots.contentMap(),
|
|
76
|
+
pageContext: snapshots.pageContext()
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Nunjucks Global: _runtime
|
|
82
|
+
*
|
|
83
|
+
* Exposes internal Nunjucks runtime state:
|
|
84
|
+
* - env → environment instance
|
|
85
|
+
* - ctx → current render context
|
|
86
|
+
* - globals → registered global values
|
|
87
|
+
*/
|
|
88
|
+
eleventyConfig.addNunjucksGlobal('_runtime', function () {
|
|
89
|
+
return {
|
|
90
|
+
env: this.env,
|
|
91
|
+
ctx: this.ctx,
|
|
92
|
+
globals: this.env?.globals
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Nunjucks Global: _ctx
|
|
98
|
+
*
|
|
99
|
+
* Direct reference to the template execution context.
|
|
100
|
+
* Useful for debugging data shape at render time.
|
|
101
|
+
*/
|
|
102
|
+
eleventyConfig.addNunjucksGlobal('_ctx', function () {
|
|
103
|
+
return this.ctx;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Virtual Debug Template
|
|
108
|
+
*
|
|
109
|
+
* Registers a synthetic Eleventy page that dumps runtime context.
|
|
110
|
+
* This is only enabled when explicitly configured via options.
|
|
111
|
+
*/
|
|
112
|
+
if (renderTemplate) {
|
|
113
|
+
const templatePath = path.join(__dirname, './templates/navigator-core.html');
|
|
114
|
+
const virtualTemplateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
115
|
+
|
|
116
|
+
eleventyConfig.addTemplate('navigator-core.html', virtualTemplateContent, {
|
|
117
|
+
permalink: '/navigator-core.html',
|
|
118
|
+
title: 'Navigator Core',
|
|
119
|
+
description: 'Eleventy + Baseline internals',
|
|
120
|
+
layout: null,
|
|
121
|
+
eleventyExcludeFromCollections: true,
|
|
122
|
+
_internal: false,
|
|
123
|
+
|
|
124
|
+
// Debug control surface
|
|
125
|
+
inspectorDepth
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
log.info('Navigator template registered at /navigator-core.html');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Debug Filters
|
|
133
|
+
*
|
|
134
|
+
* Lightweight helpers for inspecting values in templates.
|
|
135
|
+
* These are intentionally prefixed to avoid collisions.
|
|
136
|
+
*/
|
|
137
|
+
eleventyConfig.addFilter('_inspect', debug.inspect);
|
|
138
|
+
eleventyConfig.addFilter('_json', debug.json);
|
|
139
|
+
eleventyConfig.addFilter('_keys', debug.keys);
|
|
140
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
|
|
3
|
+
// Structural schema for the `options.navigator` slice. Accepts boolean
|
|
4
|
+
// shorthand or object form; permissive on unknown keys, typed on the keys
|
|
5
|
+
// the module reads. Non-throwing at the call site.
|
|
6
|
+
|
|
7
|
+
export const optionsSchema = z.union([
|
|
8
|
+
z.boolean(),
|
|
9
|
+
z.looseObject({
|
|
10
|
+
template: z.boolean().optional(),
|
|
11
|
+
inspectorDepth: z.number().int().min(0).optional()
|
|
12
|
+
})
|
|
13
|
+
]).optional();
|
|
@@ -4,7 +4,7 @@ permalink: /navigator-core.html
|
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
<!DOCTYPE html>
|
|
7
|
-
<html lang="{{ lang | default(
|
|
7
|
+
<html lang="{{ lang | default(settings.defaultLanguage) }}">
|
|
8
8
|
|
|
9
9
|
<head>
|
|
10
10
|
<baseline-head></baseline-head>
|
|
@@ -28,9 +28,9 @@ permalink: /navigator-core.html
|
|
|
28
28
|
</a>
|
|
29
29
|
<header>
|
|
30
30
|
<div class="_navigator__wrapper">
|
|
31
|
-
<h1>{{
|
|
31
|
+
<h1>{{ settings.title }} Navigator</h1>
|
|
32
32
|
<div>
|
|
33
|
-
<p>{{
|
|
33
|
+
<p>{{ settings.tagline }}</p>
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
36
|
</header>
|
|
@@ -38,7 +38,13 @@ permalink: /navigator-core.html
|
|
|
38
38
|
<div class="_navigator__wrapper">
|
|
39
39
|
<p><a id="go-back" href=""><span style="vertical-align: text-bottom;">←</span> Go back</a></p>
|
|
40
40
|
<h2><u>Navigator</u></h2>
|
|
41
|
-
|
|
41
|
+
<details>
|
|
42
|
+
<summary><strong>Page Context</strong></summary>
|
|
43
|
+
<pre>
|
|
44
|
+
{{- _snapshot.pageContext | _inspect({ depth: null }) -}}
|
|
45
|
+
</pre>
|
|
46
|
+
</details>
|
|
47
|
+
{% for key, value in _runtime() %}
|
|
42
48
|
<details>
|
|
43
49
|
<summary><strong>{{ key }}</strong></summary>
|
|
44
50
|
{% if value | isString %}
|