---
title: 'Eleventy Excellent'
description: 'Integrate the eleventy-excellent starter with Baseline, step by step: project structure, slugs, the head, the assets pipeline, and what a green build hides.'
slug: 'integrate-eleventy-excellent'
type: 'article'
date: 2026-09-04T00:00:00.000Z
lang: 'en'
url: 'https://www.eleventy-baseline.dev/docs/integrations/integrate-eleventy-excellent/'
---

{% if part.index == 0 %}
Baseline and `eleventy-excellent` make a lot of the same decisions. Both keep config in its own directory instead of one long file, and both keep content clear of the machinery around it. CSS and JS go through a real pipeline. Share cards are generated at build time, and the head is composed from data rather than typed out page by page.

Both projects landed in the same place because they were answering the same question: what a site needs settled before you can start writing. There are only so many good answers to that.

What changes when you integrate is who keeps those answers working. Right now the head partials, the asset build and the share-card generator are yours to maintain. Afterwards they arrive as a dependency you update, and the code that did the same job leaves the repository.

---

## Before you start

The overlap is why this guide is shaped the way it is. Most of the integration is renaming. You are not replacing the starter's thinking so much as moving it onto conventions that already match, then handing over the parts a plugin can keep up to date for you.

The longest chapters are about conventions rather than about the plugin. Where files sit and what they are called takes more explaining than the thing that reads them. Where the two disagree, the guide says so.

---

## Prerequisites

The starter cloned and running, Node 22 or newer, and somewhere to set `BASELINE_URL` on your host. 

This guide was written against `@apleasantview/eleventy-plugin-baseline@0.1.0-next.46` and eleventy-excellent 4.7.1.

