No description
Find a file
2026-04-16 13:25:59 +02:00
.github/workflows ci: add dist externals verification step 2026-03-17 19:41:47 +01:00
dist feat: add stealth: true frontmatter for permanently hidden encrypted pages 2026-04-11 14:52:11 +02:00
src feat: add stealth: true frontmatter for permanently hidden encrypted pages 2026-04-11 14:52:11 +02:00
test feat: add stealth: true frontmatter for permanently hidden encrypted pages 2026-04-11 14:52:11 +02:00
types fix: centralize type declarations and add missing dependencies 2026-03-16 18:54:32 +01:00
.eslintrc.json fix: centralize type declarations and add missing dependencies 2026-03-16 18:54:32 +01:00
.gitignore feat!: replace visibility with unlisted convention and shadow content index 2026-04-11 13:48:48 +02:00
.prettierignore fix: centralize type declarations and add missing dependencies 2026-03-16 18:54:32 +01:00
.prettierrc Initial commit 2026-03-12 19:20:13 +01:00
CHANGELOG.md feat: add stealth: true frontmatter for permanently hidden encrypted pages 2026-04-11 14:52:11 +02:00
LICENSE Initial commit 2026-03-12 19:20:13 +01:00
package-lock.json deps: update @quartz-community/types and @quartz-community/utils 2026-04-16 13:25:59 +02:00
package.json fix: declare plugin as both transformer and emitter in manifest 2026-04-11 14:07:25 +02:00
README.md docs: list bases views in the set of server-side surfaces 2026-04-11 15:26:05 +02:00
tsconfig.build.json fix: use separate tsconfig for DTS build to avoid vitest dependency 2026-03-22 13:12:01 +01:00
tsconfig.json Initial commit 2026-03-12 19:20:13 +01:00
tsup.config.ts fix: move tsconfig from dts object to top-level option 2026-03-22 13:25:12 +01:00
vitest.config.ts Initial commit 2026-03-12 19:20:13 +01:00

@quartz-community/encrypted-pages

Password-protected encrypted pages for Quartz v5.

How it works

Build-time encryption with AES-256-GCM, client-side decryption via the Web Crypto API, PBKDF2-SHA256 key derivation.

Two plugins work together:

  • EncryptedPages (transformer) — encrypts the rendered HTML of any page whose frontmatter contains a password.
  • EncryptedContentIndex (emitter) — emits a sibling shadow content index that client-side decryption uses to dynamically reveal unlisted encrypted pages in graph, explorer, and search after a successful unlock.

Installation

npx quartz plugin add github:quartz-community/encrypted-pages

Usage

Add a password field to the frontmatter of any page you want to encrypt. Optionally add unlisted: true to hide the page from every build-time listing surface; the page will be revealed dynamically to graph/explorer/search on successful client-side decryption.

---
title: My Secret Page
password: mysecretpassword
unlisted: true
---

Configuration

This plugin ships both a transformer (EncryptedPages) and an emitter (EncryptedContentIndex) in a single package. Quartz v5 instantiates both automatically from one config entry — you do not need to register them separately.

quartz.config.yaml

- source: github:quartz-community/encrypted-pages
  enabled: true
  options:
    iterations: 600000
    passwordField: password
    unlistWhenEncrypted: true

Plugin ordering matters. EncryptedPages replaces the entire HAST tree of an encrypted page with an opaque ciphertext container, so any transformer that must see the real HTML — in particular CrawlLinks, which populates file.data.links for the shadow content index — must run before EncryptedPages. Use the order field in your config to ensure CrawlLinks has a lower order value than EncryptedPages.

Component

Add the EncryptedPage component to your body layout in quartz.layout.ts.

Options

All options below are set at the single plugin-entry level and are shared between the transformer and the emitter. Quartz passes the same merged options object to both factories automatically.

Option Type Default Used by Description
iterations number 600000 transformer PBKDF2 iteration count for key derivation. Higher is more secure but slower to unlock.
passwordField string "password" transformer, emitter Frontmatter field name that holds the page password.
unlistWhenEncrypted boolean false transformer When true, encrypted pages are marked file.data.unlisted = true, hiding them from every build-time listing surface that respects the unlisted convention (contentIndex, RSS, sitemap, backlinks, recent-notes, folder-page, tag-page, graph, explorer, search). Per-page frontmatter.unlisted overrides.
outputPath string "static/encryptedContentIndex.json" emitter Output path for the shadow content index, relative to Quartz's output directory.

