hush-hush-render

Turns a Notion Content Calendar entry into a branded hush_hush_ltd post preview (PNG), and publishes it to a per-client calendar page your clients can click through, no Canva, no manual upload, triggered from n8n.

One shared repo serves every client. Adding a client is just a new client_slug in the data n8n sends, nothing to duplicate here.

templates/   the HTML preview template + the client calendar-page template
scripts/     the render script (HTML -> PNG, updates the client's page)
docs/        what GitHub Pages actually serves publicly. Everything else
             in this repo (scripts, templates, this README) stays private.

Each client ends up with docs/<client_slug>/, containing their rendered previews (previews/*.png), a small JSON manifest of their posts (posts.json), and their calendar page (index.html).


One-time setup

1. Create the repo

Create a private GitHub repo (e.g. hush-hush-render) under your account and push this folder to it.

git remote add origin https://github.com/<your-username>/hush-hush-render.git
git branch -M main
git add .
git commit -m "Initial commit"
git push -u origin main

2. Turn on GitHub Pages

Repo Settings → Pages → Source: Deploy from a branch → Branch: main, folder: /docs. Save.

3. Point your custom domain at it

Pick a subdomain, e.g. clients.hushhushltd.com.

DNS can take a little while to propagate. Once it’s live, a client’s page is at https://clients.hushhushltd.com/<client_slug>/.

4. Generate a token for n8n

n8n needs a token to trigger this repo’s workflow. Settings → Developer settings → Personal access tokens → Fine-grained token, scoped to just this repo, with Contents: read and write and Actions: read and write permissions. Store it as a credential in n8n (HTTP Header Auth, or n8n’s built-in GitHub credential type both work).


How n8n drives this

Trigger a render — HTTP Request node, POST:

https://api.github.com/repos/<your-username>/hush-hush-render/actions/workflows/render.yml/dispatches

Body:

{
  "ref": "main",
  "inputs": {
    "post_data_json": "{...single JSON string, see field mapping below...}"
  }
}

Wait for it to finish — poll GET /repos/<your-username>/hush-hush-render/actions/runs?event=workflow_dispatch until the newest run’s status is completed (an n8n Wait node + loop, a minute apart, works fine, most renders finish in well under a minute).

Write the link back to Notion — once done, the client’s page is simply:

https://clients.hushhushltd.com/<client_slug>/

Write that into the post’s Client Preview property in Notion. No need to fetch or download anything from GitHub, the image is already published.

Field mapping (Notion → post_data_json)

A post is one JSON object with a slides array, one entry per photo. A single photo/video post is just a one-item array; a carousel is the same shape with more items, each one gets its own rendered PNG, and the client page groups them into one calendar entry they can swipe through (with per-slide captions), instead of showing up as separate days.

Notion property post_data_json field notes
Page ID post_id unique per post
Client (relation) client_slug url-safe, e.g. pelos-deli, stays consistent per client, it’s the folder name + URL path
Client (relation) client_name display name, e.g. Pelo's Deli
Platform channel  
Scheduled Date date_iso as Notion gives it, YYYY-MM-DD, drives the calendar grid
Scheduled Date scheduled_date human-readable, format however you like
Content Type content_type Carousel | Video | Photo
Content Pillar theme  
(today’s date) prep_date  
per slide, inside slides[]:    
Creative Asset (one file URL per slide) slides[i].photo_url re-fetch these in the same n8n run, Notion file URLs expire after about an hour
Caption/Copy (split per slide) slides[i].caption this client uses Instagram’s per-slide caption feature, so each slide’s caption is its own string, not one shared caption
Clip URL slides[i].video_link only on a video slide

asset_index/asset_total (“(asset N of X)”) are computed automatically from each slide’s position, nothing to pass in for that.


Testing locally

npm install
POST_DATA_JSON='{
  "post_id": "test-1",
  "client_slug": "pelos-deli",
  "client_name": "Pelo'\''s Deli",
  "channel": "Instagram",
  "date_iso": "2026-09-03",
  "scheduled_date": "Sept 3, 2026",
  "content_type": "Carousel",
  "theme": "Fall Cocktail Menu",
  "prep_date": "Sept 3, 2026",
  "slides": [
    { "photo_url": "https://example.com/sunspill.jpg", "caption": "1/8 — Sun Spill\nOur fall cocktail menu is here..." },
    { "photo_url": "https://example.com/nosaints.jpg", "caption": "2/8 — No Saints\n..." }
  ]
}' npm run render
open docs/pelos-deli/index.html

Adding a new client

Nothing to set up. The first post you render for a new client_slug creates docs/<client_slug>/ automatically. Send them https://clients.hushhushltd.com/<client_slug>/, that’s their whole portal.

A note on access

Client pages are unlisted, not password-protected: robots.txt blocks indexing and noindex meta tags are on every page, but anyone with the exact URL can view it. Don’t publish a directory of client links anywhere public. If you outgrow this later, a free layer like Cloudflare Access can add real login in front of GitHub Pages without rebuilding anything here.