@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
package/README.md CHANGED
@@ -2,24 +2,16 @@
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 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.
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 the parts that have well-tested answers. Your design, your content, the things that make your site yours those stay yours.
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
 
11
11
  ## Install
12
12
 
13
- If you already have Eleventy and eleventy-img installed:
14
-
15
- ```bash
16
- npm install @apleasantview/eleventy-plugin-baseline
17
- ```
18
-
19
- For a fresh project (install Eleventy and eleventy-img too):
20
-
21
13
  ```bash
22
- npm install @11ty/eleventy @11ty/eleventy-img @apleasantview/eleventy-plugin-baseline
14
+ npm install @11ty/eleventy @apleasantview/eleventy-plugin-baseline @11ty/eleventy-img
23
15
  ```
24
16
 
25
17
  Requires Eleventy 3.x and Node >=20.
@@ -30,33 +22,36 @@ Add the plugin and re-export the config. The config export sets the directory st
30
22
 
31
23
  ```js
32
24
  import baseline, { config as baselineConfig } from '@apleasantview/eleventy-plugin-baseline';
25
+ import settings from './src/_data/settings.js';
33
26
 
34
- export default function (eleventyConfig) {
35
- eleventyConfig.addPlugin(baseline());
27
+ export default async function (eleventyConfig) {
28
+ await eleventyConfig.addPlugin(baseline(settings, {}));
36
29
  }
37
30
 
38
31
  export const config = baselineConfig;
39
32
  ```
40
33
 
