@apleasantview/eleventy-plugin-baseline 0.1.0-next.40 → 0.1.0-next.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -32
- package/core/back-compat/options.js +69 -0
- package/core/content-graph/backlinks.js +65 -0
- package/core/content-graph/extractors.js +185 -0
- package/core/content-graph/graph.js +121 -0
- package/core/content-graph/index.js +2 -0
- package/core/content-graph/prepass.js +121 -0
- package/core/dates/git-date.js +71 -0
- package/core/dates/index.js +55 -0
- package/core/locale/derive-lang.js +19 -0
- package/core/locale/index.js +6 -0
- package/core/locale/normalize-lang.js +13 -0
- package/core/locale/normalize-locale.js +20 -0
- package/core/locale/open-graph-locale.js +14 -0
- package/core/locale/resolve-default.js +27 -0
- package/core/locale/resolve-locale.js +16 -0
- package/core/logging/banner.js +49 -0
- package/core/{logging.js → logging/index.js} +19 -2
- package/core/logging/quips.js +30 -0
- package/core/markdown/auto-heading-ids.js +86 -0
- package/core/markdown/index.js +5 -0
- package/core/markdown/safe-use.js +42 -0
- package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
- package/core/page-context/build.js +336 -0
- package/core/page-context/index.js +1 -0
- package/core/page-context/register.js +73 -0
- package/core/page-context/seo-helpers.js +56 -0
- package/core/schema.js +22 -2
- package/core/seo-graph/adapter.js +246 -0
- package/core/seo-graph/build.js +87 -0
- package/core/seo-graph/index.js +1 -0
- package/core/seo-graph/open-graph.js +130 -0
- package/core/seo-graph/register.js +42 -0
- package/core/seo-graph/schema.js +18 -0
- package/core/slug-index.js +2 -2
- package/core/state.js +75 -0
- package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
- package/core/surface/index.js +22 -0
- package/core/types.js +3 -0
- package/core/utils/add-trailing-slash.js +11 -0
- package/core/utils/ensure-dot-slash-dir.js +13 -0
- package/core/utils/normalize-language-map.js +37 -0
- package/core/utils/resolve-field.js +9 -0
- package/core/utils/resolve-subdir.js +20 -0
- package/core/utils/slugify.js +15 -0
- package/core/utils/title-case-slug.js +15 -0
- package/core/utils/unique-by.js +25 -0
- package/core/virtual-dir.js +11 -10
- package/index.js +161 -118
- package/modules/assets/index.js +4 -2
- package/modules/assets/processors/esbuild-process.js +2 -2
- package/modules/assets/processors/postcss-process.js +2 -2
- package/modules/head/drivers/posthtml-head-elements.js +92 -12
- package/modules/head/index.js +23 -19
- package/modules/head/schema.js +7 -3
- package/modules/multilang/filters/i18n-default-translation.js +2 -4
- package/modules/multilang/filters/i18n-translation-in.js +2 -2
- package/modules/multilang/filters/i18n-translations-for.js +2 -2
- package/modules/multilang/index.js +80 -39
- package/modules/navigator/index.js +39 -25
- package/modules/navigator/templates/navigator-core.html +1 -1
- package/modules/sitemap/index.js +8 -4
- package/modules/sitemap/templates/sitemap-core.html +1 -1
- package/package.json +5 -2
- package/core/filters/index.js +0 -4
- package/core/global-functions/index.js +0 -6
- package/core/page-context.js +0 -310
- package/core/shortcodes/index.js +0 -2
- package/core/utils/helpers.js +0 -75
- /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
- /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
- /package/core/{filters → surface/filters}/isString.js +0 -0
- /package/core/{filters → surface/filters}/related-posts.js +0 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { setEntry } from '../registry.js';
|
|
2
|
+
import { slugify } from '../utils/slugify.js';
|
|
3
|
+
import { titleCaseSlug } from '../utils/title-case-slug.js';
|
|
4
|
+
import { uniqueBy } from '../utils/unique-by.js';
|
|
5
|
+
import { resolveField } from '../utils/resolve-field.js';
|
|
6
|
+
import { extractFirstParagraph, normalizeCanonical } from './seo-helpers.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Apply a title template, replacing tokens with resolved values. Tokens:
|
|
10
|
+
* `%s` (page title), `%siteTitle%`, `%tagline%`. One regex with the longer
|
|
11
|
+
* tokens first so `%s` does not eat the `%s` inside `%siteTitle%`.
|
|
12
|
+
* Replacement is literal: an empty value leaves the surrounding template
|
|
13
|
+
* text as the author wrote it.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} template
|
|
16
|
+
* @param {{ title?: string, siteTitle?: string, tagline?: string }} tokens
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function applyTitleTemplate(template, { title, siteTitle, tagline }) {
|
|
20
|
+
const values = { '%s': title, '%siteTitle%': siteTitle, '%tagline%': tagline };
|
|
21
|
+
return template.replace(/%siteTitle%|%tagline%|%s/g, (token) => values[token] ?? '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the final `<title>` text.
|
|
26
|
+
*
|
|
27
|
+
* Precedence: per-page bare opt-out (`titleTemplate: null`) → per-page
|
|
28
|
+
* template → titleless home (baked-in `siteTitle` + `tagline`) → global
|
|
29
|
+
* template → default (`page – site`, guarded so a page named like the site
|
|
30
|
+
* stays bare). With no template set anywhere this reproduces the legacy
|
|
31
|
+
* separator composition exactly.
|
|
32
|
+
*
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function resolveTitle({ data, isHome, pageTitle, siteTitle, tagline, separator, globalTemplate }) {
|
|
36
|
+
const pageTemplate = data?.titleTemplate;
|
|
37
|
+
const base = pageTitle ?? siteTitle;
|
|
38
|
+
const tokens = { title: pageTitle, siteTitle, tagline };
|
|
39
|
+
|
|
40
|
+
if (pageTemplate === null) return base;
|
|
41
|
+
if (typeof pageTemplate === 'string') return applyTitleTemplate(pageTemplate, tokens);
|
|
42
|
+
if (isHome && !data?.seo?.title) return tagline ? `${siteTitle}${separator}${tagline}` : base;
|
|
43
|
+
if (typeof globalTemplate === 'string') return applyTitleTemplate(globalTemplate, tokens);
|
|
44
|
+
if (!isHome && pageTitle && siteTitle && pageTitle !== siteTitle) return `${pageTitle}${separator}${siteTitle}`;
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a breadcrumb trail from the page's ancestor section path.
|
|
50
|
+
*
|
|
51
|
+
* `section` is the containing-directory chain, not a path that ends at the
|
|
52
|
+
* page: a leaf (/docs/module/head/) and its section index (/docs/module/)
|
|
53
|
+
* share the same section. The tell is the URL. When the page IS its section
|
|
54
|
+
* index, the last segment names it (relabelled with the page title); otherwise
|
|
55
|
+
* the page is a leaf and gets appended as its own crumb. The final crumb keeps
|
|
56
|
+
* its URL so the schema renderer can wire its @id, and is flagged `current` so
|
|
57
|
+
* the visible renderer knows not to link it. In multilang, a deliberately
|
|
58
|
+
* non-default language prefixes every URL with `/{lang}`.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ section?: string[], url?: string, title?: string, lang?: string, isDefaultLang?: boolean }} input
|
|
61
|
+
* @returns {Array<{ label: string, url: string, current?: boolean }>}
|
|
62
|
+
*/
|
|
63
|
+
export function buildBreadcrumbs({ section = [], url, title, lang, isDefaultLang } = {}) {
|
|
64
|
+
if (!section?.length || !url) return [];
|
|
65
|
+
|
|
66
|
+
// Only a deliberately non-default language prefixes the path; absence
|
|
67
|
+
// (no multilang) keeps the root, never a spurious `/{lang}`.
|
|
68
|
+
const base = isDefaultLang === false && lang ? `/${lang}` : '';
|
|
69
|
+
|
|
70
|
+
const crumbs = [{ label: 'Home', url: `${base}/` }];
|
|
71
|
+
let acc = base;
|
|
72
|
+
for (const seg of section) {
|
|
73
|
+
acc += `/${seg}`;
|
|
74
|
+
crumbs.push({ label: titleCaseSlug(seg), url: `${acc}/` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sectionUrl = `${base}/${section.join('/')}/`;
|
|
78
|
+
if (url === sectionUrl) {
|
|
79
|
+
crumbs[crumbs.length - 1].label = title ?? crumbs[crumbs.length - 1].label;
|
|
80
|
+
} else {
|
|
81
|
+
crumbs.push({ label: title ?? titleCaseSlug(section[section.length - 1]), url });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
crumbs[crumbs.length - 1].current = true;
|
|
85
|
+
return crumbs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Page context — builder factory
|
|
90
|
+
*
|
|
91
|
+
* Returns a `buildPageContext` function bound to the runtime dependencies
|
|
92
|
+
* it needs (scope, slug index, resolved settings, runtime substrate handles,
|
|
93
|
+
* options). Each top-level key in the page context (`site`, `page`, `entry`,
|
|
94
|
+
* `query`, `meta`, `render`, `head`) has its own builder inside the closure.
|
|
95
|
+
*
|
|
96
|
+
* Architecture layer:
|
|
97
|
+
* runtime substrate (page-context internal)
|
|
98
|
+
*
|
|
99
|
+
* System role:
|
|
100
|
+
* Pure transformation of Eleventy data → normalised page context. The
|
|
101
|
+
* factory keeps cross-builder dependencies (separator, site.url, contentMap)
|
|
102
|
+
* in one place without threading them through every builder signature.
|
|
103
|
+
*
|
|
104
|
+
* @param {{
|
|
105
|
+
* scope: { values: Map },
|
|
106
|
+
* slugIndex: { set: (slug: string, url: string, inputPath: string) => void } | null,
|
|
107
|
+
* settings: import('../types.js').BaselineSettings,
|
|
108
|
+
* runtime: { contentMap: any },
|
|
109
|
+
* options: import('../types.js').BaselineOptions,
|
|
110
|
+
* log?: { warn: (...args: unknown[]) => void }
|
|
111
|
+
* }} deps
|
|
112
|
+
* @returns {(data: any) => object}
|
|
113
|
+
*/
|
|
114
|
+
export function createPageContext({ scope, slugIndex, settings, runtime, options, log }) {
|
|
115
|
+
const separator = options.head?.titleSeparator ?? ' – ';
|
|
116
|
+
|
|
117
|
+
function buildSite(lang, userSettings) {
|
|
118
|
+
const langEntry = lang ? userSettings.languages?.[lang] : undefined;
|
|
119
|
+
return {
|
|
120
|
+
title: langEntry?.title ?? userSettings.title ?? '',
|
|
121
|
+
tagline: langEntry?.tagline ?? userSettings.tagline ?? '',
|
|
122
|
+
description: langEntry?.description ?? userSettings.description ?? '',
|
|
123
|
+
url: userSettings.url ?? '',
|
|
124
|
+
noindex: userSettings.noindex === true
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildPage(pageInput) {
|
|
129
|
+
return {
|
|
130
|
+
inputPath: pageInput?.inputPath,
|
|
131
|
+
fileSlug: pageInput?.fileSlug,
|
|
132
|
+
filePathStem: pageInput?.filePathStem,
|
|
133
|
+
outputFileExtension: pageInput?.outputFileExtension,
|
|
134
|
+
templateSyntax: pageInput?.templateSyntax,
|
|
135
|
+
date: pageInput?.date,
|
|
136
|
+
url: pageInput?.url,
|
|
137
|
+
outputPath: pageInput?.outputPath,
|
|
138
|
+
lang: pageInput?.lang,
|
|
139
|
+
locale: pageInput?.locale,
|
|
140
|
+
translationKey: pageInput?.translationKey,
|
|
141
|
+
isDefaultLang: pageInput?.isDefaultLang,
|
|
142
|
+
sitemap: pageInput?.sitemap
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the `entry` branch — the author's view of the page.
|
|
148
|
+
*
|
|
149
|
+
* Holds the content's self-description (title, description, excerpt), its
|
|
150
|
+
* identity (slug), structural classification (section as a hierarchical
|
|
151
|
+
* path array, type as a free-form classifier), and per-page head extras.
|
|
152
|
+
* Values pass through raw; consumers normalise.
|
|
153
|
+
*/
|
|
154
|
+
function buildEntry(data) {
|
|
155
|
+
const rawSlug = data?.slug ?? data?.page?.fileSlug;
|
|
156
|
+
|
|
157
|
+
// Coerce a string section to a single-element array, with a dev warning.
|
|
158
|
+
// Strict contract is "section is always an array"; runtime stays forgiving.
|
|
159
|
+
let section = data?.section;
|
|
160
|
+
if (typeof section === 'string') {
|
|
161
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
162
|
+
log?.warn(
|
|
163
|
+
`entry.section should be an array, got string "${section}" at ${data?.page?.url ?? data?.page?.inputPath}. Use ['${section}'] instead.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
section = [section];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
title: data?.seo?.title ?? data?.title,
|
|
171
|
+
description: data?.seo?.description ?? data?.description,
|
|
172
|
+
excerpt: data?.excerpt,
|
|
173
|
+
slug: slugify(rawSlug),
|
|
174
|
+
section,
|
|
175
|
+
type: data?.type,
|
|
176
|
+
head: data?.head,
|
|
177
|
+
breadcrumbs: buildBreadcrumbs({
|
|
178
|
+
section,
|
|
179
|
+
url: data?.page?.url,
|
|
180
|
+
title: data?.seo?.title ?? data?.title,
|
|
181
|
+
lang: data?.page?.lang,
|
|
182
|
+
isDefaultLang: data?.page?.isDefaultLang
|
|
183
|
+
})
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildQuery({ page }) {
|
|
188
|
+
return {
|
|
189
|
+
isHome: page.url === '/'
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildMeta({ data, site, page, query }) {
|
|
194
|
+
const noindex = site.noindex || data?.noindex === true;
|
|
195
|
+
|
|
196
|
+
const robots = noindex
|
|
197
|
+
? 'noindex, nofollow'
|
|
198
|
+
: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
|
|
199
|
+
|
|
200
|
+
const contentMap = runtime.contentMap;
|
|
201
|
+
|
|
202
|
+
const siteTitle = site.title;
|
|
203
|
+
const siteDescription = site.description;
|
|
204
|
+
const tagline = site.tagline;
|
|
205
|
+
|
|
206
|
+
const pageTitle = data?.seo?.title ?? data?.title ?? siteTitle;
|
|
207
|
+
const pageDescription = data?.seo?.description ?? data?.description ?? data?.excerpt ?? extractFirstParagraph(data);
|
|
208
|
+
|
|
209
|
+
// ---- DESCRIPTION ----
|
|
210
|
+
const description = resolveField({
|
|
211
|
+
pageValue: pageDescription,
|
|
212
|
+
siteValue: siteDescription,
|
|
213
|
+
isHome: query.isHome
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---- TITLE ----
|
|
217
|
+
const title = resolveTitle({
|
|
218
|
+
data,
|
|
219
|
+
isHome: query.isHome,
|
|
220
|
+
pageTitle,
|
|
221
|
+
siteTitle,
|
|
222
|
+
tagline,
|
|
223
|
+
separator,
|
|
224
|
+
globalTemplate: options.head?.titleTemplate
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- CANONICAL ----
|
|
228
|
+
let canonical;
|
|
229
|
+
|
|
230
|
+
if (!noindex) {
|
|
231
|
+
const rawCanonical =
|
|
232
|
+
data?.canonical ?? page.url ?? (page.inputPath && contentMap?.inputPathToUrl?.[page.inputPath]?.[0]);
|
|
233
|
+
|
|
234
|
+
canonical = normalizeCanonical(rawCanonical, site.url);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
title,
|
|
239
|
+
description,
|
|
240
|
+
canonical,
|
|
241
|
+
robots,
|
|
242
|
+
noindex
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildRender(data) {
|
|
247
|
+
return {
|
|
248
|
+
generator: data?.eleventy?.generator
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- HEAD (global + page-level merge + dedupe) ---
|
|
253
|
+
function buildHead({ userSettings, data, siteUrl }) {
|
|
254
|
+
const userHead = userSettings.head ?? {};
|
|
255
|
+
const pageHead = data?.head ?? {};
|
|
256
|
+
|
|
257
|
+
// Keys must distinguish tags that legitimately differ: links by rel + href
|
|
258
|
+
// (so preconnect and dns-prefetch to one host both survive), metas by their
|
|
259
|
+
// identifying attribute (name / property / charset / http-equiv) so og:*
|
|
260
|
+
// property tags are not collapsed. The head driver runs the authoritative
|
|
261
|
+
// final pass; this just merges settings + front-matter without losing tags.
|
|
262
|
+
// Links key on rel + href so tags that share a host but differ in rel
|
|
263
|
+
// (preconnect vs dns-prefetch) are not collapsed. Metas key on their
|
|
264
|
+
// identifying attribute so two tags with the same key (e.g. a repeated
|
|
265
|
+
// og:title) collapse to the last, matching the driver's authoritative
|
|
266
|
+
// pass instead of leaning on uniqueBy's by-shape fallback.
|
|
267
|
+
const link = uniqueBy([...(userHead.link ?? []), ...(pageHead.link ?? [])], (item) => {
|
|
268
|
+
if (item?.rel === 'canonical') {
|
|
269
|
+
try {
|
|
270
|
+
return `canonical|${normalizeCanonical(item.href, siteUrl)}`;
|
|
271
|
+
} catch {
|
|
272
|
+
return `canonical|${item?.href}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return item?.href ? `${item.rel ?? ''}|${item.href}` : undefined;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const script = uniqueBy([...(userHead.script ?? []), ...(pageHead.script ?? [])], 'src');
|
|
279
|
+
|
|
280
|
+
const style = uniqueBy([...(userHead.style ?? []), ...(pageHead.style ?? [])], 'href');
|
|
281
|
+
|
|
282
|
+
const meta = uniqueBy([...(userHead.meta ?? []), ...(pageHead.meta ?? [])], (item) => {
|
|
283
|
+
if (item?.charset) return 'charset';
|
|
284
|
+
if (item?.name) return `name:${item.name}`;
|
|
285
|
+
if (item?.property) return `prop:${item.property}`;
|
|
286
|
+
if (item?.['http-equiv']) return `http:${item['http-equiv']}`;
|
|
287
|
+
return undefined;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
link,
|
|
292
|
+
script,
|
|
293
|
+
style,
|
|
294
|
+
meta
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Main context builder.
|
|
300
|
+
* Pure transformation: Eleventy data → normalised page context.
|
|
301
|
+
*/
|
|
302
|
+
return function buildPageContext(data) {
|
|
303
|
+
const pageInput = data.page ?? {};
|
|
304
|
+
const userSettings = data.settings ?? settings;
|
|
305
|
+
|
|
306
|
+
const page = buildPage(pageInput);
|
|
307
|
+
const site = buildSite(page.lang, userSettings);
|
|
308
|
+
const entry = buildEntry(data);
|
|
309
|
+
const query = buildQuery({ entry, page });
|
|
310
|
+
const meta = buildMeta({ data, site, page, query });
|
|
311
|
+
const render = buildRender(data);
|
|
312
|
+
const head = buildHead({ userSettings, data, siteUrl: site.url });
|
|
313
|
+
|
|
314
|
+
const context = {
|
|
315
|
+
site,
|
|
316
|
+
page,
|
|
317
|
+
entry,
|
|
318
|
+
query,
|
|
319
|
+
meta,
|
|
320
|
+
render,
|
|
321
|
+
head
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const inspectionKey = context.page.url ?? context.page.inputPath;
|
|
325
|
+
if (inspectionKey) setEntry(scope, inspectionKey, context);
|
|
326
|
+
|
|
327
|
+
if (slugIndex && entry.slug && page.url) {
|
|
328
|
+
const eligible = page.isDefaultLang === true;
|
|
329
|
+
if (eligible) {
|
|
330
|
+
slugIndex.set(entry.slug, page.url, page.inputPath);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return context;
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -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,26 @@ export const settingsSchema = z.object({
|
|
|
58
58
|
url: z.string().optional(),
|
|
59
59
|
noindex: z.boolean().optional(),
|
|
60
60
|
defaultLanguage: z.string().optional(),
|
|
61
|
-
|
|
61
|
+
defaultLocale: z.string().optional(),
|
|
62
|
+
languages: z
|
|
63
|
+
.unknown()
|
|
64
|
+
.optional()
|
|
65
|
+
.superRefine((value, ctx) => {
|
|
66
|
+
if (value === undefined) return;
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
const arrayResult = z.array(z.string().min(1)).safeParse(value);
|
|
70
|
+
if (!arrayResult.success) {
|
|
71
|
+
for (const issue of arrayResult.error.issues) ctx.addIssue(issue);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const recordResult = z.record(z.string(), z.looseObject({})).safeParse(value);
|
|
77
|
+
if (!recordResult.success) {
|
|
78
|
+
for (const issue of recordResult.error.issues) ctx.addIssue(issue);
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
62
81
|
head: z
|
|
63
82
|
.object({
|
|
64
83
|
link: z.array(z.looseObject({})).optional(),
|
|
@@ -66,5 +85,6 @@ export const settingsSchema = z.object({
|
|
|
66
85
|
meta: z.array(z.looseObject({})).optional(),
|
|
67
86
|
style: z.array(z.looseObject({})).optional()
|
|
68
87
|
})
|
|
69
|
-
.optional()
|
|
88
|
+
.optional(),
|
|
89
|
+
seo: z.looseObject({}).optional()
|
|
70
90
|
});
|