Post

The Client Wants to Fix a Typo

An agency's relationship with a static site generator is lopsided. Building the site is the part you are good at and the part that takes a week. Everything after it — the client emailing about a phone number in the footer, the third site this month that needs the same testimonial block, the analytics that was supposedly installed in March — is the part that takes a year.

1.8.60 is a release about that second part. There is very little in it about generating HTML faster.

The email

Here is the one every agency knows. A client writes: "Can you change the headline on the services page? It should say Warsaw, not Warszawa."

That is fifteen seconds of work and forty minutes of process. Someone opens the repository, finds the file, edits the frontmatter, commits, waits for the build, sends a link, and the client replies asking for one more word.

ssg --http --watch --edit turns the preview server into an editor. The client opens the preview, clicks the headline, changes it, and saves. The change lands in the Markdown file it came from, the site rebuilds, the page reloads, and the edit is committed to a git branch of its own — never to the branch you have checked out.

flowchart LR
    A[Client clicks a headline] --> B[Form built from content_schemas]
    B --> C[Save → the Markdown file]
    C --> D[Rebuild + live reload]
    D --> E[Commit on edit/2026-09-10-…]
    E --> F[You review a diff]

You still review a diff. That is the entire point: the client gets to make the change, and you get to see exactly what changed, in the tool you already use to see what changed.

What they can and cannot touch

The form is built from your content_schemas. A field declared as an enum becomes a menu of exactly its allowed values. A date becomes a date picker. A required field is refused when empty, with the rule that refused it:

1status must be one of: publish, draft
22026-13-45 is not a date (write it as 2026-09-10)

A page is editable where your theme says so, with one attribute:

1<h1 data-ssg-edit="frontmatter:title">{{ .Post.Title }}</h1>

Everything else is inert. That is a deliberate limit rather than an unfinished one. Most of the text on a rendered page did not come from the body of a file — a heading built from title:, a date run through a formatter, a label the template composed — and an editor that guesses where text came from is an editor that eventually changes the wrong paragraph. Refusing is cheap; a wrong silent save is not.

The attributes never reach a published page. A build without --edit strips them, byte for byte, so your themes can carry them permanently.

This release does frontmatter — titles, dates, descriptions, tags — and, by the time it shipped, body text as well. Editing a paragraph is the harder half, because the HTML on screen has been through six passes that have no inverse: the paragraph you clicked is not in the file covers the rule that decides which block a click means, and why the editor sometimes refuses to guess.

The same block, on the ninth site

Every agency ends up with a house component library: a testimonial block, a pricing table, a video embed with the right privacy host. Until now the only way to ship one in SSG was a shortcode defined in the site config — a fixed name rendering fixed data. Two galleries meant two config entries.

A component is now a directory with a contract:

1components/testimonial/
2  component.yaml    props: their types, which are required, defaults
3  template.html     the markup
4  assets/           css that travels with it
1# component.yaml
2props:
3  quote:  { type: string, required: true }
4  author: { type: string, required: true }
5  role:   { type: string }
6  tone:   { type: string, enum: [light, dark], default: light }

Called from content, with its arguments:

1{{< testimonial quote="They shipped in three weeks." author="A. Nowak" tone="dark" >}}

The schema is what makes this worth having across a portfolio. A component becomes usable by someone who has never read its template; a call that forgets quote fails the build with the file, the prop and the reason rather than rendering an empty box; and the component's CSS is copied and linked only on the pages that use it, so site number nine does not ship the stylesheet for a block it does not have.

There is one more consequence worth naming. Every build publishes components.json — the props, their types, what is required, and a copyable example. That is the difference between an AI assistant that can write content for a client site and one that can only write prose: without the contract it either avoids your components or invents attributes for them, and nobody notices until a reader does.

Two things are not calls, and both were learned the hard way while building this: a call naming a component the site does not have is left in the page as written, and a call inside a code fence is not a call at all. Documentation that quotes the syntax stays quoted.

The images you inherited

