# Metalsmith Component Builder

> Build static websites from structured content and installable Nunjucks components, using the metalsmith2025-structured-content-starter. Triggers on phrases like "build me a website", "create a landing page", "help me make a site", "add a section to my page", "install a component", or when working with the structured content starter, nunjucks-components.com, or sectionType frontmatter. Assumes Node 22, Git, and an empty project directory.

- Skill: `wernerglinka/metalsmith-component-builder` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds add wernerglinka/metalsmith-component-builder`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wernerglinka/metalsmith-component-builder/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: wernerglinka (https://skillmd.com/u/wernerglinka)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/wernerglinka/metalsmith-component-builder

---


# Metalsmith Structured Content Website Builder

Build websites by installing components and writing structured frontmatter. The user
describes what they want. You install the components and author the pages.

## The one thing that changed

You do not read prose documentation to find out what a component accepts. Two published
JSON files are the contract:

- `https://nunjucks-components.com/downloads/manifest.json` is the catalog. What exists,
  what version, what it requires.
- `https://nunjucks-components.com/assets/components-schema.json` is the field tree for
  every authorable section, with widgets, defaults, enums and required flags.

Read `references/contract-artifacts.md` before phase 3. It teaches both files. It is the
only reference you need for component configuration.

## Hard rules

Violating any of these breaks the site or destroys information. They are repeated in
context below.

1. Node must be 22 or higher. The starter refuses to run below it.
2. Install components with `npm run components`. There is no `curl` plus `install.sh`
   step. Do not invent one.
3. The installer writes one git commit per component. Never squash them, never commit over
   them with a single "Initial website", never rewrite history before pushing.
4. Never edit files under `lib/layouts/components/`. The next install of that component
   replaces them wholesale. Customize with tokens, then overrides.
5. Validation warns. It does not fail the build. Read the build output every time.

## Workflow

1. **Setup** Verify Node, clone, install, run, initialize the page shell, clear demo content
2. **Discovery** Understand what the site needs
3. **Component selection** Read the catalog, install what is needed
4. **Page building** Write frontmatter from the schema, one page at a time
5. **Appearance** Tokens, then overrides, never canon
6. **Publish** Add the remote, push, deploy on Netlify

---

## Phase 1: Setup

### Verify Node first

```bash
node --version
```

The starter sets `engines.node` to `>=22.0.0`. If the version printed is below 22, stop.
Do not clone. Tell the user:

> Your Node version is [version]. This starter needs Node 22 or newer. Install Node 22
> from nodejs.org, or run `nvm install 22 && nvm use 22` if you have nvm, then tell me
> when it is done.

Wait for them to confirm before continuing.

### Clone and start

Run these in the empty project directory the user has open.

```bash
git clone https://github.com/wernerglinka/metalsmith2025-structured-content-starter.git .
npm install
npm start
```

`npm start` runs a browser-sync dev server at `http://localhost:3000` and watches for
changes. Leave it running for the whole session.

Tell the user:

> Your project is set up and the dev server is running at http://localhost:3000. Open
> that in a browser. You should see the starter's demo site. Keep the server running while
> we work.

Wait for them to confirm they can see it.

The starter ships roughly thirty components already installed under
`lib/layouts/components/sections/` and `lib/layouts/components/_partials/`, so most sites
need few or no additional installs.

### Initialize the page shell

The page shell is the frame every page shares: head, header, footer, navigation,
branding, breadcrumbs. Three parts of it are optional. Settle them now, before writing
pages.

```bash
npm run init
```

This is interactive. It asks y/n for three features:

- Theme switcher, dark and light. Off by default.
- Language switcher. Off by default.
- Breadcrumbs. On by default. Turn it off for small sites.

Ask the user what they want before running it, then answer the prompts. To change one
feature later without the prompts:

```bash
node scripts/init-starter.mjs enable breadcrumbs
node scripts/init-starter.mjs disable breadcrumbs
node scripts/init-starter.mjs status
```

`enable` and `disable` take one or more feature names and throw on an unknown one.
`status` takes no arguments and prints the state of all three.

### Offer to clear the demo content

The starter ships a demo site in `src/`. Ask before deleting anything:

> The starter comes with demo pages that show off the components. I can remove them so we
> start clean, or leave them as reference. Which do you prefer?

If they want it cleared, remove the demo pages and keep `index.md` and `404.html`:

```bash
rm -rf src/about.md src/sections.md src/more-sections.md src/blog.md src/blog
```

`src/index.md` stays. Rewrite its frontmatter in phase 4 rather than deleting it. It is
the working example of the page shape.

Commit the cleanup on its own:

