Extend the "How unlisted works" section of the README to mention bases
views alongside backlinks/recent-notes/folder-page/tag-page as a
server-side rendered listing surface. The behavior has been correct
since the bases-page plugin started respecting the file.data.unlisted
convention -- this commit only makes the existing limitation discoverable
from the encrypted-pages README instead of hiding it.
Specifically, the README now says:
- Unlisted encrypted pages are absent from every server-side listing
that respects the unlisted convention: backlinks, recent-notes,
folder-page, tag-page, and bases views from @quartz-community/bases-page.
- Those server-side surfaces remain statically hidden even after
client-side decryption. 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.
No code changes.
Stealth pages are encrypted pages that are hidden from every listing
surface permanently, even after a successful client-side decryption.
Their metadata is never written to the shadow content index, so there
is nothing for the client to recover -- the page remains invisible to
graph, explorer, search, backlinks, recent-notes, folder-page, and
tag-page for the entire browser session.
This fills a gap between "unlisted" (hidden pre-decryption, revealed
post-decryption via shadow index) and the desire for "secret door"
pages that should only be accessible to users who already know the
exact URL.
Semantics:
- `stealth: true` implies `unlisted: true`. Setting stealth on a page
automatically hides it from listings regardless of the `unlisted`
field or the `unlistWhenEncrypted` plugin option.
- `stealth: true` overrides explicit `unlisted: false`. A user who
writes `stealth: true, unlisted: false` gets a hidden stealth page,
because stealth without unlisted is semantically incoherent.
- `stealth: true` has no effect on non-encrypted pages. Stealth only
controls shadow-index emission, which only happens for encrypted
unlisted pages. Non-encrypted pages with `stealth: true` are silently
ignored (use `unlisted: true` for non-encrypted hiding, via the
`unlisted-pages` plugin).
- Non-boolean stealth values are ignored.
Implementation:
- transformer.ts: after setting `data.unlisted`, check if
`frontmatter.stealth === true` and if so set `data.stealth = true`
and force `data.unlisted = true`.
- emitter.ts: in the shadow-index loop, skip any page where
`data.stealth === true`.
Tests: 8 new (6 transformer + 2 emitter) covering stealth -> unlisted
implication, stealth overriding explicit unlisted: false, stealth on
non-encrypted pages being a no-op, non-boolean stealth values being
ignored, stealth pages being excluded from the shadow index while
non-stealth pages pass through unchanged, and mixed stealth/non-stealth
content being correctly partitioned. 29 tests total passing.
The previous commit added an EncryptedContentIndex emitter to the package
exports, but left package.json > quartz.category as "transformer". Quartz
v5's plugin loader (quartz/plugins/loader/config-loader.ts) reads the
manifest category field to decide which buckets a plugin belongs to. Since
only "transformer" was declared, the loader only ever searched for a
transformer factory in this module -- EncryptedContentIndex was never
instantiated, emit() was never called, and static/encryptedContentIndex.json
was never written. The shadow content index was effectively dead code.
Change quartz.category from "transformer" to ["transformer", "emitter"].
Quartz v5 supports array categories and pushes the plugin entry into every
matching processing bucket (see config-loader.ts lines 329-337), then
instantiates each category separately via findFactory(module, category),
which iterates all exports and picks the one whose shape matches. Both
EncryptedPages (has htmlPlugins) and EncryptedContentIndex (has emit) are
now correctly resolved from a single config entry in quartz.config.yaml.
Follow-on cleanups:
- EncryptedContentIndexOptions gains a passwordField field so the emitter
reads it from its own merged options instead of spelunking
ctx.cfg.plugins.transformers for the transformer's option. The Quartz
loader passes the same merged options object to every factory instantiated
from a given config entry, so the two plugins stay in sync automatically.
- Removed the warnIfEmitterMissing() helper and its dead BuildCtx import.
With category: ["transformer", "emitter"], the loader always instantiates
both when the package entry is enabled -- the warning is obsolete.
- package.json defaultOptions / optionSchema gain the outputPath field so
users can override the shadow-index output path from quartz.config.yaml.
- README no longer instructs users to register two plugin entries.
All 21 tests still pass. Build clean.
The previous visibility option on the transformer was a dead flag (nothing
read file.data.encryptedVisibility), and EncryptedPageFilter used shouldPublish
to return false, which removed encrypted pages from the build entirely --
directly contradicting the README promise that pages remain accessible by
direct URL.
This change rips both out and replaces them with a first-class file.data.unlisted
convention plus a companion emitter that writes an encrypted shadow content
index. On client-side decryption, cached passwords are replayed against the
shadow index, and the resolved fetchData object is mutated in place so graph,
explorer, and search pick up decrypted pages for the rest of the browser
session without any Quartz core changes.
BREAKING CHANGES:
- Removed EncryptedPageFilter. It was actively harmful -- it deleted pages
from the build instead of hiding them from listings. Use unlistWhenEncrypted
or per-page frontmatter unlisted: true instead.
- Removed visibility option from EncryptedPages transformer. It had no effect.
- Removed EncryptedPagesOptions.visibility and EncryptedPageFilterOptions types.
New API:
- EncryptedPages.unlistWhenEncrypted: boolean (default false) -- marks all
encrypted pages with file.data.unlisted = true.
- Per-page frontmatter unlisted: true | false overrides unlistWhenEncrypted.
- New EncryptedContentIndex emitter writes static/encryptedContentIndex.json
as a versioned {version: 1, entries: [...]} wrapper. Each entry is an
opaque {ciphertext, iterations} blob. Slugs, titles, and link relationships
never leak -- they live only inside the ciphertext.
- Client-side encrypted.inline.ts fetches the shadow index when cached
passwords exist, tries each cached password against each blob, batches
successful patches, awaits fetchData, Object.assigns patches onto the
resolved object, and dispatches content-index-updated + render events.
- Transformer emits a build-time warning if unlistWhenEncrypted: true but
EncryptedContentIndex is not registered in plugins.emitters.
- encryptAesGcm, decrypt, SHADOW_INDEX_VERSION, and shadow index types are
exported for test and extension use.
Plugin ordering: EncryptedPages must run after CrawlLinks (or any other
transformer that populates file.data.links), because it replaces the HAST
tree with an opaque ciphertext container.
Tests: 21 passing (13 transformer + 8 emitter). Covers visibility removal,
unlistWhenEncrypted, frontmatter override precedence in both directions,
shadow index format, roundtrip decryption with per-page iteration counts,
non-encrypted skip, and independent ciphertexts across pages sharing a
password.
Plugins run server-side during the Quartz build step, not in the
browser. Setting platform: 'node' tells esbuild to treat Node builtins
as available externals and avoids CJS-in-ESM require() failures for
any bundled dependencies that use Node APIs.
Move non-allowlisted packages from dependencies/peerDependencies to
devDependencies so tsup bundles them into dist/ instead of leaving
them as external imports that may not resolve at runtime.
vfile is imported in source/test files but was only listed as a
peerDependency (or not at all). CI needs it installed to resolve
type declarations during tsc --noEmit and tsup DTS build.
Peer dependencies needed at build time for TypeScript declaration file
generation were missing from devDependencies. Without them, the DTS build
fails silently, producing no index.d.ts.
Pre-built output is now committed to the repository so that
Quartz can skip the build step during plugin installation.
The prepare script is removed to prevent redundant builds
when installing from npm/git.
tsup and typescript are only needed at build time — move to devDependencies
so they can be pruned after build. Dependencies already available from the
host Quartz installation are moved to peerDependencies to avoid duplication.
- Add popover-hint class to encrypted container so popovers pick up the element
- Add MutationObserver to detect encrypted containers injected into the DOM
(popovers append elements without firing nav/render events)