If you migrate WordPress sites, you have hundreds of content images that look like this:

1![Our office](/wp-content/uploads/2019/office.jpg)

Bare. No srcset, no width, no height, no loading="lazy". SSG has had a responsive image pipeline for a while, and it was reachable only from templates — so it never touched a single image inside migrated content. That is the Largest Contentful Paint problem on exactly the sites that have it.

Render hooks close that. A hook is a template that renders one kind of Markdown node, at the syntax tree rather than by rewriting HTML afterwards:

1render_hooks:
2  image: hooks/image.html
3  link: hooks/link.html
{{/* hooks/image.html — every content image on every site, at once */}}
<figure class="content-image">
  <img src="{{ .Src }}" alt="{{ .Alt }}" loading="lazy" decoding="async">
  {{- with .Title }}<figcaption>{{ . }}</figcaption>{{ end }}
</figure>

And the one nobody remembers to do by hand:

{{/* hooks/link.html */}}
{{- if .IsExternal -}}
  <a href="{{ .Href }}" rel="noopener noreferrer nofollow" target="_blank">{{ .Text }}</a>
{{- else -}}
  <a href="{{ .Href }}">{{ .Text }}</a>
{{- end -}}

rel="noopener" is a security property, not a nicety, and before this release there was no way to apply it to links written in content.

Six hooks ship: image, link, heading, code, table and blockquote. Without them, nothing is registered and your output is byte for byte what it was.

The analytics that was never installed

This one is uncomfortable. Google Tag Manager is two tags: a script in <head> and an iframe immediately after <body>. SSG emitted the first and not the second. And the snippet rode inside the SEO pass, which meant two things at once: a site with seo: false got no tracking at all despite having asked for it, and a site with both flags on had an untracked home page and untracked archives — the pages an analytics report is mostly about.

Both are fixed, and a site can finally declare its own id:

1analytics_ids:
2  gtm: GTM-XXXXXXX

Declaring one is the consent that analytics: true exists to ask for, so it renders on its own. Before this, the only way an id could reach a build was for a migration's crawl to have found one — which meant a hand-built client site could not run a tag manager without you editing a theme.

If you run client sites with GTM, it is worth checking a page source today.

Changing forty configs without destroying them

ssg config set highlight_style github-dark
ssg config set taxonomies.audience.multiple true
ssg config set headers."/css/*".Cache-Control "public, max-age=86400"
ssg config unset mermaid_background

The part that matters for a portfolio is what it does not do. Configs carry their reasoning in comments — why this cache header is a day and not a year, why that language is disabled. The editor splices the change into the text at the position it found, so setting one value changes one line and leaves every comment, blank line and key order alone. An edit that would break the config is validated in a scratch file first, so a rejected change never touches the real one.

It is scriptable across a directory of repositories, which is how it will actually get used.

The rest, briefly

  • --profile answers where a build's time goes: phases in order, the counters it kept, the ten slowest pages. Useful the first time a client site crosses forty seconds and you need to know whether it is images, the search index, or one enormous page.
  • site_graph: true publishes one JSON model of the site — every page, section, taxonomy, link and redirect — and ssg mcp answers questions from it. "Which pages link to /pricing/?" is one call rather than a grep.
  • Records as pages: a client's product CSV or a headless CMS endpoint becomes one page per record, with real URLs and taxonomy archives, via a content_map that says which field is the title and which is the body.
  • Versions and relations in frontmatter, for documentation sites: old revisions canonicalise to the current one instead of competing with it in search results.

What I would turn on first

In order of what pays off soonest across a portfolio:

11. analytics_ids — check one client's page source; you may be missing half a GTM install
22. render_hooks.image — one template, every content image on every site
33. render_hooks.link — rel="noopener" on external links, everywhere
44. components/ — move your house blocks out of per-site config
55. --edit — offer it to the one client who emails you most

The first three are configuration. The fourth is a directory. The fifth is the one that changes how a client thinks about their own site, so it is worth starting with a client you like.