@apleasantview/eleventy-plugin-baseline 0.1.0-next.40 → 0.1.0-next.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +30 -32
  2. package/core/back-compat/options.js +69 -0
  3. package/core/content-graph/backlinks.js +65 -0
  4. package/core/content-graph/extractors.js +185 -0
  5. package/core/content-graph/graph.js +121 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/dates/git-date.js +71 -0
  9. package/core/dates/index.js +55 -0
  10. package/core/locale/derive-lang.js +19 -0
  11. package/core/locale/index.js +6 -0
  12. package/core/locale/normalize-lang.js +13 -0
  13. package/core/locale/normalize-locale.js +20 -0
  14. package/core/locale/open-graph-locale.js +14 -0
  15. package/core/locale/resolve-default.js +27 -0
  16. package/core/locale/resolve-locale.js +16 -0
  17. package/core/logging/banner.js +49 -0
  18. package/core/{logging.js → logging/index.js} +19 -2
  19. package/core/logging/quips.js +30 -0
  20. package/core/markdown/auto-heading-ids.js +86 -0
  21. package/core/markdown/index.js +5 -0
  22. package/core/markdown/safe-use.js +42 -0
  23. package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
  24. package/core/page-context/build.js +336 -0
  25. package/core/page-context/index.js +1 -0
  26. package/core/page-context/register.js +73 -0
  27. package/core/page-context/seo-helpers.js +56 -0
  28. package/core/schema.js +22 -2
  29. package/core/seo-graph/adapter.js +246 -0
  30. package/core/seo-graph/build.js +87 -0
  31. package/core/seo-graph/index.js +1 -0
  32. package/core/seo-graph/open-graph.js +130 -0
  33. package/core/seo-graph/register.js +42 -0
  34. package/core/seo-graph/schema.js +18 -0
  35. package/core/slug-index.js +2 -2
  36. package/core/state.js +75 -0
  37. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  38. package/core/surface/index.js +22 -0
  39. package/core/types.js +3 -0
  40. package/core/utils/add-trailing-slash.js +11 -0
  41. package/core/utils/ensure-dot-slash-dir.js +13 -0
  42. package/core/utils/normalize-language-map.js +37 -0
  43. package/core/utils/resolve-field.js +9 -0
  44. package/core/utils/resolve-subdir.js +20 -0
  45. package/core/utils/slugify.js +15 -0
  46. package/core/utils/title-case-slug.js +15 -0
  47. package/core/utils/unique-by.js +25 -0
  48. package/core/virtual-dir.js +11 -10
  49. package/index.js +161 -118
  50. package/modules/assets/index.js +4 -2
  51. package/modules/assets/processors/esbuild-process.js +2 -2
  52. package/modules/assets/processors/postcss-process.js +2 -2
  53. package/modules/head/drivers/posthtml-head-elements.js +92 -12
  54. package/modules/head/index.js +23 -19
  55. package/modules/head/schema.js +7 -3
  56. package/modules/multilang/filters/i18n-default-translation.js +2 -4
  57. package/modules/multilang/filters/i18n-translation-in.js +2 -2
  58. package/modules/multilang/filters/i18n-translations-for.js +2 -2
  59. package/modules/multilang/index.js +80 -39
  60. package/modules/navigator/index.js +39 -25
  61. package/modules/navigator/templates/navigator-core.html +1 -1
  62. package/modules/sitemap/index.js +8 -4
  63. package/modules/sitemap/templates/sitemap-core.html +1 -1
  64. package/package.json +5 -2
  65. package/core/filters/index.js +0 -4
  66. package/core/global-functions/index.js +0 -6
  67. package/core/page-context.js +0 -310
  68. package/core/shortcodes/index.js +0 -2
  69. package/core/utils/helpers.js +0 -75
  70. /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
  71. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  72. /package/core/{filters → surface/filters}/isString.js +0 -0
  73. /package/core/{filters → surface/filters}/related-posts.js +0 -0
@@ -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
+ }
@@ -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();
@@ -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 | null, snapshot: () => Record<string, {url: string, inputPath?: 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 ?? null;
55
+ return getEntry(scope, slug)?.url;
56
56
  },
