Skip to content

Latest commit

 

History

73 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Foil

A markdown editor that lives entirely in your browser. Type, format, share a link or an HTML file.

Open Foil on Cloudflare Pages. If that site is unavailable, use the GitHub Pages backup.

Foil also has a local Chrome/Edge Manifest V3 extension. Its toolbar button opens the full packaged editor in a new tab. The website and extension share the same editor and file formats, with separate local libraries.

Install the extension locally

With Node 22.22.3 and Bun 1.4.2, run from this repository:

bun install --frozen-lockfile
bun run typecheck
bun run test
bun run --cwd apps/extension package

Open chrome://extensions in Chrome or edge://extensions in Edge, enable Developer mode, select Load unpacked, and choose apps/extension/dist. Pin Foil from the Extensions menu, then click its icon to open the editor. Each click opens a new tab; installing it opens none. To use apps/extension/artifacts/foil-extension-0.1.0.zip, extract it and load the directory containing manifest.json. This ZIP is a local deliverable, not a published store listing. See the extension guide for watch/reload commands and configuration, and the official Chrome and Edge instructions.

Extension links and exported files use https://foil-47v.pages.dev/ by default. Set VITE_FOIL_SHARE_BASE_URL at build time to use another HTTP(S) website, including a subpath. Open shared link accepts a deliberately pasted website URL or Foil fragment, opens a read-only packaged preview, and never visits the pasted host. Pass any password/time gates, then select Edit anyway to add a local copy. This does not synchronize or migrate the website library.

Local editing and ordinary/password sharing and files work offline, including first export and file re-export. Time capsules require drand when sealing or decrypting. Extension documents and settings live in the browser profile; clearing extension data or uninstalling can remove them. Keep exported backups. There is no cloud sync or guarantee against storage exhaustion or simultaneous edits to the same document.

Automated tests load the production extension in bundled Playwright Chromium and open actual downloaded files in Chromium and WebKit. They exercise the compiled toolbar handler through DevTools. Native toolbar clicking and manual installations in branded Chrome/Edge have not been performed; automated Chromium results do not establish that manual coverage.

Privacy

There is no backend. There is no database. Nothing you write leaves your device.

Your local documents are kept in localStorage. When you want to share one, Foil packs it into a link of the form:

https://foil.example/#d=H4sIAAAAAAAAA02OTQ...

The piece after # is called the URL fragment. Browsers, by design, never send fragments to the server in HTTP requests. So the host that serves Foil sees only that you loaded the page; it has no way to read what you wrote, what's in the link you opened, or what links you shared. When a recipient opens a share link, Foil decodes the fragment and immediately strips it from the address bar, so the encoded blob doesn't linger in tab titles or screen-shared windows.

What this means in practice:

  • No accounts, no telemetry, no logs. The static HTML/JS is the entire app.
  • Your library is local. Documents live in your browser's localStorage under per-doc keys; clearing site data wipes them. Switch between them, rename, or delete from the title-bar dropdown.
  • Share a link or HTML file. Each contains a snapshot of the title, text and comments. Anyone holding an unprotected copy can read it.
  • Shared links open read-only. Recipients see a preview; one click forks it into their own local library, where their edits stay on their device.
  • Encrypted sharing. Optionally lock a link or HTML file with a password before sharing. The document is encrypted with AES-GCM-256 using a key derived via PBKDF2-SHA256 (600,000 rounds) from your password. The password is never in the link or file — only the ciphertext, salt, and IV are. Protected files use a generic filename and hide the title, text and comments until unlocked.
  • Time capsules. Optionally seal a link or HTML file until a future date. Until then nobody — not even you — can open it. This uses drand time-lock encryption (tlock): the document is encrypted against a future round of the drand "quicknet" beacon, and only becomes decryptable once that round's signature is published. The wait is enforced by a public randomness network, not by Foil. Combine it with a password and the chain is gzip → tlock → AES-GCM, so opening it needs both the published signature and the password.
  • Settings stay local too. Theme, font, and your display name for comments are kept in localStorage and never travel with a share link or HTML file.

If the host disappears tomorrow, your old links keep working as long as you have a copy of Foil's static files and the URL.

Threat model, briefly

  • Hosting provider can't read your docs — fragments aren't transmitted.
  • Anyone with the link can read it — treat unencrypted links like a file you emailed. Use the password option for sensitive content.
  • Browser history, sync, and clipboard managers will see the full URL. If you share a link over a channel that logs URLs (some chat apps, analytics-laden redirectors), the document goes with it. Encrypt first if that matters.
  • No forward secrecy. A leaked password decrypts password-protected links and files made with it; time capsules also require their drand unlock signature.
  • Time capsules depend on drand. A capsule can only be opened once the drand quicknet beacon publishes the unlock round. If that network disappears permanently, a sealed capsule is unrecoverable — even by you. Holding the link does not let anyone open it early.

