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

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 (51) hide show
  1. package/README.md +21 -23
  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 +140 -0
  5. package/core/content-graph/graph.js +118 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/logging/banner.js +49 -0
  9. package/core/{logging.js → logging/index.js} +19 -2
  10. package/core/logging/quips.js +30 -0
  11. package/core/markdown/auto-heading-ids.js +86 -0
  12. package/core/markdown/index.js +5 -0
  13. package/core/markdown/safe-use.js +42 -0
  14. package/core/{wikilinks.js → markdown/wikilinks.js} +3 -3
  15. package/core/page-context/build.js +239 -0
  16. package/core/page-context/index.js +1 -0
  17. package/core/page-context/register.js +73 -0
  18. package/core/page-context/seo-helpers.js +56 -0
  19. package/core/schema.js +19 -1
  20. package/core/slug-index.js +2 -2
  21. package/core/state.js +73 -0
  22. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  23. package/core/surface/index.js +22 -0
  24. package/core/utils/add-trailing-slash.js +11 -0
  25. package/core/utils/ensure-dot-slash-dir.js +13 -0
  26. package/core/utils/normalize-languages.js +28 -0
  27. package/core/utils/resolve-field.js +9 -0
  28. package/core/utils/resolve-subdir.js +20 -0
  29. package/core/utils/slugify.js +15 -0
  30. package/core/utils/unique-by.js +25 -0
  31. package/core/virtual-dir.js +11 -10
  32. package/index.js +152 -115
  33. package/modules/assets/index.js +4 -2
  34. package/modules/assets/processors/esbuild-process.js +2 -2
  35. package/modules/assets/processors/postcss-process.js +2 -2
  36. package/modules/head/drivers/posthtml-head-elements.js +1 -3
  37. package/modules/head/index.js +7 -10
  38. package/modules/multilang/index.js +4 -2
  39. package/modules/navigator/index.js +33 -20
  40. package/modules/navigator/templates/navigator-core.html +1 -1
  41. package/modules/sitemap/index.js +7 -3
  42. package/package.json +4 -2
  43. package/core/filters/index.js +0 -4
  44. package/core/global-functions/index.js +0 -6
  45. package/core/page-context.js +0 -310
  46. package/core/shortcodes/index.js +0 -2
  47. package/core/utils/helpers.js +0 -75
  48. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  49. /package/core/{filters → surface/filters}/isString.js +0 -0
  50. /package/core/{filters → surface/filters}/related-posts.js +0 -0
  51. /package/core/{global-functions/date.js → surface/global-date-function.js} +0 -0
package/README.md CHANGED
@@ -10,16 +10,8 @@ This is a working plugin, not a finished product. Things might shift, break, or
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,14 +22,17 @@ 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
 