```bash
git add -A
git commit -m "chore: remove starter demo content"
```

---

## Phase 2: Discovery

Have a conversation. Do not read this as a checklist to the user. Ask what is relevant,
follow up on what they say, skip what does not apply.

**About the website**

- What is this website for? Business, portfolio, nonprofit, personal, event.
- Who will visit it?
- What should visitors do when they arrive?

**About the homepage**

- What is the first thing visitors should see?
- What is the main message or call to action?
- Do you have images or video in mind?

**About other pages**

- What pages beyond the homepage? About, Services, Contact, Blog, Team, Pricing.
- Do you have content ready, or do you need help writing it?

**About special features**

- Need a contact form?
- Will you write blog posts?
- Anything else? Search, image galleries, testimonials, maps, pricing tables.

**About look and feel**

- Any brand colors or fonts you have to match?
- Do you want a dark mode toggle? This is the theme switcher from phase 1.

Summarize before proceeding, and get agreement:

> So you need a photography portfolio with a hero showing your best work, an about page
> with your background, a gallery page, and a contact form. Your brand color is the deep
> green from your logo. Does that sound right?

---

## Phase 3: Component selection

### Read the catalog

Do not choose from memory and do not choose from a table. The catalog is current. Fetch
it.

```bash
curl -s https://nunjucks-components.com/downloads/manifest.json -o /tmp/catalog.json
node -e "
const c = JSON.parse(require('fs').readFileSync('/tmp/catalog.json', 'utf8'));
for (const group of ['sections', 'partials']) {
  console.log('== ' + group + ' ==');
  for (const e of c[group] || []) {
    console.log(e.name.padEnd(22), e.version.padEnd(8), 'requires: ' + (e.requires || []).join(' '));
  }
}
"
```

Then fetch the library schema so you can check what each candidate actually accepts before
committing to it:

```bash
curl -s https://nunjucks-components.com/assets/components-schema.json -o /tmp/schema.json
```

See `references/contract-artifacts.md` for how to read both.

### Check what is already installed

Most of what you need is already there.

```bash
ls lib/layouts/components/sections
npm run components:status
```

`npm run components:status` compares three hashes per installed component: the files on
disk, the catalog's hash for that component fetched live, and the `Content-Hash` trailer
from the last install commit. It groups the results by status and prints a count for each:
`diverged`, `outdated`, `modified`, `differs`, `local-only`, `current`. When anything is
outdated or diverged it prints the `install-components.mjs` line that would fix it. It ends
by noting that the hash covers template, styles, scripts and modules, so a change to a
manifest alone is invisible to it. `--json` gives the rows if you need to parse them.

Everything under `current` is untouched and up to date. Anything under `modified` means
the site edited canon, which phase 5 says never to do; move the change into an override.

### Install

Interactive, which is the normal path:

```bash
npm run components
```

Non-interactive, when you already know the names:

```bash
node scripts/install-components.mjs hero accordion testimonial
```

The only flags are `--force` and `--no-commit`.

The installer resolves dependencies for you. It reads each entry's `requires` array
depth-first, downloads every archive in the closure, and unzips sections and partials into
their own directories. You never install a dependency by hand.

### Install commits are the record. Preserve them.

The installer writes one git commit per component, staged from that component's directory
only:

```
component: install hero@1.4.0 from nunjucks-components.com

Component-Name: hero
Component-Version: 1.4.0
Content-Hash: d2015e3a200be7a3
```

These are the only record of what version arrived and what has changed since. Later
tooling reads them. Treat them as data, not as noise to be tidied.

- Never squash them.
- Never run `git commit -m "Initial website"` over a tree that contains them.
- Never rewrite history before pushing.

If the user asks why their history has thirty commits before they wrote a word, explain:
each one records a component version, and losing them loses the ability to tell what has
been customized.

### When the installer refuses

The installer refuses to run when a target component directory has uncommitted changes.
The error names the dirty paths. This is the guard against silently discarding local
edits.

Do not reach for `--force`. `--force` discards those edits. First find out what the edits
are:

```bash
git status lib/layouts/components
git diff lib/layouts/components
```

If they are accidental, commit or stash them. If they are deliberate customizations to
canon, that is the mistake described in phase 5: move them into
`lib/overrides/<name>/<name>.css`, revert the canon files, then install.

`--no-commit` places files without committing. It does not lift the refusal.

### Restart after installing

```bash
# Ctrl+C in the terminal running npm start, then
npm start
```

---

## Phase 4: Page building

### Read the schema, then write

Once the site has been built at least once, prefer the site's own emitted schema over the
library's. It covers exactly what this site has installed.

```bash
ls build/assets/components-schema.json
```

