Content as code: sync a folder
Keep your blog posts, docs pages, FAQ questions, changelog releases and blog categories in a repository, as Markdown with front matter, and let one command make your spaces match it. forumulate sync <folder> reads the folder, compares it with what is live, and prints a plan. --apply carries the plan out. A second run with nothing changed writes nothing at all.
The same sync runs on our side when you push, if you connect the repository with git sync (see When you push). Both use one planner, so a plan you read in a pull request is the run the push makes.
It is part of @forumulate/cli from version 0.9.0. It extends forumulate publish, which publishes straight away and is documented there. Everything that guide says about docs folders, collections, order and the manifest applies here too.
The short version
npm install -g @forumulate/cli
forumulate login # or FORUMULATE_TOKEN in CI
forumulate sync . # the plan: nothing is written
forumulate sync . --diff # the plan, with what changed inside each piece
forumulate sync . --apply # do it, then read it back
forumulate sync . --apply --prune # and remove what the folder no longer hassync is a plan unless you pass --apply. Passing --dry-run together with --apply is refused rather than guessed. forumulate publish is the other way round: it applies unless you pass --dry-run.
One repository, several spaces
A forumulate.yml (or forumulate.yaml, or forumulate.json) at the top of the folder maps folders to spaces:
org: acme
spaces:
- path: blog
space: blog
tags:
releases: { label: Releases, color: '#0a7f5a', cover: img/releases.png }
- path: docs
space: docs
readme: skip
- path: faq
space: help
- path: changelog
space: changelogEach entry names a folder (path) and a space (space, an id or a slug). A space that does not exist yet can be created with create: { type: BLOG, name: News }. Without a manifest, the folder publishes into the space you name with --space.
What becomes what
What a file becomes depends on the type of the space its folder maps to:
Space type | Each Markdown file is | Matched by |
|---|---|---|
Blog | a post | slug |
Changelog | a release | slug, then title (a release's address comes from its title) |
Docs, custom pages, legal, careers, courses | a page; a folder is a collection, a nested folder a parent page | slug |
FAQ | a question; a folder is a section | slug |
Forum, roadmap and other post-based types | a piece, through | slug |
A post's slug is its slug: in front matter, or else its file name without a date (2026-10-01-launch.md → launch) and without a number prefix (02-launch.md → launch). A name made only of numbers, such as 2-4-0.md or 1.10.md, is a version, so it is kept whole (2-4-0).
Front matter
These are the fields every post-based space reads, under the names other tools use as well:
Field | What it sets |
|---|---|
| The title. Without one, the first |
| The address. |
| The publish date. A |
| A list, or |
| The cover image. A local path is uploaded. |
| The excerpt. |
| The byline. |
| The two flags. |
| Left out of the sync. See Drafts. |
| Publish without telling subscribers, for a back catalogue. |
| The release's version. |
A blog post also takes subtitle, series, seo, socialImage, furtherReading, commentsEnabled and bylines. A docs page takes every field the page model has (see Publish from git).
Declared fields. The fields a space's type declares go in front matter under their own names. An FAQ question takes topic, featured, audience, pinned, badge and hideOnLanding, and a docs page takes audience and deprecated. The plan lists any key it does not understand and leaves it unsent, so a misspelt key does not get dropped without a trace.
Named slots. A page whose type has named slots fills them from front matter. On an FAQ question, the body is the answer, short-answer: is the short answer most readers stop at, and related: (a list) is "If that was not it". Any spelling of the slot's key works: short_answer, shortAnswer and short-answer are the same slot.
---
title: Can I get a refund?
short-answer: Yes, within 30 days.
topic: Billing
featured: true
related: [How do I cancel?, Where is my invoice?]
---
Email support within 30 days of paying and the money is back on your card
within a week.Blog categories
A blog's tags can carry a label, colours, a description, a cover and an icon. You can declare them in the manifest, under the space's tags:, or in a _tags.yml file beside the posts. Where both name the same tag, the manifest wins key by key.
# blog/_tags.yml
releases:
label: Releases
color: '#0a7f5a'
description: Every release, in order.
cover: img/releases.png # uploaded once, like any image
guides:
label: Guides
icon: book-open # a glyph name, or an image path
parent: releasesThese are the keys a tag takes. They are the ones PUT …/blog/tags/{slug} takes: label, color, textColor, colorDark, textColorDark, gradient, description, pageScheme, pagePalette, coverUrl, icon, parent, translations and internal. The common spellings name, colour, cover and image work too. A tag is compared key by key, and only a tag that differs is written. Its posts are never touched, because a post's tags come from its own front matter.
Matching, re-runs and the diff
Everything is matched by slug. A file with no live counterpart is created. A live piece whose words, title, fields or pictures differ is updated in place, at the same address. Anything else is left alone. A second run with nothing changed writes nothing at all.
After --apply, the command plans once more against what is now live. If that plan still has anything to do, the change did not land, and the command lists it and exits 1. Use --no-verify to skip this step.
--diff prints what changed inside each update. For the words, it shows the paragraph as it is live (-) and as the file has it (+), with one paragraph of context. For fields, it shows the old and new value, for example ~ color: "#0a7f5a" → "#123456". The comparison works by paragraph, because a hard-wrapped file and a stored page wrap differently.
--json prints the whole plan for a pipeline to read.
Pictures, uploaded once
An image or file that a post points at, in the body or in front matter, is uploaded the first time it is seen, and the reference is rewritten to its URL. The stored name begins with a hash of the file's contents, so:
a picture used by ten posts is uploaded once;
the same picture saved under another name, renamed or moved is not uploaded again;
a picture you edit gets a new hash. It is uploaded once, and the posts that use it are updated to point at the new copy.
A plan never uploads anything. It names the pictures a real run would upload.
Reserved names
These names are never content. They are left out wherever they are, and the plan lists each Markdown file it skipped and why (for example skip README.md repository paperwork — not content):
Name | Why |
|---|---|
anything starting with | hidden |
anything starting with | private. The two metadata files are read, never published. |
| dependencies |
| the manifest |
| repository paperwork |
README in a docs space. In a docs tree, a folder's README.md is traditionally the folder's own page (GitBook's convention), and docs repositories rely on that. A page-based space therefore keeps it as the folder's page by default. Set readme: skip on the space to leave it out. A post-based space has no folder pages, so there a README is always skipped.
Your own names. ignore: on a space takes globs, relative to the space folder. * matches within one folder, ** matches any number of folders, and a pattern without a / matches a name at any depth:
spaces:
- path: blog
space: blog
ignore: ['*.wip.md', 'archive/**']Drafts
A file with draft: true is never created or published, and the plan says so. It is also never pruned. If you mark a post that is already live as a draft, the live post is left as it is, not deleted. To take a post down, unpublish it in the studio, or delete the file and run with --prune.
Prune
--prune removes what the folder no longer has:
posts and pages with no file go to the trash, where you can restore them;
collections with no folder are removed;
a blog category that has a look but is no longer declared loses its look. Its posts keep the tag.
Prune never removes a draft, anything listed in the manifest's keep:, a page that still has kept pages under it, or anything at all on a run narrowed with --only.
When you push
Git sync can run the same sync on our side whenever you push. Connect the repository to one of its spaces with contentSync on:
PUT /orgs/{orgId}/spaces/{spaceId}/github
{
"repo": "acme/site",
"branch": "main",
"path": "content",
"token": "<a token that can read the repository>",
"contentSync": true,
"contentSyncPrune": false
}path is the folder that holds forumulate.yml. Without a manifest, the folder syncs into the connected space. Add the webhook as Publish from git describes. From then on, a push to the connected branch runs the sync, and a push to any other branch is ignored. With contentSync off, a push runs the docs import as before.
You can run the sync by hand, and see its plan before you switch pushes on:
Call | What it does |
|---|---|
| Queues a run and answers 202 with it. |
| The recent runs, newest first, without their plan lines. |
| One run: |
A few things to know about a run:
Who it acts as. A run acts as the member who started it, or for a push, the member who connected the repository. It uses a credential that is minted for that run alone (read and write, this organization only, one hour). The credential is revoked when the run ends, and it never appears in anyone's token list. A run can do only what that member can do.
The organization. A repository connected here publishes here. An
org:in its manifest is ignored.Size limits. The branch is downloaded as one archive of up to 64 MB, and the connection's
pathis unpacked from it, up to 256 MB and 20,000 files. Symbolic links and paths that climb out of the folder are skipped. A bigger repository should runforumulate syncfrom CI, which reads the checkout it already has.Failure. A run that fails says why in
erroranderrors. The next run plans from what is live, so it picks up where the failed one stopped.
In CI
# .github/workflows/content.yml
name: Content
on:
push: { branches: [main] }
pull_request:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
# On a pull request: the plan, with what changes inside each piece.
- if: github.event_name == 'pull_request'
run: npx -y @forumulate/cli@0.9 sync . --diff
env: { FORUMULATE_TOKEN: '${{ secrets.FORUMULATE_TOKEN }}' }
- if: github.event_name == 'push'
run: npx -y @forumulate/cli@0.9 sync . --apply --prune
env: { FORUMULATE_TOKEN: '${{ secrets.FORUMULATE_TOKEN }}' }The command exits 1 when a run has errors or when the read-back finds a change that did not land. Otherwise it exits 0, including for a plan that has changes in it.