π’ Live and wired end-to-end. Custom pages are the Client-website
authoring model. Each page is hand-coded HTML/CSS living as files on disk
(custom_pages/{org_id}/{slug}/), bind-mounted read-write into the hub so edits
serve live on save β no rebuild. Live hub features (Client login, lead form,
gallery, booking) drop in through server-expanded{{gb:...}}shortcodes, so
a flat file still gets real action URLs and the org's data. Pages can be filed
into folders, and a folder can publish as its own standalone, separately
branded website. The old visual section-builder was removed β custom pages
and folder-sites are the model.
Audience: π οΈ Builder / operator Β· π§βπ» Engineer Β· π£ Marketer
See also: Blog & SEO Β· Marketing CC Β·
Showcase Β· the glossary (house terms: a unit of work is a
Job; the person you build for is a Client).
| Badge | Meaning |
|---|---|
| π’ | Live and wired end-to-end |
| π‘ | Live but partial / caveated |
| βͺ | Built but dormant (code exists, nothing drives it by default) |
| β | Not built |
A custom page is a bespoke, hand-coded HTML page attached to a Client's
website. It is the escape hatch for when the themed builder sections don't fit
β a financing page, a one-off sales landing page, a service deep-dive, an imported
pixel-signed-off mock.
The important design decision: the visual/templated section-builder was
removed. A Client's website is now built from two surfaces only β
Both still render through a kind='site' LandingPage row, which owns the
site slug, subdomain, custom domain and the public shell (nav + footer + theme).
That substrate is created on demand and never edited by a builder UI β it's just
plumbing (_ensure_site, app/routers/website.py:46). If an org has no site row
yet, opening the website manager mints a minimal, unpublished one so the
custom-pages manager always works.
A page is served publicly at /page/{slug}, wearing the site's nav, footer and
theme. The file body is dropped into the shell's <main> as a single custom
section, so nav/footer/theme/Client-login all come from the shared shell
(_render_custom, app/routers/landing_portal.py:335).
Content lives on disk β one folder per page β so it is editable and git-trackable
outside the database, and serves live with no rebuild:
custom_pages/{org_id}/{slug}/
index.html # the page body β rendered inside the site shell's <main>
style.css # optional, injected as a <style> block ahead of the body
.versions/ # editor snapshots (last 12), one dir per save
The root is BASE (app/services/custom_pages.py:37), which defaults to
<repo>/custom_pages and is overridden in production by the CUSTOM_PAGES_DIR
env var to /app/custom_pages. The directory is a bind-mount, declared in
docker-compose.prod.yml:
environment:
CUSTOM_PAGES_DIR: /app/custom_pages
CUSTOM_PAGES_OWNER: "1000:1000" # hand scaffolded files to the host user (SSH-editable)
volumes:
- ./custom_pages:/app/custom_pages # RW: operator-authored custom pages, live-editable on save
Two consequences worth internalising:
custom_pages/ is mounted straight into thewrite_html / write_parts just write the fileapp/services/custom_pages.py:315, :381)._reownapp/services/custom_pages.py:49) chowns newly scaffolded files toCUSTOM_PAGES_OWNER (1000:1000) β but only when running as root, no-op onThe CustomPage DB row (app/models/custom_page.py) is only the index: it
carries the publish flag, slug, nav/SEO metadata and folder assignment. The
custom_pages.py service owns the disk side; the row and the files are kept in
sync by going through the service, never by editing the table by hand.
Trust model: these files may contain full HTML/JS. This is an operator-only
lane β pages are rendered with |safe and are not nh3-sanitized (unlike the
tenant-facing AI-HTML block). Authoring is gated to the org owner
(app/services/custom_pages.py:17, and the owner-gated routes in Β§7).
There are three authoring paths; all write to the same files, so mixing them
is fine.
gobuild-page skill (scaffold β author β wire β publish) π’The gobuild-page skill (.claude/skills/gobuild-page/SKILL.md) drives the whole
flow end to end. It:
list, folders).create SITE --slug S --title "T" --folder "Path"), writing a starter index.html + a draft DB row.custom_pages/{org_id}/{slug}/index.html, staying on-brand with theme CSSlint to catch unknown shortcodes, render topublish SITE --slug S) and reports the live URL.The skill drives a small CLI, scripts/custom_page.py, run inside the hub
container β it owns the DB row + scaffold + checks so the model never touches the
DB directly. Authoring conventions the skill (and any author) must respect:
<html>/<head>/nav/footer; the shell supplies--pri (brand colour), --dark, --soft, --radius, --btn-radius,--font-display, --font-body, --sec-pad. Tailwind utilities and thegb-card / gb-zoom / gb-eyebrow / font-display / reveal helper classesThe owner can edit a page's index.html + style.css, per-page SEO, and nav in
the browser at /portal/website/custom/{id}/edit (edit_custom,
app/routers/website.py:353). It ships a live preview, a shortcode reference, and
version history β a snapshot is kept on every save (last 12), with one-click
restore (snapshot / list_versions / restore,
app/services/custom_pages.py:344β412; restore_custom route
app/routers/website.py:402).
Because the directory is bind-mounted, editing
custom_pages/{org_id}/{slug}/index.html directly (repo, SSH, IDE) and saving is
enough β the change is live on the next request. The in-hub editor and direct file
editing write the same files, so either is fine.
The starter file that scaffold writes (app/services/custom_pages.py:415)
is a themed hero section plus a documented list of {{gb:...}} shortcodes and a
live {{gb:lead_form}} β a working page from the first save.
A page is created as a draft (published=False). Toggling publish is a single
route (toggle_custom, app/routers/website.py:316) that flips cp.published. A
published, nav-visible page also appears in the public site nav automatically
(nav_entries, app/services/custom_pages.py:281; merged into the shell nav at
app/routers/landing_portal.py:201).
Draft pages are still viewable via ?preview=1 before publish. The publish wizard
posts via fetch and gets a JSON reply so it can show a success step instead of a
full reload.
Renaming a page changes its display title / nav label but deliberately leaves
the slug (live URL) untouched, so existing links never break
(rename_custom, app/routers/website.py:299).
Custom pages are static HTML, but often need live hub features. Those need
server-side wiring β correct action URLs, CSRF, the org's data β which can't live
in a flat file. So the author drops a placeholder and the server expands it into a
trusted, themed partial at render time
(app/services/shortcodes.py, expansion at _render_custom,
app/routers/landing_portal.py:369).
{{gb:client_login}} Client login β real OTP (texts a code)
{{gb:lead_form title="Request a quote"}} lead capture β the org's CRM (source=Website)
{{gb:contact}} alias of lead_form (id="contact" anchor)
{{gb:chat}} / {{gb:messenger}} GoBuild Live chat widget
{{gb:gallery limit="6"}} the org's project photos
{{gb:reviews limit="3"}} testimonials from the site content
{{gb:services limit="6"}} service cards from the site content
{{gb:booking href="/book"}} CTA to the booking page
{{gb:cta label="Get a quote" href="..."}} themed call-to-action band
{{gb:business field="phone"}} inline text: phone | email | areas | hours
Key facts:
REGISTRY (app/services/shortcodes.py:129) maps 11 namescontact and messenger aliases) to handlers; widget markuptemplates/partials/landing/shortcodes/*.html (8 partials β business{{gb:lead_form}} widget posts into the live org's CRM with source=Website{{gb:client_login}} runs the real OTP login.<!-- ... --> are left as-is, so theexpand, app/services/shortcodes.py:191).lint (app/services/shortcodes.py:148) reports it for thebuild_context, app/services/shortcodes.py:160).π‘ Shortcode coverage is finite. Only the 11 registered names expand; any
other live feature must either be added to the registry or hand-wired (and
hand-wiring to real endpoints is explicitly discouraged). The context is drawn
from the main site's home-page sections, so a folder-site's shortcode data
(gallery/services/reviews) reflects the org's main site content, not the folder.
A single Client can have many websites' worth of pages, so the page library is
organized into nested folders (CustomPageFolder). Folders are organizational
metadata β page files still live flat on disk at custom_pages/{org_id}/{slug}/
regardless of folder (folder_outline, app/services/custom_pages.py:250).
A folder can BE its own website. Each folder can publish as a fully
independent, separately branded site β its own name, theme/palette, a designated
home page, and its own subdomain ({label}.gobuild.ca) or custom domain
(save_folder_site, app/routers/website.py:221; disk/service helpers
folder_home / folder_nav / folder_site_origin / folder_is_live,
app/services/custom_pages.py:195β238).
On that folder's domain the site shows only that folder's published pages,
under its own brand, with a folder-scoped nav (the home page at /, other pages at
/page/{slug}). This is what a "microsite" is: a folder with a subdomain and a
home page.
| Custom page | Folder-site | |
|---|---|---|
| Unit | one HTML page | a folder of pages published together |
| Served at | /page/{slug} on the org's main site |
its own subdomain / custom domain |
| Brand/theme | inherits the main site shell | its own name, theme, palette |
| Nav | merged into the main site nav | scoped to just the folder's pages |
| Home | n/a | a designated home_page_id, served at / |
Folder-sites are also the unit the Blog & SEO surfaces
attach to β each folder-site gets its own blog and its own SEO score
(_folder_seo_badges, app/routers/website.py:157). The
Showcase importer builds folder-sites of custom pages
directly (admin_api.py:591+): it creates a folder, writes each mock page's HTML,
publishes them, and picks a home.
Imported full-page mocks are a special case: if a page's HTML starts with
<!doctype/<html> (a complete document, e.g. a signed-off Showcase mock), it is
served verbatim β shortcodes already expanded β skipping the site shell so the
Client's approved design stays pixel-identical (_render_custom,
app/routers/landing_portal.py:375). Fragment pages fall through to the themed
shell. Such folder-sites also get the PWA installable
manifest/service-worker injected when the org opts in.
There is a third, narrower shape: a custom block β one hand-coded section
dropped into an otherwise-themed page, stored under {org}/_sections/{ref}/
(section_dir / read_section / scaffold_section,
app/services/custom_pages.py:490+). It renders inside a builder page with theme
vars + shortcodes intact. Given the section-builder was removed, this is a
carry-over surface; no _sections/ directories exist on disk today, so treat it as
built-but-unused.
Custom-page authoring is owner-gated. The website router requires the
marketing entitlement and a PM/office role
for the manager, and the create/edit/publish/delete/folder routes add
Depends(require_portal_role()) (owner) on top
(app/routers/website.py:29, and per-route dependencies=[...]). Field role is
excluded β the website builder and custom domain are an office/marketing surface.
Path safety: slugs are validated against SLUG_RE
(app/services/custom_pages.py:40) before being joined into a path, so a page
directory can never traverse outside BASE/{org_id}.
| Thing | Where |
|---|---|
| Disk service (scaffold/read/write/versions/folders) | app/services/custom_pages.py |
| DB models | app/models/custom_page.py (CustomPage, CustomPageFolder) |
| Manager + editor routes | app/routers/website.py |
| Public render | _render_custom, app/routers/landing_portal.py:335 |
| Shortcode engine + registry | app/services/shortcodes.py |
| Shortcode widgets | templates/partials/landing/shortcodes/*.html |
| In-hub editor URL | /portal/website/custom/{id}/edit |
| Public page URL | /page/{slug} (org site) Β· / or /page/{slug} (folder-site) |
| Authoring skill | .claude/skills/gobuild-page/SKILL.md + scripts/custom_page.py |
| On-disk root | custom_pages/{org_id}/{slug}/ (CUSTOM_PAGES_DIR) |
| README | custom_pages/README.md |