If it exists, read that. If not, read `/tmp/schema.json` from phase 3.

Before writing frontmatter for a section type you have not written before, print its
field tree. The walker script is in `references/contract-artifacts.md`. Then write only
what differs from the defaults.

### Page shape

Pages live in `src/` as markdown files with no body. The `sections` array is the page.

```yaml
---
layout: pages/sections.njk
bodyClasses: 'home-page'

seo:
  title: Page title
  description: Page description

sections:
  - sectionType: hero
    classes: 'first-section'
    containerTag: section
    containerFields:
      inContainer: false
      isAnimated: true
    text:
      title: Welcome
      titleTag: h1
      prose: |-
        Markdown body.
---
```

Rules that are not in the schema and that you have to get right:

- **No markdown body.** Content goes in the `sections` array, not below the frontmatter.
- **`classes: 'first-section'`** goes on whatever section is first on the page. It clears
  the fixed header. Every page needs it, on whatever section type happens to be first.
- **`sectionType` must match the directory name** under
  `lib/layouts/components/sections/` exactly.

### Site configuration

Update `lib/data/site.json` with the user's details. Everything in `lib/data/*.json` is
loaded into metadata, so `lib/data/site.json` is `data.site` in templates.

### Data-driven components

Some components take a `source` key naming a file in `lib/data/`. The schema tells you the
field exists. It does not describe the JSON that file must hold. Read an existing data
file in `lib/data/` for the shape.

The starter ships `site.json`, `author.json`, `socialLinks.json`, and `artMuseums.json`.

**Restart the dev server after creating a new data file.** The watcher can catch a file
mid-write and error on a partial JSON parse.

### Build incrementally

One page at a time. One section at a time on the first page.

1. Write or edit the page file.
2. Ask the user to refresh localhost:3000.
3. Read the build output in the terminal running `npm start`. Validation warnings appear
   there and the build stays green regardless. A green build does not mean the frontmatter
   is right.
4. Ask a specific question, not "how does it look".
5. Adjust, then move on.

> I've added the hero and the about section. Refresh your browser. The hero image is
> running full-bleed behind the text. Is that what you wanted, or should it sit inside the
> content column?

---

## Phase 5: Appearance

Three levels, in order of preference. Work down the list. Do not skip to the bottom.

### 1. Tokens

`lib/assets/styles/_design-tokens.css` defines the vocabulary every component consumes.
Colors, spacing, typography. Retune the site's whole appearance here without touching a
single component.

This is the right place for brand colors, brand fonts, and overall spacing rhythm. Change
a token and every component that consumes it follows.

The `body.dark-theme` block in the same file carries the dark values. If the theme
switcher was enabled in phase 1, edit both.

### 2. Overrides

For appearance specific to one component, write
`lib/overrides/<name>/<name>.css`. The bundler wraps it in `@layer site.<name>`.

The layer order, from `site-config.js`, is:

```
['tokens', 'base', 'vendor', 'components', 'site']
```

`site` is last, so an override beats the component it overrides by layer order alone,
regardless of selector specificity. You do not need `!important` and you do not need a
more specific selector. Write the plainest selector that matches.

Prefer setting the component's published custom properties over redeclaring its selectors.
Each component's stylesheet lists them in its header comment. Read that first.

**Anything left unlayered beats all five layers.** Any new stylesheet you add must declare
its layer. An unlayered rule will outrank the whole cascade and be very confusing later.

### 3. Never edit canon

Files under `lib/layouts/components/` are canon. The next install of that component
replaces them wholesale, and the edit disappears with no warning. `npm run components:status`
exists to catch this.

If you find canon has been edited, move the change into an override, revert the canon file,
and confirm `npm run components:status` comes back clean.

### After any style work

```bash
npm run tokens:check
```

This audits both directions of the token contract. It must stay at zero errors.

Run it because CSS will not tell you. A `var()` reference to a token nothing defines is
dropped silently at computed-value time. Nothing errors, the build stays green, and the
property simply has no value. If a style you wrote appears to do nothing at all, a
misspelled token name is the first thing to check.

### Custom CSS and JavaScript

When writing layout CSS beyond token changes, apply the patterns in
`../css-layout-development/SKILL.md`.

When writing any JavaScript, apply the patterns in `../javascript-development/SKILL.md`.

---

## Phase 6: Publish

The install commits are already in history. Publishing adds a remote and pushes. It does
not rewrite anything.

### Commit the content work

```bash
git add -A
git commit -m "content: build out site pages"
```

One commit for the content work is fine. It sits on top of the install commits, it does
not replace them.

### Add the remote and push

