@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.
Files changed (58) hide show
  1. package/README.md +48 -23
  2. package/core/content-map-store.js +51 -0
  3. package/core/filters/index.js +4 -0
  4. package/core/filters/isString.js +1 -1
  5. package/core/filters/related-posts.js +1 -1
  6. package/core/global-functions/index.js +6 -0
  7. package/core/logging.js +25 -25
  8. package/core/page-context.js +310 -0
  9. package/core/registry.js +110 -0
  10. package/core/schema.js +37 -0
  11. package/core/shortcodes/image.js +8 -3
  12. package/core/shortcodes/index.js +2 -0
  13. package/core/slug-index.js +61 -0
  14. package/core/translation-map-store.js +46 -0
  15. package/core/types.js +73 -0
  16. package/core/utils/helpers.js +75 -0
  17. package/core/utils/pick.js +7 -0
  18. package/core/virtual-dir.js +111 -0
  19. package/core/wikilinks.js +152 -0
  20. package/index.js +364 -0
  21. package/modules/assets/index.js +162 -0
  22. package/modules/{assets-esbuild/process.js → assets/processors/esbuild-process.js} +3 -1
  23. package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +5 -2
  24. package/modules/assets/schema.js +14 -0
  25. package/modules/head/drivers/capo-adapter.js +72 -0
  26. package/modules/head/drivers/posthtml-head-elements.js +140 -0
  27. package/modules/head/index.js +106 -0
  28. package/modules/head/schema.js +42 -0
  29. package/modules/head/utils/alternates.js +11 -0
  30. package/modules/head/utils/dedupe.js +47 -0
  31. package/modules/multilang/index.js +149 -0
  32. package/modules/navigator/index.js +140 -0
  33. package/modules/navigator/schema.js +13 -0
  34. package/modules/{navigator-core → navigator}/templates/navigator-core.html +10 -4
  35. package/{core → modules/navigator/utils}/debug.js +7 -1
  36. package/modules/sitemap/index.js +121 -0
  37. package/modules/{sitemap-core → sitemap}/templates/sitemap-core.html +2 -2
  38. package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
  39. package/modules.js +6 -0
  40. package/package.json +15 -6
  41. package/core/filters.js +0 -9
  42. package/core/globals.js +0 -6
  43. package/core/helpers.js +0 -36
  44. package/core/modules.js +0 -18
  45. package/core/shortcodes.js +0 -3
  46. package/eleventy.config.js +0 -169
  47. package/modules/assets-core/plugins/assets-core.js +0 -197
  48. package/modules/head-core/drivers/posthtml-head-elements.js +0 -127
  49. package/modules/head-core/plugins/head-core.js +0 -75
  50. package/modules/head-core/utils/head-utils.js +0 -249
  51. package/modules/multilang-core/plugins/multilang-core.js +0 -118
  52. package/modules/navigator-core/plugins/navigator-core.js +0 -57
  53. package/modules/sitemap-core/plugins/sitemap-core.js +0 -88
  54. /package/core/{globals → global-functions}/date.js +0 -0
  55. /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
  56. /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
  57. /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
  58. /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(site.defaultLanguage) }}">
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>{{ site.title }} Navigator</h1>
31
+ <h1>{{ settings.title }} Navigator</h1>
32
32
  <div>
33
- <p>{{ site.tagline }}</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;">&#8592;</span>&nbsp;Go back</a></p>
40
40
  <h2><u>Navigator</u></h2>
41
- {% for key, value in _navigator() %}
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 %}