@apleasantview/eleventy-plugin-baseline 0.1.0-next.41 → 0.1.0-next.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -19
- package/core/content-graph/extractors.js +63 -18
- package/core/content-graph/graph.js +5 -2
- package/core/dates/git-date.js +71 -0
- package/core/dates/index.js +55 -0
- package/core/locale/derive-lang.js +19 -0
- package/core/locale/index.js +6 -0
- package/core/locale/normalize-lang.js +13 -0
- package/core/locale/normalize-locale.js +20 -0
- package/core/locale/open-graph-locale.js +14 -0
- package/core/locale/resolve-default.js +27 -0
- package/core/locale/resolve-locale.js +16 -0
- package/core/markdown/wikilinks.js +1 -1
- package/core/page-context/build.js +120 -23
- package/core/schema.js +3 -1
- package/core/seo-graph/adapter.js +246 -0
- package/core/seo-graph/build.js +87 -0
- package/core/seo-graph/index.js +1 -0
- package/core/seo-graph/open-graph.js +130 -0
- package/core/seo-graph/register.js +42 -0
- package/core/seo-graph/schema.js +18 -0
- package/core/state.js +3 -1
- package/core/surface/index.js +1 -1
- package/core/types.js +3 -0
- package/core/utils/{normalize-languages.js → normalize-language-map.js} +14 -5
- package/core/utils/title-case-slug.js +15 -0
- package/index.js +15 -9
- package/modules/head/drivers/posthtml-head-elements.js +92 -10
- package/modules/head/index.js +16 -9
- package/modules/head/schema.js +7 -3
- package/modules/multilang/filters/i18n-default-translation.js +2 -4
- package/modules/multilang/filters/i18n-translation-in.js +2 -2
- package/modules/multilang/filters/i18n-translations-for.js +2 -2
- package/modules/multilang/index.js +78 -39
- package/modules/navigator/index.js +6 -5
- package/modules/sitemap/index.js +4 -4
- package/modules/sitemap/templates/sitemap-core.html +1 -1
- package/package.json +2 -1
- /package/core/{surface/global-date-function.js → dates/date-global.js} +0 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Baseline makes the structural decisions that Eleventy leaves open: directory layout, asset pipeline, image handling, SEO, sitemaps.
|
|
4
4
|
|
|
5
|
-
If you've started a new Eleventy project and found yourself wiring up the same things for the third time, this is for you. Directory structure, template engine, image formats, meta tags, asset bundling, sitemap
|
|
5
|
+
If you've started a new Eleventy project and found yourself wiring up the same things for the third time, this is for you. Directory structure, template engine, image formats, meta tags, asset bundling, sitemap – decisions that are individually small but collectively slow you down. Baseline makes them together, so they fit together. You get to skip the setup and start building.
|
|
6
6
|
|
|
7
|
-
You still own your site. Baseline handles the infrastructure
|
|
7
|
+
You still own your site. Baseline handles the infrastructure – the parts that have well-tested answers. Your design, your content, the things that make your site yours – those stay yours.
|
|
8
8
|
|
|
9
9
|
This is a working plugin, not a finished product. Things might shift, break, or get renamed.
|
|
10
10
|
|
|
@@ -33,14 +33,14 @@ export const config = baselineConfig;
|
|
|
33
33
|
|
|
34
34
|
`baseline()` returns an async closure (Eleventy's documented async-plugin pattern), so the call to `addPlugin` is awaited.
|
|
35
35
|
|
|
36
|
-
The plugin takes two arguments: `settings` (site identity
|
|
36
|
+
The plugin takes two arguments: `settings` (site identity – title, url, languages, head extras, SEO defaults) and `options` (runtime behavior – verbose, sitemap, navigator).
|
|
37
37
|
|
|
38
38
|
```js
|
|
39
39
|
const settings = {
|
|
40
40
|
title: 'My Site',
|
|
41
41
|
tagline: 'Built with Baseline',
|
|
42
42
|
url: 'https://www.example.com/',
|
|
43
|
-
|
|
43
|
+
defaultLocale: 'en-US',
|
|
44
44
|
languages: {
|
|
45
45
|
en: { title: 'My Site' },
|
|
46
46
|
nl: { title: 'Mijn Site' }
|
|
@@ -60,30 +60,30 @@ await eleventyConfig.addPlugin(
|
|
|
60
60
|
|
|
61
61
|
The plugin registers everything on load. No setup beyond the config above.
|
|
62
62
|
|
|
63
|
-
**Core**
|
|
63
|
+
**Core** – always active:
|
|
64
64
|
|
|
65
|
-
- An image shortcode (via eleventy-img)
|
|
66
|
-
- Wikilinks in Markdown
|
|
67
|
-
- Auto heading IDs
|
|
68
|
-
- Element attributes in Markdown
|
|
65
|
+
- An image shortcode (via eleventy-img) – AVIF and WebP, responsive widths, lazy loading. Alt text is required – the build warns if you skip it.
|
|
66
|
+
- Wikilinks in Markdown – `[[slug]]`, `[[slug:lang]]`, `[[slug#anchor]]`, `[[slug|alias]]`, combinable. Forward links only.
|
|
67
|
+
- Auto heading IDs – every heading gets a stable slugified `id` with WordPress-style dedup; manual `{#id}` always wins.
|
|
68
|
+
- Element attributes in Markdown – `{#id .class key="value"}` syntax attaches attributes to any block element (via `markdown-it-attrs`).
|
|
69
69
|
- Filters: `markdownify`, `relatedPosts`, `isString`
|
|
70
70
|
- A date-formatting global
|
|
71
|
-
- Drafts preprocessor
|
|
71
|
+
- Drafts preprocessor – drafts stay out of production builds automatically
|
|
72
72
|
- Static passthrough (`src/static/` → site root)
|
|
73
73
|
|
|
74
|
-
**Modules**
|
|
74
|
+
**Modules** – `head` and `assets` are always on; `sitemap` is on by default; `navigator` is on in development; `multilang` is opt-in.
|
|
75
75
|
|
|
76
|
-
| Module | What it does
|
|
77
|
-
| ----------- |
|
|
78
|
-
| `assets` | The asset pipeline. One entry point per directory (`index.css`, `index.js`). Bundles JS via esbuild and processes CSS via PostCSS. Inline filters (`inlinePostCSS`, `inlineESbuild`) for critical-path assets
|
|
79
|
-
| `head` | `<head>` tags
|
|
80
|
-
| `multilang` | Directory-based multilingual support. Per-language collections, translation mapping, i18n filters. Wraps Eleventy's I18n plugin
|
|
81
|
-
| `navigator` |
|
|
82
|
-
| `sitemap` | XML sitemap. Every page is included unless you exclude it. Multilingual sites get per-language sitemaps plus an index
|
|
76
|
+
| Module | What it does |
|
|
77
|
+
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| `assets` | The asset pipeline. One entry point per directory (`index.css`, `index.js`). Bundles JS via esbuild and processes CSS via PostCSS. Inline filters (`inlinePostCSS`, `inlineESbuild`) for critical-path assets |
|
|
79
|
+
| `head` | `<head>` tags handled for you by dropping `<baseline-head>` in your layout: charset, viewport, title, description, robots, canonical, hreflang, plus a full SEO payload (Open Graph, Twitter Cards, and a JSON-LD structured-data graph) |
|
|
80
|
+
| `multilang` | Directory-based multilingual support. Per-language collections, translation mapping, i18n filters. Wraps Eleventy's I18n plugin |
|
|
81
|
+
| `navigator` | The content-graph read surface (`_navigator`: nodes, edges, backlinks), plus debug tooling: globals for inspecting template data, debug filters (`_inspect`, `_json`, `_keys`), and an optional virtual debug page |
|
|
82
|
+
| `sitemap` | XML sitemap. Every page is included unless you exclude it. Multilingual sites get per-language sitemaps plus an index |
|
|
83
83
|
|
|
84
84
|
## Docs
|
|
85
85
|
|
|
86
|
-
Full documentation
|
|
86
|
+
Full documentation – tutorials, how-to guides, and reference – lives at:
|
|
87
87
|
[https://www.eleventy-baseline.dev/](https://www.eleventy-baseline.dev/)
|
|
88
88
|
|
|
89
89
|
## Contributing
|
|
@@ -31,20 +31,58 @@ import { slugify } from '../utils/slugify.js';
|
|
|
31
31
|
* parsed document + knownOrigins → per-page record
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
// Live DOM id wins (matches anchored markup); fall back to a slugified id
|
|
35
|
+
// so consumers always have a stable handle, even when the rendering pipeline
|
|
36
|
+
// did not auto-anchor headings.
|
|
37
|
+
function headingRecord(el) {
|
|
38
|
+
const text = (el.textContent || '').trim();
|
|
39
|
+
return {
|
|
40
|
+
level: Number(el.tagName[1]),
|
|
41
|
+
text,
|
|
42
|
+
id: el.id || slugify(text) || null
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
function extractHeadings(root) {
|
|
35
47
|
const nodes = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
48
|
+
return Array.from(nodes).map(headingRecord);
|
|
49
|
+
}
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
function extractSections(root) {
|
|
52
|
+
const rootLevel = 2;
|
|
53
|
+
const sections = [];
|
|
54
|
+
let current = null;
|
|
55
|
+
|
|
56
|
+
// Shallow walk: markdown-rendered HTML keeps headings as siblings of
|
|
57
|
+
// their prose, which is the shape sections need.
|
|
58
|
+
for (const el of Array.from(root.children)) {
|
|
59
|
+
const isHeading = /^H[1-6]$/.test(el.tagName);
|
|
60
|
+
const level = isHeading ? Number(el.tagName[1]) : null;
|
|
61
|
+
|
|
62
|
+
if (isHeading && level === rootLevel) {
|
|
63
|
+
if (current) sections.push(finalizeSection(current));
|
|
64
|
+
current = { heading: headingRecord(el), parts: [] };
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// H1 is the document title. Never opens a section, never folds in.
|
|
69
|
+
if (isHeading && level < rootLevel) continue;
|
|
70
|
+
|
|
71
|
+
// Pre-H2 content has no section to attach to; `excerpt` covers the lead.
|
|
72
|
+
if (current) current.parts.push(el);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (current) sections.push(finalizeSection(current));
|
|
76
|
+
return sections;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function finalizeSection({ heading, parts }) {
|
|
80
|
+
const text = parts
|
|
81
|
+
.map((el) => el.textContent || '')
|
|
82
|
+
.join(' ')
|
|
83
|
+
.replace(/\s+/g, ' ')
|
|
84
|
+
.trim();
|
|
85
|
+
return { heading, text };
|
|
48
86
|
}
|
|
49
87
|
|
|
50
88
|
function extractLinks(root, currentPage, knownOrigins) {
|
|
@@ -52,21 +90,27 @@ function extractLinks(root, currentPage, knownOrigins) {
|
|
|
52
90
|
|
|
53
91
|
return Array.from(anchors).map((a) => {
|
|
54
92
|
const raw = a.getAttribute('href');
|
|
55
|
-
const page = currentPage;
|
|
56
93
|
const href = normaliseHref(raw, knownOrigins);
|
|
57
|
-
const internal = isInternal(href);
|
|
58
|
-
const type = internal ? 'link' : 'external';
|
|
59
94
|
|
|
60
95
|
return {
|
|
61
|
-
internal,
|
|
62
|
-
from:
|
|
96
|
+
internal: isInternal(href),
|
|
97
|
+
from: currentPage,
|
|
63
98
|
to: href,
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
text: (a.textContent || '').trim(),
|
|
100
|
+
rel: extractRel(a)
|
|
66
101
|
};
|
|
67
102
|
});
|
|
68
103
|
}
|
|
69
104
|
|
|
105
|
+
// HTML `rel` is a space-separated token list, case-insensitive per spec.
|
|
106
|
+
// Lowercased and deduped so consumers can do membership checks without
|
|
107
|
+
// caring about author-side whitespace or casing.
|
|
108
|
+
function extractRel(el) {
|
|
109
|
+
const raw = el.getAttribute('rel');
|
|
110
|
+
if (!raw) return [];
|
|
111
|
+
return Array.from(new Set(raw.trim().toLowerCase().split(/\s+/).filter(Boolean)));
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
// HtmlBasePlugin rewrites internal hrefs to absolute URLs at render time
|
|
71
115
|
// (using process.env.URL or the dev server origin). Strip a known origin
|
|
72
116
|
// so hrefs land back as path-only; leave external URLs alone.
|
|
@@ -133,6 +177,7 @@ export function extractGraph(document, options = {}) {
|
|
|
133
177
|
node: {
|
|
134
178
|
excerpt: extractExcerpt(root, text),
|
|
135
179
|
headings: extractHeadings(root),
|
|
180
|
+
sections: extractSections(root),
|
|
136
181
|
images: extractImages(root)
|
|
137
182
|
},
|
|
138
183
|
edges: extractLinks(root, currentpage, knownOrigins)
|
|
@@ -41,7 +41,7 @@ import { buildBacklinkIndex } from './backlinks.js';
|
|
|
41
41
|
/**
|
|
42
42
|
* @param {Array<{ url: string, content?: string, data?: object }>} pages
|
|
43
43
|
* @param {{ knownOrigins?: Set<string>, log?: { warn: (...args: unknown[]) => void } }} [options] - Origins to strip from internal hrefs (HtmlBasePlugin rewrites them at render time). `log` routes dev-mode extraction warnings through the scoped logger.
|
|
44
|
-
* @returns {{ nodes: Record<string, object>, edges: Array<{ internal: boolean, from: string, to: string,
|
|
44
|
+
* @returns {{ nodes: Record<string, object>, edges: Array<{ internal: boolean, from: string, to: string, text: string, rel: string[] }>, backlinks: Record<string, Array<{ url: string, title?: string, excerpt?: string }>> }}
|
|
45
45
|
*/
|
|
46
46
|
export function buildGraph(pages, options = {}) {
|
|
47
47
|
const { log } = options;
|
|
@@ -53,7 +53,7 @@ export function buildGraph(pages, options = {}) {
|
|
|
53
53
|
if (!page?.url || typeof page.content !== 'string') continue;
|
|
54
54
|
if (!page.outputPath?.endsWith('.html')) continue;
|
|
55
55
|
// Honour the same opt-out 404s, drafts and internal templates already use.
|
|
56
|
-
if (page.data?.
|
|
56
|
+
if (page.data?._internal === true) continue;
|
|
57
57
|
if (page.data?.baselineExcludeFromGraph === true) continue;
|
|
58
58
|
|
|
59
59
|
try {
|
|
@@ -68,9 +68,12 @@ export function buildGraph(pages, options = {}) {
|
|
|
68
68
|
slug: ctx.entry?.slug,
|
|
69
69
|
description: ctx.entry?.description,
|
|
70
70
|
section: ctx.entry?.section,
|
|
71
|
+
breadcrumbs: ctx.entry?.breadcrumbs,
|
|
71
72
|
type: ctx.entry?.type,
|
|
72
73
|
lang: ctx.page?.lang,
|
|
73
74
|
locale: ctx.page?.locale,
|
|
75
|
+
translationKey: ctx.page?.translationKey,
|
|
76
|
+
isDefaultLang: ctx.page?.isDefaultLang,
|
|
74
77
|
date: ctx.page?.date,
|
|
75
78
|
url
|
|
76
79
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
let cache = null;
|
|
5
|
+
|
|
6
|
+
// Build a { repoRelativePath: ISO date } map from one `git log` walk.
|
|
7
|
+
// Each commit emits its date, then the files it touched; first date wins
|
|
8
|
+
// because git log is newest-first.
|
|
9
|
+
function buildCache() {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
const marker = '__BASELINE_COMMIT__';
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = execFileSync(
|
|
15
|
+
'git',
|
|
16
|
+
['log', '--name-only', '--no-renames', `--pretty=format:${marker}%cI`],
|
|
17
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
|
|
18
|
+
);
|
|
19
|
+
} catch {
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let currentDate = null;
|
|
24
|
+
for (const line of raw.split('\n')) {
|
|
25
|
+
if (line.startsWith(marker)) {
|
|
26
|
+
// Normalise to UTC so all outputs (sitemap, JSON-LD, schemamap) match.
|
|
27
|
+
currentDate = new Date(line.slice(marker.length)).toISOString();
|
|
28
|
+
} else if (line && currentDate && !map.has(line)) {
|
|
29
|
+
map.set(line, currentDate);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalize(inputPath) {
|
|
36
|
+
const abs = path.resolve(inputPath);
|
|
37
|
+
const rel = path.relative(process.cwd(), abs);
|
|
38
|
+
return rel.split(path.sep).join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Last-commit date (UTC ISO) for a file, or `null` when git has no record of
|
|
43
|
+
* it (untracked, or no git history available — e.g. a shallow CI clone).
|
|
44
|
+
*
|
|
45
|
+
* Unlike the docs-site copy this carries no mtime/now fallback: the date floor
|
|
46
|
+
* is `page.date`, applied by `resolveDates`, so this stays a pure "what does
|
|
47
|
+
* git say, or nothing" lookup.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} inputPath
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
export function gitModified(inputPath) {
|
|
53
|
+
if (!cache) cache = buildCache();
|
|
54
|
+
return cache.get(normalize(inputPath)) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The most recent `gitModified` across several paths, or `null` if none resolve.
|
|
59
|
+
*
|
|
60
|
+
* @param {Array<string | undefined | null>} inputPaths
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function maxGitModified(inputPaths) {
|
|
64
|
+
let max = null;
|
|
65
|
+
for (const p of inputPaths) {
|
|
66
|
+
if (!p) continue;
|
|
67
|
+
const iso = gitModified(p);
|
|
68
|
+
if (iso && (!max || iso > max)) max = iso;
|
|
69
|
+
}
|
|
70
|
+
return max;
|
|
71
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates substrate
|
|
3
|
+
*
|
|
4
|
+
* One home for date concerns: the Nunjucks `date` global (formatting), the
|
|
5
|
+
* git-backed last-commit lookup, and `resolveDates` — the single source for a
|
|
6
|
+
* page's publish/modified dates that the seo-graph substrate and any other
|
|
7
|
+
* consumer read from instead of re-deriving the chain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { gitModified, maxGitModified } from './git-date.js';
|
|
11
|
+
|
|
12
|
+
export { registerDateGlobal } from './date-global.js';
|
|
13
|
+
export { gitModified, maxGitModified };
|
|
14
|
+
|
|
15
|
+
/** Coerce a value to a valid `Date`, or `undefined` if it can't be parsed. */
|
|
16
|
+
function toDate(value) {
|
|
17
|
+
if (!value) return undefined;
|
|
18
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
19
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a page's publish and modified dates from one place. All three author
|
|
24
|
+
* keys are optional; the chain degrades to `page.date`, which Eleventy always
|
|
25
|
+
* backfills (front matter, else file birthtime), so output is never empty.
|
|
26
|
+
*
|
|
27
|
+
* - `datePublished` → front-matter `datePublished` → `page.date` (the floor).
|
|
28
|
+
* - `dateModified` → front-matter `dateModified` → git last-commit → resolved
|
|
29
|
+
* `datePublished`.
|
|
30
|
+
*
|
|
31
|
+
* `git-date` is the middle rung of the modified chain and is allowed to yield
|
|
32
|
+
* nothing; flooring `dateModified` to the *resolved* `datePublished` (not raw
|
|
33
|
+
* `page.date`) keeps the pair coherent when an author overrides `datePublished`.
|
|
34
|
+
* No clamp to `modified >= published`: a scheduled post can legitimately be
|
|
35
|
+
* modified before its publish date.
|
|
36
|
+
*
|
|
37
|
+
* Takes the full cascade `data` bag (matches the substrate convention) and
|
|
38
|
+
* returns normalised `Date` objects, ready for the seo-graph piece builders.
|
|
39
|
+
*
|
|
40
|
+
* `gitLookup` is an injection seam for tests; production calls pass `data` only
|
|
41
|
+
* and get the real git-backed lookup.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ page?: any, datePublished?: unknown, dateModified?: unknown }} data
|
|
44
|
+
* @param {(inputPath: string) => string | null} [gitLookup]
|
|
45
|
+
* @returns {{ datePublished: Date | undefined, dateModified: Date | undefined }}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveDates(data, gitLookup = gitModified) {
|
|
48
|
+
const page = data?.page ?? {};
|
|
49
|
+
const datePublished = toDate(data?.datePublished) ?? toDate(page.date);
|
|
50
|
+
const dateModified =
|
|
51
|
+
toDate(data?.dateModified) ??
|
|
52
|
+
toDate(page.inputPath ? gitLookup(page.inputPath) : null) ??
|
|
53
|
+
datePublished;
|
|
54
|
+
return { datePublished, dateModified };
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the short language subtag from a BCP 47 tag.
|
|
3
|
+
*
|
|
4
|
+
* `'en-US'` → `'en'`, `'zh-Hant-HK'` → `'zh'`. Normalises via `Intl.Locale`;
|
|
5
|
+
* null for empty input or tags it rejects.
|
|
6
|
+
*
|
|
7
|
+
* @param {unknown} locale
|
|
8
|
+
* @returns {string | null}
|
|
9
|
+
*/
|
|
10
|
+
export function deriveLang(locale) {
|
|
11
|
+
if (locale == null) return null;
|
|
12
|
+
const str = String(locale).trim();
|
|
13
|
+
if (str === '') return null;
|
|
14
|
+
try {
|
|
15
|
+
return new Intl.Locale(str).language;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { normalizeLang } from './normalize-lang.js';
|
|
2
|
+
export { normalizeLocale } from './normalize-locale.js';
|
|
3
|
+
export { deriveLang } from './derive-lang.js';
|
|
4
|
+
export { resolveDefault } from './resolve-default.js';
|
|
5
|
+
export { resolveLocale } from './resolve-locale.js';
|
|
6
|
+
export { toOpenGraphLocale } from './open-graph-locale.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a short language code: lowercase and trim.
|
|
3
|
+
*
|
|
4
|
+
* The substrate's lightest helper. Empty string for null/undefined; coerces
|
|
5
|
+
* non-string input via `String()`.
|
|
6
|
+
*
|
|
7
|
+
* @param {unknown} raw
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeLang(raw) {
|
|
11
|
+
if (raw == null) return '';
|
|
12
|
+
return String(raw).toLowerCase().trim();
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a BCP 47 locale tag to conventional casing using `Intl.Locale`.
|
|
3
|
+
*
|
|
4
|
+
* `Intl.Locale` handles language-script-region casing and subtag rules without
|
|
5
|
+
* reimplementing the spec. Returns null for empty/whitespace input or tags it
|
|
6
|
+
* rejects.
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} raw
|
|
9
|
+
* @returns {string | null}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeLocale(raw) {
|
|
12
|
+
if (raw == null) return null;
|
|
13
|
+
const str = String(raw).trim();
|
|
14
|
+
if (str === '') return null;
|
|
15
|
+
try {
|
|
16
|
+
return new Intl.Locale(str).toString();
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { normalizeLocale } from './normalize-locale.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a BCP 47 tag in Open Graph's `language_TERRITORY` form (`en-US` →
|
|
5
|
+
* `en_US`). Normalises casing first; null for tags `Intl.Locale` rejects.
|
|
6
|
+
* `replaceAll` so script+region tags convert fully (`zh-Hant-HK` → `zh_Hant_HK`).
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} raw A BCP 47 locale tag.
|
|
9
|
+
* @returns {string | null}
|
|
10
|
+
*/
|
|
11
|
+
export function toOpenGraphLocale(raw) {
|
|
12
|
+
const normalized = normalizeLocale(raw);
|
|
13
|
+
return normalized ? normalized.replaceAll('-', '_') : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { normalizeLang } from './normalize-lang.js';
|
|
2
|
+
import { normalizeLocale } from './normalize-locale.js';
|
|
3
|
+
import { deriveLang } from './derive-lang.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the effective `{ lang, locale }` default from settings.
|
|
7
|
+
*
|
|
8
|
+
* defaultLocale wins; defaultLanguage is a writer-side alias. Given only
|
|
9
|
+
* defaultLanguage, locale is derived via `Intl.Locale`.
|
|
10
|
+
*
|
|
11
|
+
* @param {{ defaultLanguage?: string, defaultLocale?: string }} settings
|
|
12
|
+
* @returns {{ lang: string, locale: string | null }}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveDefault(settings) {
|
|
15
|
+
const explicitLang = normalizeLang(settings?.defaultLanguage);
|
|
16
|
+
const explicitLocale = normalizeLocale(settings?.defaultLocale);
|
|
17
|
+
|
|
18
|
+
if (explicitLocale) {
|
|
19
|
+
return { lang: deriveLang(explicitLocale) ?? explicitLang, locale: explicitLocale };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (explicitLang) {
|
|
23
|
+
return { lang: explicitLang, locale: normalizeLocale(explicitLang) ?? explicitLang };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { lang: '', locale: null };
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick the page's BCP 47 locale out of the cascade.
|
|
3
|
+
*
|
|
4
|
+
* Reads, never normalises — trusts multilang's already-resolved `page.locale`
|
|
5
|
+
* first, then bag-level locale, the language's configured locale, and the bare
|
|
6
|
+
* `lang` tag last.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ locale?: string } | undefined} node The navigator node, if any.
|
|
9
|
+
* @param {Record<string, any>} data The Eleventy cascade data bag.
|
|
10
|
+
* @param {{ languages?: Record<string, { locale?: string }> } | undefined} settings
|
|
11
|
+
* @param {string} lang Resolved language subtag; the final fallback.
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function resolveLocale(node, data, settings, lang) {
|
|
15
|
+
return node?.locale || data?.page?.locale || data?.locale || settings?.languages?.[lang]?.locale || lang;
|
|
16
|
+
}
|
|
@@ -88,7 +88,7 @@ export function wikilinks(md, { slugIndex, pageContextRegistry, translationMapSt
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const lang = rawLang.toLowerCase();
|
|
91
|
-
const translationKey = ctx?.page?.
|
|
91
|
+
const translationKey = ctx?.page?.translationKey;
|
|
92
92
|
if (!translationKey) return null;
|
|
93
93
|
|
|
94
94
|
const map = translationMapStore?.get?.();
|