EncryptedPage (component)

Option Type Default Description
className string "encrypted-page-wrapper" CSS class for the component wrapper.

Frontmatter fields

Field Type Description
password string Enables encryption for this page. The field name is configurable via passwordField.
unlisted boolean Overrides unlistWhenEncrypted. true forces the page unlisted; false forces it listed even when unlistWhenEncrypted is also set.
stealth boolean When true and the page is encrypted, suppresses the page's entry in the shadow content index. The page stays hidden from every listing surface even after successful client-side decryption. Implies unlisted: true (overrides any explicit unlisted: false). No effect on non-encrypted pages.

How unlisted works

When an encrypted page is marked unlisted: true:

  • The page HTML is still emitted at its normal URL.
  • It is absent from contentIndex.json, RSS, sitemap, and every server-side listing that respects the unlisted convention (backlinks, recent-notes, folder-page, tag-page, and bases views from @quartz-community/bases-page).
  • Its metadata (slug, title, links, tags) is encrypted with the page's own password and emitted to static/encryptedContentIndex.json in an opaque, flat JSON array. No slugs or titles leak to anonymous visitors.
  • On any page load, if the user has cached passwords in sessionStorage from a previous successful decryption, the client script fetches the shadow index, decrypts entries matching those passwords, and patches the in-memory content index in place. A content-index-updated event is dispatched so graph, explorer, and search reinitialize with the unlocked entries.
  • Server-side rendered listings (backlinks, recent-notes, folder-page, tag-page, bases views) remain statically hidden even after client-side decryption. This is a deliberate trade-off: those surfaces are HTML baked at build time and cannot be patched client-side. Graph, explorer, and search all re-hydrate from the patched in-memory content index and show newly-unlocked pages for the rest of the session — base views do not, because they were fully materialized at build time.

How stealth works

When an encrypted page is marked stealth: true, it behaves like an unlisted page with one critical difference: its entry is never emitted to the shadow content index. As a result:

  • The page HTML is still emitted at its normal URL and accessible to anyone who knows it.
  • The page is absent from contentIndex.json, RSS, sitemap, graph, explorer, search, backlinks, recent notes, folder listings, and tag listings — same as any unlisted page.
  • Unlike unlisted, successful client-side decryption does not reveal the page in graph, explorer, or search. There is no shadow-index entry to decrypt, so the in-memory content index is never patched for this page.
  • The password cache still works: a user who unlocks a stealth page once will not have to re-enter the password on subsequent visits within the same session. Only discovery surfaces are affected.

Use stealth: true when you want a "secret door" page: accessible only to users who already know the exact URL, invisible to everyone else permanently, even to users who have successfully decrypted other encrypted pages on the same site.

---
title: Deep Secret
password: hunter2
stealth: true
---

Setting stealth: true is equivalent to unlisted: true on a page that does not also have a password field — the flag has no effect on non-encrypted pages, because stealth only controls the shadow-index emission, and non-encrypted pages do not have shadow-index entries in the first place.

Security

  • AES-256-GCM with 16-byte salt, 12-byte IV, 16-byte auth tag.
  • PBKDF2-SHA256 with 600,000 default iterations.
  • Plaintext leakage prevention: file.data.text and file.data.description are cleared by the transformer.
  • The shadow content index entries are individually encrypted with the same password as the page they describe. An attacker downloading encryptedContentIndex.json learns only the number of unlisted encrypted pages and the PBKDF2 iteration count. No slugs, titles, or link relationships are exposed.
  • Passwords are cached in sessionStorage, which clears when the browser session ends.
  • This is client-side encryption for a static site. A determined attacker with access to the ciphertext can run an offline brute-force. Use strong passwords.

Password caching

Successful passwords are cached in sessionStorage so subsequent encrypted pages with the same password auto-unlock. Successfully decrypted shadow entries are also cached for the session to avoid re-running PBKDF2 on every navigation.

Render events

After a page is decrypted, a render CustomEvent is dispatched. After the shadow content index is patched, a content-index-updated CustomEvent is dispatched with a detail.slugs array of newly-added slugs. Graph, explorer, and search re-initialize on these events.

License

MIT