34
+ `baseline()` returns an async closure (Eleventy's documented async-plugin pattern), so the call to `addPlugin` is awaited.
35
+
41
36
  The plugin takes two arguments: `settings` (site identity — title, url, languages, head extras) and `options` (runtime behavior — verbose, sitemap, navigator).
42
37
 
43
38
  ```js
@@ -52,11 +47,11 @@ const settings = {
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
  ```
@@ -68,25 +63,28 @@ The plugin registers everything on load. No setup beyond the config above.
68
63
  **Core** — always active:
69
64
 
70
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
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
- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | Module | What it does |
77
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
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 |
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 |
79
+ | `head` | `<head>` tags (charset, viewport, title, description, robots, canonical, hreflang) handled for you by dropping `<baseline-head>` in your layout |
80
+ | `multilang` | Directory-based multilingual support. Per-language collections, translation mapping, i18n filters. Wraps Eleventy's I18n plugin |
81
+ | `navigator` | Debug tooling. Globals for inspecting template data, plus debug filters (`_inspect`, `_json`, `_keys`). 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
86
  Full documentation — tutorials, how-to guides, and reference — lives at:
89
- [https://eleventy-plugin-baseline.netlify.app/](https://eleventy-plugin-baseline.netlify.app/)
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,140 @@
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
+ function extractHeadings(root) {
35
+ const nodes = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
36
+
37
+ return Array.from(nodes).map((el) => {
38
+ const text = (el.textContent || '').trim();
39
+ // Live DOM id wins (matches anchored markup); fall back to a slugified
40
+ // id so consumers always have a stable handle, even when the rendering
41
+ // pipeline did not auto-anchor headings.
42
+ return {
43
+ level: Number(el.tagName[1]),
44
+ text,
45
+ id: el.id || slugify(text) || null
46
+ };
47
+ });
48
+ }
49
+
50
+ function extractLinks(root, currentPage, knownOrigins) {
51
+ const anchors = root.querySelectorAll('a[href]');
52
+
53
+ return Array.from(anchors).map((a) => {
54
+ const raw = a.getAttribute('href');
55
+ const page = currentPage;
56
+ const href = normaliseHref(raw, knownOrigins);
57
+ const internal = isInternal(href);
58
+ const type = internal ? 'link' : 'external';
59
+
60
+ return {
61
+ internal,
62
+ from: page,
63
+ to: href,
64
+ type: type,
65
+ text: (a.textContent || '').trim()
66
+ };
67
+ });
68
+ }
69
+
70
+ // HtmlBasePlugin rewrites internal hrefs to absolute URLs at render time
71
+ // (using process.env.URL or the dev server origin). Strip a known origin
72
+ // so hrefs land back as path-only; leave external URLs alone.
73
+ function normaliseHref(href, knownOrigins) {
74
+ if (!href) return href;
75
+ try {
76
+ const url = new URL(href);
77
+ if (knownOrigins?.has(url.origin)) {
78
+ return url.pathname + url.search + url.hash;
79
+ }
80
+ return href;
81
+ } catch {
82
+ return href;
83
+ }
84
+ }
85
+
86
+ function isInternal(href) {
87
+ if (!href) return false;
88
+ return href.startsWith('/') || href.startsWith('#');
89
+ }
90
+
91
+ function extractImages(root) {
92
+ const imgs = root.querySelectorAll('img[src]');
93
+
94
+ return Array.from(imgs).map((img) => ({
95
+ src: img.getAttribute('src'),
96
+ alt: img.getAttribute('alt') || null
97
+ }));
98
+ }
99
+
100
+ function extractExcerpt(root, text) {
101
+ const firstP = root.querySelector('p');
102
+ const fromP = firstP?.textContent?.trim();
103
+ if (fromP) return fromP;
104
+
105
+ if (!text) return;
106
+ return text.length > 200 ? text.slice(0, 200).trimEnd() + '…' : text;
107
+ }
108
+
109
+ /**
110
+ * Extract per-page records from rendered HTML.
111
+ *
112
+ * Scope rule:
113
+ * - Single `<article>` inside `<main>` → article is the boundary.
114
+ * Encourages semantic HTML; keeps chapter TOCs and sibling nav out.
115
+ * - Multiple `<article>`s (listing pages) → fall back to `<main>`,
116
+ * because the listing as a whole is the page's content.
117
+ * - No `<main>` → fall back to `<body>`. Defensive only; a site without
118
+ * `<main>` is giving up the semantic boundary that makes any of this
119
+ * meaningful.
120
+ */
121
+ export function extractGraph(document, options = {}) {
122
+ const main = document.querySelector('main');
123
+ const articles = main?.querySelectorAll('article') ?? [];
124
+ const root = articles.length === 1 ? articles[0] : (main ?? document.body);
125
+
126
+ if (!root) return { text: undefined, excerpt: undefined, headings: [], links: [], images: [] };
127
+
128
+ const text = root.textContent?.trim();
129
+ const currentpage = options.url;
130
+ const knownOrigins = options.knownOrigins;
131
+
132
+ return {
133
+ node: {
134
+ excerpt: extractExcerpt(root, text),
135
+ headings: extractHeadings(root),
136
+ images: extractImages(root)
137
+ },
138
+ edges: extractLinks(root, currentpage, knownOrigins)
139
+ };
140
+ }
@@ -0,0 +1,118 @@
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, type: string, text: 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?.eleventyExcludeFromCollections === 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
+ type: ctx.entry?.type,
72
+ lang: ctx.page?.lang,
73
+ locale: ctx.page?.locale,
74
+ date: ctx.page?.date,
75
+ url
76
+ };
77
+
78
+ nodes[url] = {
79
+ ...nodeIdentity,
80
+ ...graph.node
81
+ };
82
+
83
+ edges.push(...graph.edges);
84
+
85
+ sourceMeta[url] = { title: nodeIdentity.title };
86
+ } catch (err) {
87
+ if (process.env.NODE_ENV !== 'production') {
88
+ log?.warn(`Graph extraction failed for ${page.url}`, err);
89
+ }
90
+ continue;
91
+ }
92
+ }
93
+
94
+ const backlinks = buildBacklinkIndex(edges, nodes, sourceMeta);
95
+
96
+ return { nodes: nodes, edges: edges, backlinks };
97
+ }
98
+
99
+ /**
100
+ * Build the accessor surface templates see through the cascade. Closes
101
+ * over a getter so the underlying graph reference can be swapped (e.g.
102
+ * on serve-mode rebuilds) without re-registering global data.
103
+ *
104
+ * @param {() => ({ nodes: Record<string, object>, edges: Array<object>, backlinks: Record<string, Array<{ url: string, title?: string, excerpt?: string }>> } | null)} getGraph
105
+ */
106
+ export function createAccessors(getGraph) {
107
+ return {
108
+ isReady: () => getGraph() !== null,
109
+ getPage: (url) => getGraph()?.nodes[url],
110
+ getHeadings: (url) => getGraph()?.nodes[url]?.headings ?? [],
111
+ getOutgoingLinks: (url) => getGraph()?.nodes[url]?.links ?? [],
112
+ getImages: (url) => getGraph()?.nodes[url]?.images ?? [],
113
+ getText: (url) => getGraph()?.nodes[url]?.text,
114
+ getExcerpt: (url) => getGraph()?.nodes[url]?.excerpt,
115
+ getBacklinks: (url) => getGraph()?.backlinks[url] ?? [],
116
+ all: () => getGraph()?.nodes ?? {}
117
+ };
118
+ }
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from 'kleur';
2
+
3
+ const BANNER_GLOBAL_KEY = Symbol.for('eleventy:baseline:banner');
4
+ const FALLBACK_NAME = 'Eleventy Baseline';
5
+ const MARGIN = 4;
6
+
7
+ /**
8
+ * Resolve the label shown inside the banner. Reads `npm_package_name`
9
+ * (set by npm when Baseline runs under `npm run …`) and falls back to
10
+ * the plugin's own name when the env var is absent (raw `npx`, direct
11
+ * node, programmatic use).
12
+ *
13
+ * @returns {string}
14
+ */
15
+ function resolveBannerLabel() {
16
+ return process.env.npm_package_name || FALLBACK_NAME;
17
+ }
18
+
19
+ /**
20
+ * Render the boxed startup banner string. Pure, label-only.
21
+ * Width scales with the label so any project name fits.
22
+ *
23
+ * @param {string} [label]
24
+ * @returns {string}
25
+ */
26
+ export function baselineBanner(label = resolveBannerLabel()) {
27
+ const inner = label.length + MARGIN * 2;
28
+ const top = '╔' + '═'.repeat(inner) + '╗';
29
+ const middle = '║' + ' '.repeat(MARGIN) + chalk.bold().white(label) + ' '.repeat(MARGIN) + '║';
30
+ const bottom = '╚' + '═'.repeat(inner) + '╝';
31
+
32
+ return ['', top, middle, bottom, ''].join('\n');
33
+ }
34
+
35
+ /**
36
+ * Print the banner and an intro line once per process. Guarded by a global
37
+ * symbol so repeated plugin invocations (inner pre-pass Eleventy,
38
+ * multi-instance setups) don't re-print.
39
+ *
40
+ * @param {import('./index.js').BaselineLogger} log
41
+ * @param {{ version: string, eleventyVersion?: string }} versions
42
+ */
43
+ export function printBannerOnce(log, { version, eleventyVersion } = {}) {
44
+ if (globalThis[BANNER_GLOBAL_KEY]) return;
45
+ globalThis[BANNER_GLOBAL_KEY] = true;
46
+ log.print(baselineBanner());
47
+ const tail = eleventyVersion ? `, running Eleventy v${eleventyVersion}` : '';
48
+ log.info(`Baseline v${version}${tail}`);
49
+ }