@apleasantview/eleventy-plugin-baseline 0.1.0-next.33 → 0.1.0-next.40

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 (59) 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 +54 -23
  8. package/core/page-context.js +310 -0
  9. package/core/registry.js +110 -0
  10. package/core/schema.js +70 -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/processors/esbuild-process.js +68 -0
  23. package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +39 -2
  24. package/modules/assets/schema.js +14 -0
  25. package/modules/head/drivers/capo-adapter.js +94 -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/assets-esbuild/process.js +0 -33
  49. package/modules/head-core/drivers/posthtml-head-elements.js +0 -127
  50. package/modules/head-core/plugins/head-core.js +0 -75
  51. package/modules/head-core/utils/head-utils.js +0 -249
  52. package/modules/multilang-core/plugins/multilang-core.js +0 -118
  53. package/modules/navigator-core/plugins/navigator-core.js +0 -57
  54. package/modules/sitemap-core/plugins/sitemap-core.js +0 -88
  55. /package/core/{globals → global-functions}/date.js +0 -0
  56. /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
  57. /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
  58. /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
  59. /package/modules/{multilang-core → multilang}/filters/i18n-translations-for.js +0 -0
@@ -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
+ }
package/core/schema.js ADDED
@@ -0,0 +1,70 @@
1
+ import * as z from 'zod';
2
+
3
+ /**
4
+ * Schemas (runtime substrate)
5
+ *
6
+ * Zod schemas for the two user-facing inputs Baseline validates: the
7
+ * directory `config` export and the `settings` argument. Structural only.
8
+ * Value-level preferences stay permissive.
9
+ *
10
+ * Architecture layer:
11
+ * runtime substrate
12
+ *
13
+ * System role:
14
+ * Validation seam at the public boundary. The composition root parses
15
+ * `settings` non-fatally at init; the directory `config` is checked in
16
+ * the test suite, not at runtime.
17
+ *
18
+ * Lifecycle:
19
+ * build-time → composition root calls `settingsSchema.safeParse(settings)`
20
+ * and logs structural mismatches under `info`
21
+ *
22
+ * Why this exists:
23
+ * Eleventy accepts almost anything users pass through `addPlugin`. A
24
+ * structural gate catches typos and shape drift early without forcing
25
+ * a hard failure on imperfect input.
26
+ *
27
+ * Scope:
28
+ * Owns the structural shape of `settings` and `config`. Does not own
29
+ * defaults, value semantics, or required-field policy; those live in
30
+ * the composition root and individual modules.
31
+ *
32
+ * Data flow:
33
+ * user input → safeParse → issues logged or accepted
34
+ */
35
+
36
+ export const configSchema = z.object({
37
+ dir: z.object({
38
+ input: z.string().min(1),
39
+ output: z.string().min(1),
40
+ data: z.string().min(1),
41
+ includes: z.string().min(1),
42
+ assets: z.string().min(1),
43
+ public: z.string().min(1)
44
+ }),
45
+ htmlTemplateEngine: z.string().min(1),
46
+ markdownTemplateEngine: z.string().min(1),
47
+ templateFormats: z
48
+ .array(z.string().min(1))
49
+ .min(1)
50
+ .refine((templateFormats) => templateFormats.includes('njk'), {
51
+ error: 'Baseline requires njk in templateFormats'
52
+ })
53
+ });
54
+
55
+ export const settingsSchema = z.object({
56
+ title: z.string().optional(),
57
+ tagline: z.string().optional(),
58
+ url: z.string().optional(),
59
+ noindex: z.boolean().optional(),
60
+ defaultLanguage: z.string().optional(),
61
+ languages: z.record(z.string(), z.looseObject({})).optional(),
62
+ head: z
63
+ .object({
64
+ link: z.array(z.looseObject({})).optional(),
65
+ script: z.array(z.looseObject({})).optional(),
66
+ meta: z.array(z.looseObject({})).optional(),
67
+ style: z.array(z.looseObject({})).optional()
68
+ })
69
+ .optional()
70
+ });
@@ -1,5 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import Image from '@11ty/eleventy-img';
3
+ import { createLogger } from '../logging.js';
4
+
5
+ // Module-level logger. Image shortcode only uses `.warn`, which emits regardless
6
+ // of verbose, so we don't thread verbose through the shortcode signature.
7
+ const log = createLogger('image');
3
8
 
4
9
  const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 'auto'];