Share an HTML file

  1. Open Share, choose an optional password and/or time lock, and click Export HTML.
  2. Send the downloaded .html file directly. Give the recipient any password separately.
  3. The recipient saves the attachment and opens it in a current browser with JavaScript enabled. Enter the password if prompted; a time capsule also waits for its unlock date, then offers Decrypt.

The file includes its entire reading program and styles. Ordinary and password-protected files work offline, including reopening, refreshing and exporting another file. Time capsules need drand access when created or decrypted, including when the unlock date has already passed. No document text or ciphertext is sent to drand. If the connection fails, retry after restoring it; cancelling leaves the file available to reopen.

Files provide a read-only preview: title, Markdown text, all existing comments and replies, desktop comment anchors, a mobile comment drawer, text selection/copy, reading statistics, settings, help and sharing. They have no editor, document library, title editing or comment-writing controls. Website share links can still be forked into a local library with Edit anyway.

Each export is a snapshot and never follows later author edits. Reading settings change presentation, not the document saved in the file; they remain usable for the session if browser storage is denied. From the file's Share dialog you can export again or copy a link to its source website. Each new sharing session starts without protection: select any password or time lock again. Website links still have a 256 KiB limit; larger documents within the file format's limits can use HTML export.

Actual local file opening is tested in Playwright Chromium and WebKit. Support is not guaranteed for every browser or mail/chat attachment preview; save the attachment and open it in a browser instead of relying on an embedded preview.

How it works

Local editing. Each document is a JSON blob in localStorage under foil_doc_<id>. The currently open doc is tracked in sessionStorage, so two tabs can edit different docs side by side. Edits debounce-save back to the same key; the URL is never used for storage.

Sharing. When you open the share dialog, Foil serializes the document to JSON, compresses it with CompressionStream('gzip'), and packs it into one of four fragment schemes depending on the options you pick:

Fragment Options Layers
#d=… plain gzip → base64url
#e=… password gzip → AES-GCM → base64url
#td=… time capsule gzip → tlock → base64url
#te=… time capsule + password gzip → tlock → AES-GCM → base64url

The password layer is always outermost, so someone without the password cannot read the capsule's unlock round or tlock envelope. Copy writes a website link to the clipboard. Export HTML embeds the same payload schemes in versioned file data with a self-contained read-only program; it has a separate bounded transport budget and retains the codec's decompression and document-structure limits.

Loading a shared link. On load, if the URL has a fragment, Foil decodes it and clears the fragment from the address bar. Password links prompt for the password; time capsules show an unlock screen and stay sealed until drand publishes the unlock round. Once open, the document renders read-only — click the edit affordance to fork it into your local library.

See packages/editor/src/lib/url-codec.ts (packing), packages/editor/src/lib/timecapsule.ts (drand tlock), and packages/editor/src/lib/doc-store.ts (local storage) for the full implementations.

Features

  • WYSIWYG-ish markdown — formatting renders inline as you type
  • Local document library: switch, rename, delete from the title-bar dropdown
  • Inline comments anchored to text, traveling with the link
  • Password-encrypted share links
  • Time-capsule share links sealed until a future date via drand tlock (and optionally password-protected too)
  • Read-only previews for shared links, one-click fork into your library
  • Self-contained HTML snapshots, read-only with offline ordinary/password reading and re-export
  • Light/dark/auto theme, configurable accent, prose font, width, and density
  • Keyboard shortcuts: ⌘B / ⌘I / ⌘K

Repository layout

This is a Turborepo monorepo using Bun workspaces and one root lockfile.

apps/
  web/                       # @foil/web: the Foil website
    src/                     # Website mounting entry
    tests/e2e/               # Playwright website and local-file tests
    dist/                    # Generated static website (ignored)
  extension/                 # @foil/extension: MV3 toolbar + packaged editor tab
    tests/e2e/               # Installed Chromium and cross-host/file regressions
    dist/                    # Load unpacked from here (ignored)
    artifacts/               # Verified extension ZIP (ignored)
packages/
  editor/                    # @foil/editor: source-first shared application
    src/                     # Editor, standalone reader, assets and unit tests
    build/                   # Node-only standalone resource builder
  typescript-config/         # @foil/typescript-config: base and React TS configs
turbo.json                   # Task dependencies and cache settings
package.json                # Workspace definitions and root commands
bun.lock                    # Shared dependency lockfile

New applications belong in apps/<name> and shared libraries/configuration in packages/<name>. Give each a unique package name (for example @foil/api) and its own package.json, scripts and dependencies, then run bun install at the repository root. Declare internal dependencies with workspace:*; React apps can depend on @foil/typescript-config and extend @foil/typescript-config/react.json. Turbo discovers matching scripts automatically. Use a different development port for each app.

Develop

Run these commands from the repository root with the versions pinned in .node-version and package.json (Node 22.22.3 and Bun 1.4.2):

