Copy doc to dev page#
With the rules, catalog and previews in place, a page is built from a copy doc without opening HubSpot until the very end.
In Claude Code
The hubspot-page-build skill of the bb-hubspot plugin covers this whole page. It stops at the review gate and publishes to dev only when you ask.
flowchart LR
doc[Copy doc<br/>imports/campaign/slug.md] --> build[build-page.py] --> draft[drafts/slug.html<br/>+ gap report]
draft --> qa[qa-draft.py] --> review{Review gate}
review -- changes --> draft
review -- client go --> pub[publish-page.py] --> dev[Dev portal page]
The copy doc#
One Markdown file per page in imports/<campaign>/<slug>.md, with no layout hints:
---
title: "Industries | Logistics | Example Co"
description: "Example Co helps logistics teams replace legacy platforms and connect every shipment to one view."
type: industry
template: base.html
---
Industries — Logistics
# Logistics Platforms Built to Move at Network Speed
Connect carriers, warehouses and customers on one platform …
[Explore Solutions](/solutions)
[View Case Study](/case-studies/acme-freight)

The Challenge
## Logistics Moves Faster Than Legacy Platforms Allow
### Disconnected Systems
Every team works from its own copy of the truth …
The grammar: frontmatter (title, description, source, type), then an eyebrow line, one # H1, the lead
paragraphs, ## sections each optionally preceded by an eyebrow line, ### items, - bullets, [label](url) links
and  images.
tools/export-copydocs.py (written for each site, like the crawler) writes one copy doc for every crawled live page. That gives you a regression set the
size of the site on day one: if the generator can rebuild every live page at full copy coverage, it can build a
new one. For client review in Google Docs, $BBH/scripts/copydoc-to-docx.py makes a Word copy.
The draft#
python3 tools/build-page.py <slug> [campaign] # the site's own generator
python3 $BBH/scripts/serve-drafts.py 8815 # → http://127.0.0.1:8815/drafts/<slug>.html
The generator picks the archetype, applies the rhythm recipe and the copy-shape table, and writes
drafts/<slug>.html as HubSpot would render it: header, bands, panels, footer, each in the modules' real markup.
Before every section it writes an @section annotation, the single source of layout and field values, already in
Pages API shape:
{"name": "s2", "label": "industry-logistics - Logistics Moves Faster …",
"wrapper": {"class": "site-section site-section--black site-section--fixed",
"padding": [90, 32, 90, 32], "bg_image": "https://…/bg-extended-2.webp"},
"rows": [[{"w": 12, "modules": [{
"path": "cards", "hubspot_path": "/Main Theme/cards", "module_id": 123456789,
"fields": {"header": {"eyebrow": "The Challenge", "title": "Logistics Moves Faster …"},
"cards": [{"card_title": "Disconnected Systems", "…": "…"}],
"style": {"preset": "grid", "theme": {"mode": "dark"}, "columns": "2"}}}]}]]}
It prints one line per section, plus a gap report that cites rule numbers for anything the rules didn't cover,
and registers the page in pages.json.
Hand-written assemblers for one-off pages
When a page needs module settings the generic mapper won't pick by itself, write a small assembler for it
(for example tools/build-<page>.py). It uses the same copy-doc parser, the same module renderer and the same
annotations, but with the section plan written out. It may use only modules that already exist.
QA#
python3 $BBH/scripts/qa-draft.py [slug …] # → reports/qa/<slug>.md + index
- Coverage. Every copy-doc text block of 30 characters or more must appear in the draft's visible text. Near misses are reported as reworded.
- Links. Every
[label](url)must become anhref. - Placeholders. No
<Insert…>, lorem ipsum, TBD, emptyhrefor missing imagesrc. - Structure. Exactly one H1, a closer unless the archetype has none, no empty panels.
The script exits non-zero on missing copy or placeholders, and that blocks the next step. Run it after every generator change. All of the live-page drafts are the regression set.
The review gate#
Hand over the draft, the gap report, and the slug, title and meta description. Then stop. Review rounds happen
on the local draft. Each fix that generalises becomes a new numbered rule in DESIGN-RULES.md.
Publish to dev#
Only when the client asks:
hs filemanager upload assets/heroes /heroes --account <client>-dev # repo-local assets first
python3 $BBH/scripts/publish-page.py <slug> [--draft-only]
The publisher:
- turns the annotations into
layoutSections.main_content: per-section row metadata (band class, padding, background image), rows ofcustom_widgetmodules, and cells for multi-column sections - rewrites
../assets/…to the target portal's File Manager URLs - creates the page as a DRAFT, or PATCHes the existing page with that slug
- schedules publish (unless
--draft-only), polls until it is live, and recordsdev_page_id/dev_urlinpages.json
Then QA the published page against the draft: verify-live.py measures overflow, console errors, failed
requests, empty gaps and WCAG contrast at any width.
The first page is where the API surprises are
The first end-to-end page on a new site takes about half a day, and turned up most of the facts on the
HubSpot facts page: nested columns are cells keyed by their x, section
backgrounds live in row metadata, and the schedule call needs a future date and returns no body.
The registry#
pages.json maps each slug to everything the tools need:
{
"slug": "industry-logistics",
"draft": "drafts/industry-logistics.html",
"copydoc": "imports/industries/industry-logistics.md",
"htmlTitle": "Industries | Logistics | Example Co",
"metaDescription": "Example Co helps logistics teams …",
"template": "Main Theme/templates/base.html",
"type": "industry",
"dev_page_id": "100000000001",
"dev_url": "https://11111111.hs-sites.com/industry-logistics",
"live_page_id": "200000000001",
"live_state": "DRAFT"
}
Optional fields the tools honour: live_slug (the URL on live when it differs from the repo slug), head_html
(sent as the page's Head HTML), source (the live URL, used by the regression gate), and cloned_from (pages
mirrored from live).