The clone points `origin` at the starter repo. Replace it with the user's repo.

If they have not created a repo yet:

> Go to github.com, click the + icon at the top right, choose "New repository". Name it
> whatever you want the project called. Keep it public unless you have a reason not to.
> Do not check any of the "initialize" boxes. Then paste me the URL.

Then:

```bash
git remote remove origin
git remote add origin https://github.com/USERNAME/REPO-NAME.git
git branch -M main
git push -u origin main
```

Do not add `--force`. Do not rebase, squash or amend on the way out. The history that goes
up should be the history that was built, install commits and all.

### Netlify

If the user connected Netlify to GitHub already, the site deploys on push.

If not:

1. Go to app.netlify.com.
2. "Add new site", then "Import an existing project".
3. "Deploy with GitHub", authorize, pick the repository.
4. Build command: `npm run build`
5. Publish directory: `build`
6. Deploy.

> Your site is deploying. In about a minute Netlify will give you a URL like
> random-name-123.netlify.app. That is your live site. Every push to main redeploys it.

---

## Making changes later

When the user returns:

1. They open the project and start Claude Code.
2. They describe the change.
3. You edit, they preview at localhost:3000.
4. Commit and push:

```bash
git add -A
git commit -m "Describe the change"
git push
```

Netlify redeploys.

If a component needs updating to a newer published version, re-run `npm run components`
and pick it. The installer writes a new install commit recording the new version.

---

## Reference commands

```
npm run init               # interactive page-shell setup
npm start                  # dev server, NODE_ENV=development, watch, localhost:3000
npm run dev                # one development build
npm run build              # production build to ./build
npm run serve              # serve the built site
npm run components         # interactive component install
npm run components:status  # drift report against canon
npm run manifest -- <name> # regenerate a component manifest; the -- is required
npm run tokens:check       # design-token contract audit
npm test                   # node --test
npm run fix                # biome check --write
```

The `--` in `npm run manifest -- <name>` is required. Without it npm swallows the argument
and the script runs with no target.

---

## Troubleshooting

**YAML errors.** Check indentation. Two spaces, no tabs. YAML is unforgiving about
spacing, and a mis-indented key silently becomes a child of the wrong parent instead of
erroring.

**A section does not render.** Verify `sectionType` matches the directory name under
`lib/layouts/components/sections/` exactly. A typo produces no output and no error.

**Images do not show.** Paths in frontmatter must start with `/assets/`. The source files
go in `lib/assets/images/`.

**A new data file is not picked up.** Restart the dev server after creating any new JSON
file in `lib/data/`. The watcher can read the file mid-write and fail to parse it.

**Text is invisible on a dark background.** Set `isDark: true` in `containerFields`
alongside the dark background. That switches text to light colors. `imageScreen: dark`
only adds an overlay over a background image; it does not change text color and it does
nothing for a solid color background.

**A component's styles do not apply, and the site looks fine otherwise.** Run
`npm run components:status`. If it reports drift, someone edited canon under
`lib/layouts/components/` and a later install overwrote it. Move the change into
`lib/overrides/<name>/<name>.css` and revert the canon file.

**A style rule has no effect and nothing errors.** Check the token names in it. A `var()`
pointing at a token nothing defines is dropped silently at computed-value time. The build
stays green and the property just has no value. Run `npm run tokens:check`.

**An override does not win.** Confirm the file is at `lib/overrides/<name>/<name>.css`,
with the directory name and the file name both matching the component name. The bundler
wraps that path in `@layer site.<name>`. A file somewhere else is not layered and will
behave unpredictably. Also check that nothing unlayered is in play: unlayered CSS beats
every layer including `site`.

**The installer refuses to run.** The error lists component paths with uncommitted
changes. Read the diff before doing anything. `--force` discards those changes with no
recovery. Commit, stash, or move the change into an override, then re-run.

**The build is green but the section is wrong.** Read the terminal output. Validation
reports warnings and does not fail the build, so the warnings are easy to scroll past.
They are usually a missing required field or a field at the wrong nesting level.

**Node version errors on install or start.** The starter needs Node 22 or newer. Check
with `node --version`.

**Git push fails.** Confirm the remote exists and the user has access: `git remote -v`.
Do not resolve a rejected push by force-pushing. The install commits are on that branch.

**Netlify build fails.** Read the deploy log. Confirm build command is `npm run build`
and publish directory is `build`. Confirm Netlify's Node version is 22 or newer.

---

## Reference files

- `references/contract-artifacts.md` The catalog and the schema. Read before phase 3.
- `references/starter-files.md` The starter's directory tree, config files and page shape.
- `references/learning-resources.md` Where to go for background.

