@apleasantview/eleventy-plugin-baseline 0.1.0-next.32 → 0.1.0-next.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) 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 +6 -1
  5. package/core/filters/markdown.js +6 -0
  6. package/core/filters/related-posts.js +7 -1
  7. package/core/global-functions/index.js +6 -0
  8. package/core/logging.js +25 -25
  9. package/core/page-context.js +310 -0
  10. package/core/registry.js +110 -0
  11. package/core/schema.js +37 -0
  12. package/core/shortcodes/image.js +167 -144
  13. package/core/shortcodes/index.js +2 -0
  14. package/core/slug-index.js +61 -0
  15. package/core/translation-map-store.js +46 -0
  16. package/core/types.js +73 -0
  17. package/core/utils/helpers.js +75 -0
  18. package/core/utils/pick.js +7 -0
  19. package/core/virtual-dir.js +111 -0
  20. package/core/wikilinks.js +152 -0
  21. package/index.js +364 -0
  22. package/modules/assets/index.js +162 -0
  23. package/modules/assets/processors/esbuild-process.js +35 -0
  24. package/modules/assets/processors/postcss-process.js +52 -0
  25. package/modules/assets/schema.js +14 -0
  26. package/modules/head/drivers/capo-adapter.js +72 -0
  27. package/modules/head/drivers/posthtml-head-elements.js +140 -0
  28. package/modules/head/index.js +106 -0
  29. package/modules/head/schema.js +42 -0
  30. package/modules/head/utils/alternates.js +11 -0
  31. package/modules/head/utils/dedupe.js +47 -0
  32. package/modules/multilang/index.js +149 -0
  33. package/modules/navigator/index.js +140 -0
  34. package/modules/navigator/schema.js +13 -0
  35. package/modules/{navigator-core → navigator}/templates/navigator-core.html +14 -8
  36. package/modules/navigator/utils/debug.js +41 -0
  37. package/modules/sitemap/index.js +121 -0
  38. package/modules/sitemap/templates/sitemap-core.html +34 -0
  39. package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
  40. package/modules.js +6 -0
  41. package/package.json +15 -6
  42. package/core/debug.js +0 -20
  43. package/core/filters.js +0 -9
  44. package/core/globals.js +0 -6
  45. package/core/helpers.js +0 -127
  46. package/core/modules.js +0 -22
  47. package/core/shortcodes.js +0 -3
  48. package/eleventy.config.js +0 -157
  49. package/modules/assets-core/plugins/assets-core.js +0 -84
  50. package/modules/assets-esbuild/filters/inline-esbuild.js +0 -24
  51. package/modules/assets-esbuild/plugins/assets-esbuild.js +0 -71
  52. package/modules/assets-postcss/filters/inline-postcss.js +0 -38
  53. package/modules/assets-postcss/plugins/assets-postcss.js +0 -75
  54. package/modules/head-core/drivers/posthtml-head-elements.js +0 -132
  55. package/modules/head-core/plugins/head-core.js +0 -57
  56. package/modules/head-core/utils/head-utils.js +0 -183
  57. package/modules/multilang-core/plugins/multilang-core.js +0 -101
  58. package/modules/navigator-core/plugins/navigator-core.js +0 -39
  59. package/modules/sitemap-core/plugins/sitemap-core.js +0 -65
  60. package/modules/sitemap-core/templates/sitemap-core.html +0 -25
  61. /package/core/{globals → global-functions}/date.js +0 -0
  62. /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
  63. /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
  64. /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
  65. /package/modules/{multilang-core → multilang}/filters/i18n-translations-for.js +0 -0
package/README.md CHANGED
@@ -1,14 +1,12 @@
1
1
  # Eleventy Baseline
2
2
 
3
- _An experimental Swiss army knife for Eleventy._
3
+ Baseline makes the structural decisions that Eleventy leaves open: directory layout, asset pipeline, image handling, SEO, sitemaps.
4
4
 