The reference implementation is the [`0.1.0-next.46` branch of eleventy-baseline-excellent](https://github.com/apleasantview/eleventy-baseline-excellent/tree/0.1.0-next.46), whose commits line up with the chapters below, apart from the config export, which is presented earlier here.

---

## The shape of it

The first three chapters do not use Baseline. They are structure, naming and config shape, and the site builds identically to upstream all the way through them. You install the plugin at the start but nothing registers it until the fourth chapter, so you can do all of this before deciding whether you want it, and stop at the seam with a tidier project and nothing else changed.

By the end, the dependency count is down from 45 to 42, `src/_includes/head/` is gone with its seven partials, the two schema templates are replaced by one data file, and the CSS and JS build events are replaced by entry points. The integration removes more than it adds.
{% endif %}
{% if part.index == 1 %}
## Add the usual suspects to `.gitignore`

Before anything else happens. Ignore rules first means your notes are covered before you take the first one, and a personal global gitignore cannot swallow a source file you are about to write. `dist` is already ignored by the starter, so this is `temp/` and two negations:

```
# Usual suspects
temp/
!/src/content/tags/
!/src/assets/css/local/
```

The two negations matter here. `tags` and `local` are common global-gitignore entries, `tags` because that is what ctags writes, and both are real source directories in this project.

## Pin Baseline to latest

The peer ranges line up with what the starter already pins, so nothing needs forcing.

```bash
npm install @apleasantview/eleventy-plugin-baseline --save-exact
npm approve-scripts --all
```

Two things npm will not tell you. Baseline pins `esbuild` exactly, so npm nests a second copy rather than deduping with the starter's, and `cssnano` brings a second PostCSS stack the same way. And if you use `allowScripts`, check it afterwards: `npm approve-scripts --all` drops the starter's own `esbuild` as stale and approves Baseline's nested copy.

## Add the helper package and rewrite the scripts

One dependency to add, then the scripts get Baseline's shape.

```bash
npm install npm-run-all --save-dev
```

`npm-run-all` is what lets `start` and `build` chain `clean` in front of the Eleventy run, so a build is always a clean build:

```json
{
  "clean": "rimraf dist/ src/_includes/css src/_includes/scripts",
  "clean:og": "rimraf src/assets/og-images",
  "dev:11ty": "cross-env ELEVENTY_ENV=development eleventy --serve",
  "build:11ty": "cross-env ELEVENTY_ENV=production eleventy",
  "start": "npm-run-all clean dev:11ty",
  "build": "npm-run-all clean build:11ty",
  "dryrun": "eleventy --dryrun"
}
```

`clean` still names the old pipeline's output directories, and `clean:og` still exists. Both go in the last part, once the things that wrote to them are gone.
{% endif %}
{% if part.index == 2 %}
## Adopt Baseline's project structure

`_config/` comes out of the input directory and the content directories go under `src/content/`, leaving `src/` holding only what Eleventy treats specially. Almost all of this is renaming.

```
_config/            events, filters, plugins, setup, shortcodes, utils
src/
  _data/
  _includes/        head, partials, schemas, webc
  _layouts/
  assets/           css, fonts, images, scripts, svg, og-images
  content/          common, docs, pages, posts, tags
eleventy.config.js
```

`src/common/` is the exception. Upstream it mixes the feeds, robots and the pa11y config with `404.md` and the two tag templates. Split it, so that what stays behind is machine output and nothing else. Three directory-level keys land there later and they only work if nothing in the directory is a page someone visits.

{% detailsBlock "View the three files that leave `common/`" %}

```
src/common/404.md        ->  src/content/pages/404.md
src/common/tagList.njk   ->  src/content/tags/tagList.njk
src/common/tags.njk      ->  src/content/tags/tags.njk
```

{% enddetailsBlock %}

Check every collection glob afterwards. `getFilteredByGlob` returns an empty array for a glob that matches nothing, and does not warn. Two of the starter's three break here. The first empties `/blog/`, its second page, the thirteen OG cards and the homepage post list, all while the build stays green. The second silently narrows the sitemap from 23 URLs to 21 by no longer reaching the tag templates, which is why `common/` has to move under `src/content/` in the same commit. The third survives because it walks `getAll()` rather than a path.

{% detailsBlock "View the two globs in `_config/collections.js`" %}

```js
// getAllPosts, was './src/posts/**/*.md'
collection.getFilteredByGlob('./src/content/posts/**/*.md');
```

```js
// showInSitemap, was './src/**/*.{md,njk}'
collection.getFilteredByGlob('./src/content/**/*.{md,njk}');
```

{% enddetailsBlock %}

Globs are the ones that fail quietly, but they are not the only hard-coded paths. The rest are the `_config/` imports at the top of `eleventy.config.js`, an `ignores` entry pointing at `pa11y.njk`, and Tailwind's content globs.

## Normalise the Eleventy config

The starter returns its config from inside the callback. Eleventy documents a static named `config` export as the canonical shape, and adopting it now is what lets Baseline's own export drop in later.

```js
export default async function (eleventyConfig) {
  // ...
}

export const config = {
  markdownTemplateEngine: 'njk',
  dir: {
    output: 'dist',
    input: 'src',
    includes: '_includes',
    layouts: '_layouts'
  }
};
```

Same values, moved out of the return. Nothing about the build changes.

## Switch to JS data files and front matter

The two `.json` directory data files become `.11tydata.js`, and the three templates that paginate move to `---js` front matter. That second one is an ordinary Eleventy feature rather than anything Baseline adds: open the fence with `---js` instead of `---` and the block is JavaScript, where each `const` becomes a front matter key. Pagination is the reason: it turns one file into many pages, and each of those pages inherits the same `fileSlug`, so a computed slug is the only way to give them separate identities.

{% detailsBlock "View the five directory data files" %}

```js
// src/assets/assets.11tydata.js
export default {
  eleventyExcludeFromCollections: true
};
```

```js
// src/content/common/common.11tydata.js
export default {};
```

```js
// src/content/content.11tydata.js
export default {};
```

```js
// src/content/docs/docs.11tydata.js
export default {
  tags: 'docs',
  permalink: false
};
```

{% raw %}

```js
// src/content/posts/posts.11tydata.js
export default {
  layout: 'post',
  tags: 'posts',
  permalink: '/blog/{{ title | slugify }}/index.html'
};
```

{% endraw %}

{% enddetailsBlock %}

{% detailsBlock "View the three front matter blocks in full" %}

Every value the YAML held becomes a `const` of the same name. The only addition is `eleventyComputed.slug`.

{% raw %}

```js
---js
// src/content/common/og-images.njk
// Needed when generating locally: the font must be installed on your system.
const fontDisplay = "'Red Hat Display', Ubuntu";
const fontBody = "'Atkinson Hyperlegible', Ubuntu";

const background = '#FBBE25';
const text = '#161616';
const siteUrl = 'eleventy-excellent.netlify.app';

const pagination = {
  data: 'collections.allPosts',
  size: 1,
  alias: 'post'
};

const permalink = '/assets/og-images/{{ post.data.title | slugify }}-preview.svg';

const eleventyExcludeFromCollections = true;

const eleventyComputed = {
  // One SVG per post, all from this one file. A per-page slug keeps Baseline's
  // slug index from seeing thirteen pages claiming this file's slug.
  slug: (data) => `og-${data.post.data.page.fileSlug}`
};
---
```

```js
---js
// src/content/pages/blog.njk
const layout = 'base';
const title = 'Blog';
const description = 'All blog posts can be found here';

const pagination = {
  data: 'collections.allPosts',
  size: 8
};

const permalink =
  'blog/{% if pagination.pageNumber >=1 %}page-{{ pagination.pageNumber + 1 }}/{% endif %}index.html';

const eleventyComputed = {
  // Mirrors the permalink: page one is /blog/, the rest are /blog/page-N/.
  slug: (data) =>
    data.pagination.pageNumber === 0 ? 'blog' : `blog-page-${data.pagination.pageNumber + 1}`
};
---
```

```js
---js
// src/content/tags/tagList.njk
const layout = 'tags';

const pagination = {
  data: 'collections.tagList',
  size: 1,
  alias: 'tag'
};

const permalink = '/tags/{{ tag | slugify }}/';

const eleventyComputed = {
  title: '{{ meta.blog.tagSingle }}: {{ tag }}',

  // One input file, one page per tag. Without a per-page slug every one of
  // them inherits the file's, and Baseline's slug index throws on the second.
  slug: (data) => data.tag
};
---
```

{% endraw %}

Permalinks stay template strings inside a JS block, which is why the braces are still there. The og-images permalink is corrected later, in the step that moves the cards to `dist/`.

{% enddetailsBlock %}
{% endif %}
{% if part.index == 3 %}
## Add slugs to posts

Baseline registers every page under a slug, in one index shared across the whole site, so that wikilinks can resolve `[[a-page]]` to a URL. Registering the same slug twice throws, whether or not anything reads it, so a post whose filename matches a tag name collides with that tag's page:

```
Wikilink slug collision: "markdown" used by 2022-11-02-markdown.md and tagList.njk
```

Assume every page that emits a URL needs a stable, unique slug. Add one to the front matter of every file in `src/content/posts/`, at the top level alongside `title`:

```yaml
---
title: 'Post with all the markdown'
slug: 'post-with-all-the-markdown'
description: 'A lot of markdown packages are installed to help you write your posts. All presets are personal preference.'
date: 2022-11-02
tags: ['markdown', 'feature']
---
```

The value is yours to choose and does not have to match the filename or the URL. It has to be unique across every page in the project, tag pages included.

You will not see the error yet, because the index only exists once Baseline is registered. This step and the next are what stop it being the first thing that happens when it is.

## Set directory data keys

The other half of the same problem, and the reason `common/` was emptied of pages. Nine machine outputs live there, and one of them collides too: `_redirects.njk` slugifies to `redirects`, which is also a tag.

A `robots.txt` has no identity to collide with, so rather than inventing slugs, opt these files out of having one. The keys go on the directory rather than into nine files:

```js
// src/content/common/common.11tydata.js
export default {
  layout: false,
  eleventyExcludeFromCollections: true,
  _internal: true
};
```

Get that filename exactly right. Eleventy reads `<dirname>.11tydata.js` and nothing else, so `common.11ty.data.js`, one dot out, is an ordinary module no build ever loads. Nothing warns you, and none of the three keys apply.

`_internal` is Baseline's own key, and it opts a file out of the three things Baseline builds for each page: its page context, its entry in the SEO graph, and its node in the content graph. A `robots.txt` needs none of them. Hoisting to the directory also means the tenth file added there is correct without anyone remembering.

## Stop here and ship, if you like

This is the seam. Everything above adopts a set of conventions and Baseline has not run once, so the output is byte-for-byte what it was when you started. Everything below hands a surface over.
{% endif %}
{% if part.index == 4 %}
## Wire in Baseline

Baseline arrives as one `addPlugin` call, and the settings file arrives with it.

```js
import baseline, {config as baselineConfig} from '@apleasantview/eleventy-plugin-baseline';
import settings from './src/_data/settings.js';

export default async function (eleventyConfig) {
  await eleventyConfig.addPlugin(baseline(settings));
  // ...
}
```

Write `src/_data/settings.js` in full now rather than starting with an inline object. Three later steps add keys to it, and the schema step cannot read it at all while it is a `const` in the config, because a data file cannot import from `eleventy.config.js`. A file in `src/_data/` is global data under its own name, so `eleventyConfig.addGlobalData('settings', settings)` is not needed either.

Give the file a default export and nothing else. A named export beside it hands templates the module namespace instead of the object, so `settings.url` is `undefined` everywhere, silently, on a green build.

{% detailsBlock "View `src/_data/settings.js` in full" %}

```js
const siteUrl = process.env.BASELINE_URL || 'http://localhost:8080/';
const absolute = (path) => new URL(path, siteUrl).href;

export default {
  title: 'Eleventy Excellent',
  description: 'Eleventy starter for building modern, resilient websites',
  url: siteUrl,

  // meta.js says en_EN. Baseline derives og:locale from the locale, so give it
  // the locale rather than the short code.
  defaultLanguage: 'en',
  defaultLocale: 'en-GB',

  seo: {
    // Absolute, because Baseline passes this through untouched.
    ogImage: {
      url: absolute('/assets/images/template/opengraph-default.jpg'),
      width: 1200,
      height: 630,
      alt: 'An Eleventy starter with CUBE CSS, Every Layout and design tokens'
    }
  },

  head: {
    // theme-color is emitted from base.njk instead. See the head step.
    meta: [
      {name: 'color-scheme', content: 'light dark'},
      {name: 'format-detection', content: 'telephone=no'},
      {name: 'fediverse:creator', content: '@lene@front-end.social'}
    ],

    link: [
      // Empty string, not `true`: attributes pass through to the serialiser
      // raw, so `crossorigin: true` would emit crossorigin="true".
      {
        rel: 'preload',
        href: '/assets/fonts/redhat/red-hat-display-v7-latin-900.woff2',
        as: 'font',
        type: 'font/woff2',
        crossorigin: ''
      },
      {rel: 'stylesheet', href: '/assets/css/global/index.css'},
      {rel: 'stylesheet', href: '/assets/css/local/index.css'},
      {rel: 'author', href: '/humans.txt'},
      {rel: 'me', href: 'https://front-end.social/@lene'},
      {rel: 'alternate', type: 'application/atom+xml', title: 'Atom Feed', href: '/feed.xml'},
      {rel: 'alternate', type: 'application/json', title: 'JSON Feed', href: '/feed.json'},
      {rel: 'icon', href: '/favicon.ico', sizes: 'any'},
      {rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml'},
      {rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png'},
      {rel: 'manifest', href: '/site.webmanifest', crossorigin: 'use-credentials'}
    ],

    script: [{src: '/assets/js/index.js', defer: true, module: true}]
  }
};
```

Every entry in `head.link` and `head.meta` is a tag lifted out of a hand-written partial, which the head step deletes. The second font preload is cut here for length.

{% enddetailsBlock %}

Two of those values are corrections rather than moves. `rel: author` is relative in the starter, so it 404s on every page but the home page, and `defaultLocale` is there because `meta.js` carries `en_EN`, which is not a locale.

`settings.head` also declares two stylesheets and a script that do not exist until the assets step. Nothing reads them until `<baseline-head>` arrives, so the exposure is one step: the head swap links assets that 404, which turns the unstyled window into a 404 window.

### Delete the sitemap template in the same commit

Not before, and not after. The starter's `sitemap.njk` declares `permalink: /sitemap.xml` and Baseline registers a virtual template at the same path. Eleventy treats a duplicate output path as fatal, so deleting first leaves you with no sitemap, and wiring first leaves you with a build that fails.

This is the one deletion that cannot wait for the sweep at the end. On any site with its own sitemap template, it is also the first thing that happens when Baseline is registered: the build stops, and the error names a Baseline-internal template you have never seen. The `image` shortcode handles the same kind of clash by standing down and telling you in the log. The sitemap module has no equivalent check.

### What the build says

```
[baseline] Baseline v0.1.0-next.46, running Eleventy v3.1.6
[baseline] Modules: sitemap, navigator, head, assets
[baseline] Already defined here, left alone: image
```

The third line is the one to read. Baseline registers its own `image` shortcode only where nothing has claimed the name, so yours keeps it. It behaves that way throughout: it takes a surface only if you have not already claimed it, and tells you when it leaves one alone.

## Guard the image work against the pre-pass

Baseline renders your whole site once before the real build, in a second Eleventy instance in the same process, to construct the content graph. Every template runs for real, so anything expensive your config does happens twice.

`BASELINE_PREPASS_ACTIVE` is `'1'` while that runs, and it is set before the inner instance reads your config, so a registration-time guard works as well as a run-time one. Compare it to `'1'` explicitly: it is reset to the string `'0'` rather than deleted, so a bare truthiness check stays true for the rest of the process.

Three places want guarding, and none of them skips work outright. Two switch eleventy-img to `statsOnly`, because the pre-pass still has to resolve the metadata its markup is built from.

{% detailsBlock "View the three guards" %}

```js
// eleventy.config.js, skipping the transform registration entirely
if (process.env.BASELINE_PREPASS_ACTIVE !== '1') {
  eleventyConfig.addPlugin(plugins.eleventyImageTransformPlugin, {
    // ...
  });
}
```

```js
// _config/shortcodes/image.js and _config/events/svg-to-jpeg.js
statsOnly: process.env.BASELINE_PREPASS_ACTIVE === '1',
```

{% enddetailsBlock %}

The asset compile is the one you cannot guard. Templates inline its output, and the pre-pass renders those templates for real, so skipping it fails the pre-pass with `template not found`. The rule: guard work whose product the pre-pass does not need, and never guard work its render depends on.

Check the numbers rather than trusting them. Fresh encodes are what matters, and with all three guards in place they come to 46 either side of Baseline.

```
154 optimized (108 cached)   no guards
133 optimized  (87 cached)   + transform skipped
 95 optimized  (49 cached)   + shortcode and svgToJpeg
```

The totals stay above the pre-Baseline figure because the pre-pass still looks images up. Those are metadata reads rather than encodes.

## Swap in Baseline's config export

Spread Baseline's config rather than re-exporting it.

```js
export const config = {
  ...baselineConfig,
  markdownTemplateEngine: 'njk',
  dir: {...baselineConfig.dir, layouts: '_layouts'}
};
```

The directory conventions you adopted by hand now come from the plugin, and they agree because you made them agree first. One override earns its place: Baseline's contract has no `layouts` key, so a wholesale re-export moves layout resolution into `_includes/`, which means moving every layout and rewriting every reference to one. Keeping the directory is one line, and spreading is what makes keeping it possible.
{% endif %}
{% if part.index == 5 %}
## Add a `schema.js` file

One file in `src/_data/`, and no wiring. Dropping it in is the wiring, because `schema` is a cascade key.

```js
// src/_data/schema.js
import settings from './settings.js';
import * as meta from './meta.js';

// Swap which one is null to model the site as a person instead.
const isPerson = meta.siteType === 'Person';

export default {
  organization: isPerson
    ? null
    : {'@type': 'Organization', name: settings.title, url: settings.url},

  person: isPerson
    ? {
        '@type': 'Person',
        name: meta.author.name,
        url: settings.url,
        // No origin, no image. undefined is stripped before output.
        image: settings.url ? new URL(meta.author.avatar, settings.url).href : undefined
      }
    : null,

  pieces: []
};
```

Those two imports are why settings had to be a data file: a file in `src/_data/` can import a sibling, but not `eleventy.config.js`.

`organization` and `person` are reserved. Baseline builds the spine nodes and wires the `@id`s between them, and `null` means not set rather than empty, so it is stripped. `pieces` takes raw schema.org nodes and passes them through untouched, which is where anything Baseline does not model goes.

Nothing in the output changes yet. Baseline emits the JSON-LD from inside `<baseline-head>`, and the placeholder does not reach your layout until the head step, two on from here. The identity is ready and waiting when it does.

## Generate the OG images into `dist/`

The share cards move out of the source tree and into build output. Six changes land together because none of them work alone.

The starter writes JPEGs into `src/assets/og-images/` and lets passthrough copy them across. At `eleventy.after` passthrough has already run, so a card generated during a build only appears on the next one. That is why the generator is guarded to development and fourteen JPEGs are committed to the repository. Writing straight to `dist/` dissolves the arrangement: the guard, the passthrough entry, the `clean:og` script and the committed output all go.

It is not free. The target now sits inside a directory `clean` wipes every build, so every card regenerates every time. That is Sharp work per post, traded for no generated artefacts in source.

Two bugs surface the moment output moves. Both are harmless only while nothing consumes the cards until the following build.

{% detailsBlock "View the three edits" %}

The output directory, in `_config/events/svg-to-jpeg.js`:

```js
const ogImagesDir = './dist/assets/og-images';
```

The loop, in the same file. `forEach` discards the promise it is handed, so `eleventy.after` returns before any card has finished encoding:

```js
const files = await fsPromises.readdir(socialPreviewImagesDir);
for (const filename of files) {
  // ...
}
```

And the permalink, in `src/content/common/og-images.njk`:

{% raw %}

```js
const permalink = '/assets/og-images/{{ post.data.slug | slugify }}-preview.svg';
```

{% endraw %}

That one is `post.data.title | slugify` in the starter, while the share-card tags build the `og:image` URL from `slug`. The two agree only while every title happens to slugify to its slug, and diverge the first time a title is edited. Fix it here, before the head step puts those tags in `base.njk` and gives the mismatch somewhere to show.

{% enddetailsBlock %}

Both worked only because nothing read the cards until the next build.
{% endif %}
{% if part.index == 6 %}
## Swap the hand-written head for `<baseline-head>`

`base.njk` loses its entire `<head>` block and gains the placeholder. Seven partials stop being included, and everything worth keeping is already in `settings.head`. One of the seven comes straight back as an inline block, under "What stays in the template".

Your site renders unstyled from here until the assets step. The stylesheet is wired through one of the partials you just stopped including, so this is expected rather than broken.

The dead partials stay on disk until the sweep at the end. That is deliberate, and it is what makes the sweep one legible pass rather than a footnote in five separate steps.

## What Baseline replaces

Baseline composes the head from data, which is what the partials were doing by hand. Most of what you had moves across unchanged, and you pick up a handful of tags you were not emitting at all.

The new ones are `charset`, `viewport`, a composed `<title>`, `twitter:card` on every page, `article:published_time` and `article:modified_time` on posts, and the JSON-LD graph. Two more change rather than appear: `robots` arrives with a fuller set of directives than the `googlebot` tag it supersedes, and `og:type` becomes `article` on posts instead of `website` everywhere.

Three of these are worth checking against your own settings before you delete anything, since they carry values rather than just moving: the `og:image` URL has to be absolute, `rel=author` has to resolve from every page, and the locale has to be a real one.

{% detailsBlock "View what moves, and what is dropped" %}

{% tableBlock true %}

| tag | outcome |
| --- | --- |
| `og:image` and its `alt`, `width`, `height` | `settings.seo.ogImage`, and the URL must be absolute |
| `color-scheme`, `format-detection`, `fediverse:creator` | `settings.head.meta` |
| `rel=author`, `rel=me`, both feeds, three icons, the manifest | `settings.head.link` |
| `theme-color` ×2 | stays in the template, see below |
| `googlebot` | dropped, Baseline's `robots` is a superset |
| `generator` | dropped, use `options.head.showGenerator` instead |
| `article:author` | dropped, Baseline emits it and gates it correctly |

{% endtableBlock %}

{% enddetailsBlock %}

Almost everything in that table is a move rather than a loss. The order to work in is the same anywhere: chain onto what your starter already declares, and reserve replacement for genuine overlap.

## What stays in the template

Two `theme-color` tags cannot go into `settings.head.meta`. They differ only by `media`, and the cascade-time merge keys metas on `name` alone, so one is discarded before the head driver sees it.

Put them below the placeholder rather than inside it. Children of `<baseline-head>` are discarded; siblings survive, because the HTML parser folds `meta`, `link`, `base`, `script`, `style` and `title` back into the head element. That is the escape hatch for anything Baseline's dedupe would collapse.

Only the `theme-color` pair has to be there. The speculation-rules block below could equally live in `settings.head.script`, because a script entry accepts a `content` key that Baseline renders as the element's body. Keeping it in the template is a choice about where a block of JSON is easiest to read.

{% detailsBlock "View the whole head region of `base.njk`" %}

{% raw %}

```njk
<baseline-head></baseline-head>
<meta name="theme-color" content="{{ meta.themeDark }}" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="{{ meta.themeLight }}" media="(prefers-color-scheme: light)" />

<!-- Prefetches likely next pages on link hover / interaction. Progressive enhancement:
  supported in chromium browsers, everyone else ignores the script block.
  https://developer.chrome.com/docs/web-platform/prerender-pages -->
<script type="speculationrules">
  {
    "prefetch": [
      {
        "where": {"href_matches": "/*"},
        "eagerness": "moderate"
      }
    ]
  }
</script>

{# Per-post share card. Emitted here because it cannot come from data:
    posts.11tydata.js declines Baseline's with `ogImage: false`. #}
{% if 'posts' in (tags or []) and slug %}
  <meta property="og:image" content="{{ ('/assets/og-images/' + slug + '-preview.jpeg') | url | absoluteUrl(settings.url) }}" />
  <meta property="og:image:alt" content="{{ title }}" />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
{% endif %}
```

{% endraw %}

Three separate things, all of them siblings: the pair Baseline's dedupe would collapse, a block that could live in settings and does not, and a workaround.

{% enddetailsBlock %}

The share card at the bottom is the workaround, and it is there for a different reason from the other two.

### Why the share card is in there

A per-post `og:image` cannot come from data. Baseline's SEO graph is built before `eleventyComputed` resolves, so a computed `ogImage` never reaches it and every post falls back to the site default without saying so. Decline Baseline's card and emit your own from the layout, which is what those four tags do.

{% raw %}

```js
// src/content/posts/posts.11tydata.js, in its final form
export default {
  layout: 'post',
  tags: 'posts',
  permalink: '/blog/{{ title | slugify }}/index.html',
  type: 'article',
  articleType: 'BlogPosting',
  ogImage: false
};
```

{% endraw %}

`false`, not an empty string. `type` and `articleType` are unrelated to the workaround: they tell Baseline these pages are articles, which is what gates `og:type` and `article:published_time`.

This is a workaround rather than a pattern.
{% endif %}
{% if part.index == 7 %}
## Move the assets pipeline to Baseline

Entry points replace the starter's per-page bundle calls, and PostCSS and esbuild run inside the plugin.

```
postcss.config.js                      new, at the project root
src/assets/css/global/global.css  ->   global/index.css     entry point
src/assets/css/local/index.css         new, ten explicit imports
src/assets/scripts/               ->   src/assets/js/
src/assets/js/index.js                 new, entry point
```

Those entry points reach the page through `settings.head`, which is why this step follows the head one rather than leading it.

## Create the three entry points

Baseline compiles one bundle per directory, from the `index` file it finds there, so you need three. `global/index.css` is the starter's existing entry renamed and keeps its layer order and its globs untouched, `local/index.css` is new and replaces the eleven per-page bundles, and the JS entry is two lines because most of this starter's JavaScript stays page-scoped.

{% detailsBlock "View the three entry points" %}

```css
/* src/assets/css/global/index.css */
@import 'tailwindcss/base' layer(tailwindBase);

@import 'base/reset.css' layer(reset);
@import 'base/fonts.css' layer(fonts);

@import 'tailwindcss/components' layer(tailwindComponents);

@import 'base/variables.css' layer(variables);
@import 'base/global-styles.css' layer(global);
@import 'base/view-transitions.css' layer(global);

@import-glob 'compositions/*.css' layer(compositions);
@import-glob 'blocks/*.css' layer(blocks);
@import-glob 'utilities/*.css' layer(utilities);

@import 'tailwindcss/utilities' layer(tailwindUtilities);
```

Its imports are listed one by one rather than globbed. A glob would work here, since this is an entry file, but `@import-glob` fails silently the moment a file stops being one, and the next part covers what that costs:

```css
/* src/assets/css/local/index.css */
@import './custom-card.css';
@import './details.css';
@import './footnotes.css';
@import './gallery.css';
@import './nav-main-drawer-cls.css';
@import './pagination.css';
@import './post.css';
@import './styleguide.css';
@import './table.css';
@import './forms.css';
```

And the JS entry:

```js
// src/assets/js/index.js
import './bundle/is-land.js';
```

`is-land.js` moves here because it is site-wide and worth caching once. Everything else stays where it is used.

{% enddetailsBlock %}

Only `is-land.js` graduates to the entry point. The rest of the JavaScript keeps its per-page scoping, by a different route covered in the next part.

## Declare them in settings

The three files reach the page through the settings you already wrote, and PostCSS needs a config at the project root because it runs inside the plugin now rather than through `postcss-cli`.

{% detailsBlock "View the declaration and the PostCSS config" %}

```js
link:   [{rel: 'stylesheet', href: '/assets/css/global/index.css'},
         {rel: 'stylesheet', href: '/assets/css/local/index.css'}],
script: [{src: '/assets/js/index.js', defer: true, module: true}]
```

```js
// postcss.config.js
import postcssImportExtGlob from 'postcss-import-ext-glob';
import postcssImport from 'postcss-import';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';

const isProd = process.env.ELEVENTY_ENV === 'production';

const plugins = [postcssImportExtGlob, postcssImport, tailwindcss, autoprefixer];

if (isProd) {
  plugins.push(cssnano);
}

export default {
  map: !isProd,
  plugins
};
```

PostCSS normalises plugins itself, so `tailwindcss` and `tailwindcss()` both work. Call one only to pass it options.

{% enddetailsBlock %}

With the entry points declared, what is left is the eleven CSS blocks and five JS blocks in the templates that used to do this work per page.
{% endif %}
{% if part.index == 8 %}
## Where the call sites go

The starter declares its assets where they are used, with a `{% raw %}{% css %}{% endraw %}` or `{% raw %}{% js %}{% endraw %}` block wrapping an include. Baseline works one bundle per directory instead, so you will not treat those blocks the same way. Delete the CSS ones, translate the JS ones, and move two things to the config.

## Delete the eleven CSS blocks

They all look like this, and they all go:

{% raw %}

```njk
{% css "local" %}
  {% include "css/custom-card.css" %}
{% endcss %}
```

{% endraw %}

Delete the block, not the stylesheet. The file it included is one of the ten now listed in `local/index.css`, so the CSS still ships, it just ships on every page instead of the pages that use it. That is the per-page scoping you are giving up, and it is the one trade here you might not want to make.

Two of those files are Markdown rather than templates, which is worth knowing before you go looking: a `{% raw %}{% css %}{% endraw %}` block can sit in a post.

{% detailsBlock "View the eleven blocks and the file each one included" %}

{% tableBlock true %}

| file | included |
| --- | --- |
| `_includes/partials/card-blog.njk` | `custom-card.css` |
| `_includes/partials/card-tag.njk` | `custom-card.css` |
| `_includes/partials/details.njk` | `details.css` |
| `_includes/partials/gallery.njk` | `gallery.css` |
| `_includes/partials/main-nav.njk` | `nav-main-drawer-cls.css` |
| `_includes/partials/pagination.njk` | `pagination.css` |
| `_layouts/post.njk` | `post.css`, `footnotes.css` |
| `content/pages/built-with.njk` | `custom-card.css` |
| `content/pages/get-started.md` | `custom-card.css` |
| `content/pages/styleguide.njk` | `styleguide.css`, `table.css` |
| `content/posts/2022/2022-11-02-markdown.md` | `table.css` |

{% endtableBlock %}

Four of them include the same `custom-card.css`, which is the clearest argument for the bundle: the old arrangement was already shipping it four ways.

{% enddetailsBlock %}

{% detailsBlock "Two things worth knowing about the bundled CSS" %}

**Inline or linked, measured.** The starter inlines its whole stylesheet into every page in production. Baseline links it. Linked costs about 9.6KB on a cold first visit and saves about 34KB on every page after that, so it breaks even on the second page and removes 1.16MB of duplicated CSS across the site. That is bytes rather than perceived speed: a single-page visit does genuinely make more round trips than it did.

**`@import-glob` only fires in the entry file.** Do not put a thin entry point above a stylesheet that uses it. Globs one level down are never processed, silently drop everything they matched, and survive into the output as dead text. `global/index.css` globs three directories safely, because it is the entry itself. Had it been reached through a wrapper, those three lines would have shipped to the browser as text and taken every composition, block and utility with them. `local/index.css` lists its ten imports by hand for that reason. It could glob today, but it would break quietly if anything were ever put above it.

{% enddetailsBlock %}

## Translate the five JS blocks to `inlineESbuild`

The JavaScript keeps the scoping the CSS gave up, because these blocks are genuinely page-scoped. Swap each block for the filter, in place:

{% raw %}

```njk
{% js "defer" %}
  {% include "scripts/nav-drawer.js" %}
{% endjs %}
```

```njk
{% set jsNavDrawerPath = _baseline.paths.assets ~ "js/bundle/nav-drawer.js" %}
{{ jsNavDrawerPath | inlineESbuild({ minify: true }) | safe }}
```

{% endraw %}

`_baseline.paths.assets` is a Baseline global, so the path is not hard-coded against the directory conventions. The five sites are `details.njk`, `gallery.njk`, `main-nav.njk` twice, and `base.njk` for the theme toggle.

## Remove two registrations, then add a passthrough

Take the pre-build events and the CSS bundle out of the config. The plugin does both jobs now, and leaving them in means two things writing to the same place:

```js
// eleventy.config.js, all of this goes
eleventyConfig.on('eleventy.before', async () => {
  await events.buildAllCss();
  await events.buildAllJs();
});

eleventyConfig.addBundle('css', {hoist: true});
```

Then add one entry back. The custom elements are loaded with `<script src>` rather than imported, so no entry point reaches them and nothing compiles them. They need copying across as they are:

```js
// eleventy.config.js, the last entry is the new one
eleventyConfig.addPassthroughCopy({
  'src/assets/images/favicon/*': '/',
  'node_modules/lite-youtube-embed/src/lite-yt-embed.{css,js}': `assets/components/`,

  // Custom elements referenced by <script src> rather than bundled.
  'src/assets/js/components/': `assets/js/components/`
});
```

While you are in there, repoint the two tags that load them. The directory they name moved from `src/assets/scripts/` to `src/assets/js/` at the top of this chapter, so both are one word out:

{% raw %}

```njk
{# src/_includes/webc/custom-masonry.webc #}
<script src="/assets/js/components/custom-masonry.js" type="module"></script>

{# src/_layouts/base.njk #}
<script type="module" src="/assets/js/components/custom-easteregg.js"></script>
```

{% endraw %}

Neither of those would have failed the build. They would have 404'd in the browser, on the two pages that use them.

## Watch for template syntax in your asset sources

Your asset sources cannot contain template syntax any more. Under the old pipeline the scripts are pulled in through an include, so Nunjucks substitutes the {% raw %}`{{ … }}`{% endraw %} inside them by accident of the mechanism. Baseline compiles the file directly, so those braces ship to the browser verbatim. Nothing fails, the build is green, and the JavaScript is broken.

Grep every asset source for {% raw %}`{{`{% endraw %} and {% raw %}`{%`{% endraw %} before you move it. Two scripts have it here, `theme-toggle.js` reading two colours and `nav-sub.js` reading a breakpoint. Hand those values in from the template instead, in `base.njk`, as a sibling below `<baseline-head>` and above the theme toggle you inlined a moment ago:

{% detailsBlock "View the block in place in `base.njk`" %}

{% raw %}

```njk
<baseline-head></baseline-head>
<!-- the theme-color metas and the share card sit here -->

<script>
  window.__site = {
    themeDark: '{{ meta.themeDark }}',
    themeLight: '{{ meta.themeLight }}',
    navBreakpoint: '{{ designTokens.viewports.navigation }}'
  };
</script>

{% set jsThemeTogglePath = _baseline.paths.assets ~ "js/bundle/theme-toggle.js" %}
{{ jsThemeTogglePath | inlineESbuild({ minify: true }) | safe }}
```

{% endraw %}

The comment stands in for what the head step already put there. Nothing above the placeholder changes.

{% enddetailsBlock %}

Order matters here, because the object has to exist before anything reads it. The head is the earliest place it can go, and it covers `nav-sub.js` too, which is inlined further down in `main-nav.njk`. Read the values defensively on the other side, `window.__site?.themeDark || '#2e2e2e'`, so a page that somehow renders without the block still runs.
{% endif %}
{% if part.index == 9 %}
## Delete what the integration retired

Everything dead has been left in the tree until now, which makes this one pass you can verify in a single check. Confirm zero inbound references for each file before it goes.

{% tableBlock true %}

| what | files | dead since |
| --- | --- | --- |
| head partials | 7 | the head swap |
| schema templates | 2 | the schema data file |
| asset build events | 2 | the assets pipeline |
| committed OG images | 14 | the move to `dist/` |

{% endtableBlock %}

Plus `_config/filters/striptags.js`, orphaned by the head swap along with the partial that used it. With the partials gone, `src/_includes/head/` is empty and the directory goes too.

The fourteen JPEGs are the deletion worth pausing on. They are build output living in source control, regenerated on every build and committed anyway, and nothing marks them as a problem until generation moves to `dist/`. If your starter commits its share cards, you have the same fourteen files.

## Drop the dependencies that lost their importer

Four packages come out, and you land on 42 rather than 41, because `npm-run-all` went in earlier.

```bash
npm uninstall esbuild postcss-cli fast-glob sanitize-html
```

The first three had one importer each, all of it in code you deleted in the assets step. `sanitize-html` is the odd one out: nothing in the starter imports it, in any version. You find things like that by making yourself justify every remaining dependency once.

---

## What a green build will not tell you

Eleventy exits zero on a site that is quietly broken, and most of what goes wrong in this integration is silent rather than fatal.

Walk the site on the dev server first, looking for what a person would notice. Then build it for production with the origin you actually deploy to, because `--serve` and `npm run build` take different branches through the asset pipeline.

```bash
BASELINE_URL=https://www.example.com/ npm run build
```

It will pass. Go and look at `dist/` rather than at the exit code, and count things rather than checking they exist.

{% detailsBlock "View the six checks" %}

- **The file count.** `[11ty] Wrote N files` against the number you had before you started. It is the cheapest check available and it catches an empty collection, which nothing else here will.
- **Tag counts, not tag presence.** A uniform drop from ten OG tags to nine is invisible to a presence check. Look at a post rather than the home page: a per-page `og:image` resolves through a different path than the site default, and a missing one degrades to absent rather than to the fallback.
- **Attributes, not elements.** A tag census cannot see a lost attribute. Grep the output for each attribute you believe you configured and count against the page count: `grep -rl 'use-credentials' dist --include='*.html' | wc -l`.
- **Payload, not markup.** A `<link>` proves the tag, not the stylesheet behind it. Grep the built output for a rule you know is in your CSS.
- **Template syntax in built assets.** No {% raw %}`{{`{% endraw %} or {% raw %}`{%`{% endraw %} anywhere under `dist/assets/`, and no `@import-glob` surviving into the built CSS. Neither esbuild nor PostCSS renders templates, and neither complains.
- **Machine outputs that are not web pages.** `head -c 40` on `robots.txt`, the feeds and the manifest. Eleventy has no notion that a file ending `.txt` should not have a layout applied to it.

{% enddetailsBlock %}

They are all counts rather than looks, because a presence check will pass on a page that is missing exactly the thing you changed.

---

## Not covered here

**Markdown plugins.** Baseline composes with an existing markdown-it stack rather than replacing it, and this starter's is load-bearing. Leave yours alone.

**The starter's own prose.** `docs/` still describes mechanisms this integration removes. That is the starter author's copy rather than the integrator's.

**Multilingual sites.** The starter is single-language and so is this guide. The multilang module is a different conversation.

---

## See also

- [[config-export | Config export reference]]
- [[site-settings | Site settings]]
- [[assets | Assets module]]
- [[head | Head module]]
- [[seo-graph | SEO graph]]
- [[integrate-eleventy-base-blog | Integrating eleventy-base-blog]]
{% endif %}