41
- The plugin takes two arguments: `settings` (site identity title, url, languages, head extras) and `options` (runtime behavior — verbose, sitemap, navigator).
34
+ `baseline()` returns an async closure (Eleventy's documented async-plugin pattern), so the call to `addPlugin` is awaited.
35
+
36
+ The plugin takes two arguments: `settings` (site identity – title, url, languages, head extras, SEO defaults) and `options` (runtime behavior – verbose, sitemap, navigator).
42
37
 
43
38
  ```js
44
39
  const settings = {
45
40
  title: 'My Site',
46
41
  tagline: 'Built with Baseline',
47
42
  url: 'https://www.example.com/',
48
- defaultLanguage: 'en',
43
+ defaultLocale: 'en-US',
49
44
  languages: {
50
45
  en: { title: 'My Site' },
51
46
  nl: { title: 'Mijn Site' }
52
47
  }
53
48
  };
54
49
 
55
- eleventyConfig.addPlugin(
50
+ await eleventyConfig.addPlugin(
56
51
  baseline(settings, {
57
- verbose: false, // extra logging during builds
58
- sitemap: true, // XML sitemap generation
59
- navigator: false // debug page for inspecting template data
52
+ verbose: true, // build-narrative logging (default: true)
53
+ sitemap: true, // XML sitemap generation (default: true)
54
+ navigator: false // debug page for inspecting template data (default: on in development)
60
55
  })
61
56
  );
62
57
  ```
@@ -65,28 +60,31 @@ eleventyConfig.addPlugin(
65
60
 
66
61
  The plugin registers everything on load. No setup beyond the config above.
67
62
 
68
- **Core** always active:
63
+ **Core** always active:
69
64
 
70
- - An image shortcode (via eleventy-img) AVIF and WebP, responsive widths, lazy loading. Alt text is required the build warns if you skip it.
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`).
71
69
  - Filters: `markdownify`, `relatedPosts`, `isString`
72
70
  - A date-formatting global
73
- - Drafts preprocessor drafts stay out of production builds automatically
71
+ - Drafts preprocessor drafts stay out of production builds automatically
74
72
  - Static passthrough (`src/static/` → site root)
75
73
 
76
- **Modules** opt-in, loaded individually:
74
+ **Modules** `head` and `assets` are always on; `sitemap` is on by default; `navigator` is on in development; `multilang` is opt-in.
77
75
 
78
- | Module | What it does |
79
- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `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 |
81
- | `head` | `<head>` tags (charset, viewport, title, description, robots, canonical, hreflang) handled for you by dropping `<baseline-head>` in your layout |
82
- | `multilang` | Directory-based multilingual support. Per-language collections, translation mapping, i18n filters. Wraps Eleventy's I18n plugin |
83
- | `navigator` | Debug tooling. Globals for inspecting template data, plus debug filters (`_inspect`, `_json`, `_keys`). Optional virtual debug page |
84
- | `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 |
85
83
 
86
84
  ## Docs
87
85
 
88
- Full documentation tutorials, how-to guides, and reference lives at:
89
- [https://eleventy-plugin-baseline.netlify.app/](https://eleventy-plugin-baseline.netlify.app/)
86
+ Full documentation tutorials, how-to guides, and reference lives at:
87
+ [https://www.eleventy-baseline.dev/](https://www.eleventy-baseline.dev/)
90
88
 
91
89
  ## Contributing
92
90
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Back-compat: legacy options shape (composition root helper)
3
+ *
4
+ * The original plugin API accepted a single merged configuration object.
5
+ * The current contract splits site identity (`settings`) from runtime
6
+ * behaviour (`options`). This shim detects the old shape and converts it.
7
+ *
8
+ * Architecture layer:
9
+ * composition root (back-compat helper)
10
+ *
11
+ * System role:
12
+ * Pure shape detection and normalisation. Lets the entry point accept
13
+ * either the legacy single-object form or the current two-arg form
14
+ * without conditional logic at every read site.
15
+ *
16
+ * Why this exists:
17
+ * Past-me changed the plugin's input contract. This file keeps that
18
+ * change non-breaking for anyone (including past-me) still on the old
19
+ * shape. Removable once nobody's calling baseline() with a single object.
20
+ *
21
+ * Scope:
22
+ * Owns the legacy-key whitelist, shape detection, and the split into
23
+ * the canonical pair. Does not log; the caller decides whether and how
24
+ * to surface the deprecation.
25
+ *
26
+ * Data flow:
27
+ * legacy-shaped object → { settings, options }
28
+ */
29
+
30
+ export const LEGACY_OPTION_KEYS = [
31
+ 'verbose',
32
+ 'enableNavigatorTemplate',
33
+ 'enableSitemapTemplate',
34
+ 'assetsESBuild',
35
+ 'multilingual'
36
+ ];
37
+
38
+ /**
39
+ * Detect the legacy single-object plugin invocation.
40
+ *
41
+ * NOTE: arguments.length is required because default parameters mask arity.
42
+ *
43
+ * @param {unknown} firstArg
44
+ * @param {number} argsLength
45
+ * @returns {boolean}
46
+ */
47
+ export function isLegacyShape(firstArg, argsLength) {
48
+ if (argsLength >= 2) return false;
49
+ if (!firstArg || typeof firstArg !== 'object') return false;
50
+ return LEGACY_OPTION_KEYS.some((key) => key in firstArg);
51
+ }
52
+
53
+ /**
54
+ * Convert a legacy single-object input into the current (settings, options)
55
+ * pair.
56
+ *
57
+ * - settings → site identity (content + SEO concerns)
58
+ * - options → runtime behaviour flags
59
+ *
60
+ * @param {object} legacy
61
+ * @returns {{ settings: object, options: object }}
62
+ */
63
+ export function normalizeLegacyShape(legacy) {
64
+ const { defaultLanguage, languages, ...rest } = legacy;
65
+ return {
66
+ settings: { defaultLanguage, languages },
67
+ options: rest
68
+ };
69
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Backlink index (runtime substrate)
3
+ *
4
+ * Inverts the per-page outbound links into a target-keyed lookup. Built
5
+ * once at graph time so accessor reads are a hash lookup, not a scan
6
+ * over every page.
7
+ *
8
+ * Architecture layer:
9
+ * runtime substrate
10
+ *
11
+ * System role:
12
+ * Cross-page index built alongside the per-page records. Lives outside
13
+ * the per-page extractors because A's backlinks can only be known
14
+ * after walking B, C, D's outgoing links.
15
+ *
16
+ * Lifecycle:
17
+ * build-time → buildGraph calls this once over the records map
18
+ *
19
+ * Why this exists:
20
+ * Eleventy has no notion of inverse references. Templates that want
21
+ * to render "what links here" need this index built up front.
22
+ *
23
+ * Scope:
24
+ * Owns the inversion logic and fragment-stripping rule (so /foo/#x
25
+ * folds into /foo/).
26
+ * Does not own outbound link extraction (extractors.js) or how the
27
+ * index is exposed to templates (graph.js / index.js).
28
+ *
29
+ * Data flow:
30
+ * per-page records → inverted lookup keyed by target url
31
+ */
32
+
33
+ /**
34
+ * @param {Record<string, { links: Array<{ href: string, internal: boolean }>, excerpt?: string }>} nodes
35
+ * @param {Record<string, { title?: string }>} [sourceMeta] - Per-source metadata to enrich entries with.
36
+ * @returns {Record<string, Array<{ url: string, title?: string, excerpt?: string }>>}
37
+ */
38
+ export function buildBacklinkIndex(edges, nodes = {}, sourceMeta = {}) {
39
+ const index = {};
40
+ const seen = {};
41
+
42
+ for (const edge of edges) {
43
+ if (!edge.internal) continue;
44
+ if (!edge.to || !edge.from) continue;
45
+
46
+ // Strip fragments so /foo/#section folds into /foo/
47
+ const target = edge.to.split('#')[0];
48
+
49
+ if (!index[target]) {
50
+ index[target] = [];
51
+ seen[target] = new Set();
52
+ }
53
+
54
+ if (seen[target].has(edge.from)) continue;
55
+ seen[target].add(edge.from);
56
+
57
+ index[target].push({
58
+ url: edge.from,
59
+ title: sourceMeta[edge.from]?.title || nodes[edge.from]?.title,
60
+ excerpt: nodes[edge.from]?.excerpt
61
+ });
62
+ }
63
+
64
+ return index;
65
+ }
@@ -0,0 +1,185 @@
1
+ import { slugify } from '../utils/slugify.js';
2
+
3
+ /**
4
+ * Extractors (runtime substrate)
5
+ *
6
+ * Pulls structured records out of a rendered HTML document. One pure
7
+ * function per concern (headings, links, images, excerpt) plus the
8
+ * boundary rule that decides which root the page-level extract reads.
9
+ *
10
+ * Architecture layer:
11
+ * runtime substrate
12
+ *
13
+ * System role:
14
+ * The fixed v1 extractor set called by the graph builder. Side-effect
15
+ * free; one document in, one record out.
16
+ *
17
+ * Lifecycle:
18
+ * build-time → buildGraph parses each page and calls extractGraph
19
+ *
20
+ * Why this exists:
21
+ * Eleventy doesn't expose rendered HTML as data. These extractors give
22
+ * the cascade something queryable: link graphs, headings, excerpts.
23
+ *
24
+ * Scope:
25
+ * Owns the per-page extractor shape and the article/main/body boundary
26
+ * rule.
27
+ * Does not own backlink inversion (backlinks.js) or origin assembly
28
+ * (the composition root passes knownOrigins in).
29
+ *
30
+ * Data flow:
31
+ * parsed document + knownOrigins → per-page record
32
+ */
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
+
46
+ function extractHeadings(root) {
47
+ const nodes = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
48
+ return Array.from(nodes).map(headingRecord);
49
+ }
50
+
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 };
86
+ }
87
+
88
+ function extractLinks(root, currentPage, knownOrigins) {
89
+ const anchors = root.querySelectorAll('a[href]');
90
+
91
+ return Array.from(anchors).map((a) => {
92
+ const raw = a.getAttribute('href');
93
+ const href = normaliseHref(raw, knownOrigins);
94
+
95
+ return {
96
+ internal: isInternal(href),
97
+ from: currentPage,
98
+ to: href,
99
+ text: (a.textContent || '').trim(),
100
+ rel: extractRel(a)
101
+ };
102
+ });
103
+ }
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
+
114
+ // HtmlBasePlugin rewrites internal hrefs to absolute URLs at render time
115
+ // (using process.env.URL or the dev server origin). Strip a known origin
116
+ // so hrefs land back as path-only; leave external URLs alone.
117
+ function normaliseHref(href, knownOrigins) {
118
+ if (!href) return href;
119
+ try {
120
+ const url = new URL(href);
121
+ if (knownOrigins?.has(url.origin)) {
122
+ return url.pathname + url.search + url.hash;
123
+ }
124
+ return href;
125
+ } catch {
126
+ return href;
127
+ }
128
+ }
129
+
130
+ function isInternal(href) {
131
+ if (!href) return false;
132
+ return href.startsWith('/') || href.startsWith('#');
133
+ }
134
+
135
+ function extractImages(root) {
136
+ const imgs = root.querySelectorAll('img[src]');
137
+
138
+ return Array.from(imgs).map((img) => ({
139
+ src: img.getAttribute('src'),
140
+ alt: img.getAttribute('alt') || null
141
+ }));
142
+ }
143
+
144
+ function extractExcerpt(root, text) {
145
+ const firstP = root.querySelector('p');
146
+ const fromP = firstP?.textContent?.trim();
147
+ if (fromP) return fromP;
148
+
149
+ if (!text) return;
150
+ return text.length > 200 ? text.slice(0, 200).trimEnd() + '…' : text;
151
+ }
152
+
153
+ /**
154
+ * Extract per-page records from rendered HTML.
155
+ *
156
+ * Scope rule:
157
+ * - Single `<article>` inside `<main>` → article is the boundary.
158
+ * Encourages semantic HTML; keeps chapter TOCs and sibling nav out.
159
+ * - Multiple `<article>`s (listing pages) → fall back to `<main>`,
160
+ * because the listing as a whole is the page's content.
161
+ * - No `<main>` → fall back to `<body>`. Defensive only; a site without
162
+ * `<main>` is giving up the semantic boundary that makes any of this
163
+ * meaningful.
164
+ */
165
+ export function extractGraph(document, options = {}) {
166
+ const main = document.querySelector('main');
167
+ const articles = main?.querySelectorAll('article') ?? [];
168
+ const root = articles.length === 1 ? articles[0] : (main ?? document.body);
169
+
170
+ if (!root) return { text: undefined, excerpt: undefined, headings: [], links: [], images: [] };
171
+
172
+ const text = root.textContent?.trim();
173
+ const currentpage = options.url;
174
+ const knownOrigins = options.knownOrigins;
175
+
176
+ return {
177
+ node: {
178
+ excerpt: extractExcerpt(root, text),
179
+ headings: extractHeadings(root),
180
+ sections: extractSections(root),
181
+ images: extractImages(root)
182
+ },
183
+ edges: extractLinks(root, currentpage, knownOrigins)
184
+ };
185
+ }
@@ -0,0 +1,121 @@
1
+ import { parseHTML } from 'linkedom';
2
+
3
+ import { extractGraph } from './extractors.js';
4
+ import { buildBacklinkIndex } from './backlinks.js';
5
+
6
+ /**
7
+ * Content graph (runtime substrate)
8
+ *
9
+ * Turns rendered HTML into the `{ nodes, edges, backlinks }` graph that
10
+ * templates query through the cascade. Node shape carries identity
11
+ * (from page-context) merged with extracted fields (excerpt, headings,
12
+ * images). Edges are flat anchor records. Backlinks is the target-keyed
13
+ * inverse index, pre-enriched with the source page's title and excerpt.
14
+ *
15
+ * Architecture layer:
16
+ * runtime substrate
17
+ *
18
+ * System role:
19
+ * The transformation between rendered output and the queryable
20
+ * per-page records the cascade exposes. Backlink index is built here
21
+ * so accessor reads stay O(1).
22
+ *
23
+ * Lifecycle:
24
+ * build-time → buildGraph runs once over the pre-pass output
25
+ * transform-time → createAccessors hands the cascade a live read surface
26
+ *
27
+ * Why this exists:
28
+ * Eleventy's cascade can't see across pages or into rendered bodies.
29
+ * This is the layer that lets it.
30
+ *
31
+ * Scope:
32
+ * Owns the per-page record shape, the backlink index, and the
33
+ * getter-backed accessor surface.
34
+ * Does not own the synthetic Eleventy run (prepass.js) or the
35
+ * per-page extraction logic (extractors.js).
36
+ *
37
+ * Data flow:
38
+ * pre-pass pages → linkedom parse → extractors → records + backlinks
39
+ */
40
+
41
+ /**
42
+ * @param {Array<{ url: string, content?: string, data?: object }>} pages
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, text: string, rel: string[] }>, backlinks: Record<string, Array<{ url: string, title?: string, excerpt?: string }>> }}
45
+ */
46
+ export function buildGraph(pages, options = {}) {
47
+ const { log } = options;
48
+ const nodes = {};
49
+ const edges = [];
50
+ const sourceMeta = {};
51
+
52
+ for (const page of pages) {
53
+ if (!page?.url || typeof page.content !== 'string') continue;
54
+ if (!page.outputPath?.endsWith('.html')) continue;
55
+ // Honour the same opt-out 404s, drafts and internal templates already use.
56
+ if (page.data?._internal === true) continue;
57
+ if (page.data?.baselineExcludeFromGraph === true) continue;
58
+
59
+ try {
60
+ const { document } = parseHTML(page.content);
61
+ const ctx = page.data?._pageContext ?? {};
62
+ const url = ctx.page?.url ?? page.url;
63
+
64
+ const graph = extractGraph(document, { ...options, url });
65
+
66
+ const nodeIdentity = {
67
+ title: ctx.entry?.title,
68
+ slug: ctx.entry?.slug,
69
+ description: ctx.entry?.description,
70
+ section: ctx.entry?.section,
71
+ breadcrumbs: ctx.entry?.breadcrumbs,
72
+ type: ctx.entry?.type,
73
+ lang: ctx.page?.lang,
74
+ locale: ctx.page?.locale,
75
+ translationKey: ctx.page?.translationKey,
76
+ isDefaultLang: ctx.page?.isDefaultLang,
77
+ date: ctx.page?.date,
78
+ url
79
+ };
80
+
81
+ nodes[url] = {
82
+ ...nodeIdentity,
83
+ ...graph.node
84
+ };
85
+
86
+ edges.push(...graph.edges);
87
+
88
+ sourceMeta[url] = { title: nodeIdentity.title };
89
+ } catch (err) {
90
+ if (process.env.NODE_ENV !== 'production') {
91
+ log?.warn(`Graph extraction failed for ${page.url}`, err);
92
+ }
93
+ continue;
94
+ }
95
+ }
96
+
97
+ const backlinks = buildBacklinkIndex(edges, nodes, sourceMeta);
98
+
99
+ return { nodes: nodes, edges: edges, backlinks };
100
+ }
101
+
102
+ /**
103
+ * Build the accessor surface templates see through the cascade. Closes
104
+ * over a getter so the underlying graph reference can be swapped (e.g.
105
+ * on serve-mode rebuilds) without re-registering global data.
106
+ *
107
+ * @param {() => ({ nodes: Record<string, object>, edges: Array<object>, backlinks: Record<string, Array<{ url: string, title?: string, excerpt?: string }>> } | null)} getGraph
108
+ */
109
+ export function createAccessors(getGraph) {
110
+ return {
111
+ isReady: () => getGraph() !== null,
112
+ getPage: (url) => getGraph()?.nodes[url],
113
+ getHeadings: (url) => getGraph()?.nodes[url]?.headings ?? [],
114
+ getOutgoingLinks: (url) => getGraph()?.nodes[url]?.links ?? [],
115
+ getImages: (url) => getGraph()?.nodes[url]?.images ?? [],
116
+ getText: (url) => getGraph()?.nodes[url]?.text,
117
+ getExcerpt: (url) => getGraph()?.nodes[url]?.excerpt,
118
+ getBacklinks: (url) => getGraph()?.backlinks[url] ?? [],
119
+ all: () => getGraph()?.nodes ?? {}
120
+ };
121
+ }
@@ -0,0 +1,2 @@
1
+ export { buildGraph, createAccessors } from './graph.js';
2
+ export { runPrepass, readGraphFromDisk, GRAPH_CACHE_PATH, PREPASS_SENTINEL } from './prepass.js';
@@ -0,0 +1,121 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ import Eleventy from '@11ty/eleventy';
5
+ import chalk from 'kleur';
6
+
7
+ import { pickRepetitionQuip } from '../logging/index.js';
8
+ import { buildGraph } from './graph.js';
9
+
10
+ /**
11
+ * Pre-pass (runtime substrate)
12
+ *
13
+ * Runs a programmatic Eleventy in dryRun mode, hands its rendered output
14
+ * to the graph builder, and writes the result to disk for serve-mode
15
+ * rebuilds to read between cycles.
16
+ *
17
+ * Architecture layer:
18
+ * runtime substrate
19
+ *
20
+ * System role:
21
+ * Build-time entry point for the content graph. Owns the re-entry
22
+ * sentinel, the log-suppression scope, and the cache file path.
23
+ *
24
+ * Lifecycle:
25
+ * build-time → fires on each Eleventy `eleventy.before` event; spawns a
26
+ * synthetic Eleventy, captures rendered HTML via toJSON(),
27
+ * and persists the graph before the outer cycle renders.
28
+ *
29
+ * Why this exists:
30
+ * Eleventy's data cascade is blind to rendered output. A pre-pass is the
31
+ * way to read the rendered shape of every page before the real build
32
+ * composes its templates. Running it every cycle keeps the graph current
33
+ * in serve mode without a separate merge mechanic.
34
+ *
35
+ * Scope:
36
+ * Owns the synthetic Eleventy run, sentinel handling, and cache I/O.
37
+ * Does not own extraction or the graph shape (graph.js owns those).
38
+ *
39
+ * Data flow:
40
+ * inner Eleventy toJSON() → buildGraph → cache file + in-memory graph
41
+ */
42
+
43
+ // Re-entry guard: set once by the outer process, read at call-time on
44
+ // the inner re-entry to skip the pre-pass. Permanent for the life of
45
+ // the outer process — the pre-pass runs exactly once.
46
+ export const PREPASS_SENTINEL = 'BASELINE_PREPASS_RUNNING';
47
+
48
+ // Log-suppression scope: set only while runPrepass is executing. Read by
49
+ // the logger to silence baseline's own info-level chatter during the
50
+ // inner build. Different lifetime to PREPASS_SENTINEL on purpose.
51
+ export const PREPASS_ACTIVE = 'BASELINE_PREPASS_ACTIVE';
52
+
53
+ export const GRAPH_CACHE_PATH = resolve(process.cwd(), '.cache/_baseline/content-graph.json');
54
+
55
+ /**
56
+ * Run a programmatic Eleventy, extract the content graph, write it to disk,
57
+ * return the in-memory graph.
58
+ *
59
+ * Sets the sentinel before constructing Eleventy so the inner re-entry into
60
+ * baseline() skips its own pre-pass.
61
+ *
62
+ * Always runs — there is no skip-if-cache-exists check. The pre-pass is fast
63
+ * enough today that unconditional execution is the honest default, and the
64
+ * cache file's primary job is the serve-mode handoff between rebuilds, not
65
+ * build-skip caching. When the cost earns it, mtime-based skip belongs at the
66
+ * call site (compare cache mtime to newest input mtime), not baked in here —
67
+ * keeps mechanic and policy separated.
68
+ *
69
+ * @param {string} input
70
+ * @param {string} output
71
+ * @param {(namespace: string) => { info: Function, warn: Function, error: Function }} scopedLog - Factory the composition root passes through so the pre-pass and the cache-write step can be scoped separately.
72
+ * @param {object} [options]
73
+ * @param {Set<string>} [options.knownOrigins] - Origins to strip from internal hrefs during extraction.
74
+ * @returns {Promise<object>}
75
+ */
76
+ export async function runPrepass(input, output, scopedLog, options = {}) {
77
+ const log = scopedLog('pre-pass');
78
+ const graphLog = scopedLog('content-graph');
79
+
80
+ log.info('Pre-pass starting');
81
+ log.info(chalk.cyan(pickRepetitionQuip()));
82
+ graphLog.info('Caching content graph');
83
+ process.env[PREPASS_SENTINEL] = '1';
84
+ process.env[PREPASS_ACTIVE] = '1';
85
+
86
+ // knownOrigins is consumed by the graph builder, not Eleventy.
87
+ const { knownOrigins, ...elevOptions } = options;
88
+
89
+ let graph;
90
+ try {
91
+ const elev = new Eleventy(input, output, {
92
+ ...elevOptions,
93
+ dryRun: true,
94
+ // Surface fields the graph and backlink enrichment read off `data`.
95
+ config: function (eleventyConfig) {
96
+ eleventyConfig.dataFilterSelectors.add('_pageContext'); // -> Future pass.
97
+ eleventyConfig.dataFilterSelectors.add('eleventyExcludeFromCollections');
98
+ eleventyConfig.dataFilterSelectors.add('baselineExcludeFromGraph');
99
+ }
100
+ });
101
+ const pages = await elev.toJSON();
102
+ graph = buildGraph(pages, { knownOrigins, log: graphLog });
103
+
104
+ await mkdir(dirname(GRAPH_CACHE_PATH), { recursive: true });
105
+ await writeFile(GRAPH_CACHE_PATH, JSON.stringify(graph), 'utf8');
106
+ } finally {
107
+ process.env[PREPASS_ACTIVE] = '0';
108
+ log.info('Pre-pass done');
109
+ }
110
+
111
+ return graph;
112
+ }
113
+
114
+ export async function readGraphFromDisk() {
115
+ try {
116
+ const raw = await readFile(GRAPH_CACHE_PATH, 'utf8');
117
+ return JSON.parse(raw);
118
+ } catch {
119
+ return null;
120
+ }
121
+ }