@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
|
@@ -1,9 +1,90 @@
|
|
|
1
1
|
import { setEntry } from '../registry.js';
|
|
2
2
|
import { slugify } from '../utils/slugify.js';
|
|
3
|
+
import { titleCaseSlug } from '../utils/title-case-slug.js';
|
|
3
4
|
import { uniqueBy } from '../utils/unique-by.js';
|
|
4
5
|
import { resolveField } from '../utils/resolve-field.js';
|
|
5
6
|
import { extractFirstParagraph, normalizeCanonical } from './seo-helpers.js';
|
|
6
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
|
+
|
|
7
88
|
/**
|
|
8
89
|
* Page context — builder factory
|
|
9
90
|
*
|
|
@@ -56,6 +137,8 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
56
137
|
outputPath: pageInput?.outputPath,
|
|
57
138
|
lang: pageInput?.lang,
|
|
58
139
|
locale: pageInput?.locale,
|
|
140
|
+
translationKey: pageInput?.translationKey,
|
|
141
|
+
isDefaultLang: pageInput?.isDefaultLang,
|
|
59
142
|
sitemap: pageInput?.sitemap
|
|
60
143
|
};
|
|
61
144
|
}
|
|
@@ -90,7 +173,14 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
90
173
|
slug: slugify(rawSlug),
|
|
91
174
|
section,
|
|
92
175
|
type: data?.type,
|
|
93
|
-
head: data?.head
|
|
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
|
+
})
|
|
94
184
|
};
|
|
95
185
|
}
|
|
96
186
|
|
|
@@ -116,18 +206,6 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
116
206
|
const pageTitle = data?.seo?.title ?? data?.title ?? siteTitle;
|
|
117
207
|
const pageDescription = data?.seo?.description ?? data?.description ?? data?.excerpt ?? extractFirstParagraph(data);
|
|
118
208
|
|
|
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
209
|
// ---- DESCRIPTION ----
|
|
132
210
|
const description = resolveField({
|
|
133
211
|
pageValue: pageDescription,
|
|
@@ -136,13 +214,16 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
136
214
|
});
|
|
137
215
|
|
|
138
216
|
// ---- TITLE ----
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
217
|
+
const title = resolveTitle({
|
|
218
|
+
data,
|
|
219
|
+
isHome: query.isHome,
|
|
220
|
+
pageTitle,
|
|
221
|
+
siteTitle,
|
|
222
|
+
tagline,
|
|
223
|
+
separator,
|
|
224
|
+
globalTemplate: options.head?.titleTemplate
|
|
142
225
|
});
|
|
143
226
|
|
|
144
|
-
const title = enhance(base);
|
|
145
|
-
|
|
146
227
|
// ---- CANONICAL ----
|
|
147
228
|
let canonical;
|
|
148
229
|
|
|
@@ -173,22 +254,38 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
173
254
|
const userHead = userSettings.head ?? {};
|
|
174
255
|
const pageHead = data?.head ?? {};
|
|
175
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.
|
|
176
267
|
const link = uniqueBy([...(userHead.link ?? []), ...(pageHead.link ?? [])], (item) => {
|
|
177
268
|
if (item?.rel === 'canonical') {
|
|
178
269
|
try {
|
|
179
|
-
return normalizeCanonical(item.href, siteUrl)
|
|
270
|
+
return `canonical|${normalizeCanonical(item.href, siteUrl)}`;
|
|
180
271
|
} catch {
|
|
181
|
-
return item?.href
|
|
272
|
+
return `canonical|${item?.href}`;
|
|
182
273
|
}
|
|
183
274
|
}
|
|
184
|
-
return item?.href;
|
|
275
|
+
return item?.href ? `${item.rel ?? ''}|${item.href}` : undefined;
|
|
185
276
|
});
|
|
186
277
|
|
|
187
278
|
const script = uniqueBy([...(userHead.script ?? []), ...(pageHead.script ?? [])], 'src');
|
|
188
279
|
|
|
189
280
|
const style = uniqueBy([...(userHead.style ?? []), ...(pageHead.style ?? [])], 'href');
|
|
190
281
|
|
|
191
|
-
const meta = uniqueBy([...(userHead.meta ?? []), ...(pageHead.meta ?? [])],
|
|
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
|
+
});
|
|
192
289
|
|
|
193
290
|
return {
|
|
194
291
|
link,
|
|
@@ -228,7 +325,7 @@ export function createPageContext({ scope, slugIndex, settings, runtime, options
|
|
|
228
325
|
if (inspectionKey) setEntry(scope, inspectionKey, context);
|
|
229
326
|
|
|
230
327
|
if (slugIndex && entry.slug && page.url) {
|
|
231
|
-
const eligible = page.
|
|
328
|
+
const eligible = page.isDefaultLang === true;
|
|
232
329
|
if (eligible) {
|
|
233
330
|
slugIndex.set(entry.slug, page.url, page.inputPath);
|
|
234
331
|
}
|
package/core/schema.js
CHANGED
|
@@ -58,6 +58,7 @@ export const settingsSchema = z.object({
|
|
|
58
58
|
url: z.string().optional(),
|
|
59
59
|
noindex: z.boolean().optional(),
|
|
60
60
|
defaultLanguage: z.string().optional(),
|
|
61
|
+
defaultLocale: z.string().optional(),
|
|
61
62
|
languages: z
|
|
62
63
|
.unknown()
|
|
63
64
|
.optional()
|
|
@@ -84,5 +85,6 @@ export const settingsSchema = z.object({
|
|
|
84
85
|
meta: z.array(z.looseObject({})).optional(),
|
|
85
86
|
style: z.array(z.looseObject({})).optional()
|
|
86
87
|
})
|
|
87
|
-
.optional()
|
|
88
|
+
.optional(),
|
|
89
|
+
seo: z.looseObject({}).optional()
|
|
88
90
|
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Adapter to @jdevalk/seo-graph-core.
|
|
2
|
+
//
|
|
3
|
+
// Translates Baseline's cascade (settings, schema identity, navigator nodes,
|
|
4
|
+
// dates) into seo-graph-core's piece builders, assembles the JSON-LD @graph,
|
|
5
|
+
// and returns it for storage under data._seoGraph.schema. Auto-builds a generic spine
|
|
6
|
+
// (WebSite, Organization/Person, WebPage, Article, BreadcrumbList, image and
|
|
7
|
+
// translation refs); domain shapes ride in untouched via the schema.pieces seam.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
makeIds,
|
|
11
|
+
assembleGraph,
|
|
12
|
+
buildPiece,
|
|
13
|
+
buildWebSite,
|
|
14
|
+
buildWebPage,
|
|
15
|
+
buildArticle,
|
|
16
|
+
buildBreadcrumbList,
|
|
17
|
+
buildImageObject
|
|
18
|
+
} from '@jdevalk/seo-graph-core';
|
|
19
|
+
import { resolveDates } from '../dates/index.js';
|
|
20
|
+
import { resolveLocale } from '../locale/index.js';
|
|
21
|
+
import { slugify } from '../utils/slugify.js';
|
|
22
|
+
|
|
23
|
+
/** Recursively strip null/undefined; `_data/schema.js` uses null as "not set". */
|
|
24
|
+
function dropNulls(value) {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
const cleaned = value.map(dropNulls).filter((v) => v !== null && v !== undefined);
|
|
27
|
+
return cleaned.length ? cleaned : null;
|
|
28
|
+
}
|
|
29
|
+
if (value && typeof value === 'object') {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [k, v] of Object.entries(value)) {
|
|
32
|
+
const cleaned = dropNulls(v);
|
|
33
|
+
if (cleaned !== null && cleaned !== undefined) out[k] = cleaned;
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length ? out : null;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stable within-site slug for an Organization @id. */
|
|
41
|
+
function orgSlug(name) {
|
|
42
|
+
return slugify(name) || 'organization';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an ImageObject node, or null when pixel dimensions are unknown. The
|
|
47
|
+
* two-tier degrade: no node without width+height (the builder throws), and the
|
|
48
|
+
* OG projection still emits a url-only og:image in that case.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ url?: string, width?: number, height?: number, alt?: string } | undefined} image
|
|
51
|
+
* @param {{ id: string } | { pageUrl: string }} target Site-wide id (logo) or page primary image.
|
|
52
|
+
* @param {import('@jdevalk/seo-graph-core').IdFactory} ids
|
|
53
|
+
* @returns {object | null}
|
|
54
|
+
*/
|
|
55
|
+
function buildImage(image, target, ids) {
|
|
56
|
+
if (!image?.url || !image.width || !image.height) return null;
|
|
57
|
+
return buildImageObject(
|
|
58
|
+
{ ...target, url: image.url, width: image.width, height: image.height, caption: image.alt },
|
|
59
|
+
ids
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Inline WebPage refs for sibling translations, keyed off a shared
|
|
65
|
+
* translationKey on the navigator nodes. Emitted as nested entities (carrying
|
|
66
|
+
* @type) rather than bare @id refs, so they resolve within this page's graph.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Array<object>}
|
|
69
|
+
*/
|
|
70
|
+
function buildWorkTranslations(nodes, translationKey, currentUrl, siteRoot, ids) {
|
|
71
|
+
if (!nodes || !translationKey) return [];
|
|
72
|
+
const refs = [];
|
|
73
|
+
for (const n of Object.values(nodes)) {
|
|
74
|
+
if (n.translationKey !== translationKey || n.url === currentUrl) continue;
|
|
75
|
+
const absUrl = `${siteRoot}${n.url}`;
|
|
76
|
+
refs.push({ '@type': 'WebPage', '@id': ids.webPage(absUrl), url: absUrl, inLanguage: n.locale || n.lang });
|
|
77
|
+
}
|
|
78
|
+
return refs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Assemble the resolved JSON-LD @graph for a single page from the cascade.
|
|
83
|
+
*
|
|
84
|
+
* Pure function over the full Eleventy `data` bag: reads `data.schema`
|
|
85
|
+
* (identity + pieces), `data.settings`, `data.page`, and the navigator nodes.
|
|
86
|
+
* Identity is tidied (drop-null) and stamped; everything in `schema.pieces`
|
|
87
|
+
* passes through `buildPiece` untouched. Returns the bare `@graph` array.
|
|
88
|
+
*
|
|
89
|
+
* @param {Record<string, any>} data The Eleventy cascade data bag.
|
|
90
|
+
* @returns {Array<unknown>}
|
|
91
|
+
*/
|
|
92
|
+
export function assembleSchemaGraph(data) {
|
|
93
|
+
const settings = data.settings;
|
|
94
|
+
const schema = data.schema;
|
|
95
|
+
const pageUrl = data.page?.url;
|
|
96
|
+
|
|
97
|
+
// Eleventy's dependency-discovery proxy can call this with siblings undefined.
|
|
98
|
+
if (!settings?.url || !pageUrl) return [];
|
|
99
|
+
|
|
100
|
+
const siteUrl = settings.url;
|
|
101
|
+
const siteRoot = siteUrl.replace(/\/+$/, '');
|
|
102
|
+
const canonical = `${siteRoot}${pageUrl}`;
|
|
103
|
+
|
|
104
|
+
const navigatorNodes = data._navigator?.nodes;
|
|
105
|
+
const node = navigatorNodes?.[pageUrl];
|
|
106
|
+
|
|
107
|
+
const lang = node?.lang || data.page?.lang || data.lang || settings.defaultLanguage;
|
|
108
|
+
const locale = resolveLocale(node, data, settings, lang);
|
|
109
|
+
const siteName = settings.languages?.[lang]?.title || settings.title;
|
|
110
|
+
const multilang = data._baseline?.features?.multilang;
|
|
111
|
+
|
|
112
|
+
const personUrl = schema?.person?.url || siteUrl;
|
|
113
|
+
const ids = makeIds({ siteUrl, personUrl });
|
|
114
|
+
|
|
115
|
+
// --- Identity: build only what's configured; tidy null sentinels. ---
|
|
116
|
+
const orgConfig = schema?.organization ? dropNulls(schema.organization) : null;
|
|
117
|
+
const personConfig = schema?.person ? dropNulls(schema.person) : null;
|
|
118
|
+
|
|
119
|
+
let orgNode = null;
|
|
120
|
+
let logoNode = null;
|
|
121
|
+
if (orgConfig) {
|
|
122
|
+
const { logo, ...rest } = orgConfig;
|
|
123
|
+
logoNode = buildImage(logo, { id: `${siteRoot}/#logo` }, ids);
|
|
124
|
+
orgNode = buildPiece({
|
|
125
|
+
'@type': 'Organization',
|
|
126
|
+
...rest,
|
|
127
|
+
'@id': ids.organization(orgSlug(orgConfig.name)),
|
|
128
|
+
...(logoNode ? { logo: { '@id': logoNode['@id'] }, image: { '@id': logoNode['@id'] } } : {})
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let personNode = null;
|
|
133
|
+
if (personConfig) {
|
|
134
|
+
personNode = buildPiece({ '@type': 'Person', ...personConfig, '@id': ids.person });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Primary entity: Organization, else Person, else a synthesised floor org so
|
|
138
|
+
// a zero-config site still emits a valid publisher.
|
|
139
|
+
let primaryRef;
|
|
140
|
+
if (orgNode) {
|
|
141
|
+
primaryRef = { '@id': orgNode['@id'] };
|
|
142
|
+
} else if (personNode) {
|
|
143
|
+
primaryRef = { '@id': personNode['@id'] };
|
|
144
|
+
} else {
|
|
145
|
+
orgNode = buildPiece(
|
|
146
|
+
dropNulls({
|
|
147
|
+
'@type': 'Organization',
|
|
148
|
+
'@id': ids.organization(orgSlug(settings.title)),
|
|
149
|
+
name: settings.title,
|
|
150
|
+
url: siteUrl
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
primaryRef = { '@id': orgNode['@id'] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const website = buildWebSite(
|
|
157
|
+
{
|
|
158
|
+
url: siteUrl,
|
|
159
|
+
name: siteName,
|
|
160
|
+
publisher: primaryRef,
|
|
161
|
+
inLanguage: multilang && settings.languages ? Object.keys(settings.languages) : undefined
|
|
162
|
+
},
|
|
163
|
+
ids
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const ogImageRaw = data.ogImage ?? data.seo?.ogImage ?? settings.seo?.ogImage;
|
|
167
|
+
const ogImage = typeof ogImageRaw === 'string' ? { url: ogImageRaw } : ogImageRaw;
|
|
168
|
+
const primaryImageNode = buildImage(ogImage, { pageUrl: canonical }, ids);
|
|
169
|
+
|
|
170
|
+
// Trail is resolved once in page-context, carried onto the graph node by the
|
|
171
|
+
// prepass (the adapter's only cross-pass channel — _pageContext is not in
|
|
172
|
+
// scope here). We just absolutise the root-relative URLs. The last crumb's
|
|
173
|
+
// URL is the page's canonical, so buildBreadcrumbList resolves it to the
|
|
174
|
+
// WebPage @id.
|
|
175
|
+
const crumbs = node?.breadcrumbs || [];
|
|
176
|
+
const breadcrumbNode = crumbs.length
|
|
177
|
+
? buildBreadcrumbList(
|
|
178
|
+
{
|
|
179
|
+
url: canonical,
|
|
180
|
+
items: crumbs.map((crumb) => ({
|
|
181
|
+
name: crumb.label,
|
|
182
|
+
url: crumb.url ? `${siteRoot}${crumb.url}` : canonical
|
|
183
|
+
}))
|
|
184
|
+
},
|
|
185
|
+
ids
|
|
186
|
+
)
|
|
187
|
+
: null;
|
|
188
|
+
|
|
189
|
+
const translationKey = node?.translationKey || data.page?.translationKey || data.translationKey;
|
|
190
|
+
const workTranslation = buildWorkTranslations(navigatorNodes, translationKey, pageUrl, siteRoot, ids);
|
|
191
|
+
|
|
192
|
+
const dates = resolveDates(data);
|
|
193
|
+
|
|
194
|
+
// schema.org keywords from the `topics` front-matter convention; native `tags` untouched.
|
|
195
|
+
const keywords = data.topics?.length ? data.topics : undefined;
|
|
196
|
+
|
|
197
|
+
const webPage = buildWebPage(
|
|
198
|
+
{
|
|
199
|
+
url: canonical,
|
|
200
|
+
name: node?.title || data.title,
|
|
201
|
+
isPartOf: { '@id': ids.website },
|
|
202
|
+
description: node?.description || data.description || node?.excerpt,
|
|
203
|
+
inLanguage: locale,
|
|
204
|
+
datePublished: dates.datePublished,
|
|
205
|
+
dateModified: dates.dateModified,
|
|
206
|
+
breadcrumb: breadcrumbNode ? { '@id': breadcrumbNode['@id'] } : undefined,
|
|
207
|
+
primaryImage: primaryImageNode ? { '@id': primaryImageNode['@id'] } : undefined,
|
|
208
|
+
workTranslation: workTranslation.length ? workTranslation : undefined,
|
|
209
|
+
keywords
|
|
210
|
+
},
|
|
211
|
+
ids,
|
|
212
|
+
data.pageType || 'WebPage'
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const entryType = data.type || node?.type;
|
|
216
|
+
const articleNode =
|
|
217
|
+
entryType === 'article'
|
|
218
|
+
? buildArticle(
|
|
219
|
+
{
|
|
220
|
+
url: canonical,
|
|
221
|
+
isPartOf: { '@id': ids.webPage(canonical) },
|
|
222
|
+
author: personNode ? { '@id': personNode['@id'] } : primaryRef,
|
|
223
|
+
publisher: primaryRef,
|
|
224
|
+
headline: node?.title || data.title,
|
|
225
|
+
description: node?.description || data.description || node?.excerpt,
|
|
226
|
+
inLanguage: locale,
|
|
227
|
+
datePublished: dates.datePublished,
|
|
228
|
+
dateModified: dates.dateModified,
|
|
229
|
+
articleSection: data.sectionLabel,
|
|
230
|
+
keywords
|
|
231
|
+
},
|
|
232
|
+
ids,
|
|
233
|
+
data.articleType || 'Article'
|
|
234
|
+
)
|
|
235
|
+
: null;
|
|
236
|
+
|
|
237
|
+
const spine = [website, orgNode, personNode, logoNode, primaryImageNode, webPage, articleNode, breadcrumbNode].filter(
|
|
238
|
+
Boolean
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// The extension seam: author-supplied nodes, concat-merged across the cascade,
|
|
242
|
+
// passed through untouched.
|
|
243
|
+
const authored = Array.isArray(schema?.pieces) ? schema.pieces.map((p) => buildPiece(p)) : [];
|
|
244
|
+
|
|
245
|
+
return assembleGraph([...spine, ...authored])['@graph'];
|
|
246
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { setEntry } from '../registry.js';
|
|
2
|
+
import { assembleSchemaGraph } from './adapter.js';
|
|
3
|
+
import { buildSocialProjections } from './open-graph.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the canonical URL for the `seo` namespace.
|
|
7
|
+
*
|
|
8
|
+
* Strip-all by default: the entire query string and fragment are removed.
|
|
9
|
+
* Opt-out is a boolean carried at page level (`preserveQueryParams` in
|
|
10
|
+
* front matter) or site level (`settings.seo.preserveQueryParams`); page-level wins.
|
|
11
|
+
* The fragment is always stripped regardless.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the page or site is noindex, when `settings.url`
|
|
14
|
+
* is missing, or when no resolvable canonical input exists.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ seo?: any, data?: any, settings?: any, page?: any }} input
|
|
17
|
+
* @returns {string | undefined}
|
|
18
|
+
*/
|
|
19
|
+
export function resolveCanonicalUrl({ seo, data, settings, page }) {
|
|
20
|
+
if (!settings?.url) return undefined;
|
|
21
|
+
if (settings.noindex === true || data?.noindex === true) return undefined;
|
|
22
|
+
|
|
23
|
+
const raw = seo?.canonical ?? data?.canonical ?? page?.url;
|
|
24
|
+
if (!raw) return undefined;
|
|
25
|
+
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(raw, settings.url);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
url.hash = '';
|
|
34
|
+
|
|
35
|
+
const pagePref = data?.preserveQueryParams;
|
|
36
|
+
const sitePref = settings.seo?.preserveQueryParams;
|
|
37
|
+
const preserveQueryParams = pagePref ?? sitePref ?? false;
|
|
38
|
+
|
|
39
|
+
if (!preserveQueryParams) {
|
|
40
|
+
url.search = '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return url.href;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* SEO namespace builder factory.
|
|
48
|
+
*
|
|
49
|
+
* Returns `buildSeoNamespace(data)` which normalises the cascade into a
|
|
50
|
+
* resolved `_seoGraph` object: canonical fields, OG/Twitter projections, and the
|
|
51
|
+
* assembled JSON-LD graph at `_seoGraph.schema`.
|
|
52
|
+
*
|
|
53
|
+
* @param {{
|
|
54
|
+
* scope: { values: Map },
|
|
55
|
+
* settings: import('../types.js').BaselineSettings,
|
|
56
|
+
* runtime: any,
|
|
57
|
+
* options: import('../types.js').BaselineOptions,
|
|
58
|
+
* log?: { warn: (...args: unknown[]) => void }
|
|
59
|
+
* }} deps
|
|
60
|
+
*/
|
|
61
|
+
export function createSeoNamespace({ scope, settings, runtime, options, log }) {
|
|
62
|
+
return function buildSeoNamespace(data) {
|
|
63
|
+
const seoIn = data.seo ?? {};
|
|
64
|
+
const userSettings = data.settings ?? settings;
|
|
65
|
+
|
|
66
|
+
const seoOut = { ...seoIn };
|
|
67
|
+
|
|
68
|
+
const url = resolveCanonicalUrl({
|
|
69
|
+
seo: seoIn,
|
|
70
|
+
data,
|
|
71
|
+
settings: userSettings,
|
|
72
|
+
page: data.page
|
|
73
|
+
});
|
|
74
|
+
if (url) seoOut.url = url;
|
|
75
|
+
|
|
76
|
+
seoOut.schema = assembleSchemaGraph(data);
|
|
77
|
+
|
|
78
|
+
const social = buildSocialProjections(data, url);
|
|
79
|
+
seoOut.openGraph = social.openGraph;
|
|
80
|
+
seoOut.twitter = social.twitter;
|
|
81
|
+
|
|
82
|
+
const inspectionKey = data.page?.url ?? data.page?.inputPath;
|
|
83
|
+
if (inspectionKey) setEntry(scope, inspectionKey, seoOut);
|
|
84
|
+
|
|
85
|
+
return seoOut;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerSeoGraph } from './register.js';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// OG and Twitter projections.
|
|
2
|
+
//
|
|
3
|
+
// Render-ready normalisation of the social tags: short structured keys, every
|
|
4
|
+
// value resolved against defaults and coerced (locale to OG underscore form,
|
|
5
|
+
// dates to ISO). This module owns the decisions; the head driver owns the
|
|
6
|
+
// og:/twitter: vocabulary and the per-tag emit. Shape and split follow the
|
|
7
|
+
// prior art that productises this — Yoast's presentation-object + per-tag
|
|
8
|
+
// presenters, mirrored by Joost's buildSeoContext (seo-context.ts) + Seo.astro
|
|
9
|
+
// — written fresh against Baseline's namespace.
|
|
10
|
+
|
|
11
|
+
import { resolveDates } from '../dates/index.js';
|
|
12
|
+
import { resolveLocale, toOpenGraphLocale } from '../locale/index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sibling-locale alternates in OG underscore form, excluding the page's own
|
|
16
|
+
* locale and de-duped. Derived from navigator nodes sharing the translationKey
|
|
17
|
+
* (the same set the graph adapter's workTranslation refs draw from).
|
|
18
|
+
*
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
21
|
+
function localeAlternates(nodes, translationKey, currentUrl, primaryLocale) {
|
|
22
|
+
if (!nodes || !translationKey) return [];
|
|
23
|
+
const seen = new Set([primaryLocale]);
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const n of Object.values(nodes)) {
|
|
26
|
+
if (n.translationKey !== translationKey || n.url === currentUrl) continue;
|
|
27
|
+
const loc = toOpenGraphLocale(n.locale || n.lang);
|
|
28
|
+
if (!loc || seen.has(loc)) continue;
|
|
29
|
+
seen.add(loc);
|
|
30
|
+
out.push(loc);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the OG and Twitter projections for a single page.
|
|
37
|
+
*
|
|
38
|
+
* Pure over the cascade `data` bag plus the resolved canonical URL (computed
|
|
39
|
+
* upstream in `build.js`; `undefined` on noindex). Returns render-ready
|
|
40
|
+
* projections with short keys — the head driver maps them to `<meta property>`
|
|
41
|
+
* / `<meta name>` tags. Empty projections when `settings.url` or `page.url` is
|
|
42
|
+
* absent (the dependency-discovery proxy pass).
|
|
43
|
+
*
|
|
44
|
+
* Rules beyond the old `buildSeoMeta`'s six fields: og:url falls back to the
|
|
45
|
+
* page URL on noindex; og:type follows an editorial `article`; image dimensions
|
|
46
|
+
* and alt gate on being known; article:* gate on the article type; locale
|
|
47
|
+
* alternates derive from sibling translations; Twitter carries only the
|
|
48
|
+
* overrides that differ from their OG counterpart (it inherits the rest).
|
|
49
|
+
*
|
|
50
|
+
* @param {Record<string, any>} data The Eleventy cascade data bag.
|
|
51
|
+
* @param {string | undefined} canonicalUrl The resolved canonical (`seo.url`).
|
|
52
|
+
* @returns {{ openGraph: Record<string, unknown>, twitter: Record<string, unknown> }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildSocialProjections(data, canonicalUrl) {
|
|
55
|
+
const settings = data.settings;
|
|
56
|
+
const pageUrl = data.page?.url;
|
|
57
|
+
if (!settings?.url || !pageUrl) return { openGraph: {}, twitter: {} };
|
|
58
|
+
|
|
59
|
+
const seo = data.seo ?? {};
|
|
60
|
+
const schema = data.schema ?? {};
|
|
61
|
+
const nodes = data._navigator?.nodes;
|
|
62
|
+
const node = nodes?.[pageUrl];
|
|
63
|
+
|
|
64
|
+
const lang = node?.lang || data.page?.lang || data.lang || settings.defaultLanguage;
|
|
65
|
+
const locale = toOpenGraphLocale(resolveLocale(node, data, settings, lang)) ?? '';
|
|
66
|
+
const siteRoot = settings.url.replace(/\/+$/, '');
|
|
67
|
+
|
|
68
|
+
const title = seo.ogTitle ?? node?.title ?? data.title;
|
|
69
|
+
const description = seo.ogDescription ?? node?.description ?? data.description ?? node?.excerpt;
|
|
70
|
+
|
|
71
|
+
// Editorial `article` projects og:type article (and unlocks article:*);
|
|
72
|
+
// an explicit seo.ogType wins, then the site default, then 'website'.
|
|
73
|
+
const entryType = data.type || node?.type;
|
|
74
|
+
const type = seo.ogType ?? (entryType === 'article' ? 'article' : (settings.seo?.openGraph?.type ?? 'website'));
|
|
75
|
+
|
|
76
|
+
// og:url falls back to the absolute page URL when canonical is omitted
|
|
77
|
+
// (noindex), so share previews stay stable.
|
|
78
|
+
const url = canonicalUrl ?? `${siteRoot}${pageUrl}`;
|
|
79
|
+
|
|
80
|
+
const og = { title, type, url };
|
|
81
|
+
if (locale) og.locale = locale;
|
|
82
|
+
if (description) og.description = description;
|
|
83
|
+
const siteName = settings.languages?.[lang]?.title || settings.title;
|
|
84
|
+
if (siteName) og.siteName = siteName;
|
|
85
|
+
|
|
86
|
+
const translationKey = node?.translationKey || data.page?.translationKey || data.translationKey;
|
|
87
|
+
const localeAlt = localeAlternates(nodes, translationKey, pageUrl, locale);
|
|
88
|
+
if (localeAlt.length) og.localeAlternate = localeAlt;
|
|
89
|
+
|
|
90
|
+
// Image gating: a URL emits og:image; dimensions and alt ride only when
|
|
91
|
+
// known — the two-tier degrade the graph adapter follows for the ImageObject.
|
|
92
|
+
const ogImageRaw = data.ogImage ?? seo.ogImage ?? settings.seo?.ogImage;
|
|
93
|
+
const ogImage = typeof ogImageRaw === 'string' ? { url: ogImageRaw } : ogImageRaw;
|
|
94
|
+
if (ogImage?.url) {
|
|
95
|
+
og.image = ogImage.url;
|
|
96
|
+
if (ogImage.alt) og.imageAlt = ogImage.alt;
|
|
97
|
+
if (ogImage.width) og.imageWidth = ogImage.width;
|
|
98
|
+
if (ogImage.height) og.imageHeight = ogImage.height;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// article:* only when the page projects as an article. Author chain mirrors
|
|
102
|
+
// the graph adapter's: the Person, else the primary entity (Organization).
|
|
103
|
+
if (type === 'article') {
|
|
104
|
+
const dates = resolveDates(data);
|
|
105
|
+
const article = {};
|
|
106
|
+
if (dates.datePublished) article.publishedTime = dates.datePublished.toISOString();
|
|
107
|
+
if (dates.dateModified) article.modifiedTime = dates.dateModified.toISOString();
|
|
108
|
+
if (data.sectionLabel) article.section = data.sectionLabel;
|
|
109
|
+
// article:tag mirrors the `topics` convention (the same value the graph emits
|
|
110
|
+
// as schema.org keywords); always an array, OG tags are repeated tags.
|
|
111
|
+
if (data.topics?.length) article.tags = Array.isArray(data.topics) ? data.topics : [data.topics];
|
|
112
|
+
const author = schema.person?.name ?? schema.organization?.name;
|
|
113
|
+
if (author) article.authors = [author];
|
|
114
|
+
og.article = article;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Twitter inherits from OG automatically, so default the card and carry only
|
|
118
|
+
// the overrides that differ from their OG counterpart (emitting a duplicate
|
|
119
|
+
// is noise).
|
|
120
|
+
const twitter = { card: seo.twitterCard ?? settings.seo?.twitter?.card ?? 'summary_large_image' };
|
|
121
|
+
const twitterSite = seo.twitterSite ?? settings.seo?.twitter?.site;
|
|
122
|
+
if (twitterSite) twitter.site = twitterSite;
|
|
123
|
+
const twitterCreator = settings.seo?.twitter?.creator;
|
|
124
|
+
if (twitterCreator) twitter.creator = twitterCreator;
|
|
125
|
+
if (seo.twitterTitle && seo.twitterTitle !== title) twitter.title = seo.twitterTitle;
|
|
126
|
+
if (seo.twitterDescription && seo.twitterDescription !== description) twitter.description = seo.twitterDescription;
|
|
127
|
+
if (seo.twitterImage && seo.twitterImage !== og.image) twitter.image = seo.twitterImage;
|
|
128
|
+
|
|
129
|
+
return { openGraph: og, twitter };
|
|
130
|
+
}
|