Skip to content

Marginalia v2: Persistent, authenticated markdown annotation app #11

Description

@galsapir

Marginalia v2 — Full Architecture & Implementation Plan

Update (2026-04-15) — Decisions made during scaffolding:

  • Repo name: marginalia-cloud (avoids v2 temporal naming). Lives at https://github.com/galsapir/marginalia-cloud (private during dev, will go public).
  • Hosting flipped from Cloudflare Pages → Cloudflare Workers w/ static assets. Per CF official guidance for new projects in 2026, all investment goes to Workers; Pages is feature-frozen. Bonus: one Worker now serves both the React SPA and /api/* routes (truly same-origin, no CORS config, Access cookies trivial). Sections below have been updated to reflect this.
  • Jina proxy stays separate. The existing marginalia-reader Worker is unauthenticated; the new sync Worker will be Access-protected. Two Workers, one job each — avoids carving a public route through Access.
  • Phase 1 + Phase 2 likely merge. Porting modules just to "verify identical to v1" is wasted motion when Phase 2 will rewrite all the persistence wiring anyway. Port modules as they're wired into Dexie.
  • Initial scaffold landed: Vite 8 + React 19 + TS 6 + Tailwind 4 + @cloudflare/vite-plugin + wrangler. Builds clean. Worker entry stubs /api/* with 501.

Overview

Evolve Marginalia from a client-side-only markdown annotation tool into a persistent, authenticated experience — a "Google Docs for markdown annotation." This will be a new repository (marginalia-cloud), porting the core rendering/annotation engine from the current repo. The current repo stays live on GitHub Pages with a banner pointing to the new version.


What Exists Today (v1)

Tech Stack

  • Frontend: React 19 + TypeScript 5.9 + Vite 7 + Tailwind CSS 4
  • Hosting: GitHub Pages (static site, deployed via GitHub Actions)
  • Worker: One Cloudflare Worker as CORS proxy for Jina Reader webpage-to-markdown conversion (https://marginalia-reader.galsapir.workers.dev)
  • State: Entirely in-memory React state. No persistence. Annotations lost on refresh.
  • Auth: None

Core Engine (to be ported to v2)

These are the battle-tested modules that should be carried over:

Module Path Purpose
rehypeAnnotationMarks.ts src/lib/ Single-pass AST injection of <mark> elements for annotation highlights
remarkSourcePositions.ts src/lib/ Adds data-source-start/end attributes to map DOM → markdown offsets
positions.ts src/lib/ Maps text nodes through formatted elements to correct markdown positions
selection.ts src/lib/ Converts DOM selection ranges back to markdown character offsets
export.ts src/lib/ Three export formats: annotated markdown, notes-only, raw
github.ts src/lib/ GitHub blob URL parsing + raw content fetching + image resolution
webpage.ts src/lib/ Webpage-to-markdown via Jina Reader proxy
loadUrl.ts src/lib/ URL routing (GitHub vs. webpage)
file.ts src/lib/ File validation for drag-drop
types.ts src/lib/ Core types including Annotation
useAnnotations.ts src/hooks/ Annotation CRUD, active ID tracking, offset shifting
useTheme.ts src/hooks/ Dark/light mode with system preference detection
useFileDrop.ts src/hooks/ Drag-and-drop handler
useDragResize.ts src/hooks/ Sidebar width resize

Current Annotation Type

interface Annotation {
  id: string;              // UUID
  selectedText: string;    // The highlighted text
  note: string;           // User's annotation
  markdownStartOffset: number;
  markdownEndOffset: number;
  createdAt: number;      // Timestamp
}

Current UI Components

  • App.tsx — Root layout, state management, view orchestration
  • DocumentView.tsx — Rendered markdown with selection, highlights, inline editing
  • Sidebar.tsx — Floating annotation cards positioned at mark Y coordinates
  • InputView.tsx — Landing page: URL input, textarea paste, drag-drop, changelog
  • ExportControls.tsx — Copy/download dropdown with 3 export modes
  • AnnotationPopover.tsx — Popover for creating annotations on text selection
  • NotePopover.tsx — Popover for viewing/editing existing annotations
  • RawView.tsx — Raw markdown source display
  • ThemeToggle.tsx — Dark/light mode switch
  • DropOverlay.tsx — Fullscreen overlay during file drag

Design System

  • Fonts: Newsreader (serif for content), DM Sans (UI), JetBrains Mono (code)
  • Colors: Warm paper (cream), ink tones, sienna accent, amber highlights
  • Dark mode: Full custom dark variants
  • All defined in src/index.css as CSS custom properties + Tailwind config

Key Dependencies

react 19.2.0, react-dom 19.2.0
react-markdown 10.1.0
rehype-react 8.0.0
remark-gfm 4.0.1
unified 11.0.5
unist-util-visit 5.1.0
uuid 13.0.0

Tests

  • export.test.ts — Export format generation
  • github.test.ts — GitHub URL parsing
  • rehypeAnnotationMarks.test.ts — Highlight injection
  • loadUrl.test.ts — URL loading routing
  • webpage.test.ts — Webpage markdown conversion
  • worker/src/index.test.ts — Worker unit tests

Build Config

  • vite.config.ts — Base path /marginalia/ (GitHub Pages), build timestamp injection
  • tsconfig.json, tsconfig.app.json, tsconfig.node.json
  • eslint.config.js — ESLint 9 with TypeScript + React plugins
  • .github/workflows/deploy.yml — GitHub Actions → GitHub Pages

Decided Architecture for v2

Research Summary

Two rounds of deep research were conducted (initial + adversarial). Key findings:

  1. Cloudflare D1 has real production reliability issues — 400ms+ latency on simple queries, single-writer bottleneck, primitive transaction model (no BEGIN TRANSACTION), documented data loss cases, and a multi-day degradation incident in July 2025. Viable for a single-user app but over-engineered for this use case.

  2. Cloudflare is consolidating Pages → Workers. New projects (as of 2026) should deploy to Workers with the static-assets binding instead of Pages. Workers has full feature parity for static hosting + SSR, gets all future investment, and serving both the SPA and the API from one Worker eliminates CORS entirely. Pages remains supported but feature-frozen. Source: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/

  3. Custom OAuth is the highest-risk choice for a solo developer. Production-grade OAuth is 250-400 lines with many security pitfalls. Cloudflare Access eliminates this entirely for free (50-user limit).

  4. Real-world note-taking apps (SilverBullet, Trilium, Joplin, Obsidian) overwhelmingly use local-first architectures. None use CF Pages + D1 + custom OAuth.

  5. IndexedDB as primary database (not just a cache) is the right pattern for a single-user app. Instant writes, works offline, mature technology.

The Stack

┌─────────────────────────────────────────────────────────┐
│         Cloudflare Worker (marginalia-cloud)             │
│                                                          │
│  ┌──────────────────────┐  ┌─────────────────────────┐  │
│  │  Static assets        │  │  /api/* routes           │  │
│  │  (React SPA build)    │  │  (sync ~50 lines)        │  │
│  │                       │  │                          │  │
│  │  Dexie.js             │  │  ┌─────┐  ┌──────────┐  │  │
│  │  (IndexedDB =         │  │  │ R2  │  │ Access   │  │  │
│  │   primary DB,         │  │  │     │  │ (auth)   │  │  │
│  │   in-browser)         │  │  └─────┘  └──────────┘  │  │
│  └──────────────────────┘  └─────────────────────────┘  │
│                                                          │
│  ONE origin — no CORS, Access cookies just work          │
└─────────────────────────────────────────────────────────┘

         ┌─────────────────────────────────┐
         │  Cloudflare Worker              │
         │  (marginalia-reader, existing)  │
         │  Jina proxy, unauthenticated    │
         └─────────────────────────────────┘
Layer Technology Role
Frontend React 19 + Vite 7 + Tailwind 4 Unchanged from v1
Primary Database Dexie.js (IndexedDB) All reads/writes happen here. Auto-save is instant. Works offline.
Auth Cloudflare Access (free, 50 users) with GitHub as identity provider Zero auth code. Dashboard config only. Users see GitHub login screen.
Cloud Backup Cloudflare R2 (10GB free, S3-compatible) Worker receives JSON state export, stores in R2. Restore on new device.
API + Hosting One Cloudflare Worker w/ static assets binding Serves React build AND /api/sync routes from same origin. ~50 lines of API code. Sync protected by Access.
Existing proxy Keep separate (marginalia-reader Worker) Jina Reader CORS proxy for webpage-to-markdown. Unauthenticated; folding it in would force carving a public route through Access.
Deploy wrangler deploy (manual or via GitHub Actions / Workers Builds) No separate Pages deploy.

Why This Stack

  • 3 new technologies (Dexie.js, R2, Cloudflare Access) instead of 6 (D1, Hono, jose, Dexie, custom OAuth, service worker)
  • Zero auth code — Cloudflare Access handles GitHub OAuth, session management, CSRF, token refresh, logout
  • Instant persistence — IndexedDB writes are ~1ms, no network latency
  • Offline by default — IndexedDB is the primary store, not a cache
  • $0 total cost — Cloudflare free tier covers everything for a single user + up to 49 friends
  • Clean upgrade path — If >50 users: swap Access for custom GitHub OAuth. If need real-time sync: add D1 or Durable Objects. If need collaboration: add Yjs + PartyServer.

Auth Details

  • GitHub OAuth via Cloudflare Access — free, no code, users see standard GitHub login
  • Why GitHub over Google: Target audience is developers who already paste GitHub URLs. GitHub OAuth has zero MAU limits, no verification process, 3-field setup. Google requires OAuth consent screen verification, privacy policy URL, domain verification.
  • 50-user free limit on Access: Migration to custom GitHub OAuth is straightforward when needed — downstream code checks a JWT either way, only the token issuer changes.
  • "Share with my notes" future feature: Access protects routes all-or-nothing, so shared/public documents would be served from a separate unprotected Worker route (e.g., /public/:shareId). This doesn't block the architecture.

Data Model (Dexie.js / IndexedDB)

// Database schema
const db = new Dexie('marginalia');
db.version(1).stores({
  documents: 'id, title, updatedAt, createdAt',
  annotations: 'id, documentId, createdAt',
  versions: 'id, documentId, createdAt',
  syncState: 'key'  // last sync timestamp, etc.
});

interface Document {
  id: string;           // UUID
  title: string;
  content: string;      // Full markdown
  sourceUrl?: string;   // If loaded from GitHub/webpage
  tags: string[];
  createdAt: string;    // ISO date
  updatedAt: string;    // ISO date
}

interface Annotation {
  id: string;
  documentId: string;
  selectedText: string;
  note: string;
  markdownStartOffset: number;
  markdownEndOffset: number;
  createdAt: string;
  updatedAt: string;
}

interface DocumentVersion {
  id: string;
  documentId: string;
  content: string;      // Full markdown snapshot
  createdAt: string;
}
  • Annotations in separate store (not embedded in document) — independent CRUD, indexed lookups by document
  • Version history: Last 5 full snapshots per document (start conservative, increase later)
  • R2 backup format: Single JSON blob per user containing all documents + annotations + versions

Auto-Save Architecture

  • Primary save: Write to IndexedDB via Dexie.js — instant, ~1ms
  • Cloud backup: Debounced (every 5 minutes or on significant changes) JSON export → Worker → R2
  • Immediate save on tab blur (visibilitychange event) and beforeunload
  • Multi-tab: Web Locks API for leader election if needed, or simply let each tab write to IndexedDB (last write wins — fine for single user)
  • Save status UI: idle → saving → saved → error (React 19 useTransition)
  • No CRDTs needed for single-user

Implementation Phases

Phase 1: New Repo + Ported Core Engine

  • ✅ Create new repo (marginalia-cloud) with Vite 8 + React 19 + TypeScript + Tailwind 4 + @cloudflare/vite-plugin + wrangler
  • Port all core modules listed above (rehype plugins, selection, export, etc.) — likely interleaved with Phase 2 instead of done first
  • Port all components
  • Port tests
  • Deploy to Cloudflare Workers (static assets binding, not Pages)
  • Drop the /marginalia/ base path (root path on the new domain)

Phase 2: Local Persistence with Dexie.js

  • Add Dexie.js
  • Create IndexedDB schema (documents, annotations, versions stores)
  • Refactor useAnnotations hook to read/write from Dexie instead of in-memory state
  • Add document CRUD operations (create, rename, delete)
  • Auto-save: annotations and edits persist to IndexedDB immediately
  • Version snapshots: save to versions store on significant changes (e.g., every 10 edits or 5-minute intervals)

Phase 3: Document Library Dashboard

  • New landing page: list of saved documents (title, last modified date, created date)
  • Sort by last modified (default), title, created date
  • Search by title (client-side, Dexie query)
  • Create new blank document
  • Import from URL / paste / drag-drop → saves to library
  • Delete document (with confirmation)
  • The existing InputView.tsx flow (paste/URL/drag-drop) should still work but now saves the result as a document

Phase 4: Cloud Backup via R2

  • Set up Cloudflare Access with GitHub identity provider (dashboard config)
  • Add /api/sync routes to the existing Worker:
    • PUT /api/sync — receives full state JSON, writes to R2 as {userId}/state.json (userId derived from verified Access JWT, never client-supplied)
    • GET /api/sync — returns latest state JSON from R2 for the verified user
  • Frontend sync logic: periodic export from Dexie → POST to same-origin /api/sync
  • Restore flow: on first load in new browser, check R2 for existing state, offer to hydrate IndexedDB
  • Existing Jina proxy stays in marginalia-reader Worker (separate deploy, unauthenticated)

Phase 5: Polish & Enhancements

  • Tags/folders for document organization
  • Client-side full-text search across documents (MiniSearch or Fuse.js)
  • Version history UI (list snapshots, restore previous version)
  • Save status indicator in header
  • "Last synced" timestamp
  • Banner on v1 repo pointing to v2
  • Service Worker for offline app shell caching (vite-plugin-pwa) — optional

Upgrade Paths (When Complexity Is Justified)

Trigger Action
>50 users Swap Cloudflare Access for custom GitHub OAuth (Hono + jose, ~250 lines)
Need server-side queries Add Cloudflare D1 alongside or replacing R2 JSON blobs
Multi-device real-time sync Add Durable Objects or PartyServer + WebSocket
Collaboration Add Yjs CRDTs via y-partyserver
Large documents (>1MB) Store content in R2, metadata in D1
>100 documents per user Add D1 FTS5 full-text search server-side

Cost Analysis

Resource Free Limit Breaks At
Workers requests (incl. static asset serving) 100K/day ~100 daily active users
Workers Builds 3,000 build min/month free ~100 medium builds/month
R2 storage 10 GB ~20,000 large documents
R2 writes (Class A) 1M/month ~33K syncs/day
R2 reads (Class B) 10M/month ~333K restores/day
Cloudflare Access 50 users 50 users (then custom OAuth)

Total cost: $0 for single user + friends. First paid tier is $5/month (Workers Paid) which provides massive headroom.


Risks & Gotchas

  • IndexedDB storage limits: Browsers typically allow 50-80% of disk space. For a markdown app this is effectively unlimited, but Safari on iOS is more aggressive about evicting storage under pressure. Service Worker registration helps signal to the browser that storage is important.
  • R2 backup is not incremental: Full state JSON means the sync payload grows with document count. Fine for <100 docs, may need chunking or D1 migration after that.
  • Cloudflare Access free tier changes: Cloudflare has the most stable free tier track record (it drives their paid CDN/security business), but Heroku, PlanetScale, and others have killed free tiers. The architecture is portable — Dexie.js is browser-standard, R2 is S3-compatible.
  • GDPR: If storing any user data server-side (even just GitHub user ID in R2 key), need a privacy policy. GitHub OAuth requires one. Keep it simple — store minimal data, implement "Delete Account" that wipes R2.
  • Monitoring: Cloudflare Workers analytics (built-in) + Sentry free tier (5K errors/month) + UptimeRobot (50 monitors free).

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions