The site at docs.giveth.io. Content lives in Notion; this repo turns it into a static site served from GitHub Pages.
This replaces a paid hosted service that rendered the same Notion workspace. The background, the decisions and the salvaged assets are in NOTION-RENDERER-PLAN.md.
Notion ──► scripts/fetch-notion.mjs ──► content/ + public/notion-assets/
│ │
▼ ▼
scripts/sync-media.sh next build (static export)
│ │
▼ ▼
DigitalOcean Space (videos) gh-pages ──► docs.giveth.io
The built site never contacts Notion. Pages and images are committed to this repo, so a slow or unreachable Notion API cannot produce a half-empty page. Pages are plain HTML files.
Videos and other files live on a DigitalOcean Space, not in the repo — they are too large to commit, and Notion's own links to them expire about an hour after they are issued. The fetch points pages at the Space; CI uploads anything the Space does not have yet.
npm install
npm run fetch-notion # pull the latest content from Notion into content/
npm run build # static export into out/
npm run serve # serve out/ locally
npx serve out # ...or directlyChecks:
node scripts/check-export.mjs # every live URL exists, with chrome and content
npm run check-slugs # the URL rules still behave
npm run check-routes -- http://localhost:3000 # against a running/deployed sitefetch-notion needs network access to Notion. Nothing else does, and reading
Notion needs no credentials — the docs tree is public. Uploading videos needs
Space credentials, and only happens in CI.
Nobody has to run anything. .github/workflows/refresh-content.yml re-fetches
from Notion every three hours, commits any change, and the push triggers
deploy-docs.yml.
To publish immediately, either run that workflow from the Actions tab, or have a Notion automation (or anything else) poke it:
curl -X POST https://api.github.com/repos/Giveth/giveth-docs/dispatches \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer <token with contents:write>" \
-d '{"event_type":"notion-updated"}'The refresh job refuses to publish a suspiciously small tree and leaves the committed content alone if it cannot read Notion, so a bad fetch cannot empty the site.
One-time. Until it is done, fetch-notion stops with an error naming what is
missing, rather than publishing pages with broken videos.
- In
config/media.json, setbucketandregion(e.g.ams3).prefixis the folder files go in;publicBaseUrlis only needed for a custom CDN domain. None of these are secrets. - Create a Spaces access key, and add it to the repository secrets as
DO_SPACES_KEYandDO_SPACES_SECRET. - Enable the CDN on the Space, or set
publicBaseUrlto wherever it is served.
Files are named by a hash of their Notion URL, so each is uploaded once. A video replaced in Notion gets a new name; the old object stays on the Space until someone removes it.
scripts/sync-media.sh --verify checks every hosted file is publicly reachable,
with no credentials. The deploy workflow runs it as a warning.
| Path | Role |
|---|---|
scripts/fetch-notion.mjs |
The build stage. Walks Notion, downloads every asset. |
scripts/slug_map.json |
The live site's hand-set URLs. Irreplaceable — see below. |
content/ |
Generated. One record map per page, plus the site map. |
public/notion-assets/ |
Generated. Every image, hashed by source URL. |
content/media.json |
Generated. Videos and files, and their URLs on the Space. |
config/media.json |
Which Space hosts videos and files. |
scripts/sync-media.sh |
CI step: uploads what the Space is missing. |
config/navigation.ts |
The sidebar, navbar and footer. Hand-maintained. |
app/[...slug]/page.tsx |
Renders any page from content/. |
content/ and public/notion-assets/ are generated but committed on
purpose: that is what lets the build run without Notion.
The live URLs were set by hand in the previous host's dashboard and are not
derivable from page titles — Making a Donation was served at
/projectdonating. Those URLs are indexed and linked from giveth.io, Discord
and blog posts, so they are preserved exactly:
slug = slug_map[page title] || slugify(page title)
scripts/slug_map.json is that override table, keyed by title because the
Notion page ids the previous host stored are all stale. A page created in
Notion today falls through to slugify and gets a working URL with no code
change.
The live sidebar was curated in the previous host's dashboard: 12 sections,
against Notion's 4. Deriving nav from the Notion tree would restructure the
site, so the curated arrangement lives in config/navigation.ts and is edited
by hand.
A page does not need a nav entry to work. Every Notion page is generated at its slug either way; it just will not appear in the sidebar until someone adds it.
scripts/slug_map.json cannot be regenerated: it is the only record of the
live site's hand-set URLs.
The archive recovered from the previous host, super-salvage/, is not part of
the repository. It is gitignored and kept locally for reference. The site does
not need it — the heading font and logos are in public/, the navigation is in
config/navigation.ts, and the design tokens are in app/globals.css. It was
captured while the previous host was still live and cannot be recaptured. Its
last committed version can be restored from history:
git checkout e983b0ef1 -- super-salvage && git restore --staged super-salvageHeadings use TeX Gyre Adventor (GUST Font License, free/libre), self-hosted
from public/fonts/. Only the Bold cut was ever uploaded to the previous host,
and it was declared at font-weight: 400, so every heading on the live site is
the Bold face at normal weight. That is reproduced deliberately — see the
comment in app/globals.css.
Body text is Red Hat Display, also self-hosted.