57
57
  snapshot() {
58
58
  return Object.fromEntries(scope.values);
package/core/state.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * State derivation (composition root helper)
3
+ *
4
+ * Pure normalisation of user-supplied `settings` and `options` into the
5
+ * resolved `state` shape modules read from. No eleventyConfig, no
6
+ * environment reads beyond the `mode` argument, no side effects.
7
+ *
8
+ * Architecture layer:
9
+ * composition root (pure helper)
10
+ *
11
+ * System role:
12
+ * The single place that applies defaults, fallbacks, and feature
13
+ * inference. Extracted from the entry point so it can be reasoned
14
+ * about — and tested — without booting Eleventy.
15
+ *
16
+ * Why this exists:
17
+ * Keeping defaults and feature derivation tangled with eleventyConfig
18
+ * wiring made the entry point hard to scan. Pulling the pure half out
19
+ * leaves the composition root as a list of registration steps.
20
+ *
21
+ * Scope:
22
+ * Owns settings/options normalisation and the derived `features` map.
23
+ * Does not own validation (see core/schema.js) or any runtime wiring.
24
+ *
25
+ * Data flow:
26
+ * settings + options + { mode } → { settings, options, features }
27
+ *
28
+ * @param {import('./types.js').BaselineSettings} settings
29
+ * @param {import('./types.js').BaselineOptions} options
30
+ * @param {{ mode?: string }} [env]
31
+ * @returns {import('./types.js').BaselineState & { features: Readonly<Record<string, boolean>> }}
32
+ */
33
+ export function deriveBaselineState(settings, options, { mode } = {}) {
34
+ const isDev = mode === 'development';
35
+
36
+ const resolvedSettings = {
37
+ title: settings.title,
38
+ tagline: settings.tagline,
39
+ url: settings.url,
40
+ noindex: settings.noindex ?? false,
41
+ defaultLanguage: settings.defaultLanguage,
42
+ defaultLocale: settings.defaultLocale,
43
+ languages: settings.languages,
44
+ head: settings.head,
45
+ seo: settings.seo
46
+ };
47
+
48
+ const resolvedOptions = {
49
+ verbose: options.verbose ?? true,
50
+ multilang: options.multilingual ?? false,
51
+ sitemap: options.sitemap ?? options.enableSitemapTemplate ?? true,
52
+ navigator: options.navigator ?? options.enableNavigatorTemplate ?? isDev,
53
+ head: {
54
+ titleSeparator: options.head?.titleSeparator,
55
+ showGenerator: options.head?.showGenerator
56
+ },
57
+ assets: {
58
+ esbuild: options.assets?.esbuild ?? options.assetsESBuild ?? {}
59
+ }
60
+ };
61
+
62
+ const features = Object.freeze({
63
+ multilang: Boolean(resolvedOptions.multilang),
64
+ sitemap: Boolean(resolvedOptions.sitemap),
65
+ navigator: Boolean(resolvedOptions.navigator),
66
+ head: true,
67
+ assets: true
68
+ });
69
+
70
+ return Object.freeze({
71
+ settings: Object.freeze(resolvedSettings),
72
+ options: Object.freeze(resolvedOptions),
73
+ features
74
+ });
75
+ }
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import Image from '@11ty/eleventy-img';
3
- import { createLogger } from '../logging.js';
3
+ import { createLogger } from '../logging/index.js';
4
4
 
5
5
  // Module-level logger. Image shortcode only uses `.warn`, which emits regardless
6
6
  // of verbose, so we don't thread verbose through the shortcode signature.
7
- const log = createLogger('image');
7
+ const log = createLogger('image-shortcode');
8
8
 
9
9
  const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 'auto'];
10
10
  const DEFAULT_FORMATS = ['avif', 'webp'];
@@ -68,7 +68,7 @@ export async function imageShortcode(options = {}) {
68
68
 
69
69
  // --- Validation and normalization ---
70
70
 
71
- if (!src) throw new Error(`imageShortcode: src is required (received ${JSON.stringify(src)})`);
71
+ if (!src) throw new Error(`[baseline/image-shortcode] src is required (received ${JSON.stringify(src)})`);
72
72
  if (alt == null) {
73
73
  log.warn('alt is required (use empty string for decorative images)');
74
74
  }
@@ -106,7 +106,7 @@ export async function imageShortcode(options = {}) {
106
106
  });
107
107
  } catch (error) {
108
108
  if (process.env.ELEVENTY_RUN_MODE === 'serve') {
109
- log.warn(`transformOnRequest failed for ${src}, retrying.\n > ${error?.message || error}`);
109
+ log.warn(`transformOnRequest failed for ${src}, retrying. ${error?.message || error}`);
110
110
  metadata = await Image(resolvedSrc, imageOptions);
111
111
  } else {
112
112
  throw error;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Surface barrel
3
+ *
4
+ * Single entry point for everything Baseline registers against Eleventy that
5
+ * user templates can reach: filters, global functions, shortcodes.
6
+ */
7
+
8
+ import { registerDateGlobal } from '../dates/index.js';
9
+
10
+ // --- Filters ---
11
+ export { markdownFilter } from '../markdown/markdownify.js';
12
+ export { relatedPostsFilter } from './filters/related-posts.js';
13
+ export { isStringFilter } from './filters/isString.js';
14
+
15
+ // --- Shortcodes ---
16
+ export { imageShortcode } from './image-shortcode.js';
17
+
18
+ // --- Global functions (aggregator) ---
19
+ /** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */
20
+ export function registerGlobals(eleventyConfig) {
21
+ registerDateGlobal(eleventyConfig);
22
+ }
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
  */