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.

Embed
Preview of the card
<a href="https://forumulate.com/guides/docs-workflow"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/bf4b20bf-36f5-4606-a12f-b957b395a1f9/social-card.svg?v=3e7ae33eec8b" alt="Docs workflow — previews, link checks, analytics and redirects" width="600" height="315"></a>

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

The 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 answered no-store.

  • The link lasts 7 days from the last push (previews.ttl_days, 1–30, or a space's own settings.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

GITHUB_REPOSITORY, GITHUB_REF

GITHUB_TOKEN (with pull-requests: write)

Gitea / Forgejo Actions

the same variables

GITEA_TOKEN or GITHUB_TOKEN

GitLab CI

CI_PROJECT_PATH, CI_MERGE_REQUEST_IID

GITLAB_TOKEN (a project access token; the job token cannot write notes)

Bitbucket Pipelines

BITBUCKET_REPO_FULL_NAME, BITBUCKET_PR_ID

BITBUCKET_TOKEN (a repository access token)

Azure Pipelines

BUILD_REPOSITORY_NAME, SYSTEM_PULLREQUEST_PULLREQUESTID

post previews[0].comment yourself

Anything else

FORUMULATE_PREVIEW_PROVIDER, _REPOSITORY, _NUMBER, _REF, _SHA, _URL

post previews[0].comment yourself

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 with append: true (the CLI does this for you).

  • GET …/previews lists 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.

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 space

On 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 — #section and page#section whose heading does not exist, using the product's own anchor rule (accents folded, duplicates -2, -3, {#custom-id} and HTML id="…" 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 as external_redirect, with where it goes. --allow-redirect same-host,*.acme.com sets the allowed hosts (default same-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-cache asks 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=true lists 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 with cursor).

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.csv exports from,to,status,type,passQuery,auto; POST …/redirects/import reads the same columns (a header is optional; source/destination from other tools work too). mode: merge adds and updates rules, mode: replace makes 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: true reports without writing.

  • For a site, GET|POST /orgs/{orgId}/sites/{siteId}/redirects.csv and …/redirects/import use site paths: /docs/old is routed to the space mounted at /docs.

  • The 404 report. GET …/redirects/not-found lists 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 with redirects.track_not_found (or a space's settings.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.csv

    publish merges them into the table — it never deletes a rule the file does not name — and --dry-run shows 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.

Operation

Request

What it does

Shortcuts

LinkCheck_brokenLinks

GET /orgs/{orgId}/spaces/{spaceId}/docs/broken-links

List the broken links in a space

client.linkCheck.brokenLinks()

Preview Deployment

Operation

Request

What it does

Shortcuts

PreviewDeployment_list

GET /orgs/{orgId}/spaces/{spaceId}/previews

List the pull-request previews of a space

client.spaces.previewDeployment.list()

PreviewDeployment_create

POST /orgs/{orgId}/spaces/{spaceId}/previews

Create or update a pull-request preview

client.spaces.previewDeployment.create()

PreviewDeployment_get

GET /orgs/{orgId}/spaces/{spaceId}/previews/{previewId}

Get one pull-request preview and its link

client.spaces.previewDeployment.get()

PreviewDeployment_revoke

DELETE /orgs/{orgId}/spaces/{spaceId}/previews/{previewId}

Revoke a pull-request preview

client.spaces.previewDeployment.revoke()

Public Preview Summary

Operation

Request

What it does

Shortcuts

PublicPreviewSummary_summary

GET /public/orgs/{orgId}/spaces/{spaceId}/preview/summary

Get what a pull-request preview changes

client.spaces.publicPreviewSummary.summary()

Redirect Manager

Operation

Request

What it does

Shortcuts

RedirectManager_exportSpace

GET /orgs/{orgId}/spaces/{spaceId}/redirects.csv

Export a space’s redirects as CSV

client.redirectManager.exportSpace()

RedirectManager_importSpace

POST /orgs/{orgId}/spaces/{spaceId}/redirects/import

Import redirects from a CSV

client.redirectManager.importSpace()

RedirectManager_notFoundReport

GET /orgs/{orgId}/spaces/{spaceId}/redirects/not-found

Get the 404 report for a space

client.redirectManager.notFoundReport()

RedirectManager_dismiss

DELETE /orgs/{orgId}/spaces/{spaceId}/redirects/not-found

Dismiss a path from the 404 report

client.redirectManager.dismiss()

RedirectManager_exportSite

GET /orgs/{orgId}/sites/{siteId}/redirects.csv

Export a site’s redirects as CSV

client.redirectManager.exportSite()

RedirectManager_importSite

POST /orgs/{orgId}/sites/{siteId}/redirects/import

Import a site’s redirects from a CSV

client.redirectManager.importSite()

Space Analytics

Operation

Request

What it does

Shortcuts

SpaceAnalytics_pages

GET /orgs/{orgId}/spaces/{spaceId}/analytics/pages

Get the most viewed pages of a space

client.spaces.analytics.pages()

SpaceAnalytics_pagesCsv

GET /orgs/{orgId}/spaces/{spaceId}/analytics/pages.csv

Export the most viewed pages as CSV

client.spaces.analytics.pagesCsv()

SpaceAnalytics_search

GET /orgs/{orgId}/spaces/{spaceId}/analytics/search

Get what readers searched a space for

client.spaces.analytics.search()

SpaceAnalytics_searchCsv

GET /orgs/{orgId}/spaces/{spaceId}/analytics/search.csv

Export reader searches as CSV

client.spaces.analytics.searchCsv()

SpaceAnalytics_feedback

GET /orgs/{orgId}/spaces/{spaceId}/analytics/feedback

Get reader feedback for a space

client.spaces.analytics.feedback()

SpaceAnalytics_feedbackCsv

GET /orgs/{orgId}/spaces/{spaceId}/analytics/feedback.csv

Export reader feedback comments as CSV

client.spaces.analytics.feedbackCsv()

The Forumulate teamUpdated