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.
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

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