5
- Eleventy Baseline is a lightweight toolkit built around a simple question:
5
+ If you've started a new Eleventy project and found yourself wiring up the same things for the third time, this is for you. Directory structure, template engine, image formats, meta tags, asset bundling, sitemap — decisions that are individually small but collectively slow you down. Baseline makes them together, so they fit together. You get to skip the setup and start building.
6
6
 
7
- > What if Eleventy had a minimal, optional layer of conventions just enough to eliminate repetition, but not enough to feel restrictive?
7
+ You still own your site. Baseline handles the infrastructure the parts that have well-tested answers. Your design, your content, the things that make your site yours — those stay yours.
8
8
 
9
- It explores what a "core" for Eleventy could look like without becoming a framework or theme — small, practical tools rather than sweeping abstractions. If you've ever started a new Eleventy project and found yourself copy-pasting the same asset pipeline, the same head template, the same image shortcode for the third time, this is for you.
10
-
11
- This is a practical, evolving baseline. Things might shift, break, or get renamed as the project evolves.
9
+ This is a working plugin, not a finished product. Things might shift, break, or get renamed.
12
10
 
13
11
  ## Install
14
12
 
@@ -28,35 +26,62 @@ Requires Eleventy 3.x and Node >=20.
28
26
 
29
27
  ## Usage
30
28
 
31
- In your Eleventy config (ESM):
29
+ Add the plugin and re-export the config. The config export sets the directory structure (`src/`, `dist/`, `_includes/`, `_data/`) so Eleventy and Baseline agree on where things live.
32
30
 
33
31
  ```js
34
32
  import baseline, { config as baselineConfig } from '@apleasantview/eleventy-plugin-baseline';
35
33
 
36
34
  export default function (eleventyConfig) {
37
- eleventyConfig.addPlugin(baseline, {
38
- // verbose: false,
39
- // enableNavigatorTemplate: false,
40
- // enableSitemapTemplate: true,
41
- });
35
+ eleventyConfig.addPlugin(baseline());
42
36
  }
43
37
 
44
38
  export const config = baselineConfig;
45
39
  ```
46
40
 
41
+ The plugin takes two arguments: `settings` (site identity — title, url, languages, head extras) and `options` (runtime behavior — verbose, sitemap, navigator).
42
+
43
+ ```js
44
+ const settings = {
45
+ title: 'My Site',
46
+ tagline: 'Built with Baseline',
47
+ url: 'https://www.example.com/',
48
+ defaultLanguage: 'en',
49
+ languages: {
50
+ en: { title: 'My Site' },
51
+ nl: { title: 'Mijn Site' }
52
+ }
53
+ };
54
+
55
+ eleventyConfig.addPlugin(
56
+ baseline(settings, {
57
+ verbose: false, // extra logging during builds
58
+ sitemap: true, // XML sitemap generation
59
+ navigator: false // debug page for inspecting template data
60
+ })
61
+ );
62
+ ```
63
+
47
64
  ## What's included
48
65
 
