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

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 (51) hide show
  1. package/README.md +21 -23
  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 +140 -0
  5. package/core/content-graph/graph.js +118 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/logging/banner.js +49 -0
  9. package/core/{logging.js → logging/index.js} +19 -2
  10. package/core/logging/quips.js +30 -0
  11. package/core/markdown/auto-heading-ids.js +86 -0
  12. package/core/markdown/index.js +5 -0
  13. package/core/markdown/safe-use.js +42 -0
  14. package/core/{wikilinks.js → markdown/wikilinks.js} +3 -3
  15. package/core/page-context/build.js +239 -0
  16. package/core/page-context/index.js +1 -0
  17. package/core/page-context/register.js +73 -0
  18. package/core/page-context/seo-helpers.js +56 -0
  19. package/core/schema.js +19 -1
  20. package/core/slug-index.js +2 -2
  21. package/core/state.js +73 -0
  22. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  23. package/core/surface/index.js +22 -0
  24. package/core/utils/add-trailing-slash.js +11 -0
  25. package/core/utils/ensure-dot-slash-dir.js +13 -0
  26. package/core/utils/normalize-languages.js +28 -0
  27. package/core/utils/resolve-field.js +9 -0
  28. package/core/utils/resolve-subdir.js +20 -0
  29. package/core/utils/slugify.js +15 -0
  30. package/core/utils/unique-by.js +25 -0
  31. package/core/virtual-dir.js +11 -10
  32. package/index.js +152 -115
  33. package/modules/assets/index.js +4 -2
  34. package/modules/assets/processors/esbuild-process.js +2 -2
  35. package/modules/assets/processors/postcss-process.js +2 -2
  36. package/modules/head/drivers/posthtml-head-elements.js +1 -3
  37. package/modules/head/index.js +7 -10
  38. package/modules/multilang/index.js +4 -2
  39. package/modules/navigator/index.js +33 -20
  40. package/modules/navigator/templates/navigator-core.html +1 -1
  41. package/modules/sitemap/index.js +7 -3
  42. package/package.json +4 -2
  43. package/core/filters/index.js +0 -4
  44. package/core/global-functions/index.js +0 -6
  45. package/core/page-context.js +0 -310
  46. package/core/shortcodes/index.js +0 -2
  47. package/core/utils/helpers.js +0 -75
  48. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  49. /package/core/{filters → surface/filters}/isString.js +0 -0
  50. /package/core/{filters → surface/filters}/related-posts.js +0 -0
  51. /package/core/{global-functions/date.js → surface/global-date-function.js} +0 -0
@@ -1,5 +1,8 @@
1
1
  import chalk from 'kleur';
2
2
 
3
+ export * from './banner.js';
4
+ export * from './quips.js';
5
+
3
6
  /**
4
7
  * Logger (runtime substrate)
5
8
  *
@@ -36,11 +39,12 @@ import chalk from 'kleur';
36
39
  * @property {(...args: unknown[]) => void} info Verbose-only.
37
40
  * @property {(...args: unknown[]) => void} warn Always visible.
38
41
  * @property {(...args: unknown[]) => void} error Always visible.
42
+ * @property {(content: string) => void} print Unprefixed pass-through (used by the banner).
39
43
  */
40
44
 
41
45
  /**
42
46
  * Create a namespaced logger. Prefix is `[baseline]` at plugin root and
43
- * `[baseline:<namespace>]` inside modules. `info` is gated behind `verbose`;
47
+ * `[baseline/<namespace>]` inside modules. `info` is gated behind `verbose`;
44
48
  * `warn` and `error` always emit.
45
49
  *
46
50
  * @param {string | null | undefined} namespace
@@ -49,15 +53,28 @@ import chalk from 'kleur';
49
53
  */
