@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.
Files changed (39) hide show
  1. package/README.md +19 -19
  2. package/core/content-graph/extractors.js +63 -18
  3. package/core/content-graph/graph.js +5 -2
  4. package/core/dates/git-date.js +71 -0
  5. package/core/dates/index.js +55 -0
  6. package/core/locale/derive-lang.js +19 -0
  7. package/core/locale/index.js +6 -0
  8. package/core/locale/normalize-lang.js +13 -0
  9. package/core/locale/normalize-locale.js +20 -0
  10. package/core/locale/open-graph-locale.js +14 -0
  11. package/core/locale/resolve-default.js +27 -0
  12. package/core/locale/resolve-locale.js +16 -0
  13. package/core/markdown/wikilinks.js +1 -1
  14. package/core/page-context/build.js +120 -23
  15. package/core/schema.js +3 -1
  16. package/core/seo-graph/adapter.js +246 -0
  17. package/core/seo-graph/build.js +87 -0
  18. package/core/seo-graph/index.js +1 -0
  19. package/core/seo-graph/open-graph.js +130 -0
  20. package/core/seo-graph/register.js +42 -0
  21. package/core/seo-graph/schema.js +18 -0
  22. package/core/state.js +3 -1
  23. package/core/surface/index.js +1 -1
  24. package/core/types.js +3 -0
  25. package/core/utils/{normalize-languages.js → normalize-language-map.js} +14 -5
  26. package/core/utils/title-case-slug.js +15 -0
  27. package/index.js +15 -9
  28. package/modules/head/drivers/posthtml-head-elements.js +92 -10
  29. package/modules/head/index.js +16 -9
  30. package/modules/head/schema.js +7 -3
  31. package/modules/multilang/filters/i18n-default-translation.js +2 -4
  32. package/modules/multilang/filters/i18n-translation-in.js +2 -2
  33. package/modules/multilang/filters/i18n-translations-for.js +2 -2
  34. package/modules/multilang/index.js +78 -39
  35. package/modules/navigator/index.js +6 -5
  36. package/modules/sitemap/index.js +4 -4
  37. package/modules/sitemap/templates/sitemap-core.html +1 -1
  38. package/package.json +2 -1
  39. /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 base = resolveField({
140
- pageValue: pageTitle,
141
- siteValue: siteTitle
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 ?? [])], 'name');
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.locale?.isDefaultLang === true;
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
+ }