49
- When the plugin loads, you get core filters, Nunjucks globals, debugging utilities, and an image shortcode (via eleventy-img) out of the box. On top of that, the plugin is organized into opt-in modules — take what you need:
50
-
51
- | Module | What it does |
52
- |---|---|
53
- | `assets-core` | Shared foundation for the asset pipeline |
54
- | `assets-esbuild` | JS bundling via esbuild, with an inline injection filter for critical scripts |
55
- | `assets-postcss` | CSS processing via PostCSS + cssnano, with an inline injection filter for critical styles |
56
- | `head-core` | Drop `<baseline-head>` into your template and get sensible meta, canonical, og:image, and basic SEO defaults processed by PostHTML at build time |
57
- | `multilang-core` | Directory-based multilingual support: per-language collections, hreflang, sitemaps, and language normalization |
58
- | `navigator-core` | Navigation tree helpers and a `_navigator` Nunjucks global |
59
- | `sitemap-core` | XML sitemap generation with draft-page support |
66
+ The plugin registers everything on load. No setup beyond the config above.
67
+
68
+ **Core** always active:
69
+
70
+ - An image shortcode (via eleventy-img) AVIF and WebP, responsive widths, lazy loading. Alt text is required — the build warns if you skip it.
71
+ - Filters: `markdownify`, `relatedPosts`, `isString`
72
+ - A date-formatting global
73
+ - Drafts preprocessordrafts stay out of production builds automatically
74
+ - Static passthrough (`src/static/` site root)
75
+
76
+ **Modules** opt-in, loaded individually:
77
+
78
+ | Module | What it does |
79
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `assets` | The asset pipeline. One entry point per directory (`index.css`, `index.js`). Bundles JS via esbuild and processes CSS via PostCSS. Inline filters (`inlinePostCSS`, `inlineESbuild`) for critical-path assets |
81
+ | `head` | `<head>` tags (charset, viewport, title, description, robots, canonical, hreflang) handled for you by dropping `<baseline-head>` in your layout |
82
+ | `multilang` | Directory-based multilingual support. Per-language collections, translation mapping, i18n filters. Wraps Eleventy's I18n plugin |
83
+ | `navigator` | Debug tooling. Globals for inspecting template data, plus debug filters (`_inspect`, `_json`, `_keys`). Optional virtual debug page |
84
+ | `sitemap` | XML sitemap. Every page is included unless you exclude it. Multilingual sites get per-language sitemaps plus an index |
60
85
 
61
86
  ## Docs
62
87
 
