Rebuilds content management around a single admin route (/admin — "BlackDice Studio"), replacing blackdice-studio.html. Publishing writes one JSON content document instead of regenerating HTML files, so it can no longer overwrite hand-made site changes the way the old tool did. - /admin: click-to-edit copy/images, article CRUD with a Word-safe rich text editor, demo clip management, per-page SEO, enquiry log, publish history with rollback - Real per-page URLs for all pages and articles, each with its own meta/canonical/OG/JSON-LD - Newsroom + article pages driven by the CMS post library, seeded from the old studio's export (17 articles) plus a drafted GSMA Open Gateway press release awaiting approval - Demo sections on Mobile SDK and Halo CPE, interactive by default and upgradable to an uploaded clip per slot - Dependency-free Node API server (auth, publish, uploads, snapshots, leads, live sitemap) - Deployment configs for Node/nginx/IIS, Vercel and Netlify See docs/ARCHITECTURE.md, docs/PROJECT-STRUCTURE.md, docs/CMS-GUIDE.md and docs/DEPLOYMENT.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 KiB
BlackDice Website — Architecture
This document explains how the system is put together: the runtime pieces, how
content flows from the CMS to a visitor's browser, and why the design avoids the
failure mode that broke the old blackdice-studio.html tool.
For editors, see CMS-GUIDE.md. For hosting, see DEPLOYMENT.md. For a directory-by-directory reference, see PROJECT-STRUCTURE.md.
1. System overview
┌─────────────────────────────┐
│ Visitor's browser │
│ ┌─────────────────────────┐ │
│ │ React app (Vite build) │ │
│ │ src/main.tsx router │ │
│ └───────────┬─────────────┘ │
└──────────────┼─────────────────┘
│ HTTPS
┌──────────────────┴───────────────────┐
│ server/index.mjs │
│ (Node, zero dependencies) │
│ │
│ • serves dist/ (the built SPA) │
│ • serves /content/site-content.json │
│ • serves /content/uploads/* │
│ • /api/* (auth, publish, leads, …) │
│ • /sitemap.xml, /robots.txt (generated) │
└──────────────────┬───────────────────┘
│ reads / writes
┌──────────────────┴───────────────────┐
│ content/ │
│ site-content.json ← published copy │
│ versions/*.json ← one per publish │
│ uploads/* ← images & clips │
│ leads.jsonl ← form submissions │
└────────────────────────────────────────┘
Two things run in production:
- A static build (
dist/) — the React app, compiled once. - A small Node server (
server/index.mjs) — serves that build, serves the published content document, and exposes the API that/admincalls to publish.
There is no database. The published state is one JSON file plus a folder of uploaded media, both on disk.
2. The core design decision: content is data, not markup
The previous tool (blackdice-studio.html) worked by exporting HTML — every
save cloned the live DOM into a new index.html and rebuilt blog.html from a
template string. The export was the site. Anything a developer had hand-edited
into those files — contact-form wiring, SEO tags, routing — was silently discarded
on the next export, because there was only one copy of the truth and the CMS owned
it entirely.
This project splits that single copy into two layers that never overwrite each other:
| Layer | What it holds | Who owns it | Where it lives |
|---|---|---|---|
| Markup & code | Page structure, CSS, the router, the enquiry-form logic, the demo players | Developers, via git | src/, built into dist/ |
| Content | Copy, images, articles, demo clips, SEO text, settings | Editors, via /admin |
content/site-content.json |
/admin never writes to src/ or regenerates any HTML file. Publishing writes
one JSON document. A developer can change the page structure at any time
without touching content, and an editor can change all the content at any time
without touching structure. Requirement 1 (publishing wiping developer work)
is therefore impossible by construction, not by discipline.
How the two layers merge
src/cms/store.tsx resolves content in three ordered layers, each overriding the
one before:
- The build —
src/site/siteMarkup.txt(page HTML with CMS hooks baked in) andsrc/cms/pages.ts(routes, default SEO, demo slot definitions). - Factory content —
src/cms/generated/seedContent.json, the one-time import of the old studio's article library. This is what a fresh install shows before anyone publishes anything. - Published content —
content/site-content.json, fetched at page load and merged over the first two.
If the API is unreachable (offline, static hosting with no server), the site still renders layers 1–2 rather than failing — see §7.
3. The CMS hook system
Every editable region in the page markup carries a stable attribute, injected once by a script rather than hand-added:
| Attribute | Marks | Example |
|---|---|---|
data-cms="cNNNN" |
An editable rich-text region (its innerHTML) |
<h1 data-cms="c0013"> |
data-cms-img="imgNNN" |
A replaceable image (its src) |
<img data-cms-img="img001"> |
data-cms-num="nNNN" |
An animated statistic (data-count / data-suffix) |
<div data-cms-num="n001"> |
scripts/inject-cms-ids.mjs parses siteMarkup.txt, walks the DOM tree, and:
- Assigns an id to every heading, paragraph, list item, button label, image and
animated stat that isn't already inside another editable region (so a
<button>containing only text becomes one field, not one field per nested<span>). - Skips elements the runtime rewrites itself (the live threat feed, React mount points) — editing them there would be silently discarded.
- Is idempotent: re-running it after a markup edit keeps every existing id and only assigns new ones to new elements. This is verified in CI-equivalent fashion by asserting the stripped output is byte-identical to the pre-injection source.
- Emits
src/cms/generated/cmsFields.json— a manifest of every field (id, page, tag, text preview) that the admin's Pages panel uses to list and jump to fields.
652 fields exist today across 12 pages (625 text, 15 image, 12 animated stats).
Content editing therefore never touches siteMarkup.txt — it only ever writes
{ "c0013": "<b>new copy</b>" } into the published JSON, which is applied at
render time by applyContent() (src/cms/store.tsx) doing
el.innerHTML = override for each id present in the document.
4. Runtime rendering: legacy markup + React islands
The site's page markup was originally a single hand-authored HTML body (from the
Claude-built prototype). Rather than rewrite ~4,000 lines of markup into JSX
(high risk of visual regression, no material benefit), SiteApp.tsx renders it
as a raw string via dangerouslySetInnerHTML, then:
- Applies CMS overrides into that DOM (
applyContent). - Boots the ported vanilla controller (
siteController.js— page show/hide, nav highlighting, the enquiry-form modal, the live threat-feed animation, count-up animations). Its functions are exposed onwindowbecause the legacy markup's inlineonclick="goPage(event)"handlers need to resolve. - Portals React components into named mount points left in the markup —
<div id="news-grid-mount">,<div id="demos-p2-mount">, etc. — usingcreatePortal. This is how genuinely dynamic UI (the newsroom grid, article pages, demo tabs, the embedded threat-demo slider) lives inside the legacy DOM as idiomatic React, sharing the router and the CMS content context.
// src/site/SiteApp.tsx (shape, simplified)
<div dangerouslySetInnerHTML={{ __html: siteHtml }} />
{mounts['news-grid-mount'] && createPortal(<NewsGrid />, mounts['news-grid-mount'])}
{mounts['demos-p2-mount'] && createPortal(<DemoSection page="p2" .../>, mounts['demos-p2-mount'])}
{articleSlug && createPortal(<ArticlePage slug={articleSlug} />, mounts['pg-article'])}
/admin reuses the exact same SiteApp component to render its live preview,
passing the in-memory draft instead of the published document
(previewContent={draft}). The preview is not a simulation — it is the real
site, which is why "what you see in Studio is what visitors get" is literally
true rather than a design goal.
5. Routing
src/main.tsx defines one real <Route> per page (from src/cms/pages.ts), plus
/blog/:slug for articles, /admin for the CMS, and the standalone demo routes
inherited from the original Angel demo build.
Within the legacy markup, all twelve "pages" are <div id="pg-p1"> … <div id="pg-p12"> siblings that were originally shown/hidden by a hash-router. That
internal switch is preserved (showPage() in siteController.js, driven by
data-page attributes and id="pg-*"), but it now sits behind the real
router rather than being the only router:
- Clicking a nav item calls the legacy
go('p2')→siteController.jscallshistory.pushStatevia the injected navigator (setSiteNavigator, wired touseNavigate()). - React Router owns the URL; a route change re-renders
<SiteApp pageId="p2">; an effect callsshowPage('p2')to do the actual show/hide in the DOM. - Back/forward, direct URL loads and shared links all go through React Router first, so they work correctly without any special-casing in the legacy code.
Every route updates document.title, the meta description, canonical link, and
OG/Twitter tags via src/cms/seo.ts, and pushes a HubSpot virtual pageview.
Articles additionally emit BlogPosting JSON-LD; the home page emits
Organization JSON-LD.
Because these are real paths (not hash fragments), a host needs an SPA fallback
for deep links — provided for server/index.mjs, Vercel (vercel.json), Netlify
(public/_redirects) and IIS (public/web.config).
6. The API server
server/index.mjs is a single file with no npm dependencies, using only
Node's http, fs, crypto and path built-ins. That constraint is deliberate:
it can run anywhere Node runs, with no install step beyond Node itself, no
supply-chain surface, and no framework version to keep patched.
| Route | Auth | Purpose |
|---|---|---|
POST /api/login |
— | Password → session token (throttled 10/15min/IP) |
GET /api/content |
— | Published content document |
POST /api/content |
session | Publish a new content document (snapshots the previous one first) |
GET/POST /api/versions* |
session | List / load publish snapshots |
POST /api/upload |
session | Store a base64 data-URL as a file, return its URL |
POST /api/leads |
— | Record an enquiry-form submission |
GET /api/leads |
session | List recorded enquiries |
GET /sitemap.xml |
— | Generated live from published + factory posts |
GET /content/uploads/* |
— | Serves uploaded media (range requests supported, for <video> seeking) |
* (fallback) |
— | Serves dist/index.html — the SPA fallback |
Sessions are HMAC-signed expiry.signature tokens (crypto.createHmac), not
JWTs — there's exactly one claim (an expiry), so a JWT library would be pure
overhead. Publishing is atomic: the new document is written to a .tmp file
and rename()'d into place, so a reader can never observe a half-written file,
and the previous document is snapshotted to content/versions/ before being
replaced (last 60 kept), which is what powers one-click rollback in the History
tab.
Uploads are capped at 32MB, restricted to images/video/PDF by content type,
and images are downscaled client-side before upload (fileToOptimisedDataUrl in
src/cms/api.ts) — the old studio embedded 8000px camera originals as base64
directly in the HTML, which is most of why its exports were 20–27MB.
7. Resilience
The site is designed to degrade gracefully rather than fail hard:
- API unreachable (
fetchPublished()insrc/cms/api.ts) → the site renders factory content instead of a blank page. A 4-second timeout prevents a slow or hung API from blocking first paint indefinitely. - Nothing published yet → same fallback; a fresh install is a working site
from the moment
npm run buildfinishes. - Editor's browser closes mid-edit → the draft is mirrored to
localStorage~600ms after each change (useDraft.ts) and restored on next visit, keyed to the published document it was based on (so a stale local draft from before someone else's publish is never silently reapplied). - A field is deleted from the markup (e.g. a section is removed by a developer) → its override in the content document simply has no matching element and is a no-op; nothing throws.
- Two editors publish at once → last write wins (there is no lock), but every publish is snapshotted, so the loser's work is one click away in History, not gone.
8. Content model
src/cms/types.ts defines the single document every publish writes:
interface SiteContent {
content: Record<string, string> // data-cms id → innerHTML
images: Record<string, string> // data-cms-img id → src
numbers: Record<string, {value:number,suffix?:string}> // data-cms-num id → value
posts: Post[] // the article library
demos: Record<string, DemoSlot> // demo-slot key → clip config
seo: Record<string, PageSeo> // page id → title/description/OG image
settings: SiteSettings // form recipient, site URL, ...
}
A Post carries slug, title, category (insights/news/press/events), date,
author, excerpt, hero, sanitised HTML body, and status
(draft/published). Drafts are reachable by direct link (for review) but
excluded from listings, the sitemap, and get a noindex robots tag.
9. Article body sanitisation
Editors paste directly from Word and Outlook, which carries MsoNormal classes,
Aptos fonts, and black-on-transparent inline colours that fight the site's dark
theme. Two sanitisers — kept in step — strip this:
scripts/seed-content.mjs(regex-based) for the one-time bulk import from the old studio's JSON export, where no DOM is available (a plain Node script).src/cms/sanitise.ts(DOMParser-based) for live editing in the browser, run on paste and again on blur.
Both keep only semantic tags (h2–h4, p, lists, strong/em, links, images,
tables, blockquotes), strip every inline style and class, demote h1→h2 (an
article shouldn't compete with the page's own heading), and collapse empty
elements left behind by unwrapping <span>/<div> wrappers.
10. The demo system
Two things exist for demos so a page is never empty and a video can be added without a code change:
- Interactive fallback — the Mobile SDK's five detections (scam call, scam
SMS, SIM swap, per-device DNS, permissions) reuse
ThreatCinematic(src/demo/threats/ThreatDemo.tsx), the same phone-mockup component used in the standalone/threat-demosslider, rendered in abaremode with no frame or backdrop. - CMS clip — once a video is uploaded in Studio → Demos for a slot,
DemoSlotgains avideoURL andDemoSection(src/site/demos/DemoSection.tsx) switches from the interactive fallback to<video>for that tab, no redeploy needed.
src/cms/pages.ts declares the slot ↔ page ↔ interactive-scenario mapping once,
shared by the site and by the Demos admin panel.
11. Build & tooling
| Tool | Role |
|---|---|
| Vite | Dev server + production bundler. SiteApp, DemoScreen, DemoVideoPage and AdminApp are separate lazy chunks so the site's CSS/JS never loads on demo-only routes, and vice versa. |
TypeScript (strict) |
npm run build runs tsc --noEmit first — a type error fails the build before Vite even starts. |
| React Router v6 | Client-side routing (§5). |
| Framer Motion | Animation in the demo components only. |
| No CSS framework | The site's look is the original hand-authored styles.css; Studio's chrome is a separate, fully namespaced (.bdcms-) stylesheet so the two can never collide. |
No test runner is configured; correctness is currently established by
tsc --noEmit plus the manual/browser verification described in
DEPLOYMENT.md's release checklist.
12. Threat model / trust boundaries
/adminis the only privileged surface. It is password-gated (ADMIN_PASSWORD), sessions expire after 12 hours, and failed logins are throttled per-IP.- Article bodies and inline text are sanitised on the way in (§9), not just
escaped on the way out — an editor pasting a compromised Word document can't
inject a
<script>into the published site. - Uploads are type-checked by declared MIME (not by trusting the filename extension) and size-capped.
- There is deliberately no way for the CMS to write into
src/or execute arbitrary code — the API's write surface is exactlycontent/.
13. Deliberate non-goals
- No database. A JSON file plus a folder of uploads is sufficient at this
scale, is trivial to back up (
docs/DEPLOYMENT.md), and removes an entire class of operational dependency. - No multi-user editing / locking. Last publish wins; snapshots make that an acceptable trade-off for a small editorial team rather than a public wiki.
- No SSR/SSG. The site is client-rendered; this trades some first-paint SEO
performance for radical simplicity in the content pipeline. If that trade-off
needs revisiting (e.g. Core Web Vitals pressure), the content model in
src/cms/types.tsdoes not need to change — only the rendering entry point would.