Table of Contents

Content graph

A pre-rendered map of every page's headings, images, links, and excerpts, plus the links between pages. Built once before Eleventy's main build by a programmatic dry-run of the site; cached at .cache/_baseline/content-graph.json. Templates read it through cascade keys; modules read it through coreContext.runtime.contentGraph.


Why it exists

Eleventy's data cascade can read front matter; it cannot read what a page actually rendered. The content graph fills that gap.

A pre-pass renders every page once, extracts the structured content (headings, links, images, excerpt) from the result, and writes it to a queryable record before any consumer template runs.

The pre-pass attaches to Eleventy's eleventy.before event, so the graph rebuilds at the start of every cycle: initial build, watch rebuild, production build. The cached file is the cold-start handoff; in-memory is the hot path.

It renders pages, and only pages. Your assets directory is skipped, and the image shortcode returns markup without generating renditions, so nothing in your pipeline compiles twice per build for output the graph would throw away.


Where it surfaces

Surface Kind Notes
_node, _backlinks, _outgoing Cascade keys, per page See Globals for shape.
_edges Cascade key, reserved Listed in INTERNAL_KEYS, currently unbound.
_navigator Nunjucks global Carries { nodes, edges, backlinks }. Registered by the navigator module.
coreContext.runtime.contentGraph Runtime read A getter, so a rebuild can reassign the underlying reference without re-registering listeners.

The graph shape

Three top-level keys.

{
  nodes: {
    "/en/foo/": {
      // Identity (from data._pageContext via dataFilterSelectors)
      title, slug, description, section, type, lang, locale, translationKey,
      isDefaultLang, date, url, breadcrumbs,
      // Extracted (from the rendered DOM)
      excerpt, headings, sections, images
    }
  },
  edges: [
    { internal, from, to, text, rel }
    // One entry per <a href> in each page's extracted root.
  ],
  backlinks: {
    "/en/foo/": [
      { url, title?, excerpt? }
      // Pre-joined with the source page's title and excerpt.
    ]
  }
}

Identity fields come from data._pageContext via dataFilterSelectors.add('_pageContext') on the pre-pass Eleventy. Extracted fields come from the rendered DOM. The two halves are merged in the graph builder; each node is a single flat record. One identity field, breadcrumbs, is a derived ancestor trail (Array<{ label, url, current? }>) built from the page's section path; Baseline also renders it as a BreadcrumbList in the page's JSON-LD.

An ancestor crumb is labelled with the title its section index page gives itself, so a /docs/ folder whose index page is called "Documentation" reads as Documentation rather than Docs. A section with no index page falls back to its slug, sentence-cased. This is the one thing in a node that no single page could work out on its own, so it is resolved in one sweep once every node exists, at the end of the pre-pass.

Edges are flat. One per anchor in the extracted root. to is the href as written: the pre-pass build leaves internal links path-only, so nothing needs stripping. An absolute URL here is one you typed yourself, and it counts as external even when it points at your own site. Not deduplicated.


Reading it in a template


_node.headings for a table of contents

_node.headings is Array<{ level, text, id }>. The first heading is usually the page's <h1>; skip it and render the rest:

{% set toc = _node.headings %}
{% if toc.length %}
  <nav class="toc" aria-labelledby="toc-title">
    <p id="toc-title">On this page</p>
    <ol>
    {% for heading in toc.slice(1) %}
      <li class="toc-level-{{ heading.level }}">
        <a href="#{{ heading.id }}">{{ heading.text }}</a>
      </li>
    {% endfor %}
    </ol>
  </nav>
{% endif %}

id is the live DOM id when the rendering pipeline anchored the heading; otherwise a slugified fallback derived from text.


_node.sections for structured data

_node.sections pairs each <h2> with the prose beneath it: Array<{ heading: { level, text, id }, text }>. One entry per H2, the page's <h1> is ignored, and H3-and-deeper headings fold into their section's text. It is the rendered-content source for deriving FAQPage, HowTo, or Speakable schema without a parallel front-matter array: the question (or step) is heading.text, the answer is text.


_backlinks carries bare edges ({ internal, from, to, text, rel }), not enriched records. To render with the source page's title and excerpt, group by source URL and look up each source in _navigator.nodes:

{% if _backlinks.length %}
  <nav aria-labelledby="backlinks-title">
    <h2 id="backlinks-title">Linked from</h2>
    <ul>
    {% for from, links in _backlinks | groupby('from') %}
      {% set source = _navigator.nodes[from] %}
      <li>
        <a href="{{ from }}">{{ source.title or from }}</a>
        {% if source.excerpt %}<p>{{ source.excerpt }}</p>{% endif %}
      </li>
    {% endfor %}
    </ul>
  </nav>
{% endif %}

For an enriched lookup without the groupby step, read _navigator.backlinks[page.url] instead. The cross-page map is target-keyed and pre-joined; the per-page key is the local view.


Paginate over every target URL to generate /backlinks/<slug>/ subpages. Each entry comes pre-joined with source title and excerpt:

---
pagination:
  data: _navigator.backlinks
  size: 1
  alias: target
permalink: '/backlinks/{{ target }}/'
---

Excluding a page from the graph

Two front-matter flags. Both ride the pre-pass via dataFilterSelectors, so the graph builder sees them and skips the page entirely.

  • _internal: true is the opt-out for synthetic and internal templates (sitemap XML, schema endpoints, the markdown-sibling renderer). The graph honours it alongside the page-context registry and the head module.
  • baselineExcludeFromGraph: true is a graph-specific opt-out for pages that render but should stay outside the graph. The canonical use is a view onto the graph (a backlinks index, an outgoing-links index) that should render without self-referencing.

Note that eleventyExcludeFromCollections: true is Eleventy's collections opt-out and does not exclude a page from the graph on its own. A page can stay out of nav, listings, and the sitemap while still being indexed by the graph and the structured-data surfaces.

---
title: 'Backlinks across the site'
permalink: '/backlinks/'
baselineExcludeFromGraph: true
---

Skipping work during the pre-pass

Excluding a page is one thing; skipping work is another. The pre-pass renders your whole site into a dry run that writes nothing, so anything whose only product is a file on disk does that work twice per build and throws half of it away. Baseline exposes the sentinel it uses for this so your own config can read it:

if (process.env.BASELINE_PREPASS_ACTIVE === '1') return;

'1' while the inner build is running, '0' once it has finished.

Compare it to '1', never for truthiness. It is set back to '0' rather than deleted, so if (process.env.BASELINE_PREPASS_ACTIVE) stays true for the rest of the build and skips the work you meant to run once.

Three places inside Baseline read it, which is also a fair guide to when it is the right tool:

  • The logger silences its own info lines, so a build narrates once rather than twice.
  • The image shortcode switches to eleventy-img's statsOnly, which skips the encode but keeps the real hashed URLs, so the markup the graph sees matches the built site.
  • The assets module skips its declared-but-never-emitted check, because nothing is on disk yet and every path would look missing.

The pattern they share: the work is expensive, and its output is either written to disk or shown to a human. Data the graph needs is the opposite case, and should run in both.

There is a second variable, BASELINE_PREPASS_RUNNING, and it is not this one. It is the re-entry guard that stops the inner Eleventy starting a pre-pass of its own, and it stays set for the life of the process. Reading it as "am I in the pre-pass" gives the wrong answer everywhere after the first one.


The semantic boundary

Extraction is scoped semantically, not to the whole <body>:

  1. Single <article> inside <main> → that article is the boundary.
  2. Multiple <article>s (listing pages) → fall back to <main>.
  3. No <main> → fall back to <body>. Defensive only.

Nav, header, footer, language switcher, chapter TOC, prev/next links: none of these end up in the graph. Pages also pass an outputPath.endsWith('.html') filter, so sitemap.xml, robots.txt, and JSON feeds drop out before parsing.


Accessors (modules)

createAccessors(getGraph) is the read interface modules use through the runtime layer. Closes over a getter so the underlying graph reference can swap on rebuilds.

Accessor Returns
isReady() boolean
getPage(url) node | undefined
getHeadings(url) array
getImages(url) array
getExcerpt(url) string | undefined
getBacklinks(url) Array<{ url, title?, excerpt? }>
all() full nodes map

getText and getOutgoingLinks are present in the accessor list but currently vestigial: text is no longer carried on nodes, and outgoing links moved into the flat edges array. Both flagged for either restoration or removal.


See also

  • Page context - the per-page object the graph reads for identity.
  • Globals - the cascade-key reference for _node, _backlinks, _outgoing, and the _navigator global.
  • Navigator module - registers the _navigator global.
  • Architecture snapshot - where the content graph sits in the three layers.
  • SEO graph - the per-page SEO substrate; renders the graph's breadcrumbs as a BreadcrumbList in JSON-LD.