@@ -0,0 +1,51 @@
1
+ import { getScope, addScopeListener, setEntry, getEntry } from './registry.js';
2
+
3
+ const SCOPE_NAME = 'core:content-map-store';
4
+ const KEY = 'contentMap';
5
+
6
+ /**
7
+ * Content map store (runtime substrate)
8
+ *
9
+ * Captures Eleventy's content map (emitted once per build via
10
+ * `eleventy.contentMap`) so late-lifecycle consumers can read it back. The
11
+ * store self-attaches its listener; callers create it once during plugin init.
12
+ *
13
+ * Architecture layer:
14
+ * runtime substrate
15
+ *
16
+ * System role:
17
+ * Capture point for the content map; read by page-context for canonical
18
+ * URL resolution.
19
+ *
20
+ * Lifecycle:
21
+ * cascade-time → listener writes the map when Eleventy emits it
22
+ * transform-time → consumers read via get()
23
+ *
24
+ * Why this exists:
25
+ * The content map is event-only; without a store, late-lifecycle consumers
26
+ * have no way to read it back.
27
+ *
28
+ * Scope:
29
+ * Owns capture and read of the content map.
30
+ * Does not own the map's shape (Eleventy's) or how consumers use it.
31
+ *
32
+ * Data flow:
33
+ * eleventy.contentMap event → registry scope → get()
34
+ *
35
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
36
+ * @returns {{get: () => object | null, snapshot: () => object | null}}
37
+ */
38
+ export function createContentMapStore(eleventyConfig) {
39
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
40
+
41
+ addScopeListener(eleventyConfig, SCOPE_NAME, 'eleventy.contentMap', 'write', (scope, data) => {
42
+ setEntry(scope, KEY, data);
43
+ });
44
+
45
+ const read = () => getEntry(scope, KEY) ?? null;
46
+
47
+ return {
48
+ get: read,
49
+ snapshot: read
50
+ };
51
+ }
@@ -0,0 +1,4 @@
1
+ // Filters barrel.
2
+ export { markdownFilter } from './markdown.js';
3
+ export { relatedPostsFilter } from './related-posts.js';
4
+ export { isStringFilter } from './isString.js';
@@ -1,3 +1,8 @@
1
- export default function isStringFilter(object) {
1
+ /**
2
+ * Test whether a value is a string.
3
+ * @param {*} object - Value to test.
4
+ * @returns {boolean}
5
+ */
6
+ export function isStringFilter(object) {
2
7
  return typeof object === 'string';
3
8
  }
@@ -3,6 +3,12 @@ import markdownit from 'markdown-it';
3
3
 
4
4
  const md = markdownit();
5
5
 
6
+ /**
7
+ * Render a string as inline Markdown (no wrapping <p> tag).
8
+ * @param {string} string - Markdown source.
9
+ * @returns {string} HTML output.
10
+ */
6
11
  export const markdownFilter = (string) => {
12
+ if (!string) return '';
7
13
  return md.renderInline(string);
8
14
  };
@@ -1,4 +1,10 @@
1
- export default function relatedPostsFilter(collection = []) {
1
+ /**
2
+ * Filter the current page out of a collection.
3
+ * Uses `this.ctx.page` from the Nunjucks runtime to identify the current page.
4
+ * @param {Array<Object>} [collection=[]] - Collection to filter.
5
+ * @returns {Array<Object>} Collection without the current page.
6
+ */
7
+ export function relatedPostsFilter(collection = []) {
2
8
  const page = this?.ctx?.page;
3
9
  if (!page?.url) return collection;
4
10
  return collection.filter((post) => post.url !== page.url);
@@ -0,0 +1,6 @@
1
+ import { registerDateGlobal } from './date.js';
2
+
3
+ /** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */
4
+ export function registerGlobals(eleventyConfig) {
5
+ registerDateGlobal(eleventyConfig);
6
+ }
package/core/logging.js CHANGED
@@ -1,32 +1,32 @@
1
- /**
2
- * Gets verbose flag from Eleventy global data
3
- * @param {import("@11ty/eleventy").UserConfig} eleventyConfig
4
- * @returns {boolean}
5
- */
6
- export function getVerbose(eleventyConfig) {
7
- const baselineData = eleventyConfig.globalData?._baseline;
8
- return !!baselineData?.verbose;
9
- }
1
+ import chalk from 'kleur';
10
2
 
11
3
  /**
12
- * Logs a message if verbose mode is enabled
13
- * @param {boolean} verbose - Whether verbose logging is enabled
14
- * @param {string} message - Message to log
15
- * @param {...any} args - Additional arguments
4
+ * @typedef {Object} BaselineLogger
5
+ * @property {(...args: unknown[]) => void} info Verbose-only.
6
+ * @property {(...args: unknown[]) => void} warn Always visible.
7
+ * @property {(...args: unknown[]) => void} error Always visible.
16
8
  */
17
- export function logIfVerbose(verbose, message, ...args) {
18
- if (verbose) {
19
- console.log(`[eleventy-plugin-baseline] INFO ${message}`, ...args);
20
- }
21
- }
22
9
 
23
10
  /**
24
- * Logs a warning if verbose mode is enabled
25
- * @param {boolean} verbose - Whether verbose logging is enabled
26
- * @param {string} message - Warning message
11
+ * Create a namespaced logger. Prefix is `[baseline]` at plugin root and
12
+ * `[baseline:<namespace>]` inside modules. `info` is gated behind `verbose`;
13
+ * `warn` and `error` always emit.
14
+ *
15
+ * @param {string | null | undefined} namespace
16
+ * @param {{ verbose?: boolean }} [options]
17
+ * @returns {BaselineLogger}
27
18
  */
28
- export function warnIfVerbose(verbose, message) {
29
- if (verbose) {
30
- console.warn(`[eleventy-plugin-baseline] WARN ${message}`);
31
- }
19
+ export function createLogger(namespace, { verbose = false } = {}) {
20
+ const label = namespace ? `[baseline/${namespace}]` : '[baseline]';
21
+ return {
22
+ info: (...args) => {
23
+ if (verbose) console.log(chalk.gray(label), ...args);
24
+ },
25
+ warn: (...args) => {
26
+ console.warn(chalk.yellow().bold(label), ...args);
27
+ },
28
+ error: (...args) => {
29
+ console.error(chalk.red().bold(label), ...args);
30
+ }
31
+ };
32
32
  }
@@ -0,0 +1,310 @@
1
+ import pick from './utils/pick.js';
2
+ import { slugify } from './utils/helpers.js';
3
+ import { createLogger } from './logging.js';
4
+ import { getScope, memoize, setEntry } from './registry.js';
5
+
6
+ const SCOPE_NAME = 'core:page-context';
7
+ const COMPUTED_KEY = 'eleventyComputed._pageContext';
8
+
9
+ /**
10
+ * Page context (runtime substrate)
11
+ *
12
+ * A normalised per-page object built once at cascade-time and cached for
13
+ * transform-time consumers. The shape downstream modules read instead of
14
+ * re-deriving from raw cascade data.
15
+ *
16
+ * Architecture layer:
17
+ * runtime substrate
18
+ *
19
+ * System role:
20
+ * Lifecycle bridge between Eleventy's data cascade and the htmlTransformer.
21
+ * Head reads it via `getByKey`; navigator snapshots it for inspection.
22
+ *
23
+ * Lifecycle:
24
+ * cascade-time → eleventyComputed._pageContext builds and caches the context
25
+ * transform-time → consumers retrieve the cached context by page.url
26
+ *
27
+ * Why this exists:
28
+ * Eleventy's htmlTransformer context exposes only page metadata, not the
29
+ * data cascade. The cache lets transform-time consumers read the same
30
+ * normalised view that cascade-time produced.
31
+ *
32
+ * Scope:
33
+ * Owns the page-context shape, memoisation, key-based lookup, and snapshot.
34
+ * Does not own the meaning of any field; modules consume them as they see fit.
35
+ * Templates with `_internal: true` are skipped (synthetic sitemap pages, etc.).
36
+ *
37
+ * Data flow:
38
+ * data cascade → buildPageContext → registry scope → head, navigator
39
+ *
40
+ * @param {import("@11ty/eleventy").UserConfig} eleventyConfig
41
+ * @param {Object} coreContext - Resolved baseline core context (state, runtime, helpers).
42
+ */
43
+ export function registerPageContext(eleventyConfig, coreContext) {
44
+ const { state, runtime, site } = coreContext;
45
+ const { slugIndex } = runtime;
46
+ const { settings, options } = state;
47
+
48
+ const log = createLogger(SCOPE_NAME, { verbose: options.verbose });
49
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
50
+
51
+ // Head options.
52
+ const separator = options.head?.titleSeparator ?? ' – ';
53
+ const generator = options.head?.showGenerator ?? false;
54
+
55
+ function shouldSkip(data) {
56
+ if (data._internal) return true;
57
+ if (data.page?.outputFileExtension !== 'html') return true;
58
+ return false;
59
+ }
60
+
61
+ // --- Helpers ---
62
+ const uniqueBy = (arr, keyFn) =>
63
+ Object.values(
64
+ (arr ?? []).reduce((acc, item) => {
65
+ if (!item) return acc;
66
+
67
+ const id = typeof keyFn === 'function' ? keyFn(item) : item?.[keyFn];
68
+
69
+ if (!id) {
70
+ acc[JSON.stringify(item)] = item;
71
+ return acc;
72
+ }
73
+
74
+ acc[id] = item;
75
+ return acc;
76
+ }, {})
77
+ );
78
+
79
+ // --- SEO helpers ---
80
+ function stripTrackingParams(urlObj) {
81
+ ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid'].forEach((p) =>
82
+ urlObj.searchParams.delete(p)
83
+ );
84
+
85
+ urlObj.hash = '';
86
+ return urlObj;
87
+ }
88
+
89
+ function extractFirstParagraph(data) {
90
+ const html = data?.content;
91
+ if (!html) return null;
92
+ const match = html.match(/<p>(.*?)<\/p>/i);
93
+ return match?.[1] ?? null;
94
+ }
95
+
96
+ function normalizeCanonical(path, siteUrl) {
97
+ if (!path || !siteUrl) return null;
98
+
99
+ const url = new URL(path, siteUrl);
100
+
101
+ url.hash = '';
102
+
103
+ return stripTrackingParams(url).href;
104
+ }
105
+
106
+ // --- Field resolver ---
107
+ function resolveField({ pageValue, siteValue, fallbackValue, isHome }) {
108
+ let value = pageValue ?? siteValue ?? fallbackValue ?? null;
109
+
110
+ return value;
111
+ }
112
+
113
+ // --- Builders ---
114
+ function buildSite(lang, userSettings) {
115
+ const langEntry = lang ? userSettings.languages?.[lang] : undefined;
116
+ return {
117
+ title: langEntry?.title ?? userSettings.title ?? '',
118
+ tagline: langEntry?.tagline ?? userSettings.tagline ?? '',
119
+ description: langEntry?.description ?? userSettings.description ?? '',
120
+ url: userSettings.url ?? '',
121
+ noindex: userSettings.noindex === true
122
+ };
123
+ }
124
+
125
+ function buildPage(pageInput) {
126
+ return {
127
+ inputPath: pageInput?.inputPath ?? null,
128
+ fileSlug: pageInput?.fileSlug ?? null,
129
+ filePathStem: pageInput?.filePathStem ?? null,
130
+ outputFileExtension: pageInput?.outputFileExtension ?? null,
131
+ templateSyntax: pageInput?.templateSyntax ?? null,
132
+ date: pageInput?.date ?? null,
133
+ url: pageInput?.url ?? null,
134
+ outputPath: pageInput?.outputPath ?? null,
135
+ lang: pageInput?.lang ?? null,
136
+ locale: pageInput?.locale ?? null,
137
+ sitemap: pageInput?.sitemap ?? null
138
+ };
139
+ }
140
+
141
+ function buildEntry(data) {
142
+ const rawSlug = data?.slug ?? data?.page?.fileSlug;
143
+
144
+ return {
145
+ title: data?.seo?.title ?? data?.title ?? null,
146
+ description: data?.seo?.description ?? data?.description ?? null,
147
+ excerpt: data?.excerpt ?? null,
148
+ slug: slugify(rawSlug),
149
+ head: data?.head ?? null
150
+ };
151
+ }
152
+
153
+ function buildQuery({ entry, page }) {
154
+ return {
155
+ isHome: page.url === '/'
156
+ };
157
+ }
158
+
159
+ function buildMeta({ data, site, page, query }) {
160
+ const noindex = site.noindex || data?.noindex === true;
161
+
162
+ const robots = noindex
163
+ ? 'noindex, nofollow'
164
+ : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
165
+
166
+ const contentMap = runtime.contentMap;
167
+
168
+ const siteTitle = site.title;
169
+ const siteDescription = site.description;
170
+ const tagline = site.tagline;
171
+
172
+ const pageTitle = data?.seo?.title ?? data?.title ?? siteTitle;
173
+ const pageDescription = data?.seo?.description ?? data?.description ?? data?.excerpt ?? extractFirstParagraph(data);
174
+
175
+ function enhance(value) {
176
+ if (query.isHome && !data?.seo?.title && tagline) {
177
+ return `${siteTitle}${separator}${tagline}`;
178
+ }
179
+
180
+ if (!query.isHome && pageTitle && siteTitle && pageTitle !== siteTitle) {
181
+ return `${pageTitle}${separator}${siteTitle}`;
182
+ }
183
+
184
+ return value;
185
+ }
186
+
187
+ // ---- DESCRIPTION ----
188
+ const description = resolveField({
189
+ pageValue: pageDescription,
190
+ siteValue: siteDescription,
191
+ isHome: query.isHome
192
+ });
193
+
194
+ // ---- TITLE ----
195
+ const base = resolveField({
196
+ pageValue: pageTitle,
197
+ siteValue: siteTitle
198
+ });
199
+
200
+ const title = enhance(base);
201
+
202
+ // ---- CANONICAL ----
203
+ let canonical = null;
204
+
205
+ if (!noindex) {
206
+ const rawCanonical =
207
+ data?.canonical ?? page.url ?? (page.inputPath && contentMap?.inputPathToUrl?.[page.inputPath]?.[0]);
208
+
209
+ canonical = normalizeCanonical(rawCanonical, site.url);
210
+ }
211
+
212
+ return {
213
+ title,
214
+ description,
215
+ canonical,
216
+ robots,
217
+ noindex
218
+ };
219
+ }
220
+
221
+ function buildRender(data) {
222
+ return {
223
+ generator: data?.eleventy?.generator ?? null
224
+ };
225
+ }
226
+
227
+ // HEAD (global + page-level merge + dedupe)
228
+ function buildHead({ userSettings, data }) {
229
+ const userHead = userSettings.head ?? {};
230
+ const pageHead = data?.head ?? {};
231
+
232
+ const link = uniqueBy([...(userHead.link ?? []), ...(pageHead.link ?? [])], (item) => {
233
+ if (item?.rel === 'canonical') {
234
+ try {
235
+ return normalizeCanonical(item.href, site.url);
236
+ } catch {
237
+ return item?.href;
238
+ }
239
+ }
240
+ return item?.href;
241
+ });
242
+
243
+ const script = uniqueBy([...(userHead.script ?? []), ...(pageHead.script ?? [])], 'src');
244
+
245
+ const style = uniqueBy([...(userHead.style ?? []), ...(pageHead.style ?? [])], 'href');
246
+
247
+ const meta = uniqueBy([...(userHead.meta ?? []), ...(pageHead.meta ?? [])], 'name');
248
+
249
+ return {
250
+ link,
251
+ script,
252
+ style,
253
+ meta
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Main context builder.
259
+ * Pure transformation: Eleventy data → normalised page context.
260
+ */
261
+ function buildPageContext(data) {
262
+ const pageInput = data.page ?? {};
263
+ const userSettings = data.settings ?? settings;
264
+
265
+ const page = buildPage(pageInput);
266
+ const site = buildSite(page.lang, userSettings);
267
+ const entry = buildEntry(data);
268
+ const query = buildQuery({ entry, page });
269
+ const meta = buildMeta({ data, site, page, query });
270
+ const render = buildRender(data);
271
+ const head = buildHead({ userSettings, data });
272
+
273
+ const context = {
274
+ site,
275
+ page,
276
+ entry,
277
+ query,
278
+ meta,
279
+ render,
280
+ head
281
+ };
282
+
283
+ const inspectionKey = context.page.url ?? context.page.inputPath;
284
+ if (inspectionKey) setEntry(scope, inspectionKey, context);
285
+
286
+ if (slugIndex && entry.slug && page.url) {
287
+ const eligible = page.locale?.isDefaultLang === true;
288
+ if (eligible) {
289
+ slugIndex.set(entry.slug, page.url, page.inputPath);
290
+ }
291
+ }
292
+
293
+ return context;
294
+ }
295
+
296
+ eleventyConfig.addGlobalData(COMPUTED_KEY, () => {
297
+ return (data) => {
298
+ if (shouldSkip(data)) return null;
299
+ return memoize(scope, data, buildPageContext);
300
+ };
301
+ });
302
+
303
+ log.info('Page context added to the data cascade and registry exposed');
304
+
305
+ return {
306
+ get: (data) => scope.cache.get(data),
307
+ getByKey: (key) => scope.values.get(key),
308
+ snapshot: () => Object.fromEntries(scope.values)
309
+ };
310
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * State isolation kernel (registry)
3
+ *
4
+ * Per-config scopes that hold caches, named values, and listener dedup sets.
5
+ * Every store and sub-registry in the substrate borrows a scope here; no
6
+ * feature data lives directly inside.
7
+ *
8
+ * Architecture layer:
9
+ * registry
10
+ *
11
+ * System role:
12
+ * Underpins virtual-dir, content-map store, translation-map store, and the
13
+ * page-context registry. The seam between eleventyConfig and any long-lived
14
+ * runtime state Baseline carries.
15
+ *
16
+ * Lifecycle:
17
+ * build-time → scopes created on demand
18
+ * cascade-time → values populated by store writers
19
+ * transform-time → values read by transform-time consumers
20
+ *
21
+ * Why this exists:
22
+ * Eleventy has no first-class place to hang per-config singletons. A
23
+ * WeakMap keyed by eleventyConfig keeps state isolated across reloads and
24
+ * parallel builds without leaks, and gives every consumer a stable slot.
25
+ *
26
+ * Scope:
27
+ * Owns scope creation, listener dedup, and identity-keyed memoisation.
28
+ * Does not own any feature data; only the containers feature code lives in.
29
+ *
30
+ * Data flow:
31
+ * eleventyConfig → scope → { cache, values, listeners } → consumer
32
+ */
33
+
34
+ const roots = new WeakMap();
35
+
36
+ /**
37
+ * Get or create the per-eleventyConfig root that holds all named scopes.
38
+ */
39
+ function getRoot(eleventyConfig) {
40
+ let root = roots.get(eleventyConfig);
41
+ if (!root) {
42
+ root = new Map();
43
+ roots.set(eleventyConfig, root);
44
+ }
45
+ return root;
46
+ }
47
+
48
+ /**
49
+ * Get or create a named scope bound to an Eleventy config instance.
50
+ *
51
+ * A scope is a per-config bag of state: a cache (for identity-keyed
52
+ * memoisation), a values map (for named entries), and a listeners set
53
+ * (for deduping event hookups).
54
+ *
55
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
56
+ * @param {string} name - Scope identifier (e.g. 'page-context', 'content-store').
57
+ * @returns {{cache: WeakMap, values: Map, listeners: Set<string>}}
58
+ */
59
+ export function getScope(eleventyConfig, name) {
60
+ const root = getRoot(eleventyConfig);
61
+
62
+ if (!root.has(name)) {
63
+ root.set(name, {
64
+ cache: new WeakMap(),
65
+ values: new Map(),
66
+ listeners: new Set()
67
+ });
68
+ }
69
+
70
+ return root.get(name);
71
+ }
72
+
73
+ /**
74
+ * Attach an Eleventy event listener once per (event, key) pair within a scope.
75
+ *
76
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
77
+ * @param {string} scopeName
78
+ * @param {string} eventName - Eleventy event name (e.g. 'eleventy.contentMap').
79
+ * @param {string} listenerKey - Stable identifier for dedup.
80
+ * @param {(scope: object, payload: any) => void} handler
81
+ */
82
+ export function addScopeListener(eleventyConfig, scopeName, eventName, listenerKey, handler) {
83
+ const scope = getScope(eleventyConfig, scopeName);
84
+ const dedupKey = `${eventName}::${listenerKey}`;
85
+
86
+ if (scope.listeners.has(dedupKey)) return;
87
+ scope.listeners.add(dedupKey);
88
+
89
+ eleventyConfig.on(eventName, (payload) => handler(scope, payload));
90
+ }
91
+
92
+ /**
93
+ * Memoise a value in the scope's cache by object identity.
94
+ */
95
+ export function memoize(scope, key, factory) {
96
+ if (scope.cache.has(key)) {
97
+ return scope.cache.get(key);
98
+ }
99
+ const value = factory(key);
100
+ scope.cache.set(key, value);
101
+ return value;
102
+ }
103
+
104
+ export function setEntry(scope, name, value) {
105
+ scope.values.set(name, value);
106
+ }
107
+
108
+ export function getEntry(scope, name) {
109
+ return scope.values.get(name);
110
+ }