literature-pwa

Literature PWA

dev.antinazi.org

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.

Quick Start

  1. Drop a .txt file into public/text/
  2. Push to GitHub
  3. Actions automatically: download Workbox (first run), convert to HTML, build index, hash manifest
  4. Browse offline — reading progress is saved automatically

How It Works

You push a .txt file → GitHub Action converts to HTML5 + builds index → Hasher creates SHA1 manifest → Service worker precaches everything offline

Data Flow

  1. Text file in public/text/ triggers convert-literature.yml
  2. convert.mjs parses Gutenberg header, chapters, footnotes
  3. Book created at public/literature/[slug]/index.html
  4. build-index.mjs creates public/literature/index.json with all book metadata
  5. hash-manifest.yml runs after conversion, generates precache-manifest.json
  6. Service worker (sw.js) loads Workbox + IDB, precaches all files
  7. Reading progress tracked via IDB messages between page and SW

Local Development

npm 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

Repository Structure

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

Cloudflare Setup

Configuration

Create wrangler.jsonc in the repository root:

{
  "name": "literature-pwa",
  "compatibility_date": "2026-08-20",
  "pages_build_output_dir": "./public"
}

MIME Type Rules

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

Deployment

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.

Continuous Deployment with GitHub Actions

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:

  1. Create a Cloudflare API token with the Cloudflare Pages — Edit permission (Account → Pages → Edit).
  2. Add it as the CLOUDFLARE_API_TOKEN secret.
  3. Add the Cloudflare account ID as the 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.

Troubleshooting _headers and MIME Types

These 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.

Symptom: Non-HTML assets served as text/html

Plain-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.

Symptom: “Unexpected keyword ‘export’” in the Service Worker

iOS Safari threw SyntaxError: Unexpected keyword 'export' when registering the Service Worker, despite the script being a valid ES module.

Symptom: “Missing Workbox Libraries”

Service Worker registration failed citing missing Workbox libraries, even though the files existed at the expected paths.

Symptom: manifest.webmanifest not recognized

PWABuilder and iOS Safari failed to recognize the web app manifest, preventing installation and displaying without the standalone display mode.

Symptom: Local diagnostics pass, PWABuilder fails

Registration succeeds in the browser, but PWABuilder.com reports no Service Worker detected.

Symptom: Rendering differences between browser and standalone/fullscreen modes

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.

Fallback: JavaScript Worker

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:

  1. Remove public/_headers from the deployment.
  2. Create 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.
  3. Re-verify every MIME type with 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.

Appendix A: JavaScript Header Worker (Fallback Implementation)

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:

  1. MIME types: derives 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.
  2. Security headers: applies a fixed set of headers to every response.

Placement

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

Complete source (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
  });
}

Verification checklist after activation

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.

GitHub Actions

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;

Color Palette

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

Service Worker Features

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

Supported Text Formats

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.

Tech Stack

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

Security

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.

License

Source code: AGPL-3.0-or-later.

Plain text literature files: Public domain (sourced from Project Gutenberg).

Convert Literature CodeQL

Mozilla HTTP Observatory Grade Chromium HSTS preload