5
10
  const DEFAULT_FORMATS = ['avif', 'webp'];
@@ -59,13 +64,13 @@ export async function imageShortcode(options = {}) {
59
64
  } = options;
60
65
  // Read from global data set during plugin init. When true, `eleventy:ignore`
61
66
  // is added to the <img> (line 140) to prevent double-processing.
62
- const hasImageTransformPlugin = this.ctx._baseline.hasImageTransformPlugin;
67
+ const hasImageTransformPlugin = this.ctx._baseline.features.hasImageTransformPlugin;
63
68
 
64
69
  // --- Validation and normalization ---
65
70
 
66
71
  if (!src) throw new Error(`imageShortcode: src is required (received ${JSON.stringify(src)})`);
67
72
  if (alt == null) {
68
- console.warn('imageShortcode: alt is required (use empty string for decorative images)');
73
+ log.warn('alt is required (use empty string for decorative images)');
69
74
  }
70
75
 
71
76
  const normalizedCaption = String(caption);
@@ -101,7 +106,7 @@ export async function imageShortcode(options = {}) {
101
106
  });
102
107
  } catch (error) {
103
108
  if (process.env.ELEVENTY_RUN_MODE === 'serve') {
104
- console.warn(`imageShortcode: transformOnRequest failed for ${src}, retrying.\n > ${error?.message || error}`);
109
+ log.warn(`transformOnRequest failed for ${src}, retrying.\n > ${error?.message || error}`);
105
110
  metadata = await Image(resolvedSrc, imageOptions);
106
111
  } else {
107
112
  throw error;
@@ -0,0 +1,2 @@
1
+ // Shortcodes barrel.
2
+ export { imageShortcode } from './image.js';
@@ -0,0 +1,61 @@
1
+ import { getScope, setEntry, getEntry } from './registry.js';
2
+
3
+ const SCOPE_NAME = 'core:slug-index';
4
+
5
+ /**
6
+ * Slug index (runtime substrate)
7
+ *
8
+ * Maps wikilink-friendly slugs to canonical page URLs. Populated by the
9
+ * page-context builder as each page resolves; read by the wikilinks
10
+ * markdown-it plugin during body render.
11
+ *
12
+ * Architecture layer:
13
+ * runtime substrate
14
+ *
15
+ * System role:
16
+ * Forward-link index for the wikilinks plugin. In multilingual sites
17
+ * only defaultLanguage pages register; the wikilinks plugin uses the
18
+ * translation map to hop to other languages.
19
+ *
20
+ * Lifecycle:
21
+ * cascade-time → page-context registers slugs as templates compute
22
+ * transform-time → wikilinks plugin resolves slugs during body render
23
+ *
24
+ * Why this exists:
25
+ * Eleventy's data cascade resolves all eleventyComputed values before any
26
+ * body renders, so the index is complete by the time wikilinks need it.
27
+ * A dedicated scope keeps slug→url out of the page-context values map
28
+ * (which holds url→pageContext).
29
+ *
30
+ * Scope:
31
+ * Owns slug registration with collision detection and slug-keyed lookup.
32
+ * Does not own slug derivation (page-context) or link rendering (wikilinks).
33
+ *
34
+ * Data flow:
35
+ * page-context.buildPageContext → set() → registry scope → wikilinks getBySlug()
36
+ *
37
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
38
+ * @returns {{set: (slug: string, url: string, inputPath?: string) => void, getBySlug: (slug: string) => string | null, snapshot: () => Record<string, {url: string, inputPath?: string}>}}
39
+ */
40
+ export function createSlugIndex(eleventyConfig) {
41
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
42
+
43
+ return {
44
+ set(slug, url, inputPath) {
45
+ if (!slug || !url) return;
46
+ const existing = getEntry(scope, slug);
47
+ if (existing && existing.url !== url) {
48
+ throw new Error(
49
+ `Wikilink slug collision: "${slug}" used by both ${existing.inputPath ?? existing.url} and ${inputPath ?? url}`
50
+ );
51
+ }
52
+ setEntry(scope, slug, { url, inputPath });
53
+ },
54
+ getBySlug(slug) {
55
+ return getEntry(scope, slug)?.url ?? null;
56
+ },
57
+ snapshot() {
58
+ return Object.fromEntries(scope.values);
59
+ }
60
+ };
61
+ }
@@ -0,0 +1,46 @@
1
+ import { getScope, setEntry, getEntry } from './registry.js';
2
+
3
+ const SCOPE_NAME = 'core:translation-map-store';
4
+ const KEY = 'translationMap';
5
+
6
+ /**
7
+ * Translation map store (runtime substrate)
8
+ *
9
+ * Hand-off point for the translations map: written by multilang at
10
+ * cascade-time, read by head at transform-time.
11
+ *
12
+ * Architecture layer:
13
+ * runtime substrate
14
+ *
15
+ * System role:
16
+ * Bridge between the multilang module (writer) and the head module (reader).
17
+ * The set/get pair lives in a registry scope rather than the data cascade
18
+ * because head's PostHTML plugin runs outside the cascade.
19
+ *
20
+ * Lifecycle:
21
+ * cascade-time → multilang's translationsMap collection writes via set()
22
+ * transform-time → head reads via get() to build hreflang alternates
23
+ *
24
+ * Why this exists:
25
+ * The translations map is built inside an Eleventy collection, but head
26
+ * needs it inside an htmlTransformer plugin where collections aren't
27
+ * available.
28
+ *
29
+ * Scope:
30
+ * Owns set/get on a per-config scope.
31
+ * Does not own the map's shape, how it's built, or how head uses it.
32
+ *
33
+ * Data flow:
34
+ * multilang translationsMap collection → set() → registry scope → head get()
35
+ *
36
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
37
+ * @returns {{set: (map: object) => void, get: () => object | null}}
38
+ */
39
+ export function createTranslationMapStore(eleventyConfig) {
40
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
41
+
42
+ return {
43
+ set: (map) => setEntry(scope, KEY, map),
44
+ get: () => getEntry(scope, KEY) ?? null
45
+ };
46
+ }
package/core/types.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Baseline shared typedefs.
3
+ *
4
+ * Extracted from the composition root so file-level headers stay scannable.
5
+ * No runtime exports; this file exists for IDE and JSDoc tooling only.
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} BaselineSettings
10
+ * Site identity and SEO configuration.
11
+ *
12
+ * @property {string} [title]
13
+ * @property {string} [tagline]
14
+ * @property {string} [url]
15
+ * @property {boolean} [noindex]
16
+ * @property {string} [defaultLanguage]
17
+ * @property {Record<string, unknown>} [languages]
18
+ * @property {Object} [head]
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} BaselineOptions
23
+ * Runtime feature flags and behaviour configuration.
24
+ *
25
+ * User-facing input. Each module reads its own slice from `state.options.<module>`.
26
+ *
27
+ * @property {boolean} [verbose]
28
+ * Enables structured debug logging across modules.
29
+ *
30
+ * @property {boolean} [multilingual]
31
+ * Enables multilingual mode. Requires settings.defaultLanguage and
32
+ * settings.languages; the multilang module bails with a log otherwise.
33
+ *
34
+ * @property {boolean} [sitemap]
35
+ * Enables sitemap generation module (default: true).
36
+ *
37
+ * @property {boolean | { template?: boolean, inspectorDepth?: number }} [navigator]
38
+ * Controls navigator tooling. Boolean shorthand activates the module and
39
+ * the virtual debug page. Object form lets the page render flag and the
40
+ * inspector depth be tuned independently. Defaults to true in dev mode.
41
+ *
42
+ * @property {{ titleSeparator?: string, showGenerator?: boolean }} [head]
43
+ * Head module options.
44
+ *
45
+ * @property {{ esbuild?: { minify?: boolean, target?: string } }} [assets]
46
+ * Assets module options. The esbuild slice is permissive: any esbuild
47
+ * option is accepted; only `minify` and `target` are typed.
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} BaselineState
52
+ * Fully resolved internal plugin state.
53
+ *
54
+ * @property {Object} settings
55
+ * @property {Object} options
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} BaselineContext
60
+ * Shared module boundary contract.
61
+ *
62
+ * This context is the only supported interface between:
63
+ * - Eleventy configuration runtime
64
+ * - baseline core
65
+ * - feature modules
66
+ *
67
+ * @property {BaselineState} state
68
+ * @property {Object} runtime
69
+ * @property {Object} runtime.contentMap
70
+ * @property {Object} runtime.site
71
+ */
72
+
73
+ export {};
@@ -0,0 +1,75 @@
1
+ import { TemplatePath } from '@11ty/eleventy-utils';
2
+ import slugifyLib from 'slugify';
3
+
4
+ /**
5
+ * Helper function to add trailing slash to a path
6
+ * @param {string} path
7
+ * @returns {string}
8
+ */
9
+ export function addTrailingSlash(path) {
10
+ if (path.slice(-1) === '/') {
11
+ return path;
12
+ }
13
+ return path + '/';
14
+ }
15
+
16
+ /**
17
+ * Resolve a subdirectory under input and output.
18
+ * Joins inputDir/outputDir with rawDir, normalises, and adds trailing slashes.
19
+ * @param {string} inputDir - The input directory (e.g., "./src/").
20
+ * @param {string} outputDir - The output directory (e.g., "./dist/").
21
+ * @param {string} rawDir - Raw subdirectory value (e.g., "assets", "static").
22
+ * @returns {{input: string, output: string}}
23
+ */
24
+ export function resolveSubdir(inputDir, outputDir, rawDir) {
25
+ const joinedInput = TemplatePath.join(inputDir, rawDir || '');
26
+ const joinedOutput = TemplatePath.join(outputDir, rawDir || '');
27
+
28
+ return {
29
+ input: addTrailingSlash(TemplatePath.standardizeFilePath(joinedInput)),
30
+ output: addTrailingSlash(TemplatePath.standardizeFilePath(joinedOutput))
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Slugify a string into a wikilink-friendly key.
36
+ * Lowercases, strips diacritics, replaces non-alphanumerics with hyphens,
37
+ * trims leading/trailing hyphens. Returns null for empty input.
38
+ *
39
+ * @param {string|null|undefined} input
40
+ * @returns {string|null}
41
+ */
42
+ export function slugify(input) {
43
+ if (input == null) return null;
44
+ const slug = slugifyLib(String(input), { lower: true, strict: true, trim: true });
45
+ return slug || null;
46
+ }
47
+
48
+ /**
49
+ * Normalize language input to an object map.
50
+ * Accepts an array of language codes or an object keyed by language code.
51
+ * Returns null if input is invalid or empty.
52
+ *
53
+ * @param {Object} settings - Options object containing languages.
54
+ * @param {import('../logging.js').BaselineLogger} [logger] - Logger for dropped-entry notice.
55
+ * @returns {Record<string, Object>|null} Normalized language map, or null.
56
+ */
57
+ export function normalizeLanguages(settings, logger) {
58
+ const normalizedLanguages = Array.isArray(settings.languages)
59
+ ? Object.fromEntries(
60
+ settings.languages
61
+ .filter((lang) => typeof lang === 'string' && lang.trim())
62
+ .map((lang) => [lang.toLowerCase().trim(), {}])
63
+ )
64
+ : settings.languages && typeof settings.languages === 'object'
65
+ ? settings.languages
66
+ : null;
67
+
68
+ if (logger && Array.isArray(settings.languages)) {
69
+ const normalizedCount = normalizedLanguages ? Object.keys(normalizedLanguages).length : 0;
70
+ if (normalizedCount !== settings.languages.length) {
71
+ logger.info('Some languages entries were invalid and were dropped.');
72
+ }
73
+ }
74
+ return normalizedLanguages;
75
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Return the first value that is neither undefined nor null.
3
+ * @param {...*} values
4
+ * @returns {*}
5
+ */
6
+ const pick = (...values) => values.find((v) => v !== undefined && v !== null);
7
+ export default pick;
@@ -0,0 +1,111 @@
1
+ import { TemplatePath } from '@11ty/eleventy-utils';
2
+ import { resolveSubdir } from './utils/helpers.js';
3
+ import { createLogger } from './logging.js';
4
+ import { getScope, addScopeListener, setEntry } from './registry.js';
5
+
6
+ /**
7
+ * Virtual directories (runtime substrate)
8
+ *
9
+ * Synthesises extra keys on eleventyConfig.directories (e.g. `assets`,
10
+ * `public`) that Eleventy itself won't accept, and keeps them in sync once
11
+ * Eleventy finalises its real directory map.
12
+ *
13
+ * Architecture layer:
14
+ * runtime substrate
15
+ *
16
+ * System role:
17
+ * Adds virtual dir keys consumed by modules (assets reads
18
+ * `directories.assets`) and by the composition root (passthrough copy from
19
+ * `directories.public`).
20
+ *
21
+ * Lifecycle:
22
+ * build-time → synthesise key, pre-populate cache from eleventyConfig.dir
23
+ * build-time → on `eleventy.directories`, refresh the cache to final paths
24
+ *
25
+ * Why this exists:
26
+ * Eleventy's ProjectDirectories.setViaConfigObject() only honours input,
27
+ * output, data, includes, and layouts. Extra `dir.*` keys are silently
28
+ * ignored, and the `eleventy.directories` event exposes only the same set.
29
+ * Synthesis fills the gap so consumers can read additional dirs the same
30
+ * way they read the real ones.
31
+ *
32
+ * Scope:
33
+ * Owns synthesis of extra `eleventyConfig.directories` keys, the live
34
+ * cache, and a single shared listener for sync.
35
+ * Does not own passthrough copy or watch wiring (composition root and
36
+ * modules own those).
37
+ *
38
+ * Data flow:
39
+ * { name, outputDir } → eleventyConfig.directories[name] getter →
40
+ * live { input, output } cache → consumers
41
+ */
42
+
43
+ const SCOPE_NAME = 'core:virtual-dir';
44
+
45
+ /**
46
+ * Register a virtual directory on eleventyConfig.directories.
47
+ *
48
+ * @param {import('@11ty/eleventy').UserConfig} eleventyConfig
49
+ * @param {Object} options
50
+ * @param {string} options.key - Key to synthesise (e.g. 'assets', 'public').
51
+ * @param {string} [options.outputDir] - Override the output subdirectory. Defaults
52
+ * to the raw dir value (symmetric with input). Pass `''` to resolve to the
53
+ * output root (used by `public`, which copies to `/`).
54
+ * @returns {{input: string, output: string}} Live cache; properties refresh when
55
+ * eleventy.directories fires. Safe to read at plugin-init time.
56
+ */
57
+ export function registerVirtualDir(eleventyConfig, { key, outputDir } = {}) {
58
+ if (!key) {
59
+ throw new Error('registerVirtualDir: `name` is required');
60
+ }
61
+
62
+ const log = createLogger(SCOPE_NAME);
63
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
64
+ const rawDir = eleventyConfig.dir?.[key] || key;
65
+ const rawOutputDir = outputDir ?? rawDir;
66
+ const cache = { input: null, output: null };
67
+
68
+ // Pre-populate from eleventyConfig.dir so synchronous readers at plugin-init
69
+ // time (watch globs, ignores, compile-guard prefixes) see a valid path.
70
+ syncCache(cache, eleventyConfig.dir || {}, rawDir, rawOutputDir);
71
+
72
+ setEntry(scope, key, { rawDir, rawOutputDir, cache });
73
+
74
+ // Define the virtual key once. The getter reads the live cache, which the
75
+ // shared listener below refreshes when Eleventy emits its final directories.
76
+ const existing = Object.getOwnPropertyDescriptor(eleventyConfig.directories, key);
77
+ if (existing && existing.configurable === false) {
78
+ log.info(`directories[${key}] already defined; skipping`);
79
+ } else {
80
+ Object.defineProperty(eleventyConfig.directories, key, {
81
+ get() {
82
+ return cache.input;
83
+ },
84
+ enumerable: true,
85
+ configurable: false
86
+ });
87
+ }
88
+
89
+ // One listener services all virtual dirs registered on this config.
90
+ // addScopeListener dedupes on ('eleventy.directories', 'sync'), so
91
+ // subsequent registerVirtualDir calls don't stack handlers.
92
+ addScopeListener(eleventyConfig, SCOPE_NAME, 'eleventy.directories', 'sync', (scope, dirs) => {
93
+ for (const entry of scope.values.values()) {
94
+ syncCache(entry.cache, dirs, entry.rawDir, entry.rawOutputDir);
95
+ }
96
+ });
97
+
98
+ log.info('Virtual directories mounted');
99
+
100
+ return cache;
101
+ }
102
+
103
+ function syncCache(cache, dirs, rawDir, rawOutputDir) {
104
+ const inputDir = TemplatePath.addLeadingDotSlash(dirs.input || './');
105
+ const outputDir = TemplatePath.addLeadingDotSlash(dirs.output || './');
106
+
107
+ // resolveSubdir symmetrically resolves against input and output; call twice
108
+ // so input and output subdirs can differ (e.g. `public` copies to root).
109
+ cache.input = resolveSubdir(inputDir, outputDir, rawDir).input;
110
+ cache.output = resolveSubdir(inputDir, outputDir, rawOutputDir).output;
111
+ }