Skip to content

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)

![warehouse floor, a team reviewing a shipment on a tablet](/assets/heroes/industry-logistics-hero.jpg)

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 ![alt](src) 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 an href.
  • Placeholders. No <Insert…>, lorem ipsum, TBD, empty href or missing image src.
  • 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:

  1. turns the annotations into layoutSections.main_content: per-section row metadata (band class, padding, background image), rows of custom_widget modules, and cells for multi-column sections
  2. rewrites ../assets/… to the target portal's File Manager URLs
  3. creates the page as a DRAFT, or PATCHes the existing page with that slug
  4. schedules publish (unless --draft-only), polls until it is live, and records dev_page_id / dev_url in pages.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).