@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.
- package/README.md +30 -32
- package/core/back-compat/options.js +69 -0
- package/core/content-graph/backlinks.js +65 -0
- package/core/content-graph/extractors.js +185 -0
- package/core/content-graph/graph.js +121 -0
- package/core/content-graph/index.js +2 -0
- package/core/content-graph/prepass.js +121 -0
- 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/logging/banner.js +49 -0
- package/core/{logging.js → logging/index.js} +19 -2
- package/core/logging/quips.js +30 -0
- package/core/markdown/auto-heading-ids.js +86 -0
- package/core/markdown/index.js +5 -0
- package/core/markdown/safe-use.js +42 -0
- package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
- package/core/page-context/build.js +336 -0
- package/core/page-context/index.js +1 -0
- package/core/page-context/register.js +73 -0
- package/core/page-context/seo-helpers.js +56 -0
- package/core/schema.js +22 -2
- 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/slug-index.js +2 -2
- package/core/state.js +75 -0
- package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
- package/core/surface/index.js +22 -0
- package/core/types.js +3 -0
- package/core/utils/add-trailing-slash.js +11 -0
- package/core/utils/ensure-dot-slash-dir.js +13 -0
- package/core/utils/normalize-language-map.js +37 -0
- package/core/utils/resolve-field.js +9 -0
- package/core/utils/resolve-subdir.js +20 -0
- package/core/utils/slugify.js +15 -0
- package/core/utils/title-case-slug.js +15 -0
- package/core/utils/unique-by.js +25 -0
- package/core/virtual-dir.js +11 -10
- package/index.js +161 -118
- package/modules/assets/index.js +4 -2
- package/modules/assets/processors/esbuild-process.js +2 -2
- package/modules/assets/processors/postcss-process.js +2 -2
- package/modules/head/drivers/posthtml-head-elements.js +92 -12
- package/modules/head/index.js +23 -19
- 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 +80 -39
- package/modules/navigator/index.js +39 -25
- package/modules/navigator/templates/navigator-core.html +1 -1
- package/modules/sitemap/index.js +8 -4
- package/modules/sitemap/templates/sitemap-core.html +1 -1
- package/package.json +5 -2
- package/core/filters/index.js +0 -4
- package/core/global-functions/index.js +0 -6
- package/core/page-context.js +0 -310
- package/core/shortcodes/index.js +0 -2
- package/core/utils/helpers.js +0 -75
- /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
- /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
- /package/core/{filters → surface/filters}/isString.js +0 -0
- /package/core/{filters → surface/filters}/related-posts.js +0 -0
|
@@ -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();
|
package/core/slug-index.js
CHANGED
|
@@ -35,7 +35,7 @@ const SCOPE_NAME = 'core:slug-index';
|
|
|
35
35
|
* page-context.buildPageContext → set() → registry scope → wikilinks getBySlug()
|
|
36
36
|
*
|
|
37
37
|
* @param {import('@11ty/eleventy').UserConfig} eleventyConfig
|
|
38
|
-
* @returns {{set: (slug: string, url: string, inputPath?: string) => void, getBySlug: (slug: string) => string |
|
|
38
|
+
* @returns {{set: (slug: string, url: string, inputPath?: string) => void, getBySlug: (slug: string) => string | undefined, snapshot: () => Record<string, {url: string, inputPath?: string}>}}
|
|
39
39
|
*/
|
|
40
40
|
export function createSlugIndex(eleventyConfig) {
|
|
41
41
|
const scope = getScope(eleventyConfig, SCOPE_NAME);
|
|
@@ -52,7 +52,7 @@ export function createSlugIndex(eleventyConfig) {
|
|
|
52
52
|
setEntry(scope, slug, { url, inputPath });
|
|
53
53
|
},
|
|
54
54
|
getBySlug(slug) {
|
|
55
|
-
return getEntry(scope, slug)?.url
|
|
55
|
+
return getEntry(scope, slug)?.url;
|
|
56
56
|
},
|
|
57
57
|
snapshot() {
|
|
58
58
|
return Object.fromEntries(scope.values);
|
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(`
|
|
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
|
|
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
|
*/
|