@apleasantview/eleventy-plugin-baseline 0.1.0-next.40 → 0.1.0-next.42
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 +30 -32
- package/core/back-compat/options.js +69 -0
- package/core/content-graph/backlinks.js +65 -0
- package/core/content-graph/extractors.js +185 -0
- package/core/content-graph/graph.js +121 -0
- package/core/content-graph/index.js +2 -0
- package/core/content-graph/prepass.js +121 -0
- package/core/dates/git-date.js +71 -0
- package/core/dates/index.js +55 -0
- package/core/locale/derive-lang.js +19 -0
- package/core/locale/index.js +6 -0
- package/core/locale/normalize-lang.js +13 -0
- package/core/locale/normalize-locale.js +20 -0
- package/core/locale/open-graph-locale.js +14 -0
- package/core/locale/resolve-default.js +27 -0
- package/core/locale/resolve-locale.js +16 -0
- package/core/logging/banner.js +49 -0
- package/core/{logging.js → logging/index.js} +19 -2
- package/core/logging/quips.js +30 -0
- package/core/markdown/auto-heading-ids.js +86 -0
- package/core/markdown/index.js +5 -0
- package/core/markdown/safe-use.js +42 -0
- package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
- package/core/page-context/build.js +336 -0
- package/core/page-context/index.js +1 -0
- package/core/page-context/register.js +73 -0
- package/core/page-context/seo-helpers.js +56 -0
- package/core/schema.js +22 -2
- package/core/seo-graph/adapter.js +246 -0
- package/core/seo-graph/build.js +87 -0
- package/core/seo-graph/index.js +1 -0
- package/core/seo-graph/open-graph.js +130 -0
- package/core/seo-graph/register.js +42 -0
- package/core/seo-graph/schema.js +18 -0
- package/core/slug-index.js +2 -2
- package/core/state.js +75 -0
- package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
- package/core/surface/index.js +22 -0
- package/core/types.js +3 -0
- package/core/utils/add-trailing-slash.js +11 -0
- package/core/utils/ensure-dot-slash-dir.js +13 -0
- package/core/utils/normalize-language-map.js +37 -0
- package/core/utils/resolve-field.js +9 -0
- package/core/utils/resolve-subdir.js +20 -0
- package/core/utils/slugify.js +15 -0
- package/core/utils/title-case-slug.js +15 -0
- package/core/utils/unique-by.js +25 -0
- package/core/virtual-dir.js +11 -10
- package/index.js +161 -118
- package/modules/assets/index.js +4 -2
- package/modules/assets/processors/esbuild-process.js +2 -2
- package/modules/assets/processors/postcss-process.js +2 -2
- package/modules/head/drivers/posthtml-head-elements.js +92 -12
- package/modules/head/index.js +23 -19
- package/modules/head/schema.js +7 -3
- package/modules/multilang/filters/i18n-default-translation.js +2 -4
- package/modules/multilang/filters/i18n-translation-in.js +2 -2
- package/modules/multilang/filters/i18n-translations-for.js +2 -2
- package/modules/multilang/index.js +80 -39
- package/modules/navigator/index.js +39 -25
- package/modules/navigator/templates/navigator-core.html +1 -1
- package/modules/sitemap/index.js +8 -4
- package/modules/sitemap/templates/sitemap-core.html +1 -1
- package/package.json +5 -2
- package/core/filters/index.js +0 -4
- package/core/global-functions/index.js +0 -6
- package/core/page-context.js +0 -310
- package/core/shortcodes/index.js +0 -2
- package/core/utils/helpers.js +0 -75
- /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
- /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
- /package/core/{filters → surface/filters}/isString.js +0 -0
- /package/core/{filters → surface/filters}/related-posts.js +0 -0
|
@@ -27,32 +27,43 @@ import { dedupeMeta, dedupeLink } from '../utils/dedupe.js';
|
|
|
27
27
|
*
|
|
28
28
|
* Scope:
|
|
29
29
|
* Owns node emission, dedupe orchestration, capo sort, and placeholder
|
|
30
|
-
* replacement.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* replacement. Owns the og:/twitter: vocabulary: the seo substrate hands
|
|
31
|
+
* over short structured keys, this driver maps them to <meta>/<script>.
|
|
32
|
+
* Does not own seed shape (page context), the seo projection
|
|
33
|
+
* (core/seo-graph), hreflang building (head/utils/alternates.js), or capo's
|
|
34
|
+
* element weights (capo.js).
|
|
33
35
|
*
|
|
34
36
|
* Data flow:
|
|
35
|
-
* seeds + alternates + options → emit → dedupe → capo-sort → PostHTML
|
|
37
|
+
* seeds + seo + alternates + options → emit → dedupe → capo-sort → PostHTML
|
|
36
38
|
* tree mutation
|
|
37
39
|
*
|
|
38
40
|
* @param {Object} args
|
|
39
41
|
* @param {Object} args.seeds - Page context for the current page.
|
|
42
|
+
* @param {Object} [args.seo] - Resolved _seoGraph namespace (url, schema, openGraph, twitter).
|
|
40
43
|
* @param {Array<Object>} args.alternates - hreflang link descriptors.
|
|
41
44
|
* @param {Object} args.options - Head options (titleSeparator, showGenerator).
|
|
42
45
|
* @param {string} args.placeholderTag - Placeholder element to replace.
|
|
43
46
|
* @param {string} args.eol - End-of-line separator interleaved between nodes.
|
|
44
|
-
* @param {Object} args.log - Scoped logger.
|
|
45
47
|
* @returns {(tree: Object) => Object} PostHTML plugin function.
|
|
46
48
|
*/
|
|
47
|
-
export function renderHead({ seeds, alternates, options, placeholderTag, eol
|
|
48
|
-
|
|
49
|
+
export function renderHead({ seeds, seo, alternates, options, placeholderTag, eol }) {
|
|
50
|
+
// seo.url is the canonical source, resolved at cascade-time. It is undefined
|
|
51
|
+
// on noindex / no-settings.url pages, where the seo layer deliberately drops
|
|
52
|
+
// the canonical — emitMeta's guard then emits nothing, which is correct. The
|
|
53
|
+
// seo handle is present whenever a head renders (same skip set as page
|
|
54
|
+
// context), so there is no absent-handle case to fall back for.
|
|
55
|
+
const canonical = seo?.url;
|
|
56
|
+
const defaults = emitMeta(seeds.meta, seeds.render, options, canonical);
|
|
49
57
|
const extras = emitExtras(seeds.head, alternates);
|
|
58
|
+
const { meta: seoMeta, multi: seoMulti, scripts: seoScripts } = emitSeo(seo);
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
// seo emits last so the substrate wins a property collision with settings.head.
|
|
61
|
+
// Multi-valued tags and JSON-LD bypass dedupe (property-keyed last-wins would
|
|
62
|
+
// collapse repeated og:locale:alternate / article:author to one).
|
|
63
|
+
const deduped = dedupeAll([...defaults, ...extras, ...seoMeta]);
|
|
64
|
+
const sorted = capoSort([...deduped, ...seoMulti, ...seoScripts]);
|
|
53
65
|
|
|
54
66
|
return function rendererPlugin(tree) {
|
|
55
|
-
// log.info('injecting head for', seeds.page.inputPath || seeds.page.url);
|
|
56
67
|
tree.match({ tag: placeholderTag }, () => ({
|
|
57
68
|
tag: 'head',
|
|
58
69
|
content: interleaveEOL(sorted, eol)
|
|
@@ -61,14 +72,14 @@ export function renderHead({ seeds, alternates, options, placeholderTag, eol, lo
|
|
|
61
72
|
};
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
function emitMeta(meta, render, options) {
|
|
75
|
+
function emitMeta(meta, render, options, canonical) {
|
|
65
76
|
const nodes = [];
|
|
66
77
|
nodes.push(mkMeta({ charset: 'UTF-8' }));
|
|
67
78
|
nodes.push(mkMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' }));
|
|
68
79
|
if (meta.title) nodes.push({ tag: 'title', content: [meta.title] });
|
|
69
80
|
if (meta.description) nodes.push(mkMeta({ name: 'description', content: meta.description }));
|
|
70
81
|
nodes.push(mkMeta({ name: 'robots', content: meta.robots }));
|
|
71
|
-
if (
|
|
82
|
+
if (canonical) nodes.push(mkLink({ rel: 'canonical', href: canonical }));
|
|
72
83
|
if (options.showGenerator && render.generator) {
|
|
73
84
|
nodes.push(mkMeta({ name: 'generator', content: render.generator }));
|
|
74
85
|
}
|
|
@@ -76,6 +87,75 @@ function emitMeta(meta, render, options) {
|
|
|
76
87
|
return nodes;
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Map the resolved seo projection to head nodes. The substrate hands over short
|
|
92
|
+
* structured keys; this driver owns the og:/twitter: vocabulary. Single-valued
|
|
93
|
+
* tags ride the deduped meta flow; multi-valued ones (og:locale:alternate,
|
|
94
|
+
* repeated article:author / article:tag) go in `multi` to skip the property-keyed dedupe.
|
|
95
|
+
* The JSON-LD graph rides `scripts` (the adapter returns a bare @graph array,
|
|
96
|
+
* wrapped in its @context envelope here).
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} [seo] - Resolved seo namespace (graph, openGraph, twitter).
|
|
99
|
+
* @returns {{ meta: Array<Object>, multi: Array<Object>, scripts: Array<Object> }}
|
|
100
|
+
*/
|
|
101
|
+
function emitSeo(seo) {
|
|
102
|
+
const meta = [];
|
|
103
|
+
const multi = [];
|
|
104
|
+
const scripts = [];
|
|
105
|
+
if (!seo) return { meta, multi, scripts };
|
|
106
|
+
|
|
107
|
+
const og = seo.openGraph ?? {};
|
|
108
|
+
const tw = seo.twitter ?? {};
|
|
109
|
+
|
|
110
|
+
const prop = (property, content) => {
|
|
111
|
+
if (content !== undefined && content !== null && content !== '') meta.push(mkMeta({ property, content }));
|
|
112
|
+
};
|
|
113
|
+
const name = (key, content) => {
|
|
114
|
+
if (content !== undefined && content !== null && content !== '') meta.push(mkMeta({ name: key, content }));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
prop('og:title', og.title);
|
|
118
|
+
prop('og:type', og.type);
|
|
119
|
+
prop('og:description', og.description);
|
|
120
|
+
prop('og:url', og.url);
|
|
121
|
+
prop('og:site_name', og.siteName);
|
|
122
|
+
prop('og:locale', og.locale);
|
|
123
|
+
prop('og:image', og.image);
|
|
124
|
+
prop('og:image:alt', og.imageAlt);
|
|
125
|
+
if (og.imageWidth !== undefined) prop('og:image:width', String(og.imageWidth));
|
|
126
|
+
if (og.imageHeight !== undefined) prop('og:image:height', String(og.imageHeight));
|
|
127
|
+
|
|
128
|
+
for (const loc of asArray(og.localeAlternate)) {
|
|
129
|
+
if (loc) multi.push(mkMeta({ property: 'og:locale:alternate', content: loc }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (og.article) {
|
|
133
|
+
prop('article:published_time', og.article.publishedTime);
|
|
134
|
+
prop('article:modified_time', og.article.modifiedTime);
|
|
135
|
+
prop('article:section', og.article.section);
|
|
136
|
+
for (const author of asArray(og.article.authors)) {
|
|
137
|
+
if (author) multi.push(mkMeta({ property: 'article:author', content: author }));
|
|
138
|
+
}
|
|
139
|
+
for (const tag of asArray(og.article.tags)) {
|
|
140
|
+
if (tag) multi.push(mkMeta({ property: 'article:tag', content: tag }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
name('twitter:card', tw.card);
|
|
145
|
+
name('twitter:site', tw.site);
|
|
146
|
+
name('twitter:creator', tw.creator);
|
|
147
|
+
name('twitter:title', tw.title);
|
|
148
|
+
name('twitter:description', tw.description);
|
|
149
|
+
name('twitter:image', tw.image);
|
|
150
|
+
|
|
151
|
+
if (seo.schema?.length) {
|
|
152
|
+
const content = JSON.stringify({ '@context': 'https://schema.org', '@graph': seo.schema });
|
|
153
|
+
scripts.push(mkScript({ type: 'application/ld+json', content }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { meta, multi, scripts };
|
|
157
|
+
}
|
|
158
|
+
|
|
79
159
|
function emitExtras(head, alternates = []) {
|
|
80
160
|
const nodes = [];
|
|
81
161
|
for (const m of asArray(head?.meta)) nodes.push(mkMeta(m));
|
package/modules/head/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import { renderHead } from './drivers/posthtml-head-elements.js';
|
|
|
2
2
|
import { buildAlternates } from './utils/alternates.js';
|
|
3
3
|
import { optionsSchema } from './schema.js';
|
|
4
4
|
|
|
5
|
+
import chalk from 'kleur';
|
|
6
|
+
|
|
5
7
|
// Internal constants — not user-facing.
|
|
6
8
|
const PLACEHOLDER_TAG = 'baseline-head';
|
|
7
9
|
const EOL = '\n';
|
|
@@ -31,15 +33,16 @@ const EOL = '\n';
|
|
|
31
33
|
* every field the composer needs from cascade-time into transform-time.
|
|
32
34
|
*
|
|
33
35
|
* Scope:
|
|
34
|
-
* Owns transform-time composition and placeholder replacement.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* Does not own seed shape (page context)
|
|
36
|
+
* Owns transform-time composition and placeholder replacement. Emits
|
|
37
|
+
* charset, viewport, title, description, robots, canonical, optional
|
|
38
|
+
* generator, user extras from settings.head, hreflang alternates, plus the
|
|
39
|
+
* seo substrate's OG/Twitter projections and JSON-LD graph.
|
|
40
|
+
* Does not own seed shape (page context), the seo projection
|
|
41
|
+
* (core/seo-graph), or driver internals.
|
|
39
42
|
*
|
|
40
43
|
* Data flow:
|
|
41
|
-
* page context + translation-map store + settings.head →
|
|
42
|
-
* PostHTML tree mutation (replaces <baseline-head>)
|
|
44
|
+
* page context + seo handle + translation-map store + settings.head →
|
|
45
|
+
* driver → PostHTML tree mutation (replaces <baseline-head>)
|
|
43
46
|
*
|
|
44
47
|
* @param {import("@11ty/eleventy").UserConfig} eleventyConfig
|
|
45
48
|
* @param {Object} moduleContext
|
|
@@ -52,11 +55,12 @@ export function headCore(eleventyConfig, moduleContext) {
|
|
|
52
55
|
const parsed = optionsSchema.safeParse(options.head);
|
|
53
56
|
if (!parsed.success) {
|
|
54
57
|
for (const issue of parsed.error.issues) {
|
|
55
|
-
log.info('options:', `${issue.path.join('.')}
|
|
58
|
+
log.info('options:', `${issue.path.join('.')}, ${issue.message}`);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const pageContextRegistry = moduleContext.resolvePageContext;
|
|
63
|
+
const seoGraphRegistry = moduleContext.resolveSeoGraph;
|
|
60
64
|
|
|
61
65
|
// Resolved plugin options with defaults.
|
|
62
66
|
const headOptions = {
|
|
@@ -68,39 +72,39 @@ export function headCore(eleventyConfig, moduleContext) {
|
|
|
68
72
|
const headStats = { pages: new Set() };
|
|
69
73
|
|
|
70
74
|
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
|
-
});
|
|
75
|
+
log.info(chalk.green(`Head injected into ${headStats.pages.size} pages`));
|
|
76
76
|
headStats.pages.clear();
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
// --- Transform-time: compose and inject. ---
|
|
80
|
-
log.info('Injecting heads
|
|
80
|
+
log.info('Injecting heads');
|
|
81
81
|
eleventyConfig.htmlTransformer.addPosthtmlPlugin('html', function (context) {
|
|
82
82
|
headStats.pages.add(context?.page?.inputPath || context?.outputPath);
|
|
83
83
|
|
|
84
84
|
const key = context?.page?.url ?? context?.page?.inputPath;
|
|
85
85
|
const seeds = pageContextRegistry?.getByKey(key);
|
|
86
86
|
if (!seeds) {
|
|
87
|
-
log.warn('
|
|
87
|
+
log.warn('No head seeds for', context?.page?.inputPath || context?.outputPath);
|
|
88
88
|
return (tree) => tree;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// Peer substrate, read by the same key as the page context above. Carries
|
|
92
|
+
// the resolved canonical, OG/Twitter projections, and the JSON-LD graph.
|
|
93
|
+
const seo = seoGraphRegistry?.getByKey(key);
|
|
94
|
+
|
|
95
|
+
const translationKey = seeds.page?.translationKey;
|
|
92
96
|
|
|
93
97
|
const alternates = translationKey
|
|
94
|
-
? buildAlternates(
|
|
98
|
+
? buildAlternates(translationKey, runtime.translationMap.get(), seeds.site?.url)
|
|
95
99
|
: [];
|
|
96
100
|
|
|
97
101
|
return renderHead({
|
|
98
102
|
seeds,
|
|
103
|
+
seo,
|
|
99
104
|
alternates,
|
|
100
105
|
options: headOptions,
|
|
101
106
|
placeholderTag: PLACEHOLDER_TAG,
|
|
102
|
-
eol: EOL
|
|
103
|
-
log
|
|
107
|
+
eol: EOL
|
|
104
108
|
});
|
|
105
109
|
});
|
|
106
110
|
}
|
package/modules/head/schema.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as z from 'zod';
|
|
|
6
6
|
// `options.head` slice: render-behaviour knobs.
|
|
7
7
|
export const optionsSchema = z.looseObject({
|
|
8
8
|
titleSeparator: z.string().optional(),
|
|
9
|
+
titleTemplate: z.string().optional(),
|
|
9
10
|
showGenerator: z.boolean().optional()
|
|
10
11
|
});
|
|
11
12
|
|
|
@@ -17,10 +18,13 @@ export const settingsHeadSchema = z.looseObject({
|
|
|
17
18
|
style: z.array(z.looseObject({})).optional()
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
// `settings.seo` site-default SEO
|
|
21
|
+
// `settings.seo` site-default SEO config: canonical policy, default share
|
|
22
|
+
// image, and OG/Twitter defaults. Structural only; values stay permissive.
|
|
21
23
|
export const settingsSeoSchema = z.looseObject({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
preserveQueryParams: z.boolean().optional(),
|
|
25
|
+
ogImage: z.unknown().optional(),
|
|
26
|
+
openGraph: z.looseObject({}).optional(),
|
|
27
|
+
twitter: z.looseObject({}).optional()
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
// Page-level `seo:` block. Same scalar set as bare front matter, namespaced.
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* @returns {object|null}
|
|
6
6
|
*/
|
|
7
7
|
export default function i18nDefaultTranslation(page, collection) {
|
|
8
|
-
if (!page?.
|
|
8
|
+
if (!page?.translationKey) return null;
|
|
9
9
|
return (
|
|
10
|
-
collection.find(
|
|
11
|
-
(p) => p.locale && p.locale.translationKey === page.locale.translationKey && p.locale.isDefaultLang
|
|
12
|
-
) || null
|
|
10
|
+
collection.find((p) => p.translationKey === page.translationKey && p.isDefaultLang) || null
|
|
13
11
|
);
|
|
14
12
|
}
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* @returns {object|null}
|
|
7
7
|
*/
|
|
8
8
|
export default function i18nTranslationIn(page, collection, lang) {
|
|
9
|
-
if (!page?.
|
|
9
|
+
if (!page?.translationKey) return null;
|
|
10
10
|
|
|
11
11
|
return (
|
|
12
12
|
collection.find(
|
|
13
|
-
(p) => p.
|
|
13
|
+
(p) => p.translationKey === page.translationKey && p.lang === lang
|
|
14
14
|
) || null
|
|
15
15
|
);
|
|
16
16
|
}
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* @returns {Array<object>}
|
|
6
6
|
*/
|
|
7
7
|
export default function i18nTranslationsFor(page, collection) {
|
|
8
|
-
if (!page?.
|
|
9
|
-
return collection.filter((p) => p.
|
|
8
|
+
if (!page?.translationKey) return [];
|
|
9
|
+
return collection.filter((p) => p.translationKey === page.translationKey);
|
|
10
10
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { I18nPlugin } from '@11ty/eleventy';
|
|
2
2
|
import { DeepCopy } from '@11ty/eleventy-utils';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
normalizeLang,
|
|
5
|
+
normalizeLocale,
|
|
6
|
+
deriveLang,
|
|
7
|
+
resolveDefault
|
|
8
|
+
} from '../../core/locale/index.js';
|
|
9
|
+
import { normalizeLanguageMap } from '../../core/utils/normalize-language-map.js';
|
|
4
10
|
import i18nTranslationsFor from './filters/i18n-translations-for.js';
|
|
5
11
|
import i18nTranslationIn from './filters/i18n-translation-in.js';
|
|
6
12
|
import i18nDefaultTranslation from './filters/i18n-default-translation.js';
|
|
@@ -9,10 +15,10 @@ import i18nDefaultTranslation from './filters/i18n-default-translation.js';
|
|
|
9
15
|
* Multilang (module)
|
|
10
16
|
*
|
|
11
17
|
* Language infrastructure. Normalises language config, builds translation
|
|
12
|
-
* relationships, attaches per-page locale
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* module exits early.
|
|
18
|
+
* relationships, attaches per-page lang / locale / translationKey /
|
|
19
|
+
* isDefaultLang fields, and exposes cross-language lookup filters. Active
|
|
20
|
+
* only when options.multilingual is true and both defaultLanguage and at
|
|
21
|
+
* least one languages entry are set; otherwise the module exits early.
|
|
16
22
|
*
|
|
17
23
|
* Architecture layer:
|
|
18
24
|
* module
|
|
@@ -24,7 +30,8 @@ import i18nDefaultTranslation from './filters/i18n-default-translation.js';
|
|
|
24
30
|
*
|
|
25
31
|
* Lifecycle:
|
|
26
32
|
* build-time → normalise languages, attach I18nPlugin, register filters
|
|
27
|
-
* and computed page.locale
|
|
33
|
+
* and computed page.lang / page.locale / page.translationKey
|
|
34
|
+
* / page.isDefaultLang
|
|
28
35
|
* cascade-time → translationsMap and translations collections build the
|
|
29
36
|
* per-translationKey map and write it to the store
|
|
30
37
|
*
|
|
@@ -35,15 +42,16 @@ import i18nDefaultTranslation from './filters/i18n-default-translation.js';
|
|
|
35
42
|
* lifecycle boundary.
|
|
36
43
|
*
|
|
37
44
|
* Scope:
|
|
38
|
-
* Owns language normalisation, page
|
|
39
|
-
*
|
|
40
|
-
* (i18nTranslationsFor,
|
|
41
|
-
* Does not own URL routing
|
|
45
|
+
* Owns language normalisation, per-page flat locale fields (lang, locale,
|
|
46
|
+
* translationKey, isDefaultLang), the translations and translationsMap
|
|
47
|
+
* collections, and the i18n filters (i18nTranslationsFor,
|
|
48
|
+
* i18nTranslationIn, i18nDefaultTranslation). Does not own URL routing
|
|
49
|
+
* (I18nPlugin) or hreflang rendering (head).
|
|
42
50
|
*
|
|
43
51
|
* Data flow:
|
|
44
|
-
* settings.languages + page.lang/translationKey → normalisation
|
|
45
|
-
* I18nPlugin → collections + computed page
|
|
46
|
-
* store → head, sitemap
|
|
52
|
+
* settings.languages + page.lang/locale/translationKey → normalisation
|
|
53
|
+
* + I18nPlugin → collections + flat computed page fields +
|
|
54
|
+
* translation-map store → head, sitemap
|
|
47
55
|
*
|
|
48
56
|
* @param {import("@11ty/eleventy/src/UserConfig.js").default} eleventyConfig
|
|
49
57
|
* @param {Object} moduleContext
|
|
@@ -52,45 +60,73 @@ export function multilangCore(eleventyConfig, moduleContext) {
|
|
|
52
60
|
const { state, runtime, log } = moduleContext;
|
|
53
61
|
const { settings, options } = state;
|
|
54
62
|
|
|
55
|
-
// ---
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const defaultLanguage =
|
|
60
|
-
const languages =
|
|
63
|
+
// --- Default resolution ---
|
|
64
|
+
// resolveDefault returns { lang, locale } from settings.defaultLocale (preferred)
|
|
65
|
+
// or settings.defaultLanguage (cosmetic alias; locale derived via Intl.Locale,
|
|
66
|
+
// returning the bare language subtag when no region is given).
|
|
67
|
+
const { lang: defaultLanguage, locale: defaultLocale } = resolveDefault(settings);
|
|
68
|
+
const languages = normalizeLanguageMap(settings, log);
|
|
61
69
|
const hasLanguages = languages && Object.keys(languages).length > 0;
|
|
62
70
|
|
|
63
71
|
const isMultilingual = options.multilang === true && defaultLanguage && hasLanguages;
|
|
64
72
|
|
|
65
73
|
if (!isMultilingual) {
|
|
66
|
-
log.info('inactive
|
|
74
|
+
log.info('Multilang inactive, needs options.multilang, settings.defaultLanguage or defaultLocale, and languages');
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
log.info(`Multilang active: ${Object.keys(languages).join('/')} (default: ${defaultLanguage})`);
|
|
79
|
+
|
|
70
80
|
// Register Eleventy's built-in I18nPlugin for locale-aware URL resolution.
|
|
71
81
|
eleventyConfig.addPlugin(I18nPlugin, {
|
|
72
82
|
defaultLanguage: defaultLanguage,
|
|
73
83
|
errorMode: 'allow-fallback'
|
|
74
84
|
});
|
|
75
85
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
// --- Per-page resolvers ---
|
|
87
|
+
// Shared between the four flat eleventyComputed registrations below and
|
|
88
|
+
// the buildTranslations collection iterator. Closes over defaults and
|
|
89
|
+
// the languages map.
|
|
90
|
+
//
|
|
91
|
+
// Accept `language` as a writer-side alias for `lang`. Cheap, forgiving,
|
|
92
|
+
// and means existing front matter using either spelling keeps working.
|
|
93
|
+
// Also derives lang from data.locale when neither is set.
|
|
94
|
+
function resolvePageLang(data) {
|
|
95
|
+
return (
|
|
96
|
+
normalizeLang(data.lang || data.language || deriveLang(data.locale)) || defaultLanguage
|
|
97
|
+
);
|
|
98
|
+
}
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
function resolvePageLocale(data) {
|
|
101
|
+
if (data.locale) return normalizeLocale(data.locale);
|
|
102
|
+
const lang = resolvePageLang(data);
|
|
103
|
+
return normalizeLocale(languages?.[lang]?.locale) ?? defaultLocale;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Computed per-page fields ---
|
|
107
|
+
// Four independent registrations merge cleanly at the leaves (validated
|
|
108
|
+
// 2026-05-25 via temp/workbench/multilang-glow-up/eleventy-probe/).
|
|
109
|
+
// Replaces the historical single-bag page.locale object with flat
|
|
110
|
+
// siblings on page.
|
|
111
|
+
eleventyConfig.addGlobalData(
|
|
112
|
+
'eleventyComputed.page.lang',
|
|
113
|
+
() => (data) => resolvePageLang(data)
|
|
114
|
+
);
|
|
115
|
+
eleventyConfig.addGlobalData(
|
|
116
|
+
'eleventyComputed.page.locale',
|
|
117
|
+
() => (data) => resolvePageLocale(data)
|
|
118
|
+
);
|
|
119
|
+
eleventyConfig.addGlobalData(
|
|
120
|
+
'eleventyComputed.page.translationKey',
|
|
121
|
+
() => (data) => data.translationKey
|
|
122
|
+
);
|
|
123
|
+
eleventyConfig.addGlobalData(
|
|
124
|
+
'eleventyComputed.page.isDefaultLang',
|
|
125
|
+
() => (data) => resolvePageLang(data) === defaultLanguage
|
|
126
|
+
);
|
|
91
127
|
|
|
92
128
|
// Build a set of allowed language codes for validation during collection building.
|
|
93
|
-
const allowedLanguages = new Set(Object.keys(languages).map(
|
|
129
|
+
const allowedLanguages = new Set(Object.keys(languages).map(normalizeLang));
|
|
94
130
|
|
|
95
131
|
// Build both the map (keyed by translationKey → lang) and the flat list.
|
|
96
132
|
// Shared logic for both collections — called once per collection registration.
|
|
@@ -102,7 +138,7 @@ export function multilangCore(eleventyConfig, moduleContext) {
|
|
|
102
138
|
const translationKey = page.data.translationKey;
|
|
103
139
|
if (!translationKey) continue;
|
|
104
140
|
|
|
105
|
-
const lang = page.data
|
|
141
|
+
const lang = resolvePageLang(page.data);
|
|
106
142
|
if (!lang) continue;
|
|
107
143
|
|
|
108
144
|
if (allowedLanguages.size && !allowedLanguages.has(lang)) {
|
|
@@ -110,8 +146,13 @@ export function multilangCore(eleventyConfig, moduleContext) {
|
|
|
110
146
|
continue;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
|
-
const
|
|
114
|
-
const
|
|
149
|
+
const isDefaultLang = lang === defaultLanguage;
|
|
150
|
+
const locale = resolvePageLocale(page.data);
|
|
151
|
+
|
|
152
|
+
// Attach flat per-page fields. Mirrors the eleventyComputed shape
|
|
153
|
+
// so collection consumers read item.lang / item.locale /
|
|
154
|
+
// item.translationKey / item.isDefaultLang directly.
|
|
155
|
+
const safeCopy = DeepCopy(page, { lang, locale, translationKey, isDefaultLang });
|
|
115
156
|
list.push(safeCopy);
|
|
116
157
|
|
|
117
158
|
if (!map[translationKey]) map[translationKey] = {};
|
|
@@ -119,7 +160,7 @@ export function multilangCore(eleventyConfig, moduleContext) {
|
|
|
119
160
|
title: page.data.title,
|
|
120
161
|
url: page.url,
|
|
121
162
|
lang,
|
|
122
|
-
isDefaultLang
|
|
163
|
+
isDefaultLang,
|
|
123
164
|
data: page.data
|
|
124
165
|
};
|
|
125
166
|
}
|
|
@@ -10,39 +10,42 @@ const __dirname = path.dirname(__filename);
|
|
|
10
10
|
/**
|
|
11
11
|
* Navigator (module)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Two roles, one module: the public read surface for plugin-produced
|
|
14
|
+
* cross-page data (the content graph and its enriched backlinks), and the
|
|
15
|
+
* debug surface for inspecting Eleventy and Baseline runtime state.
|
|
16
16
|
*
|
|
17
17
|
* Architecture layer:
|
|
18
18
|
* module
|
|
19
19
|
*
|
|
20
20
|
* System role:
|
|
21
|
-
* Read-only window
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Read-only window over the runtime substrate. Surfaces the content graph
|
|
22
|
+
* for templates that need cross-page reads, and snapshots from the
|
|
23
|
+
* page-context registry and content-map store for debugging. Writes
|
|
24
|
+
* nothing back.
|
|
24
25
|
*
|
|
25
26
|
* Lifecycle:
|
|
26
|
-
* build-time → register
|
|
27
|
-
* optional virtual debug page
|
|
28
|
-
* cascade-time → eleventyComputed `_snapshot` resolves contentMap
|
|
29
|
-
* pageContext on each page
|
|
27
|
+
* build-time → register `_navigator` ({ nodes, edges, backlinks }),
|
|
28
|
+
* debug globals, filters, and the optional virtual debug page
|
|
29
|
+
* cascade-time → eleventyComputed `_snapshot` resolves contentMap,
|
|
30
|
+
* pageContext, and seoGraph on each page
|
|
30
31
|
*
|
|
31
32
|
* Why this exists:
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Templates need an addressable cross-page surface for graph reads, and
|
|
34
|
+
* render-time inspection of cascade state has no built-in equivalent.
|
|
35
|
+
* One module owns both vocabularies so feature modules stay narrow.
|
|
35
36
|
*
|
|
36
37
|
* Scope:
|
|
37
|
-
* Owns the `
|
|
38
|
+
* Owns the `_navigator` global (`{ nodes, edges, backlinks }`, the public
|
|
39
|
+
* read surface), the debug globals `_runtime` and `_ctx`, computed `_snapshot`,
|
|
38
40
|
* debug filters (`_inspect`, `_json`, `_keys`), and the optional virtual
|
|
39
41
|
* page at /navigator-core.html.
|
|
40
|
-
* Does not own the data it surfaces (page-context registry,
|
|
41
|
-
* store).
|
|
42
|
+
* Does not own the data it surfaces (content graph, page-context registry,
|
|
43
|
+
* content-map store).
|
|
42
44
|
*
|
|
43
45
|
* Data flow:
|
|
44
|
-
*
|
|
45
|
-
* `_snapshot` + virtual page →
|
|
46
|
+
* runtime.contentGraph + snapshots + this.ctx → `_navigator` + debug
|
|
47
|
+
* globals + computed `_snapshot` + virtual page → templates and
|
|
48
|
+
* developers
|
|
46
49
|
*
|
|
47
50
|
* Note: `_snapshot.contentMap` is null on the navigator template itself
|
|
48
51
|
* because it renders before `eleventy.contentMap` fires. Read `_snapshot`
|
|
@@ -51,32 +54,43 @@ const __dirname = path.dirname(__filename);
|
|
|
51
54
|
* @param {import("@11ty/eleventy").UserConfig} eleventyConfig
|
|
52
55
|
* @param {Object} moduleContext
|
|
53
56
|
* @param {Object} moduleContext.state - Resolved plugin state.
|
|
54
|
-
* @param {Object} moduleContext.
|
|
57
|
+
* @param {Object} moduleContext.runtime - Lazy access layer; reads contentGraph.
|
|
58
|
+
* @param {Object} moduleContext.snapshots - Thunks: { contentMap, pageContext, seoGraph }.
|
|
55
59
|
*/
|
|
56
60
|
export function navigatorCore(eleventyConfig, moduleContext) {
|
|
57
|
-
const { state, snapshots, log, env } = moduleContext;
|
|
58
|
-
const {
|
|
61
|
+
const { state, runtime, snapshots, log, env } = moduleContext;
|
|
62
|
+
const { options } = state;
|
|
59
63
|
|
|
60
64
|
// Structural-only options check: log on mismatch, do not throw.
|
|
61
65
|
const parsed = optionsSchema.safeParse(options.navigator);
|
|
62
66
|
if (!parsed.success) {
|
|
63
67
|
for (const issue of parsed.error.issues) {
|
|
64
|
-
log.info('options:', `${issue.path.join('.')}
|
|
68
|
+
log.info('options:', `${issue.path.join('.')}, ${issue.message}`);
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// Boolean shorthand activates the virtual page; object form lets users tune.
|
|
69
73
|
const navigatorOpts = options.navigator && typeof options.navigator === 'object' ? options.navigator : {};
|
|
70
|
-
const renderTemplate =
|
|
74
|
+
const renderTemplate =
|
|
75
|
+
navigatorOpts.template ?? (typeof options.navigator === 'boolean' ? options.navigator : env.mode === 'development');
|
|
71
76
|
const inspectorDepth = navigatorOpts.inspectorDepth ?? 4;
|
|
72
77
|
|
|
73
78
|
eleventyConfig.addGlobalData('eleventyComputed._snapshot', () => {
|
|
74
79
|
return () => ({
|
|
75
80
|
contentMap: snapshots.contentMap(),
|
|
76
|
-
pageContext: snapshots.pageContext()
|
|
81
|
+
pageContext: snapshots.pageContext(),
|
|
82
|
+
seoGraph: snapshots.seoGraph()
|
|
77
83
|
});
|
|
78
84
|
});
|
|
79
85
|
|
|
86
|
+
// Public read surface for plugin-produced cross-page data. Templates can
|
|
87
|
+
// paginate over `_navigator.backlinks` or read `_navigator.graph` directly.
|
|
88
|
+
eleventyConfig.addGlobalData('_navigator', () => ({
|
|
89
|
+
nodes: runtime.contentGraph?.nodes ?? {},
|
|
90
|
+
edges: runtime.contentGraph?.edges ?? {},
|
|
91
|
+
backlinks: runtime.contentGraph?.backlinks ?? {}
|
|
92
|
+
}));
|
|
93
|
+
|
|
80
94
|
/**
|
|
81
95
|
* Nunjucks Global: _runtime
|
|
82
96
|
*
|
|
@@ -125,7 +139,7 @@ export function navigatorCore(eleventyConfig, moduleContext) {
|
|
|
125
139
|
inspectorDepth
|
|
126
140
|
});
|
|
127
141
|
|
|
128
|
-
log.info('Navigator
|
|
142
|
+
log.info('Navigator mounted at /navigator-core.html');
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
/**
|
|
@@ -41,7 +41,7 @@ permalink: /navigator-core.html
|
|
|
41
41
|
<details>
|
|
42
42
|
<summary><strong>Page Context</strong></summary>
|
|
43
43
|
<pre>
|
|
44
|
-
{{-
|
|
44
|
+
{{- _pageContext | _inspect({ depth: null }) -}}
|
|
45
45
|
</pre>
|
|
46
46
|
</details>
|
|
47
47
|
{% for key, value in _runtime() %}
|