Files
react-website/docs/ARCHITECTURE.md
Mehboob Khan 63a36e0dca Initial commit: BlackDice site rebuild with integrated CMS
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>
2026-08-12 23:52:44 +05:00

18 KiB
Raw Permalink Blame History

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:

  1. A static build (dist/) — the React app, compiled once.
  2. A small Node server (server/index.mjs) — serves that build, serves the published content document, and exposes the API that /admin calls 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:

  1. The buildsrc/site/siteMarkup.txt (page HTML with CMS hooks baked in) and src/cms/pages.ts (routes, default SEO, demo slot definitions).
  2. Factory contentsrc/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.
  3. Published contentcontent/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 12 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:

  1. Applies CMS overrides into that DOM (applyContent).
  2. 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 on window because the legacy markup's inline onclick="goPage(event)" handlers need to resolve.
  3. Portals React components into named mount points left in the markup — <div id="news-grid-mount">, <div id="demos-p2-mount">, etc. — using createPortal. 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.js calls history.pushState via the injected navigator (setSiteNavigator, wired to useNavigate()).
  • React Router owns the URL; a route change re-renders <SiteApp pageId="p2">; an effect calls showPage('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 2027MB.


7. Resilience

The site is designed to degrade gracefully rather than fail hard:

  • API unreachable (fetchPublished() in src/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 build finishes.
  • 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 (h2h4, p, lists, strong/em, links, images, tables, blockquotes), strip every inline style and class, demote h1h2 (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-demos slider, rendered in a bare mode with no frame or backdrop.
  • CMS clip — once a video is uploaded in Studio → Demos for a slot, DemoSlot gains a video URL and DemoSection (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

  • /admin is 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 exactly content/.

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.ts does not need to change — only the rendering entry point would.