Skip to content

Markdown CMS pattern

A typical use case for content-typed sheets is a small CMS: blog posts, docs, knowledge-base entries — records whose primary content is markdown prose, with structured metadata (title, slug, tags, publish date) in TOML frontmatter.

Sheet config

# .gitsheets/posts.toml
[gitsheet]
root = 'posts'
path = '${{ slug }}'

[gitsheet.format]
type = 'markdown'
body = 'body'

# Optional: tighten markdownlint for prose vs the library defaults
[gitsheet.format.markdownlint]
default = true
MD013 = false        # line-length 80 off (default for content sheets)
MD041 = false        # first-line H1 not required
MD024 = false        # allow duplicate headings (common in long posts)

[gitsheet.schema]
type = 'object'
required = ['slug', 'title', 'body', 'publishedAt']

[gitsheet.schema.properties.slug]
type = 'string'
pattern = '^[a-z0-9-]+$'

[gitsheet.schema.properties.title]
type = 'string'
minLength = 1

[gitsheet.schema.properties.body]
type = 'string'

[gitsheet.schema.properties.publishedAt]
type = 'string'
format = 'date-time'

[gitsheet.schema.properties.tags]
type = 'array'
items.type = 'string'

On disk

posts/
  hello-world.md
  why-gitsheets.md
  product-launch.md

Each file is a markdown document with frontmatter:

+++
publishedAt = 2026-05-16T10:00:00Z
slug = "hello-world"
tags = [ "intro", "meta" ]
title = "Hello, world"
+++

# Hello, world

This is the first post. Body content here.

The on-disk file above has the title duplicated: once in frontmatter, once as the body’s H1. Without help, an author has to keep those in sync — and gets ugly diffs when one drifts.

Opt into title-from-H1 extraction with [gitsheet.format].title:

[gitsheet.format]
type = 'markdown'
body = 'body'
title = 'title'        # ← the body's first H1 is denormalized into this field

Now the body is the source of truth for the title. The library:

  • Extracts the title from the body’s first # Heading line on every write.
  • Writes the extracted value into frontmatter (so body-less reads see the title at no I/O cost).
  • Enforces the invariant record.title === <body's first H1, or undefined> — disagreement on upsert throws ValidationError.
  • Auto-enables markdownlint’s MD041 (first-line-must-be-H1) so bodies that start with prose fail loud.
// Set or rename the title via the body — title is derived on write
await sheet.upsert({
  slug: 'hello-world',
  body: '# Hello, world\n\nFirst post body.',
});

// Rename via `patch({title})` — the library rewrites the body's H1 for you
await sheet.patch({ slug: 'hello-world' }, { title: 'A new title' });
// → body becomes '# A new title\n\nFirst post body.'

// Rename via `patch({body})` — title is re-derived from the new H1
await sheet.patch(
  { slug: 'hello-world' },
  { body: '# Re-titled\n\nDifferent body.' },
);
// → record.title === 'Re-titled'

// upsert with disagreeing title + body throws — the consumer's assertion is
// self-inconsistent.
await sheet.upsert({
  slug: 'hello-world',
  title: 'A',
  body: '# B\n\nbody',
}); // throws ValidationError

The asymmetry between upsert and patch on {title: 'X'} mirrors PUT vs PATCH: upsert is a state assertion (must be self-consistent), patch is a reconciled delta (the operation figures out the rest).

When you don’t want this — say the title field exists but isn’t tied to the H1 — just leave title out of [gitsheet.format]. The sheet behaves exactly as it did pre-v1.3.

Authoring

Three workflows pair well with the format:

1. Editor on disk + commit. Authors edit posts/*.md directly in their editor of choice (VS Code with markdownlint extension, Obsidian, neovim). On commit, normal git workflow. gitsheets normalize posts re-runs the canonical write pipeline to catch any drift.

2. gitsheets edit. Open one record in $EDITOR, save, gitsheets validates + commits:

gitsheets edit posts hello-world

3. Programmatic (Node). Driving from code — useful for batch imports, scheduled publishes, headless CMS flows:

import { openRepo } from 'gitsheets';

const repo = await openRepo();
await repo.transact(
  { message: 'publish: hello-world', author: { name: 'Jane', email: 'jane@x.org' } },
  async (tx) => {
    await tx.sheet('posts').upsert({
      slug: 'hello-world',
      title: 'Hello, world',
      publishedAt: new Date(),
      tags: ['intro', 'meta'],
      body: '# Hello, world\n\nFirst post body.',
    });
  }
);

Reading

Default reads load the body — this is what you want for rendering a single post:

const posts = await repo.openSheet('posts');
const post = await posts.queryFirst({ slug: 'hello-world' });
renderHtml(post.body);

For listing pages (index, archives, tag pages) you only need frontmatter. Skip the body bytes entirely:

const recent = await posts.queryAll(
  { publishedAt: (d) => d > sevenDaysAgo() },
  { withBody: false },
);
// recent[i].body is undefined — that's the point

Hydrate the body on demand when a reader clicks through:

const full = await posts.loadBody(recent[0]);
renderHtml(full.body);

Indexing

Indexes always build with body-less reads. For an index keyed on tags, slug, or a publishedAt year — anything in the frontmatter — the build is cheap regardless of how many large bodies the sheet holds:

posts.defineIndex('byTag', (post) =>
  Array.isArray(post.tags) && post.tags.length > 0 ? post.tags[0] : undefined,
);

const intros = await posts.findByIndex('byTag', 'intro');
// intros[i].body is undefined; loadBody when you need it

Don’t index on body content. The keyFn will see undefined and the record gets excluded.

CLI workflows

The shipped CLI supports the full content-typed surface:

# List all posts, frontmatter only — fast even with many large bodies
gitsheets query posts --filter status=published --no-body

# Export the whole site as CSV (frontmatter columns only)
gitsheets query posts --no-body --format=csv --fields slug title publishedAt tags > index.csv

# Patch only the title — body is preserved automatically
gitsheets upsert posts '{"slug":"hello-world","title":"Hi"}' --patch

# Bulk import from a directory of .md files (one record per file)
# (requires custom glue — gitsheets doesn't yet ingest a directory tree)

Pairing with a static site generator

The on-disk layout matches what Hugo, Astro, Eleventy, and Jekyll expect. You can point an SSG at the gitsheets data repo’s posts/ directory and treat it as the content source — no build step to merge TOML records with attached body files.

The frontmatter sort + body normalization means git diffs are clean: field reorders never show up as noise, and a body edit shows the content change line-by-line.

Pairing with attachments

A post can still carry attachments (images, code samples) under its attachment directory at posts/<slug>/. Attachments aren’t part of the markdown body — they’re sibling blobs under the record:

posts/
  hello-world.md
  hello-world/
    hero.jpg
    diagram.svg

The body can reference them with relative markdown links:

+++
slug = "hello-world"
+++

# Hello, world

![Hero](./hello-world/hero.jpg)

(How those relative paths resolve at render time depends on your SSG or HTML pipeline — gitsheets just stores the bytes.)

Coordinates