wftoast
v0.3.1
Published
CLI for migrating a Webflow site to Astro
Downloads
1,199
Readme
wftoast
CLI for the Webflow → Astro migration. Runs a local server on http://localhost:3774
that two browser clients connect to, and writes what they return under webflow/
in the directory you run it from.
pull-cms is the exception: it reads the CMS straight from Webflow's public Data API
with a site token, so it needs no browser client at all.
For pull and fetch-dom, both clients are required and must be set up first:
packages/bridge— the Webflow Designer Extension, for pages and components. It is a Webflow App installed into your Workspace: open it from the Designer's Apps panel and leave the panel open, on the site you're pulling, for the whole run. Nothing to build.packages/extension— the Chrome extension, for each page's DOM and for listing which Designer sites are open. Download it from Releases and load it unpacked atchrome://extensions, with a Designer tab open. (No release exists yet — until one does, build it from source; see its README.)
Commands
wftoast pull
Full extraction. Both clients are required. Before pulling anything it confirms which site you mean and that the bridge panel is open on that site — so a wrong target fails with nothing written, rather than half-way through:
webflow/
site.json # global site settings (extension)
dom.json # homepage's whole /dom (extension)
pages/<pageId>/
settings.json # Webflow's raw settings (extension)
designer.json # Designer API page doc (bridge)
whtml.html # rendered WHTML (bridge)
raw.json # that page's domNodes (extension)
components/<componentId>/
whtml.html # rendered WHTML (bridge)
props.json # (bridge)
variants.json # (bridge)
settings.json # (bridge)
raw.json # nodes from `symbols` (extension)Folders get no directory — they have no DOM, and a page's publishPath already
carries its full nested path, so nothing is lost. They're reported as skipped.
Webflow returns the whole site (~6 MB, including account credentials) on every DOM
call, so the extension keeps only symbols and domNodes inside the tab; none of
the rest reaches disk.
| Flag | Purpose |
| --- | --- |
| --site <shortName\|url> | Which Designer site to use. Required only when several are open. Takes a short name (alex-sandbox-2f4b44) or any Designer URL — only the origin has to match. |
| --wait <seconds> | How long to wait for each client to connect (default 60). |
With exactly one Designer site open, --site can be omitted and the CLI reports
which site it picked.
wftoast fetch-dom [--page <pageId>]
Re-fetches a single page's DOM to webflow/pages/<pageId>/raw.json — for filling a
gap left by a partial pull (a page that 404'd, or a 401 mid-sweep) without
re-sweeping the whole site. Needs only the Chrome extension, so there's no bridge
check. Takes --site and --wait too; --page defaults to whichever page the
Designer tab has open.
wftoast pull-cms
Pulls the CMS and generates Astro content collections from it. This one uses neither browser client — CMS collections and items are in Webflow's public Data API, so it needs a site token instead:
webflow/cms/
site.json # the Data API site record, incl. locales
collections.json
<collectionSlug>/schema.json # fields + validations
<collectionSlug>/items.json # every item, all pages
webflow/
forms.json # form definitions
redirects.json # 301s — Enterprise workspaces only
assets/<fingerprint>_<name> # every referenced file, mirrored verbatim
src/
content.config.ts # registers every collection
content/<collectionSlug>/data.json # the entries
content/<collectionSlug>/index.ts # the collection: loader and zod schema
content/<collectionSlug>/images/ # the images it uses, under their uploaded nameswebflow/ is a re-fetchable mirror of what Webflow gave us. src/content/ is
generated Astro source that belongs in your repo — commit it, don't ignore it. The
collection directory name is the same in both trees, so an entry is traceable to its raw
item by eye.
Forms and redirects sit at the top level because webflow/cms/ mirrors the CMS
endpoints and neither is CMS. A failure on either is a warning, not an abort — a
non-Enterprise site gets no redirects.json rather than an empty one, so "we
couldn't look" stays distinct from "there are none".
| Flag | Purpose |
| --- | --- |
| --token <token> | Webflow site API token. Defaults to $WEBFLOW_API_TOKEN, then .env. |
| --site-id <id> | Webflow site ID. Defaults to $WEBFLOW_SITE_ID, then .env. |
| --astro-dir <path> | Astro project root to write src/content/ into. Defaults to the current directory. |
| --from-cache | Regenerate from a previous run's webflow/cms/ files, without refetching. |
Credentials resolve flags → environment → .env, so the usual setup is a .env
in the directory you run from:
WEBFLOW_API_TOKEN=...
WEBFLOW_SITE_ID=...Mint the token in Webflow at site settings → Apps & integrations → API access
→ Generate API token, with the cms:read, sites:read and forms:read scopes.
Webflow fixes scopes at creation, so mint it with all three: a token without
forms:read still exports the CMS but skips webflow/forms.json with a warning. The
token is never logged or written to disk.
Then check the generated output in the Astro project:
npx astro sync # validates every entry against every generated schema
npx astro check # ...and typechecks your pages against the generated typesTwo stages, and --from-cache re-runs only the second. Items page at 100 against a
per-token rate limit, so refetching a whole site to fix a mapping bug is expensive.
The generated schema deliberately asserts nothing the data can violate — every mapped field is optional, an Option field is a string rather than an enum, and a date that doesn't parse is dropped. Astro validates the data against these schemas at build time, so a constraint that doesn't hold would break your site rather than warn.
⚠️ Webflow's staged CMS is exported, so drafts and archived items are all present and
carry webflow.isDraft / webflow.isArchived. Nothing filters them for you:
await getCollection("blog", ({ data }) => !data.webflow.isDraft && !data.webflow.isArchived);Images are downloaded into the collection that uses them, under the name they were
uploaded with, and typed with Astro's image() — so they go straight to <Image> and get
optimised like any local asset. (sharp must be installed for that; <img src={img.src}>
works without it.)
What doesn't come across, all reported as warnings with a non-zero exit rather than
left to be discovered later: e-commerce collections, secondary locales, taxonomies,
references whose target isn't in the export, field types with no equivalent, redirects on
a non-Enterprise plan, and File fields — which can hold a PDF, so they keep their
remote URL rather than being mistyped as images. Rich text is kept as verbatim HTML,
which renders without Webflow's stylesheet, keeps its <img> tags pointing at Webflow,
and whose embedded scripts no longer have Webflow's jQuery to call. The draft/archived counts are an unconditional Note: rather than a
warning, because nothing was lost, so exit 0 genuinely means no gaps were found.
wftoast mcp
Runs a local MCP server on stdio that serves the migration guides — how to drive
this CLI, and how a Webflow → Astro migration actually goes — to Claude Code
and any other MCP client. It exposes two tools (list_guides, then get_guide for the
slugs that match), one resource per guide (wftoast://<slug>.md), a migrate_site
prompt, and a short server instructions string that tells an agent the tools exist.
Built on the official @modelcontextprotocol/server SDK, so one registration serves
both protocol eras: a modern 2026-07-28 client (per-request _meta,
server/discover) and a 2025-11-25-era client (the initialize handshake, which is
what every shipping client sends today).
It is deliberately read-only — there is no tool that runs pull or pull-cms. The
agent already has a shell, pull needs a live browser and minutes of streaming, and
pull-cms carries a site token. Nothing but JSON-RPC is written to stdout.
Register it with Claude Code:
# in a migration project — pin the version, so a cached npx never serves an older build
claude mcp add wftoast -s user -- npx -y wftoast@latest mcp
# working on this repo itself
claude mcp add wftoast -s user -- node /abs/path/to/packages/cli/dist/index.js mcpOr per project, in .mcp.json:
{
"mcpServers": {
"wftoast": {
"type": "stdio",
"command": "npx",
"args": ["-y", "wftoast@latest", "mcp"]
}
}
}Other clients take the same command / args pair — Claude Desktop, Cursor, Codex
CLI, Copilot CLI and Zed in their own mcpServers JSON; VS Code via
MCP: Add Server… → Command (stdio).
wftoast docs
The same guides, printed to the terminal — for an agent with only a shell, or a human reading along. Both commands call the same handlers the MCP tools do.
wftoast docs list # every guide, with its slug and use cases
wftoast docs get runbook astro/content-collections # one or more guides by slugErrors
This CLI never prompts — it's meant to be driven by AI agents, which have no stdin to answer with. When it needs a decision it exits with a code, a cause, every valid option, and the exact flag to re-run with:
Error [site_not_specified]: 2 Webflow Designer sites are open, so --site is required.
Cause: More than one site is open in the Designer and no --site was given, so the target would be ambiguous.
Detail:
open_designer_sites:
- site-aaa (https://site-aaa.design.webflow.com) "Site site-aaa - Webflow"
- site-bbb (https://site-bbb.design.webflow.com) "Site site-bbb - Webflow"
How to fix:
1. Re-run with: --site site-aaa
2. Re-run with: --site site-bbb
3. Or close the Designer tabs for the sites you are not migrating.Every pre-flight failure happens before any data is written, so a wrong target leaves no partial output.
Individual pages failing during phase 2 is a warning, not an abort — those pages get
no raw.json, the rest still land, and the exit code is non-zero. A 404 is normal
for some CMS/utility pages; a 401/403 means the Webflow session expired, so sign in
again in Chrome and re-run to fill the gaps.
See the root CLAUDE.md for the full list of codes and the wire protocol.
Development
pnpm --filter wftoast run build # src/ -> dist/
pnpm --filter wftoast run start # node dist/index.jsWFTOAST_PORT overrides the server port for local testing only — the browser
clients hard-code 3774, so a real pull can't use another port. WFTOAST_WEBFLOW_API
likewise points pull-cms at a fake Data API so the tests never touch a real site.
