@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.
Files changed (73) hide show
  1. package/README.md +30 -32
  2. package/core/back-compat/options.js +69 -0
  3. package/core/content-graph/backlinks.js +65 -0
  4. package/core/content-graph/extractors.js +185 -0
  5. package/core/content-graph/graph.js +121 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/dates/git-date.js +71 -0
  9. package/core/dates/index.js +55 -0
  10. package/core/locale/derive-lang.js +19 -0
  11. package/core/locale/index.js +6 -0
  12. package/core/locale/normalize-lang.js +13 -0
  13. package/core/locale/normalize-locale.js +20 -0
  14. package/core/locale/open-graph-locale.js +14 -0
  15. package/core/locale/resolve-default.js +27 -0
  16. package/core/locale/resolve-locale.js +16 -0
  17. package/core/logging/banner.js +49 -0
  18. package/core/{logging.js → logging/index.js} +19 -2
  19. package/core/logging/quips.js +30 -0
  20. package/core/markdown/auto-heading-ids.js +86 -0
  21. package/core/markdown/index.js +5 -0
  22. package/core/markdown/safe-use.js +42 -0
  23. package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
  24. package/core/page-context/build.js +336 -0
  25. package/core/page-context/index.js +1 -0
  26. package/core/page-context/register.js +73 -0
  27. package/core/page-context/seo-helpers.js +56 -0
  28. package/core/schema.js +22 -2
  29. package/core/seo-graph/adapter.js +246 -0
  30. package/core/seo-graph/build.js +87 -0
  31. package/core/seo-graph/index.js +1 -0
  32. package/core/seo-graph/open-graph.js +130 -0
  33. package/core/seo-graph/register.js +42 -0
  34. package/core/seo-graph/schema.js +18 -0
  35. package/core/slug-index.js +2 -2
  36. package/core/state.js +75 -0
  37. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  38. package/core/surface/index.js +22 -0
  39. package/core/types.js +3 -0
  40. package/core/utils/add-trailing-slash.js +11 -0
  41. package/core/utils/ensure-dot-slash-dir.js +13 -0
  42. package/core/utils/normalize-language-map.js +37 -0
  43. package/core/utils/resolve-field.js +9 -0
  44. package/core/utils/resolve-subdir.js +20 -0
  45. package/core/utils/slugify.js +15 -0
  46. package/core/utils/title-case-slug.js +15 -0
  47. package/core/utils/unique-by.js +25 -0
  48. package/core/virtual-dir.js +11 -10
  49. package/index.js +161 -118
  50. package/modules/assets/index.js +4 -2
  51. package/modules/assets/processors/esbuild-process.js +2 -2
  52. package/modules/assets/processors/postcss-process.js +2 -2
  53. package/modules/head/drivers/posthtml-head-elements.js +92 -12
  54. package/modules/head/index.js +23 -19
  55. package/modules/head/schema.js +7 -3
  56. package/modules/multilang/filters/i18n-default-translation.js +2 -4
  57. package/modules/multilang/filters/i18n-translation-in.js +2 -2
  58. package/modules/multilang/filters/i18n-translations-for.js +2 -2
  59. package/modules/multilang/index.js +80 -39
  60. package/modules/navigator/index.js +39 -25
  61. package/modules/navigator/templates/navigator-core.html +1 -1
  62. package/modules/sitemap/index.js +8 -4
  63. package/modules/sitemap/templates/sitemap-core.html +1 -1
  64. package/package.json +5 -2
  65. package/core/filters/index.js +0 -4
  66. package/core/global-functions/index.js +0 -6
  67. package/core/page-context.js +0 -310
  68. package/core/shortcodes/index.js +0 -2
  69. package/core/utils/helpers.js +0 -75
  70. /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
  71. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  72. /package/core/{filters → surface/filters}/isString.js +0 -0
  73. /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
- * Does not own seed shape (page context), hreflang building
32
- * (head/utils/alternates.js), or capo's element weights (capo.js).
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, log }) {
48
- const defaults = emitMeta(seeds.meta, seeds.render, options);
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
- const deduped = dedupeAll([...defaults, ...extras]);
52
- const sorted = capoSort(deduped);
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 (meta.canonical) nodes.push(mkLink({ rel: 'canonical', href: meta.canonical }));
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));
@@ -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
- * 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.
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 → driver →
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('.')} ${issue.message}`);
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 to pages');
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('no head seeds for', context?.page?.inputPath || context?.outputPath);
87
+ log.warn('No head seeds for', context?.page?.inputPath || context?.outputPath);
88
88
  return (tree) => tree;
89
89
  }
90
90
 
91
- const translationKey = seeds.page?.locale?.translationKey;
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(seeds.page?.locale?.translationKey, runtime.translationMap.get(), seeds.site?.url)
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
  }
@@ -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 scalars, page-overridable.
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
- ogImage: z.string().optional(),
23
- twitterSite: z.string().optional()
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?.locale?.translationKey) return null;
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?.locale?.translationKey) return null;
9
+ if (!page?.translationKey) return null;
10
10
 
11
11
  return (
12
12
  collection.find(
13
- (p) => p.locale && p.locale.translationKey === page.locale.translationKey && p.locale.lang === lang
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?.locale?.translationKey) return [];
9
- return collection.filter((p) => p.locale && p.locale.translationKey === page.locale.translationKey);
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 { normalizeLanguages } from '../../core/utils/helpers.js';
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 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.
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.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).
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.locale + translation-map
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
- // --- 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);
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: requires options.multilingual + settings.defaultLanguage + languages');
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
- // 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;
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
- return {
85
- translationKey,
86
- lang,
87
- isDefaultLang
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(normalizeLanguageCode));
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.lang || page.data.language || defaultLanguage;
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 locale = { locale: { translationKey, lang, isDefaultLang: lang === defaultLanguage } };
114
- const safeCopy = DeepCopy(page, locale);
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: lang === defaultLanguage,
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
- * 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.
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 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.
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 Nunjucks globals, debug filters, and the
27
- * optional virtual debug page
28
- * cascade-time → eleventyComputed `_snapshot` resolves contentMap and
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
- * 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.
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 `_runtime` and `_ctx` Nunjucks globals, computed `_snapshot`,
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, content-map
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
- * snapshots (contentMap, pageContext) + this.ctx → globals + computed
45
- * `_snapshot` + virtual page → developer
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.snapshots - Thunks: { contentMap, pageContext }.
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 { settings, options } = state;
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('.')} ${issue.message}`);
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 = env.mode === 'development' ?? navigatorOpts.template ?? Boolean(options.navigator);
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 template registered at /navigator-core.html');
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
- {{- _snapshot.pageContext | _inspect({ depth: null }) -}}
44
+ {{- _pageContext | _inspect({ depth: null }) -}}
45
45
  </pre>
46
46
  </details>
47
47
  {% for key, value in _runtime() %}