bun install --frozen-lockfile
bun run dev       # website on 5173 + extension build watcher (no port)
bun run build     # typecheck + bundle both apps into their own dist/
bun run preview   # serve the already-built bundle
bun run typecheck
bun run test      # all unit/component tests; run before builds, not concurrently
bun run test:e2e:install # first use: install Chromium + WebKit; Linux CI adds --with-deps
bun run test:e2e   # builds/ZIP, website + installed extension + actual local files

Root commands use Turbo. Shared unit tests run once in @foil/editor, and extension unit tests run in @foil/extension. Both apps own browser suites. build, typecheck and test are cached in .turbo/; browser tests always run. Extension e2e explicitly depends on both app builds and its checked package:dist ZIP, without an app-to-app runtime dependency. Dev and preview servers are persistent and uncached. Limit a task with --filter=@foil/web. For package-specific arguments, invoke the installed Turbo CLI directly with bunx --no-install so Bun's script runner does not consume Turbo's -- separator:

bun run dev --filter=@foil/web
bunx --no-install turbo run test --filter=@foil/editor -- src/lib/url-codec.test.ts
bunx --no-install turbo run test:e2e -- --workers=2

For root-path regression, finish the default suite first, then run these commands sequentially:

bunx --no-install turbo run build --filter=@foil/web -- --base /
FOIL_E2E_BASE=/ bun run --cwd apps/web test:e2e --workers=2
bun run build # restore the default /foil/ artifact

FOIL_E2E_PORT selects the website suite's preview port (default 4173); FOIL_EXTENSION_E2E_PORT independently selects extension recipients' preview port (default 4273). Both are strict, never reuse another server, and are explicitly forwarded by Turbo alongside FOIL_E2E_BASE, VITE_* and Playwright environment settings. Each app defaults to two workers locally and one in CI. The package-level test:e2e runs against existing outputs, so use it for the root-path variant; the root-level command ensures the default /foil/ build. Do not run two builds against the same apps/web/dist/. To run only the website file matrix after a matching build, use bun run --cwd apps/web test:e2e tests/e2e/html-export.spec.ts --workers=2 (add FOIL_E2E_BASE=/ for a root build).

Downloads, traces, screenshots and reports stay in ignored test-results/ and playwright-report/ directories under their owning app. Extension tests remove temporary profiles even after failures. The root browser command lets independent suites finish after a sibling failure, preserving their diagnostics and normal server cleanup, while still failing overall. Tests use only current-checkout website assets and fixed verified drand fixtures; unexpected HTTP(S) fails. WebKit file tests reject HTTP(S) through routing because its offline switch also prevents static file:// navigation. CI retains both apps' failure diagnostics and a verified extension ZIP for review. Pages uploads remain limited to apps/web/dist.

The shared @foil/editor package exports App and AppProps, @foil/editor/share (HTTP(S) base normalization and URL codec helpers/limits), @foil/editor/types, @foil/editor/standalone-runtime (resource types/IDs and validation), the two styles/*.css entries and brand/*.svg assets. Hosts mount <App /> for current-origin/path sharing, or pass shareBaseUrl and a React headerActions slot. The slot appears beside Settings/Share in editing and read-only headers.

Vite hosts register standalonePlugin() from the Node-only @foil/editor/build/standalone subpath. It resolves reader inputs from the shared package and emits foil-standalone.js into each host’s own build. The runtime loader resolves import.meta.env.BASE_URL against document.baseURI before importing, including relative ./ bases. The package has no build artifact; Turbo tracks it through the existing ^build dependency graph.

Stack: React 18 + TypeScript + Vite, with buffer, tlock-js and drand-client for time capsules. Website crypto stays dynamically loaded. The standalone reader includes crypto and styles in one file; its resource module is loaded by the website only when exporting HTML. Each build checks that the standalone entry has no editor/document-library dependencies or external chunks.

Deploy

With Wrangler installed and logged into the Cloudflare account that owns the foil Pages project (wrangler login), run:

bun run deploy

Bun reserves deploy as a built-in subcommand, so the run keyword is required.

This uses Turbo to typecheck and build only @foil/web into apps/web/dist/ with root asset paths (--base /), then publishes it to the foil project's main production branch at foil-47v.pages.dev. The upload runs outside Turbo's cache and environment filtering.

bun run build produces a fully static apps/web/dist/ with /foil/ asset paths for the GitHub Pages backup; its workflow uploads this directory. For a host serving at /, use bunx --no-install turbo run build --filter=@foil/web -- --base / and the same output directory. If configuring Cloudflare Pages Git integration, keep the repository root as the build root, use this root-path build command, and set the output directory to apps/web/dist.

Use Share → Export HTML to send a single document that opens directly as a local file.

About

Browser-only markdown editor with inline comments and password-encrypted, time-capsule share links.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages