@apleasantview/eleventy-plugin-baseline 0.1.0-next.41 → 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 +19 -19
- package/core/content-graph/extractors.js +63 -18
- package/core/content-graph/graph.js +5 -2
- 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/markdown/wikilinks.js +1 -1
- package/core/page-context/build.js +120 -23
- package/core/schema.js +3 -1
- 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/state.js +3 -1
- package/core/surface/index.js +1 -1
- package/core/types.js +3 -0
- package/core/utils/{normalize-languages.js → normalize-language-map.js} +14 -5
- package/core/utils/title-case-slug.js +15 -0
- package/index.js +15 -9
- package/modules/head/drivers/posthtml-head-elements.js +92 -10
- package/modules/head/index.js +16 -9
- 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 +78 -39
- package/modules/navigator/index.js +6 -5
- package/modules/sitemap/index.js +4 -4
- package/modules/sitemap/templates/sitemap-core.html +1 -1
- package/package.json +2 -1
- /package/core/{surface/global-date-function.js → dates/date-global.js} +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createLogger } from '../logging/index.js';
|
|
2
|
+
import { getScope, memoize } from '../registry.js';
|
|
3
|
+
import { createSeoNamespace } from './build.js';
|
|
4
|
+
|
|
5
|
+
const SCOPE_NAME = 'core:seo-graph';
|
|
6
|
+
const LOG_NAME = 'seo-graph';
|
|
7
|
+
const COMPUTED_KEY = 'eleventyComputed._seoGraph';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {import("@11ty/eleventy").UserConfig} eleventyConfig
|
|
11
|
+
* @param {Object} coreContext
|
|
12
|
+
*/
|
|
13
|
+
export function registerSeoGraph(eleventyConfig, coreContext) {
|
|
14
|
+
const { state, runtime } = coreContext;
|
|
15
|
+
const { settings, options } = state;
|
|
16
|
+
|
|
17
|
+
const log = createLogger(LOG_NAME, { verbose: options.verbose });
|
|
18
|
+
const scope = getScope(eleventyConfig, SCOPE_NAME);
|
|
19
|
+
|
|
20
|
+
const buildSeoNamespace = createSeoNamespace({ scope, settings, runtime, options, log });
|
|
21
|
+
|
|
22
|
+
function shouldSkip(data) {
|
|
23
|
+
if (data._internal) return true;
|
|
24
|
+
if (data.page?.outputFileExtension !== 'html') return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
eleventyConfig.addGlobalData(COMPUTED_KEY, () => {
|
|
29
|
+
return (data) => {
|
|
30
|
+
if (shouldSkip(data)) return data._seoGraph ?? null;
|
|
31
|
+
return memoize(scope, data, buildSeoNamespace);
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
log.info('SEO graph registered');
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
get: (data) => scope.cache.get(data),
|
|
39
|
+
getByKey: (key) => scope.values.get(key),
|
|
40
|
+
snapshot: () => Object.fromEntries(scope.values)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Structural shape of the resolved `seo` namespace. Permissive on values;
|
|
4
|
+
// strict only where a missing/wrong shape would break a downstream consumer.
|
|
5
|
+
export const seoSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
title: z.string().optional(),
|
|
8
|
+
description: z.string().optional(),
|
|
9
|
+
url: z.string().optional(),
|
|
10
|
+
ogImage: z.unknown().optional(),
|
|
11
|
+
locale: z.string().optional(),
|
|
12
|
+
openGraph: z.record(z.unknown()).optional(),
|
|
13
|
+
twitter: z.record(z.unknown()).optional(),
|
|
14
|
+
// The assembled JSON-LD @graph. Authored identity lives at
|
|
15
|
+
// `data.schema`; the resolved graph lives here. No `seo.schema` path.
|
|
16
|
+
graph: z.array(z.unknown()).optional()
|
|
17
|
+
})
|
|
18
|
+
.passthrough();
|
package/core/state.js
CHANGED
|
@@ -39,8 +39,10 @@ export function deriveBaselineState(settings, options, { mode } = {}) {
|
|
|
39
39
|
url: settings.url,
|
|
40
40
|
noindex: settings.noindex ?? false,
|
|
41
41
|
defaultLanguage: settings.defaultLanguage,
|
|
42
|
+
defaultLocale: settings.defaultLocale,
|
|
42
43
|
languages: settings.languages,
|
|
43
|
-
head: settings.head
|
|
44
|
+
head: settings.head,
|
|
45
|
+
seo: settings.seo
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
const resolvedOptions = {
|
package/core/surface/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* user templates can reach: filters, global functions, shortcodes.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { registerDateGlobal } from '
|
|
8
|
+
import { registerDateGlobal } from '../dates/index.js';
|
|
9
9
|
|
|
10
10
|
// --- Filters ---
|
|
11
11
|
export { markdownFilter } from '../markdown/markdownify.js';
|
package/core/types.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* @property {string} [url]
|
|
15
15
|
* @property {boolean} [noindex]
|
|
16
16
|
* @property {string} [defaultLanguage]
|
|
17
|
+
* Short language code; a writer-side alias for defaultLocale.
|
|
18
|
+
* @property {string} [defaultLocale]
|
|
19
|
+
* BCP 47 default locale; preferred when both are set.
|
|
17
20
|
* @property {Record<string, unknown>} [languages]
|
|
18
21
|
* @property {Object} [head]
|
|
19
22
|
*/
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Normalize
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Normalize the `settings.languages` config into an object map with
|
|
3
|
+
* lowercased, trimmed keys.
|
|
4
|
+
*
|
|
5
|
+
* Accepts an array of language codes or an object keyed by language code;
|
|
6
|
+
* either form ends up with `[normalizedKey]: entry`. Returns undefined if
|
|
7
|
+
* the input is invalid or empty.
|
|
8
|
+
*
|
|
9
|
+
* Lives in `core/utils/` (not `core/locale/`) because the array-vs-object
|
|
10
|
+
* shape coercion is config-shape adapting, not locale handling. The
|
|
11
|
+
* lowercasing of keys is the only locale-shaped part.
|
|
5
12
|
*
|
|
6
13
|
* @param {Object} settings - Options object containing languages.
|
|
7
14
|
* @param {import('../logging/index.js').BaselineLogger} [logger] - Logger for dropped-entry notice.
|
|
8
15
|
* @returns {Record<string, Object>|undefined} Normalized language map, or undefined.
|
|
9
16
|
*/
|
|
10
|
-
export function
|
|
17
|
+
export function normalizeLanguageMap(settings, logger) {
|
|
11
18
|
const normalizedLanguages = Array.isArray(settings.languages)
|
|
12
19
|
? Object.fromEntries(
|
|
13
20
|
settings.languages
|
|
@@ -15,7 +22,9 @@ export function normalizeLanguages(settings, logger) {
|
|
|
15
22
|
.map((lang) => [lang.toLowerCase().trim(), {}])
|
|
16
23
|
)
|
|
17
24
|
: settings.languages && typeof settings.languages === 'object'
|
|
18
|
-
?
|
|
25
|
+
? Object.fromEntries(
|
|
26
|
+
Object.entries(settings.languages).map(([k, v]) => [k.toLowerCase().trim(), v])
|
|
27
|
+
)
|
|
19
28
|
: undefined;
|
|
20
29
|
|
|
21
30
|
if (logger && Array.isArray(settings.languages)) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title-case a URL slug for display: "core-reference" → "Core Reference".
|
|
3
|
+
* The rough inverse of {@link slugify}: splits on hyphens/underscores and
|
|
4
|
+
* capitalises each word.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} slug
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function titleCaseSlug(slug) {
|
|
10
|
+
return String(slug)
|
|
11
|
+
.split(/[-_]/)
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
14
|
+
.join(' ');
|
|
15
|
+
}
|
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createContentMapStore } from './core/content-map-store.js';
|
|
|
15
15
|
import { createTranslationMapStore } from './core/translation-map-store.js';
|
|
16
16
|
import { createSlugIndex } from './core/slug-index.js';
|
|
17
17
|
import { registerPageContext } from './core/page-context/index.js';
|
|
18
|
+
import { registerSeoGraph } from './core/seo-graph/index.js';
|
|
18
19
|
import { autoHeadingIds, safeUse, wikilinks } from './core/markdown/index.js';
|
|
19
20
|
import { slugify } from './core/utils/slugify.js';
|
|
20
21
|
import { assetsCore, headCore, multilangCore, navigatorCore, sitemapCore } from './modules.js';
|
|
@@ -50,9 +51,10 @@ const INTERNAL_KEYS = [
|
|
|
50
51
|
'_snapshot',
|
|
51
52
|
'eleventyComputed._pageContext',
|
|
52
53
|
'eleventyComputed._node',
|
|
53
|
-
'eleventyComputed.
|
|
54
|
+
'eleventyComputed._seoGraph',
|
|
54
55
|
'eleventyComputed._backlinks',
|
|
55
|
-
'eleventyComputed._outgoing'
|
|
56
|
+
'eleventyComputed._outgoing',
|
|
57
|
+
'eleventyComputed._edges'
|
|
56
58
|
];
|
|
57
59
|
|
|
58
60
|
// Base logger outputs regardless of options.
|
|
@@ -174,13 +176,14 @@ export default function baseline(settings = {}, options = {}) {
|
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
INTERNAL_KEYS.forEach((key) => {
|
|
177
|
-
// We leave eleventyComputed callback
|
|
179
|
+
// We leave eleventyComputed callback keys alone, the rest are reserved-empty.
|
|
178
180
|
if (
|
|
179
181
|
key === 'eleventyComputed._pageContext' ||
|
|
180
182
|
key === 'eleventyComputed._node' ||
|
|
181
|
-
key === 'eleventyComputed.
|
|
183
|
+
key === 'eleventyComputed._seoGraph' ||
|
|
182
184
|
key === 'eleventyComputed._backlinks' ||
|
|
183
|
-
key === 'eleventyComputed._outgoing'
|
|
185
|
+
key === 'eleventyComputed._outgoing' ||
|
|
186
|
+
key === 'eleventyComputed._edges'
|
|
184
187
|
)
|
|
185
188
|
return;
|
|
186
189
|
eleventyConfig.addGlobalData(key, {});
|
|
@@ -286,8 +289,9 @@ export default function baseline(settings = {}, options = {}) {
|
|
|
286
289
|
helpers
|
|
287
290
|
};
|
|
288
291
|
|
|
289
|
-
// Page context
|
|
292
|
+
// Page context and SEO graph registries
|
|
290
293
|
const pageContextRegistry = registerPageContext(eleventyConfig, coreContext);
|
|
294
|
+
const seoGraphRegistry = registerSeoGraph(eleventyConfig, coreContext);
|
|
291
295
|
|
|
292
296
|
// --- Content graph ---
|
|
293
297
|
// Cascade hookup for the content graph. Reads via the runtime getter so
|
|
@@ -338,7 +342,8 @@ export default function baseline(settings = {}, options = {}) {
|
|
|
338
342
|
// --- Snapshots ---
|
|
339
343
|
coreContext.snapshots = {
|
|
340
344
|
contentMap: () => contentMapStore.snapshot(),
|
|
341
|
-
pageContext: () => pageContextRegistry.snapshot()
|
|
345
|
+
pageContext: () => pageContextRegistry.snapshot(),
|
|
346
|
+
seoGraph: () => seoGraphRegistry.snapshot()
|
|
342
347
|
};
|
|
343
348
|
|
|
344
349
|
// --- Module registry ---
|
|
@@ -346,7 +351,7 @@ export default function baseline(settings = {}, options = {}) {
|
|
|
346
351
|
{ when: state.features.multilang, name: 'multilang', plugin: multilangCore },
|
|
347
352
|
{ when: state.features.sitemap, name: 'sitemap', plugin: sitemapCore },
|
|
348
353
|
{ name: 'navigator', plugin: navigatorCore },
|
|
349
|
-
{ when: state.features.head, name: 'head', plugin: headCore, consumes: { pageContext: true } },
|
|
354
|
+
{ when: state.features.head, name: 'head', plugin: headCore, consumes: { pageContext: true, seoGraph: true } },
|
|
350
355
|
{ when: state.features.assets, name: 'assets', plugin: assetsCore }
|
|
351
356
|
];
|
|
352
357
|
|
|
@@ -357,7 +362,8 @@ export default function baseline(settings = {}, options = {}) {
|
|
|
357
362
|
const moduleContext = {
|
|
358
363
|
...coreContext,
|
|
359
364
|
log: scopedLog(name),
|
|
360
|
-
resolvePageContext: consumes.pageContext ? pageContextRegistry : null
|
|
365
|
+
resolvePageContext: consumes.pageContext ? pageContextRegistry : null,
|
|
366
|
+
resolveSeoGraph: consumes.seoGraph ? seoGraphRegistry : null
|
|
361
367
|
};
|
|
362
368
|
|
|
363
369
|
eleventyConfig.addPlugin(plugin, moduleContext);
|
|
@@ -27,28 +27,41 @@ import { dedupeMeta, dedupeLink } from '../utils/dedupe.js';
|
|
|
27
27
|
*
|
|
28
28
|
* Scope:
|
|
29
29
|
* Owns node emission, dedupe orchestration, capo sort, and placeholder
|
|
30
|
-
* replacement.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* replacement. Owns the og:/twitter: vocabulary: the seo substrate hands
|
|
31
|
+
* over short structured keys, this driver maps them to <meta>/<script>.
|
|
32
|
+
* Does not own seed shape (page context), the seo projection
|
|
33
|
+
* (core/seo-graph), hreflang building (head/utils/alternates.js), or capo's
|
|
34
|
+
* element weights (capo.js).
|
|
33
35
|
*
|
|
34
36
|
* Data flow:
|
|
35
|
-
* seeds + alternates + options → emit → dedupe → capo-sort → PostHTML
|
|
37
|
+
* seeds + seo + alternates + options → emit → dedupe → capo-sort → PostHTML
|
|
36
38
|
* tree mutation
|
|
37
39
|
*
|
|
38
40
|
* @param {Object} args
|
|
39
41
|
* @param {Object} args.seeds - Page context for the current page.
|
|
42
|
+
* @param {Object} [args.seo] - Resolved _seoGraph namespace (url, schema, openGraph, twitter).
|
|
40
43
|
* @param {Array<Object>} args.alternates - hreflang link descriptors.
|
|
41
44
|
* @param {Object} args.options - Head options (titleSeparator, showGenerator).
|
|
42
45
|
* @param {string} args.placeholderTag - Placeholder element to replace.
|
|
43
46
|
* @param {string} args.eol - End-of-line separator interleaved between nodes.
|
|
44
47
|
* @returns {(tree: Object) => Object} PostHTML plugin function.
|
|
45
48
|
*/
|
|
46
|
-
export function renderHead({ seeds, alternates, options, placeholderTag, eol }) {
|
|
47
|
-
|
|
49
|
+
export function renderHead({ seeds, seo, alternates, options, placeholderTag, eol }) {
|
|
50
|
+
// seo.url is the canonical source, resolved at cascade-time. It is undefined
|
|
51
|
+
// on noindex / no-settings.url pages, where the seo layer deliberately drops
|
|
52
|
+
// the canonical — emitMeta's guard then emits nothing, which is correct. The
|
|
53
|
+
// seo handle is present whenever a head renders (same skip set as page
|
|
54
|
+
// context), so there is no absent-handle case to fall back for.
|
|
55
|
+
const canonical = seo?.url;
|
|
56
|
+
const defaults = emitMeta(seeds.meta, seeds.render, options, canonical);
|
|
48
57
|
const extras = emitExtras(seeds.head, alternates);
|
|
58
|
+
const { meta: seoMeta, multi: seoMulti, scripts: seoScripts } = emitSeo(seo);
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
// seo emits last so the substrate wins a property collision with settings.head.
|
|
61
|
+
// Multi-valued tags and JSON-LD bypass dedupe (property-keyed last-wins would
|
|
62
|
+
// collapse repeated og:locale:alternate / article:author to one).
|
|
63
|
+
const deduped = dedupeAll([...defaults, ...extras, ...seoMeta]);
|
|
64
|
+
const sorted = capoSort([...deduped, ...seoMulti, ...seoScripts]);
|
|
52
65
|
|
|
53
66
|
return function rendererPlugin(tree) {
|
|
54
67
|
tree.match({ tag: placeholderTag }, () => ({
|
|
@@ -59,14 +72,14 @@ export function renderHead({ seeds, alternates, options, placeholderTag, eol })
|
|
|
59
72
|
};
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
function emitMeta(meta, render, options) {
|
|
75
|
+
function emitMeta(meta, render, options, canonical) {
|
|
63
76
|
const nodes = [];
|
|
64
77
|
nodes.push(mkMeta({ charset: 'UTF-8' }));
|
|
65
78
|
nodes.push(mkMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' }));
|
|
66
79
|
if (meta.title) nodes.push({ tag: 'title', content: [meta.title] });
|
|
67
80
|
if (meta.description) nodes.push(mkMeta({ name: 'description', content: meta.description }));
|
|
68
81
|
nodes.push(mkMeta({ name: 'robots', content: meta.robots }));
|
|
69
|
-
if (
|
|
82
|
+
if (canonical) nodes.push(mkLink({ rel: 'canonical', href: canonical }));
|
|
70
83
|
if (options.showGenerator && render.generator) {
|
|
71
84
|
nodes.push(mkMeta({ name: 'generator', content: render.generator }));
|
|
72
85
|
}
|
|
@@ -74,6 +87,75 @@ function emitMeta(meta, render, options) {
|
|
|
74
87
|
return nodes;
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Map the resolved seo projection to head nodes. The substrate hands over short
|
|
92
|
+
* structured keys; this driver owns the og:/twitter: vocabulary. Single-valued
|
|
93
|
+
* tags ride the deduped meta flow; multi-valued ones (og:locale:alternate,
|
|
94
|
+
* repeated article:author / article:tag) go in `multi` to skip the property-keyed dedupe.
|
|
95
|
+
* The JSON-LD graph rides `scripts` (the adapter returns a bare @graph array,
|
|
96
|
+
* wrapped in its @context envelope here).
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} [seo] - Resolved seo namespace (graph, openGraph, twitter).
|
|
99
|
+
* @returns {{ meta: Array<Object>, multi: Array<Object>, scripts: Array<Object> }}
|
|
100
|
+
*/
|
|
101
|
+
function emitSeo(seo) {
|
|
102
|
+
const meta = [];
|
|
103
|
+
const multi = [];
|
|
104
|
+
const scripts = [];
|
|
105
|
+
if (!seo) return { meta, multi, scripts };
|
|
106
|
+
|
|
107
|
+
const og = seo.openGraph ?? {};
|
|
108
|
+
const tw = seo.twitter ?? {};
|
|
109
|
+
|
|
110
|
+
const prop = (property, content) => {
|
|
111
|
+
if (content !== undefined && content !== null && content !== '') meta.push(mkMeta({ property, content }));
|
|
112
|
+
};
|
|
113
|
+
const name = (key, content) => {
|
|
114
|
+
if (content !== undefined && content !== null && content !== '') meta.push(mkMeta({ name: key, content }));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
prop('og:title', og.title);
|
|
118
|
+
prop('og:type', og.type);
|
|
119
|
+
prop('og:description', og.description);
|
|
120
|
+
prop('og:url', og.url);
|
|
121
|
+
prop('og:site_name', og.siteName);
|
|
122
|
+
prop('og:locale', og.locale);
|
|
123
|
+
prop('og:image', og.image);
|
|
124
|
+
prop('og:image:alt', og.imageAlt);
|
|
125
|
+
if (og.imageWidth !== undefined) prop('og:image:width', String(og.imageWidth));
|
|
126
|
+
if (og.imageHeight !== undefined) prop('og:image:height', String(og.imageHeight));
|
|
127
|
+
|
|
128
|
+
for (const loc of asArray(og.localeAlternate)) {
|
|
129
|
+
if (loc) multi.push(mkMeta({ property: 'og:locale:alternate', content: loc }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (og.article) {
|
|
133
|
+
prop('article:published_time', og.article.publishedTime);
|
|
134
|
+
prop('article:modified_time', og.article.modifiedTime);
|
|
135
|
+
prop('article:section', og.article.section);
|
|
136
|
+
for (const author of asArray(og.article.authors)) {
|
|
137
|
+
if (author) multi.push(mkMeta({ property: 'article:author', content: author }));
|
|
138
|
+
}
|
|
139
|
+
for (const tag of asArray(og.article.tags)) {
|
|
140
|
+
if (tag) multi.push(mkMeta({ property: 'article:tag', content: tag }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
name('twitter:card', tw.card);
|
|
145
|
+
name('twitter:site', tw.site);
|
|
146
|
+
name('twitter:creator', tw.creator);
|
|
147
|
+
name('twitter:title', tw.title);
|
|
148
|
+
name('twitter:description', tw.description);
|
|
149
|
+
name('twitter:image', tw.image);
|
|
150
|
+
|
|
151
|
+
if (seo.schema?.length) {
|
|
152
|
+
const content = JSON.stringify({ '@context': 'https://schema.org', '@graph': seo.schema });
|
|
153
|
+
scripts.push(mkScript({ type: 'application/ld+json', content }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { meta, multi, scripts };
|
|
157
|
+
}
|
|
158
|
+
|
|
77
159
|
function emitExtras(head, alternates = []) {
|
|
78
160
|
const nodes = [];
|
|
79
161
|
for (const m of asArray(head?.meta)) nodes.push(mkMeta(m));
|
package/modules/head/index.js
CHANGED
|
@@ -33,15 +33,16 @@ const EOL = '\n';
|
|
|
33
33
|
* every field the composer needs from cascade-time into transform-time.
|
|
34
34
|
*
|
|
35
35
|
* Scope:
|
|
36
|
-
* Owns transform-time composition and placeholder replacement.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* Does not own seed shape (page context)
|
|
36
|
+
* Owns transform-time composition and placeholder replacement. Emits
|
|
37
|
+
* charset, viewport, title, description, robots, canonical, optional
|
|
38
|
+
* generator, user extras from settings.head, hreflang alternates, plus the
|
|
39
|
+
* seo substrate's OG/Twitter projections and JSON-LD graph.
|
|
40
|
+
* Does not own seed shape (page context), the seo projection
|
|
41
|
+
* (core/seo-graph), or driver internals.
|
|
41
42
|
*
|
|
42
43
|
* Data flow:
|
|
43
|
-
* page context + translation-map store + settings.head →
|
|
44
|
-
* PostHTML tree mutation (replaces <baseline-head>)
|
|
44
|
+
* page context + seo handle + translation-map store + settings.head →
|
|
45
|
+
* driver → PostHTML tree mutation (replaces <baseline-head>)
|
|
45
46
|
*
|
|
46
47
|
* @param {import("@11ty/eleventy").UserConfig} eleventyConfig
|
|
47
48
|
* @param {Object} moduleContext
|
|
@@ -59,6 +60,7 @@ export function headCore(eleventyConfig, moduleContext) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const pageContextRegistry = moduleContext.resolvePageContext;
|
|
63
|
+
const seoGraphRegistry = moduleContext.resolveSeoGraph;
|
|
62
64
|
|
|
63
65
|
// Resolved plugin options with defaults.
|
|
64
66
|
const headOptions = {
|
|
@@ -86,14 +88,19 @@ export function headCore(eleventyConfig, moduleContext) {
|
|
|
86
88
|
return (tree) => tree;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
// Peer substrate, read by the same key as the page context above. Carries
|
|
92
|
+
// the resolved canonical, OG/Twitter projections, and the JSON-LD graph.
|
|
93
|
+
const seo = seoGraphRegistry?.getByKey(key);
|
|
94
|
+
|
|
95
|
+
const translationKey = seeds.page?.translationKey;
|
|
90
96
|
|
|
91
97
|
const alternates = translationKey
|
|
92
|
-
? buildAlternates(
|
|
98
|
+
? buildAlternates(translationKey, runtime.translationMap.get(), seeds.site?.url)
|
|
93
99
|
: [];
|
|
94
100
|
|
|
95
101
|
return renderHead({
|
|
96
102
|
seeds,
|
|
103
|
+
seo,
|
|
97
104
|
alternates,
|
|
98
105
|
options: headOptions,
|
|
99
106
|
placeholderTag: PLACEHOLDER_TAG,
|
package/modules/head/schema.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as z from 'zod';
|
|
|
6
6
|
// `options.head` slice: render-behaviour knobs.
|
|
7
7
|
export const optionsSchema = z.looseObject({
|
|
8
8
|
titleSeparator: z.string().optional(),
|
|
9
|
+
titleTemplate: z.string().optional(),
|
|
9
10
|
showGenerator: z.boolean().optional()
|
|
10
11
|
});
|
|
11
12
|
|
|
@@ -17,10 +18,13 @@ export const settingsHeadSchema = z.looseObject({
|
|
|
17
18
|
style: z.array(z.looseObject({})).optional()
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
// `settings.seo` site-default SEO
|
|
21
|
+
// `settings.seo` site-default SEO config: canonical policy, default share
|
|
22
|
+
// image, and OG/Twitter defaults. Structural only; values stay permissive.
|
|
21
23
|
export const settingsSeoSchema = z.looseObject({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
preserveQueryParams: z.boolean().optional(),
|
|
25
|
+
ogImage: z.unknown().optional(),
|
|
26
|
+
openGraph: z.looseObject({}).optional(),
|
|
27
|
+
twitter: z.looseObject({}).optional()
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
// Page-level `seo:` block. Same scalar set as bare front matter, namespaced.
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* @returns {object|null}
|
|
6
6
|
*/
|
|
7
7
|
export default function i18nDefaultTranslation(page, collection) {
|
|
8
|
-
if (!page?.
|
|
8
|
+
if (!page?.translationKey) return null;
|
|
9
9
|
return (
|
|
10
|
-
collection.find(
|
|
11
|
-
(p) => p.locale && p.locale.translationKey === page.locale.translationKey && p.locale.isDefaultLang
|
|
12
|
-
) || null
|
|
10
|
+
collection.find((p) => p.translationKey === page.translationKey && p.isDefaultLang) || null
|
|
13
11
|
);
|
|
14
12
|
}
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* @returns {object|null}
|
|
7
7
|
*/
|
|
8
8
|
export default function i18nTranslationIn(page, collection, lang) {
|
|
9
|
-
if (!page?.
|
|
9
|
+
if (!page?.translationKey) return null;
|
|
10
10
|
|
|
11
11
|
return (
|
|
12
12
|
collection.find(
|
|
13
|
-
(p) => p.
|
|
13
|
+
(p) => p.translationKey === page.translationKey && p.lang === lang
|
|
14
14
|
) || null
|
|
15
15
|
);
|
|
16
16
|
}
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* @returns {Array<object>}
|
|
6
6
|
*/
|
|
7
7
|
export default function i18nTranslationsFor(page, collection) {
|
|
8
|
-
if (!page?.
|
|
9
|
-
return collection.filter((p) => p.
|
|
8
|
+
if (!page?.translationKey) return [];
|
|
9
|
+
return collection.filter((p) => p.translationKey === page.translationKey);
|
|
10
10
|
}
|