A server-rendered Next.js markdown viewer optimized for direct .md URLs and AWS Amplify hosting.
- Live preview: https://markdown-amplified.ehlers.tv
- Public source repository: https://github.com/Xykon/markdown_amplified
This project renders markdown documents from Amazon S3 or the content/ directory with a fallback to content.default/ when content/ has no markdown files.
- GitHub-style syntax highlighting (light and dark)
- KaTeX math support
- Mermaid diagrams with toolbar and fullscreen zoom
- Table of contents sidebar — collapsible tree, active heading tracking, desktop/mobile toggle
- Site map sidebar — BFS-based content discovery, password-aware, shows loading state while indexing
- Download button for source markdown files
- Light/dark theme toggle with persistence
- Frontmatter-driven SEO metadata (title, description, keywords, canonical, robots, Open Graph image)
- JSON-LD structured data injection from frontmatter
- Built-in
robots.txt,sitemap.xml, andllms.txtendpoints for search engines and AI agents - Per-file and per-directory security: password protection and date-range gating
- Optional persistent password cookies (cross-session, cross-subdomain)
- Home button configurable per file/directory; optional site root globe button
- Admin interface: browse, upload, delete, and manage security rules at
/admin - Optional S3 content backend (set
S3_BUCKET— no local content files needed)
- Project Goals
- How Routing Works
- Features
- Project Structure
- Local Development
- Build
- Content Security
- Home Button
- Navigation Sidebar
- S3 Content Backend
- Deployment to AWS Amplify
- S3 Deployment Workflow
- Public Upstream + Private Production Workflow
- Syncing Public Changes into Private Repo
- Operational Notes
- Troubleshooting
- Keep hosting simple: server-rendered on AWS Amplify, no separate backend required
- Keep content editing simple: drop markdown files into
content/ - Support direct URL access to markdown routes such as
/my-file.md - Preserve good readability across desktop/mobile and light/dark modes
- If
content/has markdown files, those files render as the live site. - If
content/is empty, the site falls back tocontent.default/. content/index.mdrenders at/- Every
content/*.mdfile renders at/<filename>.md - Dynamic route is handled by
app/[...slug]/page.js - URL segments are decoded before file lookup so encoded URLs work
Examples:
content/sample.md->/sample.mdcontent/Message Design (Detailed) abc123.md->/Message%20Design%20(Detailed)%20abc123.md
- Markdown rendering with GFM support
- Syntax highlighting via
rehype-highlight - KaTeX math rendering via
remark-math+rehype-katex - Mermaid diagrams rendered client-side with:
- Copy SVG
- Download PNG/SVG
- Fullscreen overlay with zoom controls
- Sticky header with:
- Table of contents toggle button
- Site map toggle button
- Home button (configurable target)
- Optional site root globe button
- Download source button
- Theme toggle
- Navigation sidebar:
- Table of contents panel — collapsible tree, active heading tracking
- Site map panel — BFS-based discovery of all linked files
- Desktop: both panels can be open simultaneously
- Mobile: exclusive drawer (one panel at a time)
- Responsive layout with sidebar on desktop and drawer on mobile
Markdown files can define non-rendered metadata using YAML frontmatter. Example:
---
title: Markdown Amplified - AWS Amplify markdown viewer
description: Server-rendered markdown docs with S3 backend, security gating, and SEO metadata.
keywords:
- markdown viewer
- aws amplify markdown
- s3 markdown hosting
canonical: /
robots: index,follow
ogImage: /asset/social-card.png
schemaType: SoftwareApplication
llmSummary: Fast markdown publishing platform for Amplify-hosted docs.
---Supported keys:
titledescriptionkeywords(array or comma-separated string)canonical(relative or absolute URL)robots(for exampleindex,followornoindex,nofollow)ogImage(relative or absolute URL)schemaType(default:WebSiteon home andTechArticleon markdown pages)schema(full JSON object for custom JSON-LD)llmSummary(summary line used byllms.txt)
The frontmatter block is not rendered in the page body.
You can define optional global SEO defaults in content-security.json:
{
"siteUrl": "https://docs.example.com",
"defaultDescription": "Technical docs and project pages built with Markdown Amplified."
}siteUrlis used for canonical URLs,sitemap.xml, and absolute links inllms.txt.defaultDescriptionis used when a markdown page does not provide a frontmatterdescription.
app/
[...slug]/
page.js # Dynamic markdown route — server-rendered on each request
PageWrapper.js # Route wrapper (uses shared shell)
MarkdownRenderer.js # Markdown, highlighting, Mermaid rendering
admin/
page.js # Admin shell server component
AdminShell.js # Admin login, file browser, and rule editor
AdminSecurity.js # Security rule editor UI
api/
admin/ # Admin API: auth, file CRUD, mkdir, security config
sitemap/route.js # BFS site map API (security-aware)
asset-download/ # Gated asset download
asset/[...slug]/
route.js # Serves images and binary files from content/
downloads/[...slug]/
route.js # Serves markdown files for download (security-aware)
gate/[...slug]/
page.js # Asset gate page
Header.js # Top bar: home, site root, nav toggles, download, theme
MarkdownShell.js # Shared shell with sidebar state and layout
SecurityGate.js # Client-side password gate and stale-tab date check
SiteMap.js # Site map panel — BFS tree with loading state
TableOfContents.js # TOC panel — collapsible tree with active heading tracking
ThemeContext.js # Theme persistence and toggle
globals.css # Full UI and token styling
page.js # Root route for content/index.md
pw-cookie.js # Cookie helpers for persistent password storage
content/
...your live docs (optional)
content.default/
index.md
sample.md
README.md
...starter docs shown when content/ is empty
content-security.json # Security rules (create from .example)
content-security.json.example # Annotated example covering all rule types
lib/
security.mjs # Rule matching, display config, AES-256-GCM encryption
content-provider.mjs # FilesystemProvider and S3Provider (with CRUD)
Install dependencies:
npm installRun dev server:
npm run devOpen:
http://localhost:3000/http://localhost:3000/sample.md
Security rules from content-security.json are cached in memory for 60 seconds. In local development the simplest way to force an immediate reload is to restart the server. Install nodemon once and use the watch script to restart automatically whenever the config file changes:
npm install --save-dev nodemon
npm run dev:watchChanges to markdown files in content/ don't need a restart — the dev server re-reads them on every request.
Production build:
npm run buildThis produces a standard Next.js server bundle in .next/. AWS Amplify deploys it as a serverless SSR app (WEB_COMPUTE platform). Images and other assets are served on demand from the content/ directory via the /asset/ route; markdown files are served for download via the /downloads/ route with the same security rules applied as the page itself.
Individual files or entire directories can be protected with a password, restricted to a date range, or both. Security rules are defined in content-security.json at the project root.
For GitHub based content deployment, copy the example file and edit it:
cp content-security.json.example content-security.jsonThen rebuild and redeploy the site. For S3 based setups, see instructions below.
{
"rules": [
{
"match": "internal/",
"password": "hunter2"
},
{
"match": "announcements/launch.md",
"validFrom": "2025-09-01",
"validUntil": "2025-09-30"
},
{
"match": "board/q3-pack.md",
"password": "board2025",
"validFrom": "2025-09-08",
"validUntil": "2025-09-12"
}
]
}| Field | Description |
|---|---|
match |
Path relative to your content directory. End with / to match an entire directory. |
password |
Protects the file with a password. |
validFrom |
ISO date (e.g. 2025-09-01). File is unavailable before this date. |
validUntil |
ISO date. File is unavailable after this date. |
download |
true or false. Overrides the default download behaviour (see below). |
home |
Configures the home button for this file or directory. See Home Button. |
toc |
true or false. Controls whether the TOC panel opens by default on desktop. |
indexFile |
Filename to use as the folder index (default: index.md). The site map roots navigation at this file. |
name |
Display name for this folder or section, shown in the site map. |
show_toc |
true (default) or false. Hides the ToC panel and its toggle button entirely when false. |
show_sitemap |
true (default) or false. Hides the site map panel and its toggle button entirely when false. |
sitemap |
true opens the site map panel by default on page load. Default: false (closed). |
show_sitemap_first |
true places the site map panel above the ToC in the sidebar. Default: false. |
show_sitemap_siteroot |
true adds a "↑ Site Root" chip next to "Home" in the site map when the map is rooted at a subfolder. |
show_siteroot |
true adds a globe button to the header that navigates to the site root. Hidden automatically when home already points to /. |
comment |
Optional human note — ignored at runtime. |
All fields except match are optional and can be combined freely.
The default download behaviour is: files without a password are downloadable; password-protected files are not. Set download: true to explicitly allow downloading a password-protected file, or download: false to disable downloading any file regardless of whether it has a password.
Password protection — the markdown source is encrypted with AES-256-GCM at request time (key derived via PBKDF2, 100 000 iterations). The ciphertext is embedded in the page. A password prompt is shown in the browser; on the correct entry the content is decrypted and rendered client-side. The password is cached in sessionStorage so it only needs to be entered once per browser session.
Date gating — enforced server-side on every request. Files outside their validFrom/validUntil window return a 404 before any content is sent to the browser. No scheduled rebuilds are needed; the check runs against real server time on each page load.
Download button — controlled by the download field (see rule format above). By default, password-protected files are not downloadable and all others are. The /downloads/ route enforces this server-side, so setting download: false cannot be bypassed by hitting the URL directly.
Rule precedence — the most specific match wins. A rule for internal/welcome.md overrides a rule for internal/. A rule with no password and no dates makes a file publicly accessible even inside a protected directory.
Password protection is a hybrid of server and client. The server refuses to serve pages for date-expired files, but for password-protected files it sends the ciphertext to the browser and lets the client decrypt. This means a determined attacker with the ciphertext could attempt an offline brute-force attack against a weak password. Use a strong, random password for anything genuinely sensitive.
A home button is shown in the header by default, linking to the site root (/). It can be changed or disabled globally or per file/directory in content-security.json.
Add a top-level home key to content-security.json:
{
"home": false,
"rules": []
}| Top-level value | Effect |
|---|---|
"site" |
Link to site root / on every page (the built-in default). |
"folder" |
Link to the top-level folder of each file on every page. |
"https://..." |
Custom URL on every page. |
false |
Disable the home button site-wide. |
Add a home field to any rule to override the global default for specific files or directories. The most specific match wins, same as security rules.
{
"home": "site",
"rules": [
{
"comment": "Disable home button on the homepage itself",
"match": "index.md",
"home": false
},
{
"comment": "Disable home button for an entire section",
"match": "standalone/",
"home": false
},
{
"comment": "Link to the folder root instead of site root for nested docs",
"match": "docs/",
"home": "folder"
},
{
"comment": "Custom URL for a specific file",
"match": "reports/q3-summary.md",
"home": "https://example.com/reports/"
}
]
}The sidebar contains two independent panels: Table of Contents and Site Map. Each has its own toggle button in the header. On desktop both panels can be open at the same time — whichever you open first appears at the top. On mobile, opening one closes the other (exclusive drawer).
Auto-generated from H1–H3 headings in the document. Headings with sub-headings show a ▸ collapse toggle; clicking the heading link itself auto-expands that node. An expand-all / collapse-all button appears when there are collapsible nodes.
The ToC opens by default on desktop when a document has headings. Control this with the toc field:
| Value | Effect |
|---|---|
true |
Opens by default on desktop (built-in default). |
false |
Starts closed — the user can still open it manually. |
Set it globally or per-rule:
{
"toc": false,
"rules": [
{ "match": "docs/", "toc": true }
]
}The site map is built server-side by following markdown links breadth-first from the folder's index file (or the current file when no index exists). It only includes files that are reachable via links — unlinked files are not exposed. Password-protected directories only appear in the map after the user has unlocked them.
The panel shows an animated "Indexing…" indicator immediately while the tree is being fetched; the tree replaces it when ready.
Entries start collapsed. The expand-all / collapse-all button and "Home" entry (always visible at the top) are rendered as soon as the tree arrives.
All display fields follow the same match precedence as security rules — most specific match wins, global values are the fallback.
Visibility toggles — hide the panel and its header button entirely:
{
"rules": [
{
"match": "landing/",
"show_toc": false,
"show_sitemap": false
}
]
}Open by default — open a panel on page load without the user having to click:
{
"rules": [
{
"match": "firmware/",
"sitemap": true,
"show_sitemap_first": true
}
]
}Site map extras — show a "↑ Site Root" chip next to "Home" when the map is scoped to a subfolder, and a globe button in the header for navigating to the site root:
{
"rules": [
{
"match": "firmware/",
"home": "folder",
"show_sitemap_siteroot": true,
"show_siteroot": true
}
]
}The globe button (show_siteroot) is suppressed automatically when the home button already points to /, so the two buttons never duplicate each other.
Full display config reference:
| Field | Default | Description |
|---|---|---|
toc |
true |
Whether the ToC panel opens by default on desktop. |
show_toc |
true |
false hides the ToC panel and its toggle button entirely. |
show_sitemap |
true |
false hides the site map panel and its toggle button entirely. |
sitemap |
false |
true opens the site map panel by default on page load. |
show_sitemap_first |
false |
true places the site map panel above the ToC in the sidebar. |
show_sitemap_siteroot |
false |
true shows "↑ Site Root" chip next to "Home" in the site map. |
show_siteroot |
false |
true adds a globe button in the header linking to the site root. |
All of these can be set globally (top-level in content-security.json) or per-rule. Per-rule values override the global value for matching paths.
By default, unlock passwords are stored in sessionStorage and are forgotten when the browser tab is closed. Enabling cookie storage keeps them across sessions so returning visitors do not have to re-enter passwords.
Add a cookies block to content-security.json:
{
"cookies": {
"enabled": true,
"prefix": "md",
"maxAge": 2592000,
"domain": null,
"storeAdmin": false
}
}| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Set to true to activate cookie storage. |
prefix |
string | "md" |
Prefix for all cookie names (e.g. md-unlock-…, md-admin). Change if you run multiple instances on the same domain. |
maxAge |
number | 2592000 |
Cookie lifetime in seconds. Default is 30 days. |
domain |
string or null |
null |
If set to a domain string such as "ehlers.tv", the cookie is accessible to all subdomains (e.g. www.ehlers.tv, private.ehlers.tv). Leave null to scope cookies to the exact hostname only. |
storeAdmin |
boolean | false |
If true, the admin session token is also stored in a cookie so the admin interface stays logged in across browser sessions. |
Example — cross-subdomain cookies with a 7-day expiry:
{
"cookies": {
"enabled": true,
"prefix": "mysite",
"maxAge": 604800,
"domain": "ehlers.tv",
"storeAdmin": false
}
}Cookies are set with SameSite=Strict and, when the site is served over HTTPS, Secure. They are regular JavaScript-readable cookies (not HttpOnly) because unlock passwords must be accessible to the client-side AES decryption code.
The admin interface lives at /admin and lets you browse the active content tree, inspect per-file security flags, create folders, upload files, and delete files or folders. It uses the same content backend as the site itself, so what you can edit depends on whether the app is reading from S3 or from the local filesystem.
Enable and configure the admin interface in content-security.json with an admin block:
{
"admin": {
"enabled": true,
"password": "change-me",
"readonly": false
}
}| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Set to true to turn on the admin login. |
password |
string | required | Password used to sign in to the admin interface. |
readonly |
boolean | false |
When true, the admin UI is browse-only and all write actions are disabled. |
- Browse the current content tree and open markdown files in a new tab.
- View security markers such as password protection, date gating, and download flags.
- Create folders, upload files, and delete files or folders when write access is available.
- Show the current directory security settings for the selected folder.
When the app is deployed on Amplify without S3_BUCKET configured, the admin interface is forced into read-only mode even if content-security.json sets admin.readonly to false. This is intentional: Amplify filesystem writes are not reliable for persistent content. You can still log in and browse the content tree, but upload, delete, and folder-creation actions are disabled.
If you need persistent admin-managed content, use S3 mode instead.
By default the site reads markdown files from the local content/ directory (or content.default/). Setting the S3_BUCKET environment variable switches to an S3 backend — all file reads happen at request time from the specified bucket, with no local content needed in the deployed bundle.
In the AWS Console, go to S3 → Create bucket.
General configuration
- Bucket name — choose a globally unique name (e.g.
my-site-content). Note it down; you will set it asS3_BUCKET. - AWS Region — choose the same region as your Amplify app to minimise latency (e.g.
eu-west-1).
Object Ownership
- Leave at ACLs disabled (recommended). The app authenticates via an IAM role, not ACLs.
Block Public Access settings
- Leave all four Block Public Access checkboxes enabled (the default). The Amplify Lambda reads objects using its IAM role; the bucket does not need to be public.
Bucket Versioning — optional, off is fine.
Default encryption
- Server-side encryption — leave at SSE-S3 (Amazon S3 managed keys). No action needed.
- Bucket key — leave enabled.
Leave all other settings at their defaults and click Create bucket.
| Variable | Required | Description |
|---|---|---|
S3_BUCKET |
Yes (to enable S3 mode) | Name of the S3 bucket containing your markdown files. |
S3_PREFIX |
No | Key prefix within the bucket (e.g. docs/). Trailing slash is added automatically if omitted. |
S3_REGION |
Yes | AWS region of the bucket (e.g. eu-north-1). |
S3_ACCESS_KEY_ID |
Yes | Access key ID for the IAM user (see below). |
S3_SECRET_ACCESS_KEY |
Yes | Secret access key for the IAM user (see below). |
These values are baked into the Lambda bundle at build time. Changing them requires a redeploy.
Objects are stored at <S3_PREFIX><relpath>, where <relpath> matches the URL path of the page (e.g. index.md, guide/setup.md). Assets (images etc.) referenced from markdown are served via the /asset/ route using the same prefix.
Example with S3_PREFIX=docs/:
docs/index.md → /
docs/guide/setup.md → /guide/setup.md
docs/guide/diagram.png → /asset/guide/diagram.png
- Open your bucket in the S3 Console and click Upload.
- Click Add files or Add folder and select your content.
- Leave all upload settings (permissions, storage class, encryption) at their defaults and click Upload.
The key (path) of each object must match the structure described in Bucket structure. If you are uploading a folder, the Console preserves the local folder hierarchy automatically.
aws s3 cp path/to/index.md s3://YOUR_BUCKET/docs/index.mdThe most common workflow: keep a local content/ directory and sync it to the bucket prefix in one command.
aws s3 sync ./content s3://YOUR_BUCKET/docs/ \
--delete--deleteremoves objects from the bucket that no longer exist locally. Omit it if you want to keep old files.- Re-run the same command whenever you add, edit, or remove files — only changed files are transferred.
- For a dry run that shows what would change without uploading, add
--dryrun.
aws s3 rm s3://YOUR_BUCKET/docs/old-page.mdaws s3 ls s3://YOUR_BUCKET/docs/ --recursiveThe Amplify WEB_COMPUTE Lambda cannot resolve credentials from the standard AWS credential chain, so an explicit IAM user with S3 access is needed.
- In the AWS Console, go to IAM → Users → Create user.
- Name it something like
markdown-amplified-s3. Leave Provide user access to the AWS Management Console unchecked — this user only needs programmatic access. - On the permissions step, choose Attach policies directly → Create policy. Switch to the JSON editor and paste one of the policies below depending on whether you want to use the admin interface.
Read-only access (no admin interface, or admin interface in read-only mode; see Admin Interface):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR_BUCKET"
}
]
}Read-write access (required for the admin interface to upload, delete, and create folders):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR_BUCKET"
}
]
}Replace YOUR_BUCKET with your bucket name. Save the policy (e.g. markdown-amplified-s3), attach it, and finish creating the user.
- Open the new user in IAM, go to the Security credentials tab, and click Create access key.
- Choose Application running outside AWS as the use case.
- Copy the Access key ID and Secret access key — the secret is shown only once.
You will add these as Amplify environment variables in the next step.
In the Amplify Console → Hosting → Environment variables, add the following as global variables (branch: All branches):
| Key | Value |
|---|---|
S3_BUCKET |
your-bucket-name |
S3_PREFIX |
docs/ (optional — omit if your files are at the bucket root) |
S3_REGION |
AWS region of the bucket, e.g. eu-north-1 |
S3_ACCESS_KEY_ID |
Access key ID from Step 2 above |
S3_SECRET_ACCESS_KEY |
Secret access key from Step 2 above |
Click Save and trigger a new deployment — push a commit or use Redeploy this version in the Amplify Console. The variables are baked into the Lambda bundle at build time, so they take effect only after the next successful build.
Amplify requires at least one global value for every variable; you cannot create a per-branch-only variable. To revert a specific branch (e.g. dev) to filesystem mode while keeping S3 active on main, add a per-branch override for that branch and set S3_BUCKET to the literal value null. The app treats null, Null, and NULL as unset and falls back to the local content/ directory for that branch.
| Branch | S3_BUCKET |
Result |
|---|---|---|
| All branches | my-bucket |
S3 mode (default for every branch) |
dev override |
null |
Filesystem mode for dev only; admin is forced to read-only on Amplify |
On Amplify, filesystem mode is treated as read-only for the admin interface even if content-security.json sets admin.readonly to false. Lambda-backed filesystem writes are not reliable there, so upload, delete, and folder-creation actions are intentionally disabled. See Admin Interface for the full behavior.
Upload content-security.json to the root of your bucket (or <S3_PREFIX>content-security.json if you use a prefix). The Lambda fetches it from S3 and caches the rules for 60 seconds. After uploading a new version, wait up to one minute for the change to take effect — no redeploy is required. The path matched against the rules is always relative to the bucket prefix, identical to how it works with the filesystem backend.
-
Push repository to GitHub.
-
In AWS Amplify Console, create a new app and connect the repo.
-
Keep
amplify.ymlandcustomHttp.ymlin the repo root so Amplify uses the project build settings and custom HTTP headers. -
If deployment fails, check that the app platform is set to
WEB_COMPUTE(SSR), notWEB(static hosting). Because this is a Next.js SSR project, Amplify must be told to treat it as a compute app rather than a static site. You can check and set it via the AWS CLI:aws amplify list-apps --query 'apps[].{name:name,appId:appId,platform:platform}' --output table aws amplify update-app --app-id <APP_ID> --platform WEB_COMPUTE
Then redeploy. If you deploy this in a new application, this should be set automatically.
-
Every subsequent push triggers a fresh SSR build and deployment.
The recommended path for a new S3-backed deployment, from zero to live:
1. Fork and deploy (static mode)
Fork Xykon/markdown_amplified to your own GitHub account and connect it to a new Amplify app (WEB_COMPUTE platform). The site starts up immediately, serving the built-in sample content from content.default/. Verify the deployment is working end-to-end before touching S3.
2. Create the S3 bucket and IAM user
Follow the steps in Create the S3 bucket and Create an IAM user for S3 access. Upload your markdown files (and optionally content-security.json) to the bucket.
3. Set the environment variables and redeploy
Add S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY as global (All branches) variables in the Amplify Console (see Setting the variables on Amplify). Push a commit or use Redeploy this version to trigger a fresh build. The build bakes the credentials into the Lambda bundle and the site switches to S3 mode.
4. Publish new content without redeploying
From this point on, upload or update files in S3 and they are live immediately on the next page request. Update content-security.json in S3 and the new rules take effect within 60 seconds. No code changes or redeployments are needed for content updates.
If you want open source code publicly but host private content/config separately, use two remotes in one local clone.
- Public repo: open-source project code
- Private repo: your production deployment source (includes private markdown content)
- Local machine: one working clone with both remotes
Clone public project (HTTPS or SSH):
# HTTPS
git clone https://github.com/Xykon/markdown_amplified.git
# or SSH
git clone git@github.com:Xykon/markdown_amplified.git
cd markdown_amplifiedRename default remote to public:
git remote rename origin publicCreate an empty private repo on GitHub, then add it as the private remote.
Pick whichever protocol you have authentication set up for:
# HTTPS (uses a Personal Access Token or the Git Credential Manager)
git remote add private https://github.com/<your-user>/<your-private-repo>.git
# SSH (uses your SSH key registered with GitHub)
git remote add private git@github.com:<your-user>/<your-private-repo>.gitIf you do not yet have authentication configured for private GitHub repositories, see the GitHub docs:
- HTTPS (Personal Access Tokens): https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- SSH keys: https://docs.github.com/en/authentication/connecting-to-github-with-ssh
- Overview: https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories#cloning-with-https-urls
Push current state to private:
git push -u private mainThen connect AWS Amplify to the private repo.
When upstream public repo changes:
git fetch public
git checkout main
git merge public/mainResolve conflicts if needed, then push to private:
git push private mainIf you also maintain changes in the private repo, this merge-based flow keeps history clear and predictable.
- Filename obscurity is not security. Use
content-security.jsonrules for meaningful access control, or keep truly sensitive content in private infrastructure. - Prefer URL-safe filenames where possible to avoid host/CDN edge-case behavior.
- Spaces and parentheses are supported with proper URL encoding and route decoding.
- The download button is served by the
/downloads/route handler, which applies the same security rules as the page route. No build step is needed to make downloads available.
Check:
- File exists under
content/and ends in.md - File is not outside its
validFrom/validUntilwindow (date-gated files return 404) - URL uses encoded spaces (
%20) in browsers - Deployment has completed successfully and is serving the latest build
Mermaid is rendered client-side and should re-render on theme changes. If stale, hard refresh once and verify you are on latest deployed assets.
The download is served by the /downloads/[...slug] route handler. Check:
- The file is not password-protected (protected files intentionally return 404 from the download route)
- The file is within its
validFrom/validUntilwindow if date-gated - The deployment is on
WEB_COMPUTE— the download route requires SSR and will not work on aWEB(static) deployment
This project is released under the MIT License.