Converts plain-text public-domain literature into accessible HTML5 documents and serves them as an offline-capable Progressive Web App. Each book is placed in its own directory as index.html for clean URLs. The service worker uses self-hosted Workbox v7 for precaching and routing, with IndexedDB for reading progress tracking and offline catalogue browsing.
.txt file into public/text/You push a .txt file → GitHub Action converts to HTML5 + builds index → Hasher creates SHA1 manifest → Service worker precaches everything offline
public/text/ triggers convert-literature.ymlconvert.mjs parses Gutenberg header, chapters, footnotespublic/literature/[slug]/index.htmlbuild-index.mjs creates public/literature/index.json with all book metadatahash-manifest.yml runs after conversion, generates precache-manifest.jsonsw.js) loads Workbox + IDB, precaches all filesnpm install # Install Node modules (none required for core)
npm run setup # Download Workbox v7 libraries
npm run convert:all # Convert all text files
npm run index # Build literature JSON index
npm run hash # Generate SHA1 manifest
npm run build # All of the above
literature-pwa/
├── .github/
│ └── workflows/
│ ├── setup-dependencies.yml # Downloads Workbox on push
│ ├── convert-literature.yml # Converts text → HTML, builds index
│ └── hash-manifest.yml # Hashes files, updates manifest
├── src/
│ ├── convert.mjs # Plain-text → structured book object
│ ├── template.mjs # Book object → semantic HTML5 document
│ ├── build-index.mjs # Scans literature/, creates index.json
│ ├── hash-files.mjs # Directory walker → SHA1 manifest
│ ├── download-workbox.mjs # Downloads Workbox v7 from CDN
│ └── utils.mjs # Shared helpers
├── public/
│ ├── index.html # Landing page with book catalogue
│ ├── manifest.webmanifest # PWA manifest
│ ├── sw.js # Service worker (Workbox + IDB)
│ ├── sw-register.js # SW registration
│ ├── precache-manifest.json # Auto-generated by hash-files.mjs
│ ├── _headers # MIME type rules for Cloudflare
│ ├── css/
│ │ └── style.css # Base stylesheet (dark mode, custom props)
│ ├── js/
│ │ ├── app.js # Landing page catalogue logic
│ │ └── reader.js # Reading progress tracking per book
│ ├── lib/
│ │ └── idb.js
│ │ └── workbox/ # Self-hosted Workbox v7 files
│ ├── literature/ # Generated book directories
│ │ ├── [book-slug]/
│ │ │ └── index.html
│ │ └── index.json # Auto-generated book catalogue
│ ├── text/ # Source plain-text files (your input)
│ ├── Archive/ # Additional cached files
│ └── images/ # PWA icons, OG images
├── wrangler.jsonc # Pages config (MIME types, HTML handling)
├── package-lock.json
├── package.json
├── README.md
├── SECURITY.md
└── .gitignore
Create wrangler.jsonc in the repository root:
{
"name": "literature-pwa",
"compatibility_date": "2026-08-20",
"pages_build_output_dir": "./public"
}
Create public/_headers with the following content:
/*
Content-Type: text/html; charset=utf-8
/*.txt
Content-Type: text/plain; charset=utf-8
/*.json
Content-Type: application/json; charset=utf-8
/*.jsonc
Content-Type: application/jsonc; charset=utf-8
/*.css
Content-Type: text/css; charset=utf-8
/*.pdf
Content-Type: application/pdf; charset=utf-8
/*.js
Content-Type: text/javascript; charset=utf-8
/*.mjs
Content-Type: text/javascript; charset=utf-8
/*.webmanifest
Content-Type: application/manifest+json; charset=utf-8
/*.svg
Content-Type: image/svg+xml
/*.png
Content-Type: image/png
/*.xml
Content-Type: application/xml; charset=utf-8
With wrangler.jsonc present, deploy the site with a single command from the
repository root:
npx wrangler pages deploy
Because pages_build_output_dir = "./public" is declared in wrangler.jsonc,
there is no need to specify the output directory on the command line. The
name = "literature-pwa" setting defines the project name on your Cloudflare
account.
First-time setup requires authentication:
npx wrangler login
For CI environments (e.g., GitHub Actions), prefer an API token stored as a secret instead of interactive login:
CLOUDFLARE_API_TOKEN=<token> npx wrangler pages deploy
After deploying, verify headers are applied correctly by inspecting responses:
curl -sI https://your-domain.example.com/manifest.webmanifest
The Content-Type header in the response should read
application/manifest+json; charset=utf-8.
Create .github/workflows/deploy.yml in the repository:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: $
accountId: $
command: pages deploy
Setup requirements in the GitHub repository settings:
CLOUDFLARE_API_TOKEN secret.CLOUDFLARE_ACCOUNT_ID secret
(available on the Cloudflare dashboard overview page).Because wrangler.jsonc declares pages_build_output_dir, the workflow’s
pages deploy command needs no additional arguments. The deployments: write
permission lets Cloudflare annotate the deployment status on commits.
_headers and MIME TypesThese symptoms were all observed during development of the predecessor site (antinazi.org); each entry lists the cause and the fix carried forward into this project.
text/htmlPlain-text pages (converted Project Gutenberg literature), .json manifest
files, and occasionally .css files were delivered with a text/html
Content-Type. On iOS Safari, this typically surfaces as the stylesheet being
refused — the page renders as unstyled markup.
/* rule in _headers overriding or racing with
Cloudflare’s automatic content-type detection, with rules intermittently not
applying at all after deploys./* fallback. If instability recurs, see
Fallback: JavaScript Worker below.iOS Safari threw SyntaxError: Unexpected keyword 'export' when registering
the Service Worker, despite the script being a valid ES module.
.js/.mjs modules served with
the wrong Content-Type (missing or incorrect JavaScript MIME type). Safari
refuses to execute module scripts whose MIME type is not
text/javascript/application/javascript..js and .mjs rules in _headers explicitly set
Content-Type: text/javascript; charset=utf-8. Additionally, Workbox and
IDB scripts are self-hosted rather than loaded from jsDelivr, which
eliminates CDN-side CORS and MIME inconsistencies entirely.Service Worker registration failed citing missing Workbox libraries, even though the files existed at the expected paths.
Content-Type header on the Workbox module
files, not the file paths themselves:
curl -sI https://your-domain.example.com/path/to/workbox.jsmanifest.webmanifest not recognizedPWABuilder and iOS Safari failed to recognize the web app manifest, preventing installation and displaying without the standalone display mode.
.webmanifest served as text/plain or text/html, causing
browsers to reject it..webmanifest rule sets
application/manifest+json; charset=utf-8.Registration succeeds in the browser, but PWABuilder.com reports no Service Worker detected.
file-list.json revision hash) whenever
Service Worker or asset content changes.Navigation buttons stack vertically and content is obscured by the notch in fullscreen PWA mode, while the in-browser view renders correctly. These are treated as layout bugs to fix, not environment quirks.
safe-area-inset-*) and safe-area-aware padding differ from in-browser
mode. Bugs were also observed correlating with iOS public beta releases.viewport-fit=cover plus
env(safe-area-inset-*) padding rather than fixed offsets, so rendering is
consistent across both modes.The _headers approach was removed from the predecessor project once due to
the instability described above and replaced with a JavaScript Cloudflare
Worker that sets MIME and security headers programmatically on every
response. That Worker proved dependable where _headers did not. (An earlier
attempt to implement this Worker in Rust was abandoned — online Rust
playgrounds lacked the worker crate dependencies needed for compilation
against Cloudflare’s runtime.)
The complete, deployment-ready implementation is codified in
Appendix A: JavaScript Header Worker.
If _headers instability recurs:
public/_headers from the deployment.functions/_middleware.js at the repository root using the code in
Appendix A. Cloudflare Pages picks up the sibling functions/ directory
automatically on the next wrangler pages deploy — no additional
configuration in wrangler.jsonc is required.curl -sI before trusting the deployment.Do not run both mechanisms simultaneously. _headers rules and the middleware
both mutate responses, and interleaved ordering makes header behavior
impossible to reason about. Activate the Worker only after deleting
public/_headers.
This is the production-proven implementation from the predecessor project, adapted for this repository. It runs as a Cloudflare Pages Functions middleware — a wrapper around every request the site serves — and performs two jobs deterministically, independent of Cloudflare’s content-type sniffing:
Content-Type from the file extension via an
explicit map. Extensionless paths (pretty URLs such as /privacy/) default
to text/html, mirroring the /* catch-all rule in _headers.Create functions/_middleware.js (note: the functions/ directory sits at the
repository root, as a sibling of public/, not inside it):
repo/
├── functions/
│ └── _middleware.js
├── public/
│ ├── _headers ← remove this when activating the Worker
│ └── … site files …
└── wrangler.jsonc
functions/_middleware.js)/**
* Cloudflare Pages Functions middleware — MIME type and security
* header enforcement.
*
* Sets Content-Type from an explicit extension map (extensionless
* pretty URLs default to text/html) and applies security headers to
* every response. Deploy as functions/_middleware.js; Pages picks it
* up automatically alongside pages_build_output_dir = "./public".
*
* Do NOT run alongside public/_headers — choose one mechanism.
*/
// Explicit extension → MIME map. Mirrors the _headers rules exactly.
// Any extension not listed here falls through to the DEFAULT_MIME_TYPE.
const MIME_BY_EXTENSION = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".pdf": "application/pdf; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".xml": "application/xml; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json; charset=utf-8"
};
// Pretty URLs without a file extension (/privacy/, /Gutenberg-License/)
// are HTML documents.
const DEFAULT_MIME_TYPE = "text/html; charset=utf-8";
// Applied to every response, unconditionally.
const SECURITY_HEADERS = {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy":
"camera=(), microphone=(), geolocation=(), payment=()"
};
// CSP: strict — no unsafe-eval, no unsafe-inline. This project ships
// no inline event handlers, inline <script>, or inline <style>; all
// assets are same-origin and self-hosted (including Workbox and IDB).
// Adjust ONLY alongside a corresponding change to page markup.
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"upgrade-insecure-requests"
].join("; ");
// NOTE on COEP: deliberately omitted. Cross-Origin-Embedder-Policy:
// require-corp interacts poorly with Service Worker module loading on
// iOS, which this project depends on. COOP and CORP above provide the
// useful isolation without that risk.
/**
* Derive the correct Content-Type for the requested path.
* @param {string} url - The full request URL.
* @returns {string} MIME type string.
*/
function getMimeType(url) {
const { pathname } = new URL(url);
// Strip a trailing slash so "/dir/" does not hide "/dir/index.html"-style
// matches — an extension lookup on the last segment is what matters.
const path = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
const lastDot = path.lastIndexOf(".");
// An extension must be in the final path segment ("/foo.v2/bar" has no
// extension; "/styles.css" does). Guard the dot against directory names.
if (lastDot === -1) {
return DEFAULT_MIME_TYPE;
}
const lastSlash = path.lastIndexOf("/");
if (lastDot < lastSlash) {
return DEFAULT_MIME_TYPE;
}
const extension = path.slice(lastDot).toLowerCase();
return MIME_BY_EXTENSION[extension] ?? DEFAULT_MIME_TYPE;
}
export async function onRequest(context) {
const { request, next } = context;
// Produce the origin response (static asset or redirect).
const response = await next();
// Never touch redirect responses (3xx): their bodies are empty and
// rewriting them into a new Response can drop the Location header.
if (
response.status >= 300 &&
response.status < 400
) {
return response;
}
// Copy headers, then overwrite MIME and security values.
const headers = new Headers(response.headers);
headers.set("Content-Type", getMimeType(request.url));
headers.set("Content-Security-Policy", CONTENT_SECURITY_POLICY);
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
headers.set(name, value);
}
// Re-emit the response with the modified headers. The body stream is
// passed through untouched; no buffering occurs.
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
Run each check and confirm the expected value before trusting the deploy:
# Manifest MIME (PWA installation depends on this)
curl -sI https://your-domain.example.com/manifest.webmanifest | grep -i content-type
# expect: application/manifest+json; charset=utf-8
# ES module MIME (Service Worker registration depends on this)
curl -sI https://your-domain.example.com/js/sw.js | grep -i content-type
# expect: text/javascript; charset=utf-8
# Pretty-URL HTML (extensionless path)
curl -sI https://your-domain.example.com/privacy/ | grep -i content-type
# expect: text/html; charset=utf-8
# Security headers present
curl -sI https://your-domain.example.com/ | grep -i strict-transport-security
curl -sI https://your-domain.example.com/ | grep -i content-security-policy
If any MIME check fails while the middleware is deployed, purge the Cloudflare edge cache before investigating further — stale cached responses without the middleware’s headers are the most common false alarm.
| Action | Trigger | What it does |
|---|---|---|
setup-dependencies.yml |
Push to package.json, manual dispatch |
Downloads Workbox v7 to public/lib/workbox/ |
convert-literature.yml |
Push to public/text/**/*.txt, manual dispatch |
Converts books, builds index JSON |
hash-manifest.yml |
Push to any watched path, after Convert Literature completes | Hashes all files, updates manifest |
Font Stacks System-first, no external loading:
--mono-font: ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", "Roboto Mono", "Liberation Mono", "Noto Sans Mono", Menlo, monospace;
--sans-font: ui-sans-serif, "SF Pro Text", "Segoe UI", "Open Sans", Roboto, "Liberation Sans", "Noto Sans", sans-serif;
--serif-font: ui-serif, "New York", "Liberation Serif", "Noto Serif", "Roboto Slab", serif;
Light mode:
| Token | Hex |
|---|---|
--dusty-grape |
#54428e |
--lavender-purple |
#8963ba |
--celadon |
#afe3c0 |
--willow-green |
#90c290 |
--dusty-olive |
#688b58 |
Dark mode (same hue families, luminance adjusted for contrast):
| Token | Hex |
|---|---|
--dusty-grape |
#9d89d6 |
--lavender-purple |
#b89de0 |
--celadon |
#5a9a6a |
--willow-green |
#6fa86f |
--dusty-olive |
#94b274 |
| Feature | Implementation |
|---|---|
| Precaching | Workbox v7 with content-hash cache busting |
| HTML navigation | Network-first, fallback to cached index.html |
| CSS / JS | Stale-while-revalidate |
| JSON data | Stale-while-revalidate with IDB backup |
| Images | Cache-first with 60-entry, 30-day expiration |
| Reading progress | IndexedDB via postMessage channel |
| Offline catalogue | IDB-stored book metadata |
| Updates | SKIP_WAITING message, claim on activate |
| MIME types | Self-serve via Cloudflare _headers |
Designed around Project Gutenberg plain-text format:
** START OF / ** END OF markers strip boilerplate
Title:, Author:, Language:, Release Date: extracted from header
Chapter headings: CHAPTER I, Chapter 1, PART ONE, ACT III
ALL-CAPS lines as subsection headings Blank line paragraph separation
italic, italic, bold inline formatting
[1] footnote references with bidirectional links
If Gutenberg markers are absent, converter degrades gracefully — processes entire file as content.
Runtime: Node.js 20+ (ESM, zero dependencies)
CI: GitHub Actions (Ubuntu, Node 20)
PWA: Self-hosted Workbox v7, custom IDB wrapper
CSS: Custom properties, clamp() typography, prefers-color-scheme: dark
HTML: Semantic HTML5, ARIA, Schema.org JSON-LD
Hosting: Cloudflare Pages with wrangler.jsonc config
Dependencies: Pure Node.js standard library only
No unsafe-eval or unsafe-inline in CSP
Zero external runtime dependencies
Input sanitization (all text escaped before rendering)
Same-origin service worker enforcement SHA1 content-addressed caching for integrity
See SECURITY.md for vulnerability reporting.
Source code: AGPL-3.0-or-later.
Plain text literature files: Public domain (sourced from Project Gutenberg).