Docs workflow — previews, link checks, analytics and redirects
Preview every pull request on your own domain, fail CI on broken links and anchors, read what readers searched for and did not find, and keep old addresses working with bulk redirects and a 404 report.
Docs workflow
Four tools for running documentation like code: a preview link for every pull request, a link checker you can run in CI, the analytics API, and the redirect manager. Each works the same from the CLI, the API and the space's settings in the studio, and none of them is tied to one git host.
Preview deployments per pull request
forumulate publish docs --org acme --space docs --preview pr-123The command plans the folder against the live space exactly as a publish would, and then, instead of publishing, sends the pages the plan would add or change (and, with --prune, the ones it would remove) to a preview named pr-123. It prints a link like https://docs.acme.com/?preview=pv.…: the space as the change would publish it, on the space's own addresses.
Changed pages show their new version; new pages are served at their new address; removed pages show a notice; every other page is the published one.
Nothing is published, indexed, put in a sitemap or a feed. The link is never cached at the edge: a request with
?preview=gets the credential handshake first and is answeredno-store.The link lasts 7 days from the last push (
previews.ttl_days, 1–30, or a space's ownsettings.previews.ttlDays);--preview-ttl <days>asks for less.Running it again with the same name updates the preview and keeps the same link, so the comment on the pull request stays valid.
Revoke it any time: Settings → Pull-request previews → Revoke in the studio, or
DELETE /orgs/{orgId}/spaces/{spaceId}/previews/{previewId}. A revoked link stops at once on every address; pushing the same name again makes a new one.
--json adds previews[], each with urls, the diff summary (pages added, changed, removed, unchanged, and lines added and removed per page) and comment, a ready-made Markdown comment for the pull request.
Commenting on the pull request
Add --comment and the CLI posts that comment on the pull request it is building — and edits the same comment on every later push, found by the hidden <!-- forumulate-preview:<name> --> marker it starts with:
Host | Detected from | Token for the comment |
|---|---|---|
GitHub Actions |
|
|
Gitea / Forgejo Actions | the same variables |
|
GitLab CI |
|
|
Bitbucket Pipelines |
|
|
Azure Pipelines |
| post |
Anything else |
| post |
Ready-to-copy recipes ship with the CLI in examples/: github-action-preview.yml, gitlab-ci-preview.yml and bitbucket-pipelines-preview.yml.
The API
POST /orgs/{orgId}/spaces/{spaceId}/previews—{name, pages: [{slug, markdown, title?, file?}], removed?: [slug], source?: {provider, repository, number, ref, sha, url}, ttlDays?, append?}. Answers{preview, token, urls, comment}. A change too large for one request is sent in several, the later ones withappend: true(the CLI does this for you).GET …/previewslists them (never the token);GET …/previews/{previewId}answers one with its link again.GET /public/orgs/{orgId}/spaces/{spaceId}/preview/summary?token=…— what a preview changes, for a reviewer's script, on any domain, without a session.
Checking links
forumulate check links docs # a folder, before publishing
forumulate check links docs --external # outside links too
forumulate check links --space docs --org acme # a published spaceOn a folder it reads the files the way publish does (forumulate.yaml and every space in it) and reports, by file and line:
internal links —
./setup.md,../guide/install.md,./slug,/slug— that name no page;anchors —
#sectionandpage#sectionwhose heading does not exist, using the product's own anchor rule (accents folded, duplicates-2,-3,{#custom-id}and HTMLid="…"honoured);pictures and files next to a page that are not on disk;
with
--external, outside links that do not answer 2xx, or that redirect to a host you did not allow. Redirects are never followed: a link that now 301s to a parked domain is reported asexternal_redirect, with where it goes.--allow-redirect same-host,*.acme.comsets the allowed hosts (defaultsame-host: http → https, a trailing slash). Requests are paced per host and each verdict is cached for a day (an hour when broken) in~/.cache/forumulate/link-check.json;--no-cacheasks again.
It exits 1 when anything is broken, so it can fail a pull request. --json prints the report; --ignore <text> skips links containing that text.
On a published space (--space) it asks the API, which also knows reference pages: #getPayout and #get-v1-payouts-id are checked against the operations on the page. GET /orgs/{orgId}/spaces/{spaceId}/docs/broken-links (?external=true, ?refresh=true) answers the same report, cached ten minutes, and the studio's content-health panel shows it. The allowed redirect hosts there come from links.check_allowed_redirect_hosts (or a space's settings.linkCheck.allowedRedirectHosts). Outside requests reach public addresses only.
Docs analytics API
Three reports over any date range (from and to take a date or an ISO instant; the default is the last 30 days, the longest 366):
GET /orgs/{orgId}/spaces/{spaceId}/analytics/pages— the most viewed pages, each with its thumbs, plus views per day;GET …/analytics/search— what readers searched for, how often it found nothing;?noResults=truelists only the searches that found nothing;GET …/analytics/feedback— thumbs up and down, a satisfaction percentage, a row per page and the written comments (paged withcursor).
Each has a .csv twin (…/analytics/pages.csv, search.csv, feedback.csv), with any cell a spreadsheet would read as a formula defused. The studio shows the same numbers under Settings → Reader analytics.
Every answer carries measurement: the space's firstPartyAnalytics setting (always, with-consent or off, space → org → default) and what it means for the numbers. Nothing is counted that consent forbade: the reader counts a view or a search only after the reader granted analytics consent wherever a consent manager is active, and with off nothing is recorded for any caller — the reader, the SDK, an agent. See first-party measurement.
Redirect manager
Every space keeps one redirect table (GET|PUT …/redirects). Its from and to are paths relative to the space's mount, not to the host: on a space at yourcompany.com/docs, /api-docs is yourcompany.com/docs/api-docs. A to that leaves the space is an absolute https:// URL. See Map the old URLs. Around it:
CSV out and in.
GET /orgs/{orgId}/spaces/{spaceId}/redirects.csvexportsfrom,to,status,type,passQuery,auto;POST …/redirects/importreads the same columns (a header is optional;source/destinationfrom other tools work too).mode: mergeadds and updates rules,mode: replacemakes the file the whole manual table; the rules the platform wrote when a page was renamed are kept either way. Every row is validated; a file with any bad row, duplicate or loop writes nothing and answers 422 naming the lines.dryRun: truereports without writing.For a site,
GET|POST /orgs/{orgId}/sites/{siteId}/redirects.csvand…/redirects/importuse site paths:/docs/oldis routed to the space mounted at/docs.The 404 report.
GET …/redirects/not-foundlists the paths readers asked for that do not exist, most asked-for first, with up to three suggested pages (by slug and title similarity) to redirect each to. In the studio's Address settings, one click writes the redirect. Only the path and a count are kept — never a query string or who asked — and a path silent for 90 days is forgotten. Turn counting off withredirects.track_not_found(or a space'ssettings.notFound.track).In forumulate.yaml. A space may declare its redirects next to its pages, so a move and its redirect land in the same pull request:
spaces: - path: docs space: docs redirects: # relative to the space's mount: on a space at yourcompany.com/docs this # is yourcompany.com/docs/getting-started → yourcompany.com/docs/quickstart - { from: /getting-started, to: /quickstart } - { from: /v1/*, to: /*, status: 301 } # or: redirects: redirects.csvpublishmerges them into the table — it never deletes a rule the file does not name — and--dry-runshows which would be created or updated.
The API changelog from OpenAPI resyncs
Each resync of a registered OpenAPI spec writes what the API did — "Added POST /v1/payouts/batch", "Deprecated GET /v1/payouts", "Removed …", "Changed response of …" — to a changelog page or space, and sends the openapi.changed webhook with the structured diff. See Importing → Re-sync changelog.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.
Link Check
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List the broken links in a space |
|
Preview Deployment
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List the pull-request previews of a space |
| |
| Create or update a pull-request preview |
| |
| Get one pull-request preview and its link |
| |
| Revoke a pull-request preview |
|
Public Preview Summary
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get what a pull-request preview changes |
|
Redirect Manager
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Export a space’s redirects as CSV |
| |
| Import redirects from a CSV |
| |
| Get the 404 report for a space |
| |
| Dismiss a path from the 404 report |
| |
| Export a site’s redirects as CSV |
| |
| Import a site’s redirects from a CSV |
|
Space Analytics
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the most viewed pages of a space |
| |
| Export the most viewed pages as CSV |
| |
| Get what readers searched a space for |
| |
| Export reader searches as CSV |
| |
| Get reader feedback for a space |
| |
| Export reader feedback comments as CSV |
|