@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.
- package/README.md +21 -23
- package/core/back-compat/options.js +69 -0
- package/core/content-graph/backlinks.js +65 -0
- package/core/content-graph/extractors.js +140 -0
- package/core/content-graph/graph.js +118 -0
- package/core/content-graph/index.js +2 -0
- package/core/content-graph/prepass.js +121 -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} +3 -3
- package/core/page-context/build.js +239 -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 +19 -1
- package/core/slug-index.js +2 -2
- package/core/state.js +73 -0
- package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
- package/core/surface/index.js +22 -0
- package/core/utils/add-trailing-slash.js +11 -0
- package/core/utils/ensure-dot-slash-dir.js +13 -0
- package/core/utils/normalize-languages.js +28 -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/unique-by.js +25 -0
- package/core/virtual-dir.js +11 -10
- package/index.js +152 -115
- 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 +1 -3
- package/modules/head/index.js +7 -10
- package/modules/multilang/index.js +4 -2
- package/modules/navigator/index.js +33 -20
- package/modules/navigator/templates/navigator-core.html +1 -1
- package/modules/sitemap/index.js +7 -3
- package/package.json +4 -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/{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
- /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
|
|
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)
|
|
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,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 '
|
|
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 |
|
|
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', '
|
|
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
|
|
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(),
|
package/core/slug-index.js
CHANGED
|
@@ -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 |
|
|
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
|
|
55
|
+
return getEntry(scope, slug)?.url;
|
|
56
56
|
},
|
|
57
57
|
snapshot() {
|
|
58
58
|
return Object.fromEntries(scope.values);
|