50
54
  export function createLogger(namespace, { verbose = false } = {}) {
51
55
  const label = namespace ? `[baseline/${namespace}]` : '[baseline]';
56
+
57
+ // Pre-pass gate: silence baseline's own info during the inner Eleventy
58
+ // run so modules don't double-log every line they emit again during the
59
+ // real build. Env-var contract scoped to runPrepass's execution (set in
60
+ // try, cleared in finally). Eleventy's own `[11ty]` output is governed
61
+ // by its `quietMode` and stays untouched here.
62
+ const isPrepass = () => process.env.BASELINE_PREPASS_ACTIVE === '1';
63
+
52
64
  return {
53
65
  info: (...args) => {
54
- if (verbose) console.log(chalk.gray(label), ...args);
66
+ if (!verbose) return;
67
+ if (isPrepass()) return;
68
+ console.log(chalk.gray(label), ...args);
55
69
  },
56
70
  warn: (...args) => {
57
71
  console.warn(chalk.yellow().bold(label), ...args);
58
72
  },
59
73
  error: (...args) => {
60
74
  console.error(chalk.red().bold(label), ...args);
75
+ },
76
+ print: (content) => {
77
+ console.log(chalk.gray(content));
61
78
  }
62
79
  };
63
80
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logging quips (personality)
3
+ *
4
+ * A small set of one-liners used at the pre-pass boundary. The pre-pass
5
+ * runs Eleventy inside Eleventy, so each line is a nod to that idea of
6
+ * repetition or recursion. Picked at random per build.
7
+ *
8
+ * Architecture layer:
9
+ * runtime substrate (logging)
10
+ *
11
+ * Why this exists:
12
+ * Build logs are dry by default. A single playful line at a recurring
13
+ * moment makes the narrative feel like the project, not a config dump.
14
+ */
15
+
16
+ const REPETITION_QUIPS = [
17
+ 'Somewhere, a bowl of petunias is thinking: oh no, not again.',
18
+ 'Déjà vu is usually a glitch in the Matrix.',
19
+ 'All of this has happened before, and all of it will happen again.',
20
+ 'Yo dawg, I heard you like Eleventy, so I put Eleventy in your Eleventy…'
21
+ ];
22
+
23
+ /**
24
+ * Return a random repetition quip.
25
+ *
26
+ * @returns {string}
27
+ */
28
+ export function pickRepetitionQuip() {
29
+ return REPETITION_QUIPS[Math.floor(Math.random() * REPETITION_QUIPS.length)];
30
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Auto heading IDs (runtime substrate)
3
+ *
4
+ * Assigns stable id attributes to every heading that doesn't already have
5
+ * one. Manual ids (from markdown-it-attrs or any upstream plugin) win and
6
+ * seed the dedup map, so auto ids skip over names a manual id has claimed.
7
+ * Duplicate slugs get WordPress-style suffixes: foo, foo-2, foo-3, …
8
+ *
9
+ * Architecture layer:
10
+ * runtime substrate
11
+ *
12
+ * System role:
13
+ * Markdown-it plugin registered by the composition root via
14
+ * amendLibrary('md', ...). Mutates heading_open tokens; reads nothing
15
+ * from the runtime.
16
+ *
17
+ * Lifecycle:
18
+ * transform-time → assigns id attrs on heading_open tokens during the
19
+ * core parsing phase
20
+ *
21
+ * Why this exists:
22
+ * The content graph and deep-link consumers all need predictable heading
23
+ * ids. Doing it inside the markdown engine means the rendered HTML and
24
+ * the graph's heading record are guaranteed identical, instead of being
25
+ * "usually equal because the same slugify helper runs on both sides".
26
+ *
27
+ * Scope:
28
+ * Owns id assignment and dedup. Does not render permalink affordances;
29
+ * that's a theme concern.
30
+ *
31
+ * @param {import('markdown-it').default} md
32
+ * @param {Object} deps
33
+ * @param {(text: string) => string | undefined} deps.slugify
34
+ */
35
+ export function autoHeadingIds(md, { slugify } = {}) {
36
+ if (typeof slugify !== 'function') {
37
+ throw new Error('auto-heading-ids plugin requires { slugify }');
38
+ }
39
+
40
+ md.core.ruler.push('baseline_auto_heading_ids', (state) => {
41
+ const tokens = state.tokens;
42
+ const seen = new Set();
43
+ const counts = new Map();
44
+
45
+ // Pass 1: seed dedup with manual ids so autos never collide with them.
46
+ for (let i = 0; i < tokens.length; i++) {
47
+ const t = tokens[i];
48
+ if (t.type !== 'heading_open') continue;
49
+ const manual = t.attrGet('id');
50
+ if (manual) seen.add(manual);
51
+ }
52
+
53
+ // Pass 2: assign auto ids to headings that lack one.
54
+ for (let i = 0; i < tokens.length; i++) {
55
+ const t = tokens[i];
56
+ if (t.type !== 'heading_open') continue;
57
+ if (t.attrGet('id')) continue;
58
+
59
+ // Read from children rather than .content: markdown-it-attrs
60
+ // strips the {.class} from children tokens but leaves the parent
61
+ // inline .content untouched, so slugifying .content would fold
62
+ // the attribute text into the id.
63
+ const inline = tokens[i + 1];
64
+ const text = (inline?.children || [])
65
+ .filter((c) => c.type === 'text' || c.type === 'code_inline' || c.type === 'image')
66
+ .map((c) => (c.type === 'image' ? c.attrGet('alt') || c.content || '' : c.content))
67
+ .join('')
68
+ .trim();
69
+ if (!text) continue;
70
+
71
+ const base = slugify(text);
72
+ if (!base) continue;
73
+
74
+ let n = counts.get(base) || 0;
75
+ let id;
76
+ do {
77
+ id = n === 0 ? base : `${base}-${n + 1}`;
78
+ n += 1;
79
+ } while (seen.has(id));
80
+
81
+ counts.set(base, n);
82
+ seen.add(id);
83
+ t.attrSet('id', id);
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,5 @@
1
+ // Markdown substrate barrel.
2
+ export { autoHeadingIds } from './auto-heading-ids.js';
3
+ export { wikilinks } from './wikilinks.js';
4
+ export { safeUse } from './safe-use.js';
5
+ export { markdownFilter } from './markdownify.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Safe markdown-it plugin registration
3
+ *
4
+ * Calls md.use(plugin, options) only when no rule with the given name is
5
+ * already registered on the core, block, or inline ruler. Lets Baseline
6
+ * coexist with user configs that loaded the same plugin themselves.
7
+ *
8
+ * Architecture layer:
9
+ * utility
10
+ *
11
+ * System role:
12
+ * Helper used by the composition root when amending the markdown-it
13
+ * instance.
14
+ *
15
+ * Lifecycle:
16
+ * transform-time (config load)
17
+ *
18
+ * Why this exists:
19
+ * markdown-it has no built-in dedup. Calling .use() twice on a plugin
20
+ * that pushes a parser rule runs it twice per render. Eleventy's
21
+ * amendLibrary callbacks compose with whatever the user already wired,
22
+ * so a guard at the seam keeps both sides intact.
23
+ *
24
+ * @param {import('markdown-it').default} md
25
+ * @param {string} ruleName - canonical rule name the plugin registers
26
+ * @param {(md: any, options?: any) => void} plugin
27
+ * @param {any} [options]
28
+ * @param {{ info?: (msg: string) => void }} [log]
29
+ */
30
+ export function safeUse(md, ruleName, plugin, options, log) {
31
+ const rulers = [md.core?.ruler, md.block?.ruler, md.inline?.ruler];
32
+ const installed = rulers.some((r) =>
33
+ r?.__rules__?.some((rule) => rule.name === ruleName)
34
+ );
35
+
36
+ if (installed) {
37
+ log?.info?.(`markdown-it rule "${ruleName}" already registered, skipping`);
38
+ return;
39
+ }
40
+
41
+ md.use(plugin, options);
42
+ }
@@ -1,4 +1,4 @@
1
- import { slugify } from './utils/helpers.js';
1
+ import { slugify } from '../utils/slugify.js';
2
2
 
3
3
  /**
4
4
  * Wikilinks (runtime substrate)
@@ -38,7 +38,7 @@ import { slugify } from './utils/helpers.js';
38
38
  *
39
39
  * @param {import('markdown-it').default} md
40
40
  * @param {Object} deps
41
- * @param {{getBySlug: (slug: string) => string | null}} deps.slugIndex
41
+ * @param {{getBySlug: (slug: string) => string | undefined}} deps.slugIndex
42
42
  * @param {{getByKey: (url: string) => any}} deps.pageContextRegistry
43
43
  * @param {{get: () => Record<string, Record<string, {url: string, title?: string}>> | null}} [deps.translationMapStore]
44
44
  */
@@ -148,5 +148,5 @@ export function wikilinks(md, { slugIndex, pageContextRegistry, translationMapSt
148
148
  return true;
149
149
  }
150
150
 
151
- md.inline.ruler.before('link', 'wikilink', tokenize);
151
+ md.inline.ruler.before('link', 'baseline_wikilinks', tokenize);
152
152
  }
@@ -0,0 +1,239 @@
1
+ import { setEntry } from '../registry.js';
2
+ import { slugify } from '../utils/slugify.js';
3
+ import { uniqueBy } from '../utils/unique-by.js';
4
+ import { resolveField } from '../utils/resolve-field.js';
5
+ import { extractFirstParagraph, normalizeCanonical } from './seo-helpers.js';
6
+
7
+ /**
8
+ * Page context — builder factory
9
+ *
10
+ * Returns a `buildPageContext` function bound to the runtime dependencies
11
+ * it needs (scope, slug index, resolved settings, runtime substrate handles,
12
+ * options). Each top-level key in the page context (`site`, `page`, `entry`,
13
+ * `query`, `meta`, `render`, `head`) has its own builder inside the closure.
14
+ *
15
+ * Architecture layer:
16
+ * runtime substrate (page-context internal)
17
+ *
18
+ * System role:
19
+ * Pure transformation of Eleventy data → normalised page context. The
20
+ * factory keeps cross-builder dependencies (separator, site.url, contentMap)
21
+ * in one place without threading them through every builder signature.
22
+ *
23
+ * @param {{
24
+ * scope: { values: Map },
25
+ * slugIndex: { set: (slug: string, url: string, inputPath: string) => void } | null,
26
+ * settings: import('../types.js').BaselineSettings,
27
+ * runtime: { contentMap: any },
28
+ * options: import('../types.js').BaselineOptions,
29
+ * log?: { warn: (...args: unknown[]) => void }
30
+ * }} deps
31
+ * @returns {(data: any) => object}
32
+ */
33
+ export function createPageContext({ scope, slugIndex, settings, runtime, options, log }) {
34
+ const separator = options.head?.titleSeparator ?? ' – ';
35
+
36
+ function buildSite(lang, userSettings) {
37
+ const langEntry = lang ? userSettings.languages?.[lang] : undefined;
38
+ return {
39
+ title: langEntry?.title ?? userSettings.title ?? '',
40
+ tagline: langEntry?.tagline ?? userSettings.tagline ?? '',
41
+ description: langEntry?.description ?? userSettings.description ?? '',
42
+ url: userSettings.url ?? '',
43
+ noindex: userSettings.noindex === true
44
+ };
45
+ }
46
+
47
+ function buildPage(pageInput) {
48
+ return {
49
+ inputPath: pageInput?.inputPath,
50
+ fileSlug: pageInput?.fileSlug,
51
+ filePathStem: pageInput?.filePathStem,
52
+ outputFileExtension: pageInput?.outputFileExtension,
53
+ templateSyntax: pageInput?.templateSyntax,
54
+ date: pageInput?.date,
55
+ url: pageInput?.url,
56
+ outputPath: pageInput?.outputPath,
57
+ lang: pageInput?.lang,
58
+ locale: pageInput?.locale,
59
+ sitemap: pageInput?.sitemap
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build the `entry` branch — the author's view of the page.
65
+ *
66
+ * Holds the content's self-description (title, description, excerpt), its
67
+ * identity (slug), structural classification (section as a hierarchical
68
+ * path array, type as a free-form classifier), and per-page head extras.
69
+ * Values pass through raw; consumers normalise.
70
+ */
71
+ function buildEntry(data) {
72
+ const rawSlug = data?.slug ?? data?.page?.fileSlug;
73
+
74
+ // Coerce a string section to a single-element array, with a dev warning.
75
+ // Strict contract is "section is always an array"; runtime stays forgiving.
76
+ let section = data?.section;
77
+ if (typeof section === 'string') {
78
+ if (process.env.NODE_ENV !== 'production') {
79
+ log?.warn(
80
+ `entry.section should be an array, got string "${section}" at ${data?.page?.url ?? data?.page?.inputPath}. Use ['${section}'] instead.`
81
+ );
82
+ }
83
+ section = [section];
84
+ }
85
+
86
+ return {
87
+ title: data?.seo?.title ?? data?.title,
88
+ description: data?.seo?.description ?? data?.description,
89
+ excerpt: data?.excerpt,
90
+ slug: slugify(rawSlug),
91
+ section,
92
+ type: data?.type,
93
+ head: data?.head
94
+ };
95
+ }
96
+
97
+ function buildQuery({ page }) {
98
+ return {
99
+ isHome: page.url === '/'
100
+ };
101
+ }
102
+
103
+ function buildMeta({ data, site, page, query }) {
104
+ const noindex = site.noindex || data?.noindex === true;
105
+
106
+ const robots = noindex
107
+ ? 'noindex, nofollow'
108
+ : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
109
+
110
+ const contentMap = runtime.contentMap;
111
+
112
+ const siteTitle = site.title;
113
+ const siteDescription = site.description;
114
+ const tagline = site.tagline;
115
+
116
+ const pageTitle = data?.seo?.title ?? data?.title ?? siteTitle;
117
+ const pageDescription = data?.seo?.description ?? data?.description ?? data?.excerpt ?? extractFirstParagraph(data);
118
+
119
+ function enhance(value) {
120
+ if (query.isHome && !data?.seo?.title && tagline) {
121
+ return `${siteTitle}${separator}${tagline}`;
122
+ }
123
+
124
+ if (!query.isHome && pageTitle && siteTitle && pageTitle !== siteTitle) {
125
+ return `${pageTitle}${separator}${siteTitle}`;
126
+ }
127
+
128
+ return value;
129
+ }
130
+
131
+ // ---- DESCRIPTION ----
132
+ const description = resolveField({
133
+ pageValue: pageDescription,
134
+ siteValue: siteDescription,
135
+ isHome: query.isHome
136
+ });
137
+
138
+ // ---- TITLE ----
139
+ const base = resolveField({
140
+ pageValue: pageTitle,
141
+ siteValue: siteTitle
142
+ });
143
+
144
+ const title = enhance(base);
145
+
146
+ // ---- CANONICAL ----
147
+ let canonical;
148
+
149
+ if (!noindex) {
150
+ const rawCanonical =
151
+ data?.canonical ?? page.url ?? (page.inputPath && contentMap?.inputPathToUrl?.[page.inputPath]?.[0]);
152
+
153
+ canonical = normalizeCanonical(rawCanonical, site.url);
154
+ }
155
+
156
+ return {
157
+ title,
158
+ description,
159
+ canonical,
160
+ robots,
161
+ noindex
162
+ };
163
+ }
164
+
165
+ function buildRender(data) {
166
+ return {
167
+ generator: data?.eleventy?.generator
168
+ };
169
+ }
170
+
171
+ // --- HEAD (global + page-level merge + dedupe) ---
172
+ function buildHead({ userSettings, data, siteUrl }) {
173
+ const userHead = userSettings.head ?? {};
174
+ const pageHead = data?.head ?? {};
175
+
176
+ const link = uniqueBy([...(userHead.link ?? []), ...(pageHead.link ?? [])], (item) => {
177
+ if (item?.rel === 'canonical') {
178
+ try {
179
+ return normalizeCanonical(item.href, siteUrl);
180
+ } catch {
181
+ return item?.href;
182
+ }
183
+ }
184
+ return item?.href;
185
+ });
186
+
187
+ const script = uniqueBy([...(userHead.script ?? []), ...(pageHead.script ?? [])], 'src');
188
+
189
+ const style = uniqueBy([...(userHead.style ?? []), ...(pageHead.style ?? [])], 'href');
190
+
191
+ const meta = uniqueBy([...(userHead.meta ?? []), ...(pageHead.meta ?? [])], 'name');
192
+
193
+ return {
194
+ link,
195
+ script,
196
+ style,
197
+ meta
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Main context builder.
203
+ * Pure transformation: Eleventy data → normalised page context.
204
+ */
205
+ return function buildPageContext(data) {
206
+ const pageInput = data.page ?? {};
207
+ const userSettings = data.settings ?? settings;
208
+
209
+ const page = buildPage(pageInput);
210
+ const site = buildSite(page.lang, userSettings);
211
+ const entry = buildEntry(data);
212
+ const query = buildQuery({ entry, page });
213
+ const meta = buildMeta({ data, site, page, query });
214
+ const render = buildRender(data);
215
+ const head = buildHead({ userSettings, data, siteUrl: site.url });
216
+
217
+ const context = {
218
+ site,
219
+ page,
220
+ entry,
221
+ query,
222
+ meta,
223
+ render,
224
+ head
225
+ };
226
+
227
+ const inspectionKey = context.page.url ?? context.page.inputPath;
228
+ if (inspectionKey) setEntry(scope, inspectionKey, context);
229
+
230
+ if (slugIndex && entry.slug && page.url) {
231
+ const eligible = page.locale?.isDefaultLang === true;
232
+ if (eligible) {
233
+ slugIndex.set(entry.slug, page.url, page.inputPath);
234
+ }
235
+ }
236
+
237
+ return context;
238
+ };
239
+ }
@@ -0,0 +1 @@
1
+ export { registerPageContext } from './register.js';
@@ -0,0 +1,73 @@
1
+ import { createLogger } from '../logging/index.js';
2
+ import { getScope, memoize } from '../registry.js';
3
+ import { createPageContext } from './build.js';
4
+
5
+ const SCOPE_NAME = 'core:page-context';
6
+ const LOG_NAME = '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 } = coreContext;
45
+ const { slugIndex } = runtime;
46
+ const { settings, options } = state;
47
+
48
+ const log = createLogger(LOG_NAME, { verbose: options.verbose });
49
+ const scope = getScope(eleventyConfig, SCOPE_NAME);
50
+
51
+ const buildPageContext = createPageContext({ scope, slugIndex, settings, runtime, options, log });
52
+
53
+ function shouldSkip(data) {
54
+ if (data._internal) return true;
55
+ if (data.page?.outputFileExtension !== 'html') return true;
56
+ return false;
57
+ }
58
+
59
+ eleventyConfig.addGlobalData(COMPUTED_KEY, () => {
60
+ return (data) => {
61
+ if (shouldSkip(data)) return null;
62
+ return memoize(scope, data, buildPageContext);
63
+ };
64
+ });
65
+
66
+ log.info('Page context registered');
67
+
68
+ return {
69
+ get: (data) => scope.cache.get(data),
70
+ getByKey: (key) => scope.values.get(key),
71
+ snapshot: () => Object.fromEntries(scope.values)
72
+ };
73
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Page context — SEO helpers
3
+ *
4
+ * Pure URL/content normalisation used when building the `meta` slice of
5
+ * the page context. No Eleventy, no registry.
6
+ *
7
+ * Architecture layer:
8
+ * runtime substrate (page-context internal)
9
+ */
10
+
11
+ /**
12
+ * Strip common tracking query params and the URL fragment.
13
+ *
14
+ * @param {URL} urlObj
15
+ * @returns {URL}
16
+ */
17
+ export function stripTrackingParams(urlObj) {
18
+ ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid'].forEach((p) =>
19
+ urlObj.searchParams.delete(p)
20
+ );
21
+
22
+ urlObj.hash = '';
23
+ return urlObj;
24
+ }
25
+
26
+ /**
27
+ * Pull the first paragraph's inner HTML out of a rendered page's content.
28
+ * Used as the last-resort source for meta descriptions.
29
+ *
30
+ * @param {{ content?: string }} data
31
+ * @returns {string | undefined}
32
+ */
33
+ export function extractFirstParagraph(data) {
34
+ const html = data?.content;
35
+ if (!html) return;
36
+ const match = html.match(/<p>(.*?)<\/p>/i);
37
+ return match?.[1];
38
+ }
39
+
40
+ /**
41
+ * Resolve a path against the site URL, strip the fragment, and remove
42
+ * tracking params. Returns undefined when inputs are missing or invalid.
43
+ *
44
+ * @param {string | undefined} path
45
+ * @param {string | undefined} siteUrl
46
+ * @returns {string | undefined}
47
+ */
48
+ export function normalizeCanonical(path, siteUrl) {
49
+ if (!path || !siteUrl) return;
50
+
51
+ const url = new URL(path, siteUrl);
52
+
53
+ url.hash = '';
54
+
55
+ return stripTrackingParams(url).href;
56
+ }
package/core/schema.js CHANGED
@@ -58,7 +58,25 @@ export const settingsSchema = z.object({
58
58
  url: z.string().optional(),
59
59
  noindex: z.boolean().optional(),
60
60
  defaultLanguage: z.string().optional(),
61
- languages: z.record(z.string(), z.looseObject({})).optional(),
61
+ languages: z
62
+ .unknown()
63
+ .optional()
64
+ .superRefine((value, ctx) => {
65
+ if (value === undefined) return;
66
+
67
+ if (Array.isArray(value)) {
68
+ const arrayResult = z.array(z.string().min(1)).safeParse(value);
69
+ if (!arrayResult.success) {
70
+ for (const issue of arrayResult.error.issues) ctx.addIssue(issue);
71
+ }
72
+ return;
73
+ }
74
+
75
+ const recordResult = z.record(z.string(), z.looseObject({})).safeParse(value);
76
+ if (!recordResult.success) {
77
+ for (const issue of recordResult.error.issues) ctx.addIssue(issue);
78
+ }
79
+ }),
62
80
  head: z
63
81
  .object({
64
82
  link: z.array(z.looseObject({})).optional(),
@@ -35,7 +35,7 @@ const SCOPE_NAME = 'core:slug-index';
35
35
  * page-context.buildPageContext → set() → registry scope → wikilinks getBySlug()
36
36
  *
37
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}>}}
38
+ * @returns {{set: (slug: string, url: string, inputPath?: string) => void, getBySlug: (slug: string) => string | undefined, snapshot: () => Record<string, {url: string, inputPath?: string}>}}
39
39
  */
40
40
  export function createSlugIndex(eleventyConfig) {
41
41
  const scope = getScope(eleventyConfig, SCOPE_NAME);
@@ -52,7 +52,7 @@ export function createSlugIndex(eleventyConfig) {
52
52
  setEntry(scope, slug, { url, inputPath });
53
53
  },
54
54
  getBySlug(slug) {
55
- return getEntry(scope, slug)?.url ?? null;
55
+ return getEntry(scope, slug)?.url;
56
56
  },
57
57
  snapshot() {
58
58
  return Object.fromEntries(scope.values);