feat!: replace visibility with unlisted convention and shadow content index

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.
This commit is contained in:
saberzero1 2026-04-11 13:48:48 +02:00
parent 41d2f12c93
commit efa3032973
No known key found for this signature in database
19 changed files with 868 additions and 351 deletions

3
.gitignore vendored
View file

@ -37,3 +37,6 @@ coverage/
# Cache
.cache/
.eslintcache
# Sisyphus working artifacts (plans, research notes)
.sisyphus/

View file

@ -9,4 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial Quartz community plugin template.
- `EncryptedContentIndex` emitter that writes a versioned shadow content index (`static/encryptedContentIndex.json`) containing opaque encrypted blobs of per-page metadata (slug, title, links, tags) for pages marked `unlisted: true`. The shadow index uses a flat array format so no slugs or titles leak to anonymous visitors.
- `unlistWhenEncrypted` option on the `EncryptedPages` 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) while still emitting the HTML so the page remains accessible by direct URL.
- Per-page `unlisted: true | false` frontmatter override. Explicit `unlisted: false` forces the page listed even when `unlistWhenEncrypted` is set.
- Client-side shadow-index unlocking: on every page load with cached passwords, the client script fetches the shadow index, decrypts entries with cached passwords, patches the resolved `fetchData` object in place, and dispatches `content-index-updated` so graph, explorer, and search re-initialize with the unlocked pages.
- Build-time warning when `unlistWhenEncrypted: true` is set but the companion `EncryptedContentIndex` emitter is not registered.
- `encryptAesGcm`, `decrypt`, and `SHADOW_INDEX_VERSION` exports for test and extension use.
- `ShadowIndexBlob`, `ShadowIndexFile`, and `ShadowContentIndexEntry` type exports.
### Removed
- **Breaking:** `EncryptedPageFilter`. It used Quartz's `shouldPublish` filter mechanism, which removes pages from the entire build (no HTML emitted), directly contradicting the plugin's own README. Use `unlistWhenEncrypted: true` or per-page `unlisted: true` frontmatter instead.
- **Breaking:** `visibility` option on the `EncryptedPages` transformer. It set a `file.data.encryptedVisibility` flag that nothing in the Quartz v5 ecosystem read — the option had no effect.
- **Breaking:** `EncryptedPagesOptions.visibility` type field.
- **Breaking:** `EncryptedPageFilterOptions` type export.
### Changed
- **Breaking:** The shadow content index requires the `EncryptedContentIndex` emitter to be registered. If it is missing but `unlistWhenEncrypted` is `true`, a console warning is logged at build time and unlisted encrypted pages will not be dynamically revealed after client-side decryption.
- Plugin ordering requirement: `EncryptedPages` must run after `CrawlLinks` (or any other transformer that populates `file.data.links`) in the `htmlPlugins` chain. Documented in README.

View file

@ -4,7 +4,12 @@ Password-protected encrypted pages for Quartz v5.
## How it works
This plugin uses build-time encryption with AES-256-GCM. Decryption happens on the client side using the Web Crypto API. Key derivation is handled by PBKDF2.
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
@ -14,12 +19,13 @@ npx quartz plugin add github:quartz-community/encrypted-pages
## Usage
Add a `password` field to the frontmatter of any page you want to encrypt.
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.
```yaml
---
title: My Secret Page
password: mysecretpassword
unlisted: true
---
```
@ -27,33 +33,32 @@ password: mysecretpassword
### quartz.config.ts
Import the plugin and add it to your configuration.
```ts
import * as ExternalPlugin from "./.quartz/plugins";
// In plugins.transformers:
// plugins.transformers:
ExternalPlugin.CrawlLinks(),
ExternalPlugin.EncryptedPages({
visibility: "icon",
iterations: 600000,
iterations: 600_000,
passwordField: "password",
});
unlistWhenEncrypted: true,
}),
// In plugins.filters:
ExternalPlugin.EncryptedPageFilter({ visibility: "icon" });
// plugins.emitters:
ExternalPlugin.EncryptedContentIndex(),
```
### quartz.config.yaml
**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`. The transformer emits a console warning at build time if the `EncryptedContentIndex` emitter is not registered alongside it when `unlistWhenEncrypted: true`.
If you use the YAML configuration format:
### quartz.config.yaml
```yaml
- source: github:quartz-community/encrypted-pages
enabled: true
options:
visibility: icon
iterations: 600000
passwordField: password
unlistWhenEncrypted: true
```
### Component
@ -62,42 +67,59 @@ Add the `EncryptedPage` component to your body layout in `quartz.layout.ts`.
## Options
### EncryptedPages (Transformer)
### `EncryptedPages` (transformer)
| Option | Type | Default | Description |
| --------------- | --------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `visibility` | `"visible" \| "icon" \| "hidden"` | `"icon"` | How encrypted pages appear in graph, explorer, and backlinks. `visible` shows them normally. `icon` shows them with a lock indicator. `hidden` hides them. |
| `iterations` | `number` | `600000` | PBKDF2 iteration count for key derivation. Higher counts are more secure but slower to unlock. |
| `passwordField` | `string` | `"password"` | Frontmatter field name that holds the page password. |
| Option | Type | Default | Description |
| --------------------- | --------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `iterations` | `number` | `600000` | PBKDF2 iteration count for key derivation. Higher is more secure but slower to unlock. |
| `passwordField` | `string` | `"password"` | Frontmatter field name that holds the page password. |
| `unlistWhenEncrypted` | `boolean` | `false` | 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. |
### EncryptedPageFilter (Filter)
### `EncryptedContentIndex` (emitter)
| Option | Type | Default | Description |
| ------------ | --------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `visibility` | `"visible" \| "icon" \| "hidden"` | `"icon"` | Controls whether encrypted pages appear in search, RSS, and sitemap. `hidden` excludes them entirely. |
| Option | Type | Default | Description |
| ------------ | -------- | ------------------------------------- | ---------------------------------------------------------------------- |
| `outputPath` | `string` | `"static/encryptedContentIndex.json"` | Output path for the shadow content index, relative to Quartz's output. |
### EncryptedPage (Component)
### `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. |
## 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).
- 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) 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.
## Security
- AES-256-GCM encryption with 16-byte salt, 12-byte IV, and 16-byte auth tag.
- PBKDF2 SHA-256 key derivation with 600,000 iterations by default.
- No plaintext leakage. The `file.data.text` and `file.data.description` fields are stripped.
- Passwords are stored per-page in frontmatter. Don't commit these to public repositories.
- Session caching uses `sessionStorage`. It clears when the browser closes.
- This is client-side encryption for a static site. The encrypted HTML is in the page source. This protects against casual browsing but not against a determined attacker with access to the source.
- 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
The plugin uses `sessionStorage` to cache passwords. It enables an auto-try behavior when navigating between encrypted pages that share the same password.
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 event
## Render events
After a page is decrypted, a `render` CustomEvent is dispatched. Other components like the graph, TOC, and explorer can then re-initialize and display the decrypted content.
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

51
dist/index.d.ts vendored
View file

@ -1,30 +1,33 @@
import { QuartzTransformerPlugin, QuartzFilterPlugin } from '@quartz-community/types';
import { QuartzTransformerPlugin, QuartzEmitterPlugin } from '@quartz-community/types';
export { PageGenerator, PageMatcher, QuartzComponent, QuartzComponentConstructor, QuartzComponentProps, QuartzEmitterPlugin, QuartzFilterPlugin, QuartzPageTypePlugin, QuartzPageTypePluginInstance, QuartzTransformerPlugin, StringResource, VirtualPage } from '@quartz-community/types';
import { EncryptedPagesOptions, EncryptedPageFilterOptions } from './types.js';
import { EncryptedPagesOptions, EncryptedContentIndexOptions } from './types.js';
export { EncryptedPage, EncryptedPageComponentOptions } from './components/index.js';
/**
* Encrypted pages transformer.
*
* Reads a password from the page's frontmatter and encrypts the rendered HTML
* at build time using AES-256-GCM with PBKDF2 key derivation. The encrypted
* content is stored as a data attribute and decrypted client-side using the
* Web Crypto API.
*/
declare function encryptAesGcm(plaintext: string, password: string, iterations: number): string;
declare function decrypt(encryptedBase64: string, password: string, iterations: number): string;
declare const EncryptedPages: QuartzTransformerPlugin<Partial<EncryptedPagesOptions>>;
/**
* Filter that controls whether encrypted pages appear in the content pipeline.
*
* When `visibility` is `"hidden"`, encrypted pages are removed from the content
* array before emitters run, so they won't appear in contentIndex.json, RSS,
* or sitemap. The page HTML is still emitted (by the static-content emitter),
* but search/graph/explorer won't reference it.
*
* When `visibility` is `"visible"` or `"icon"`, encrypted pages remain in the
* pipeline. The transformer already strips `text` and `description` from file.data,
* so the content index won't leak plaintext.
*/
declare const EncryptedPageFilter: QuartzFilterPlugin<Partial<EncryptedPageFilterOptions>>;
declare const SHADOW_INDEX_VERSION: 1;
interface ShadowIndexBlob {
ciphertext: string;
iterations: number;
}
interface ShadowIndexFile {
version: typeof SHADOW_INDEX_VERSION;
entries: ShadowIndexBlob[];
}
interface ShadowContentIndexEntry {
slug: string;
entry: {
slug: string;
filePath: string;
title: string;
links: string[];
tags: string[];
content: string;
description: string;
};
}
declare const EncryptedContentIndex: QuartzEmitterPlugin<Partial<EncryptedContentIndexOptions>>;
export { EncryptedPageFilter, EncryptedPageFilterOptions, EncryptedPages, EncryptedPagesOptions };
export { EncryptedContentIndex, EncryptedContentIndexOptions, EncryptedPages, EncryptedPagesOptions, SHADOW_INDEX_VERSION, type ShadowContentIndexEntry, type ShadowIndexBlob, type ShadowIndexFile, decrypt, encryptAesGcm };

153
dist/index.js vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

52
dist/types.d.ts vendored
View file

@ -1,18 +1,6 @@
export { BuildCtx, CSSResource, ChangeEvent, JSResource, PageGenerator, PageMatcher, ProcessedContent, QuartzEmitterPlugin, QuartzEmitterPluginInstance, QuartzFilterPlugin, QuartzFilterPluginInstance, QuartzPageTypePlugin, QuartzPageTypePluginInstance, QuartzPluginData, QuartzTransformerPlugin, QuartzTransformerPluginInstance, StaticResources, VirtualPage } from '@quartz-community/types';
/** How encrypted pages appear in graph/explorer/backlinks. */
type EncryptedPageVisibility = "visible" | "icon" | "hidden";
interface EncryptedPagesOptions {
/**
* How encrypted pages appear in the graph, explorer, and backlinks.
*
* - `"visible"` Title shown normally, content is encrypted.
* - `"icon"` Title shown with a lock icon indicator.
* - `"hidden"` Completely hidden from graph/explorer.
*
* @default "icon"
*/
visibility: EncryptedPageVisibility;
/**
* PBKDF2 iteration count for key derivation.
* Higher = slower brute-force attacks, but also slower page unlock.
@ -26,18 +14,40 @@ interface EncryptedPagesOptions {
* @default "password"
*/
passwordField: string;
}
interface EncryptedPageFilterOptions {
/**
* How encrypted pages appear in content indices (search, RSS, sitemap).
* 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).
*
* - `"visible"` Page appears in index with title but no content.
* - `"icon"` Same as visible (included in index, no content leak).
* - `"hidden"` Page is excluded from content index, RSS, and sitemap.
* The page HTML is still emitted, so the page remains accessible by
* its direct URL. After successful client-side decryption, the page is
* dynamically added back to client-side discovery surfaces (graph,
* explorer, search) via the shadow content index emitted by
* {@link EncryptedContentIndex}.
*
* @default "icon"
* Per-page frontmatter `unlisted: true | false` overrides this option.
*
* NOTE: When `true`, the `EncryptedContentIndex` emitter MUST also be
* registered in your Quartz configuration. Without it, unlisted encrypted
* pages cannot be dynamically revealed after decryption and will remain
* invisible to graph/explorer/search for the entire session. The
* transformer emits a console warning at build time if it detects the
* emitter is missing.
*
* @default false
*/
visibility: EncryptedPageVisibility;
unlistWhenEncrypted: boolean;
}
interface EncryptedContentIndexOptions {
/**
* Output path for the shadow content index, relative to the Quartz output
* directory. The file is a JSON document wrapping a flat array of opaque
* encrypted blobs; see the README for the format.
*
* @default "static/encryptedContentIndex.json"
*/
outputPath: string;
}
export type { EncryptedPageFilterOptions, EncryptedPageVisibility, EncryptedPagesOptions };
export type { EncryptedContentIndexOptions, EncryptedPagesOptions };

View file

@ -100,24 +100,19 @@
"defaultOrder": 900,
"defaultEnabled": true,
"defaultOptions": {
"visibility": "icon",
"iterations": 600000,
"passwordField": "password"
"passwordField": "password",
"unlistWhenEncrypted": false
},
"optionSchema": {
"visibility": {
"type": "enum",
"values": [
"visible",
"icon",
"hidden"
]
},
"iterations": {
"type": "number"
},
"passwordField": {
"type": "string"
},
"unlistWhenEncrypted": {
"type": "boolean"
}
},
"components": {

View file

@ -1,17 +1,12 @@
// @ts-nocheck
// ============================================================================
// Encrypted Pages — Client-Side Decryption Script
// ============================================================================
// Runs in the browser. Decrypts AES-256-GCM encrypted content using the
// Web Crypto API with PBKDF2 key derivation. Caches successful passwords
// in sessionStorage for convenience across encrypted pages.
// ============================================================================
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
/** Decode a base64 string into a Uint8Array. */
const PASSWORDS_KEY = "encrypted-pages-passwords";
const DECRYPTED_ENTRIES_KEY = "encrypted-pages:decryptedShadowEntries";
const SHADOW_INDEX_VERSION = 1;
function base64ToBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
@ -21,7 +16,6 @@ function base64ToBuffer(base64) {
return bytes;
}
/** Derive an AES-256-GCM key from a password and salt using PBKDF2. */
async function deriveKey(password, salt, iterations) {
const enc = new TextEncoder();
const passwordKey = await window.crypto.subtle.importKey(
@ -46,13 +40,6 @@ async function deriveKey(password, salt, iterations) {
);
}
/**
* Decrypt an AES-256-GCM encrypted payload.
*
* Input format: base64(salt[16] + iv[12] + authTag[16] + ciphertext)
* Node.js crypto produces authTag separately, but Web Crypto expects
* tag appended to ciphertext. We reassemble accordingly.
*/
async function decryptContent(encryptedBase64, password, iterations) {
const data = base64ToBuffer(encryptedBase64);
@ -61,7 +48,6 @@ async function decryptContent(encryptedBase64, password, iterations) {
const authTag = data.slice(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.slice(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
// Web Crypto expects ciphertext + authTag concatenated
const ciphertextWithTag = new Uint8Array(ciphertext.length + authTag.length);
ciphertextWithTag.set(ciphertext, 0);
ciphertextWithTag.set(authTag, ciphertext.length);
@ -77,10 +63,9 @@ async function decryptContent(encryptedBase64, password, iterations) {
return new TextDecoder().decode(decrypted);
}
/** Get cached passwords from sessionStorage. */
function getCachedPasswords() {
try {
const raw = sessionStorage.getItem("encrypted-pages-passwords");
const raw = sessionStorage.getItem(PASSWORDS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
@ -89,16 +74,33 @@ function getCachedPasswords() {
}
}
/** Add a password to the sessionStorage cache. */
function cachePassword(password) {
const passwords = getCachedPasswords();
if (!passwords.includes(password)) {
passwords.push(password);
sessionStorage.setItem("encrypted-pages-passwords", JSON.stringify(passwords));
sessionStorage.setItem(PASSWORDS_KEY, JSON.stringify(passwords));
}
}
function getDecryptedShadowEntries() {
try {
const raw = sessionStorage.getItem(DECRYPTED_ENTRIES_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function storeDecryptedShadowEntries(entries) {
try {
sessionStorage.setItem(DECRYPTED_ENTRIES_KEY, JSON.stringify(entries));
} catch {
// sessionStorage quota - fail silently
}
}
/** Show an error message on the password form. */
function showError(container, message) {
const errorEl = container.querySelector(".encrypted-page-error");
if (errorEl) {
@ -107,7 +109,6 @@ function showError(container, message) {
}
}
/** Hide the error message. */
function hideError(container) {
const errorEl = container.querySelector(".encrypted-page-error");
if (errorEl) {
@ -116,7 +117,6 @@ function hideError(container) {
}
}
/** Set the form into a loading/disabled state. */
function setLoading(container, loading) {
const button = container.querySelector(".encrypted-page-submit");
const input = container.querySelector(".encrypted-page-input");
@ -129,11 +129,117 @@ function setLoading(container, loading) {
}
}
/**
* Attempt to decrypt and reveal the page content.
* On success, replaces the encrypted container with decrypted HTML and
* dispatches a `render` event so other components re-initialize.
*/
function resolveShadowIndexPath() {
const scripts = document.querySelectorAll("script");
for (const script of scripts) {
const text = script.textContent ?? "";
const match = text.match(/fetch\(["']([^"']+contentIndex\.json)["']\)/);
if (match) {
return match[1].replace(/contentIndex\.json$/, "encryptedContentIndex.json");
}
}
return new URL("static/encryptedContentIndex.json", document.baseURI).toString();
}
let shadowIndexPromise = null;
async function fetchShadowIndex() {
if (shadowIndexPromise) return shadowIndexPromise;
const url = resolveShadowIndexPath();
shadowIndexPromise = fetch(url)
.then((r) => {
if (!r.ok) throw new Error(`shadow index HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (!data || data.version !== SHADOW_INDEX_VERSION || !Array.isArray(data.entries)) {
return { version: SHADOW_INDEX_VERSION, entries: [] };
}
return data;
})
.catch(() => ({ version: SHADOW_INDEX_VERSION, entries: [] }));
return shadowIndexPromise;
}
async function decryptShadowEntries(shadowFile, passwords, alreadyDecrypted) {
const patch = {};
const newDecrypted = { ...alreadyDecrypted };
let changed = false;
for (let i = 0; i < shadowFile.entries.length; i++) {
const key = String(i);
if (alreadyDecrypted[key]) {
const cached = alreadyDecrypted[key];
if (cached && cached.slug) {
patch[cached.slug] = cached.entry;
}
continue;
}
const blob = shadowFile.entries[i];
if (!blob || typeof blob.ciphertext !== "string") continue;
for (const pw of passwords) {
try {
const plaintext = await decryptContent(blob.ciphertext, pw, blob.iterations);
const decoded = JSON.parse(plaintext);
if (decoded && typeof decoded.slug === "string" && decoded.entry) {
patch[decoded.slug] = decoded.entry;
newDecrypted[key] = decoded;
changed = true;
}
break;
} catch {
// try next password
}
}
}
if (changed) {
storeDecryptedShadowEntries(newDecrypted);
}
return patch;
}
async function applyShadowPatches(patch) {
const slugs = Object.keys(patch);
if (slugs.length === 0) return;
try {
const base = await (typeof fetchData !== "undefined" ? fetchData : Promise.resolve(null));
if (!base || typeof base !== "object") return;
const root = base.content && typeof base.content === "object" ? base.content : base;
for (const slug of slugs) {
if (!(slug in root)) {
root[slug] = patch[slug];
}
}
} catch {
return;
}
document.dispatchEvent(new CustomEvent("content-index-updated", { detail: { slugs } }));
document.dispatchEvent(new CustomEvent("render"));
}
let shadowUnlockInFlight = false;
async function tryUnlockShadowIndex() {
if (shadowUnlockInFlight) return;
const passwords = getCachedPasswords();
if (passwords.length === 0) return;
shadowUnlockInFlight = true;
try {
const shadowFile = await fetchShadowIndex();
if (!shadowFile.entries || shadowFile.entries.length === 0) return;
const alreadyDecrypted = getDecryptedShadowEntries();
const patch = await decryptShadowEntries(shadowFile, passwords, alreadyDecrypted);
await applyShadowPatches(patch);
} finally {
shadowUnlockInFlight = false;
}
}
async function attemptDecrypt(container, password) {
const encryptedData = container.getAttribute("data-encrypted");
const iterations = parseInt(container.getAttribute("data-iterations") || "600000", 10);
@ -143,24 +249,16 @@ async function attemptDecrypt(container, password) {
try {
const html = await decryptContent(encryptedData, password, iterations);
// Replace the encrypted container with decrypted content.
// The decrypted HTML is the full page body, so we replace the container's
// parent (article) children or the container itself.
const parent = container.parentElement;
if (parent) {
// Create a temporary wrapper to parse the HTML
const temp = document.createElement("div");
temp.innerHTML = html;
// Replace the encrypted container with decrypted content
container.replaceWith(...temp.childNodes);
}
// Cache the successful password
cachePassword(password);
// Dispatch render event so other plugins (graph, TOC, etc.) re-initialize
document.dispatchEvent(new CustomEvent("render"));
tryUnlockShadowIndex();
return true;
} catch {
@ -168,19 +266,19 @@ async function attemptDecrypt(container, password) {
}
}
/** Initialize decryption UI for all encrypted containers on the page. */
function init() {
const containers = document.querySelectorAll(".encrypted-page");
if (containers.length === 0) return;
if (containers.length === 0) {
tryUnlockShadowIndex();
return;
}
for (const container of containers) {
// Skip if already has a form (re-initialization after nav)
if (container.querySelector(".encrypted-page-form")) continue;
const encryptedData = container.getAttribute("data-encrypted");
if (!encryptedData) continue;
// Build the password prompt UI
const form = document.createElement("div");
form.className = "encrypted-page-form";
form.innerHTML = [
@ -235,7 +333,6 @@ function init() {
});
}
// Try cached passwords automatically
const cached = getCachedPasswords();
if (cached.length > 0) {
(async () => {
@ -246,30 +343,25 @@ function init() {
})();
}
}
tryUnlockShadowIndex();
}
// Listen to Quartz navigation events
document.addEventListener("nav", () => {
init();
});
// Re-initialize on render (e.g. if another plugin triggers re-render)
document.addEventListener("render", () => {
// Only re-init if there are still encrypted containers
const containers = document.querySelectorAll(".encrypted-page");
if (containers.length > 0) {
init();
}
});
// Watch for encrypted containers injected into the DOM (e.g. by popovers).
// Popovers fetch page HTML and append elements without firing nav/render events,
// so a MutationObserver ensures the password prompt is initialized.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// Check if the added node is or contains an uninitialized encrypted container
const containers = node.classList?.contains("encrypted-page")
? [node]
: [...node.querySelectorAll(".encrypted-page")];

145
src/emitter.ts Normal file
View file

@ -0,0 +1,145 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { Root, Element } from "hast";
import type {
BuildCtx,
FilePath,
ProcessedContent,
QuartzEmitterPlugin,
} from "@quartz-community/types";
import { joinSegments } from "@quartz-community/types";
import { encryptAesGcm } from "./transformer";
import type { EncryptedContentIndexOptions } from "./types";
export const SHADOW_INDEX_VERSION = 1 as const;
const defaultOptions: EncryptedContentIndexOptions = {
outputPath: "static/encryptedContentIndex.json",
};
export interface ShadowIndexBlob {
ciphertext: string;
iterations: number;
}
export interface ShadowIndexFile {
version: typeof SHADOW_INDEX_VERSION;
entries: ShadowIndexBlob[];
}
export interface ShadowContentIndexEntry {
slug: string;
entry: {
slug: string;
filePath: string;
title: string;
links: string[];
tags: string[];
content: string;
description: string;
};
}
function buildShadowEntry(data: Record<string, unknown>): ShadowContentIndexEntry | null {
const slug = data.slug;
if (typeof slug !== "string" || slug.length === 0) return null;
const frontmatter = (data.frontmatter as Record<string, unknown> | undefined) ?? {};
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
const tags = Array.isArray(frontmatter.tags)
? (frontmatter.tags as unknown[]).filter((t): t is string => typeof t === "string")
: [];
const links = Array.isArray(data.links)
? (data.links as unknown[]).filter((l): l is string => typeof l === "string")
: [];
const filePath = typeof data.relativePath === "string" ? data.relativePath : "";
return {
slug,
entry: {
slug,
filePath,
title,
links,
tags,
content: "",
description: "",
},
};
}
export const EncryptedContentIndex: QuartzEmitterPlugin<Partial<EncryptedContentIndexOptions>> = (
userOptions?: Partial<EncryptedContentIndexOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
const emitAll = async (ctx: BuildCtx, content: ProcessedContent[]): Promise<FilePath[]> => {
const passwordField = readPasswordFieldFromCtx(ctx);
const entries: ShadowIndexBlob[] = [];
for (const [tree, file] of content) {
const data = (file.data ?? {}) as Record<string, unknown>;
if (data.encrypted !== true) continue;
if (data.unlisted !== true) continue;
const frontmatter = (data.frontmatter as Record<string, unknown> | undefined) ?? {};
const password = frontmatter[passwordField];
if (typeof password !== "string" || password.length === 0) continue;
const iterations = extractIterationsFromTree(tree as Root);
const shadowEntry = buildShadowEntry(data);
if (!shadowEntry) continue;
const plaintext = JSON.stringify(shadowEntry);
const ciphertext = encryptAesGcm(plaintext, password, iterations);
entries.push({ ciphertext, iterations });
}
const shadowFile: ShadowIndexFile = {
version: SHADOW_INDEX_VERSION,
entries,
};
const outputPath = joinSegments(ctx.argv.output, options.outputPath) as FilePath;
const dir = path.dirname(outputPath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(outputPath, JSON.stringify(shadowFile));
return [outputPath];
};
return {
name: "EncryptedContentIndex",
emit: emitAll,
partialEmit: emitAll,
};
};
function readPasswordFieldFromCtx(ctx: BuildCtx): string {
const plugins = ctx.cfg?.plugins as
| { transformers?: Array<{ name?: string; options?: { passwordField?: string } }> }
| undefined;
const transformers = plugins?.transformers;
if (!Array.isArray(transformers)) return "password";
const encPlugin = transformers.find((t) => t?.name === "EncryptedPages");
const field = encPlugin?.options?.passwordField;
return typeof field === "string" && field.length > 0 ? field : "password";
}
function extractIterationsFromTree(tree: Root): number {
for (const child of tree.children ?? []) {
if (child.type !== "element") continue;
const el = child as Element;
const props = el.properties ?? {};
const dataIterations = props["data-iterations"];
if (typeof dataIterations === "string") {
const parsed = parseInt(dataIterations, 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
if (typeof dataIterations === "number" && dataIterations > 0) return dataIterations;
}
return 600_000;
}

View file

@ -1,44 +0,0 @@
import type { QuartzFilterPlugin, ProcessedContent, BuildCtx } from "@quartz-community/types";
import type { EncryptedPageFilterOptions } from "./types";
const defaultOptions: EncryptedPageFilterOptions = {
visibility: "icon",
};
/**
* Filter that controls whether encrypted pages appear in the content pipeline.
*
* When `visibility` is `"hidden"`, encrypted pages are removed from the content
* array before emitters run, so they won't appear in contentIndex.json, RSS,
* or sitemap. The page HTML is still emitted (by the static-content emitter),
* but search/graph/explorer won't reference it.
*
* When `visibility` is `"visible"` or `"icon"`, encrypted pages remain in the
* pipeline. The transformer already strips `text` and `description` from file.data,
* so the content index won't leak plaintext.
*/
export const EncryptedPageFilter: QuartzFilterPlugin<Partial<EncryptedPageFilterOptions>> = (
userOptions?: Partial<EncryptedPageFilterOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
return {
name: "EncryptedPageFilter",
shouldPublish(_ctx: BuildCtx, [_tree, vfile]: ProcessedContent) {
const data = vfile.data as Record<string, unknown>;
const isEncrypted = data.encrypted === true;
if (!isEncrypted) {
return true;
}
// "hidden" visibility means encrypted pages are excluded entirely
if (options.visibility === "hidden") {
return false;
}
// "visible" and "icon" keep the page in the pipeline
// (plaintext is already stripped by the transformer)
return true;
},
};
};

View file

@ -1,12 +1,13 @@
export { EncryptedPages } from "./transformer";
export { EncryptedPageFilter } from "./filter";
export { EncryptedPages, encryptAesGcm, decrypt } from "./transformer";
export { EncryptedContentIndex, SHADOW_INDEX_VERSION } from "./emitter";
export { default as EncryptedPage } from "./components/EncryptedPage";
export type { EncryptedPagesOptions, EncryptedPageFilterOptions } from "./types";
export type { EncryptedPagesOptions, EncryptedContentIndexOptions } from "./types";
export type { ShadowIndexBlob, ShadowIndexFile, ShadowContentIndexEntry } from "./emitter";
export type { EncryptedPageComponentOptions } from "./components/EncryptedPage";
// Re-export shared types from @quartz-community/types
export type {
QuartzComponent,
QuartzComponentProps,

View file

@ -3,7 +3,7 @@ import type { PluggableList, Plugin } from "unified";
import type { Root as HastRoot, Element, ElementContent } from "hast";
import type { VFile } from "vfile";
import { toHtml } from "hast-util-to-html";
import type { QuartzTransformerPlugin } from "@quartz-community/types";
import type { BuildCtx, QuartzTransformerPlugin } from "@quartz-community/types";
import type { EncryptedPagesOptions } from "./types";
const ALGORITHM = "aes-256-gcm";
@ -13,17 +13,12 @@ const SALT_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const defaultOptions: EncryptedPagesOptions = {
visibility: "icon",
iterations: 600_000,
passwordField: "password",
unlistWhenEncrypted: false,
};
/**
* Encrypts plaintext using AES-256-GCM with PBKDF2 key derivation.
*
* Output format (base64-encoded): salt(16) + iv(12) + authTag(16) + ciphertext
*/
function encrypt(plaintext: string, password: string, iterations: number): string {
export function encryptAesGcm(plaintext: string, password: string, iterations: number): string {
const salt = crypto.randomBytes(SALT_LENGTH);
const iv = crypto.randomBytes(IV_LENGTH);
@ -37,10 +32,6 @@ function encrypt(plaintext: string, password: string, iterations: number): strin
return result.toString("base64");
}
/**
* Decrypts base64-encoded data encrypted by the `encrypt` function above.
* Used in tests to verify roundtrip correctness.
*/
export function decrypt(encryptedBase64: string, password: string, iterations: number): string {
const buffer = Buffer.from(encryptedBase64, "base64");
@ -60,16 +51,22 @@ export function decrypt(encryptedBase64: string, password: string, iterations: n
return decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8");
}
/**
* Rehype plugin that encrypts the page body when a password is set in frontmatter.
*
* The plugin:
* 1. Checks frontmatter for the configured password field.
* 2. Serializes the HAST tree to HTML.
* 3. Encrypts the HTML using AES-256-GCM with PBKDF2 key derivation.
* 4. Replaces the tree children with a single `<div>` containing encrypted metadata.
* 5. Sets flags on `file.data` so downstream plugins (filter, emitter) can detect encryption.
*/
function warnIfEmitterMissing(ctx: BuildCtx): void {
const plugins = ctx.cfg?.plugins as { emitters?: Array<{ name?: string }> } | undefined;
const emitters = plugins?.emitters;
if (!Array.isArray(emitters)) return;
const hasEmitter = emitters.some((e) => e?.name === "EncryptedContentIndex");
if (hasEmitter) return;
console.warn(
"[EncryptedPages] `unlistWhenEncrypted: true` is set but the companion " +
"`EncryptedContentIndex` emitter is not registered in plugins.emitters. " +
"Unlisted encrypted pages will be hidden from graph/explorer/search " +
"even after successful client-side decryption. Add `EncryptedContentIndex()` " +
"to your emitters list to enable the shadow content index.",
);
}
const rehypeEncryptedPages = (options: EncryptedPagesOptions): Plugin<[], HastRoot> => {
return () => (tree: HastRoot, file: VFile) => {
const frontmatter = (file.data?.frontmatter ?? {}) as Record<string, unknown>;
@ -79,13 +76,10 @@ const rehypeEncryptedPages = (options: EncryptedPagesOptions): Plugin<[], HastRo
return;
}
// Serialize the entire tree to HTML
const html = toHtml(tree, { allowDangerousHtml: true });
// Encrypt the HTML content
const encryptedData = encrypt(html, password, options.iterations);
const encryptedData = encryptAesGcm(html, password, options.iterations);
// Build the encrypted container element
const encryptedContainer: Element = {
type: "element",
tagName: "div",
@ -97,34 +91,34 @@ const rehypeEncryptedPages = (options: EncryptedPagesOptions): Plugin<[], HastRo
children: [],
};
// Replace tree children with the encrypted container
tree.children = [encryptedContainer as ElementContent];
// Set flags for downstream plugins
(file.data as Record<string, unknown>).encrypted = true;
(file.data as Record<string, unknown>).encryptedVisibility = options.visibility;
const data = file.data as Record<string, unknown>;
data.encrypted = true;
data.text = "";
data.description = "";
// Clear plaintext from file.data to prevent content leakage through search index
(file.data as Record<string, unknown>).text = "";
(file.data as Record<string, unknown>).description = "";
const frontmatterUnlisted = frontmatter.unlisted;
if (typeof frontmatterUnlisted === "boolean") {
data.unlisted = frontmatterUnlisted;
} else if (options.unlistWhenEncrypted) {
data.unlisted = true;
}
};
};
/**
* Encrypted pages transformer.
*
* Reads a password from the page's frontmatter and encrypts the rendered HTML
* at build time using AES-256-GCM with PBKDF2 key derivation. The encrypted
* content is stored as a data attribute and decrypted client-side using the
* Web Crypto API.
*/
export const EncryptedPages: QuartzTransformerPlugin<Partial<EncryptedPagesOptions>> = (
userOptions?: Partial<EncryptedPagesOptions>,
) => {
const options = { ...defaultOptions, ...userOptions };
let warned = false;
return {
name: "EncryptedPages",
htmlPlugins(): PluggableList {
htmlPlugins(ctx: BuildCtx): PluggableList {
if (options.unlistWhenEncrypted && !warned) {
warnIfEmitterMissing(ctx);
warned = true;
}
return [rehypeEncryptedPages(options)];
},
};

View file

@ -19,21 +19,7 @@ export type {
QuartzPageTypePluginInstance,
} from "@quartz-community/types";
/** How encrypted pages appear in graph/explorer/backlinks. */
export type EncryptedPageVisibility = "visible" | "icon" | "hidden";
export interface EncryptedPagesOptions {
/**
* How encrypted pages appear in the graph, explorer, and backlinks.
*
* - `"visible"` Title shown normally, content is encrypted.
* - `"icon"` Title shown with a lock icon indicator.
* - `"hidden"` Completely hidden from graph/explorer.
*
* @default "icon"
*/
visibility: EncryptedPageVisibility;
/**
* PBKDF2 iteration count for key derivation.
* Higher = slower brute-force attacks, but also slower page unlock.
@ -48,17 +34,40 @@ export interface EncryptedPagesOptions {
* @default "password"
*/
passwordField: string;
/**
* 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).
*
* The page HTML is still emitted, so the page remains accessible by
* its direct URL. After successful client-side decryption, the page is
* dynamically added back to client-side discovery surfaces (graph,
* explorer, search) via the shadow content index emitted by
* {@link EncryptedContentIndex}.
*
* Per-page frontmatter `unlisted: true | false` overrides this option.
*
* NOTE: When `true`, the `EncryptedContentIndex` emitter MUST also be
* registered in your Quartz configuration. Without it, unlisted encrypted
* pages cannot be dynamically revealed after decryption and will remain
* invisible to graph/explorer/search for the entire session. The
* transformer emits a console warning at build time if it detects the
* emitter is missing.
*
* @default false
*/
unlistWhenEncrypted: boolean;
}
export interface EncryptedPageFilterOptions {
export interface EncryptedContentIndexOptions {
/**
* How encrypted pages appear in content indices (search, RSS, sitemap).
* Output path for the shadow content index, relative to the Quartz output
* directory. The file is a JSON document wrapping a flat array of opaque
* encrypted blobs; see the README for the format.
*
* - `"visible"` Page appears in index with title but no content.
* - `"icon"` Same as visible (included in index, no content leak).
* - `"hidden"` Page is excluded from content index, RSS, and sitemap.
*
* @default "icon"
* @default "static/encryptedContentIndex.json"
*/
visibility: EncryptedPageVisibility;
outputPath: string;
}

191
test/emitter.test.ts Normal file
View file

@ -0,0 +1,191 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import type { Root as HastRoot, Element } from "hast";
import { VFile } from "vfile";
import { EncryptedContentIndex, SHADOW_INDEX_VERSION } from "../src/emitter";
import type { ShadowIndexFile, ShadowContentIndexEntry } from "../src/emitter";
import { decrypt } from "../src/transformer";
import { createCtx } from "./helpers";
import type { ProcessedContent } from "@quartz-community/types";
function makeEncryptedContent(
slug: string,
password: string,
opts: {
unlisted?: boolean;
title?: string;
tags?: string[];
links?: string[];
iterations?: number;
} = {},
): ProcessedContent {
const tree: HastRoot = {
type: "root",
children: [
{
type: "element",
tagName: "div",
properties: {
className: ["encrypted-page", "popover-hint"],
"data-encrypted": "opaque-ciphertext",
"data-iterations": String(opts.iterations ?? 600_000),
},
children: [],
} as Element,
],
};
const vfile = new VFile("");
vfile.data = {
slug,
relativePath: `${slug}.md`,
encrypted: true,
unlisted: opts.unlisted ?? true,
text: "",
description: "",
links: opts.links ?? [],
frontmatter: {
title: opts.title ?? slug,
password,
tags: opts.tags ?? [],
},
} as Record<string, unknown>;
return [tree, vfile];
}
function makeNonEncryptedContent(slug: string): ProcessedContent {
const tree: HastRoot = { type: "root", children: [] };
const vfile = new VFile("");
vfile.data = {
slug,
relativePath: `${slug}.md`,
text: "public content",
frontmatter: { title: slug },
} as Record<string, unknown>;
return [tree, vfile];
}
describe("EncryptedContentIndex emitter", () => {
let outputDir: string;
beforeEach(async () => {
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "encpages-emitter-test-"));
});
afterEach(async () => {
await fs.rm(outputDir, { recursive: true, force: true });
});
async function runEmitter(content: ProcessedContent[]): Promise<ShadowIndexFile> {
const ctx = createCtx({ argv: { output: outputDir } });
const emitter = EncryptedContentIndex();
const paths = await emitter.emit(ctx, content, { css: [], js: [], additionalHead: [] });
expect(Array.isArray(paths)).toBe(true);
const outputs = paths as string[];
expect(outputs).toHaveLength(1);
expect(outputs[0]).toMatch(/encryptedContentIndex\.json$/);
const raw = await fs.readFile(outputs[0]!, "utf8");
return JSON.parse(raw) as ShadowIndexFile;
}
it("emits a versioned JSON file with a flat entries array", async () => {
const content = [makeEncryptedContent("secret/page-a", "pw1")];
const shadow = await runEmitter(content);
expect(shadow.version).toBe(SHADOW_INDEX_VERSION);
expect(Array.isArray(shadow.entries)).toBe(true);
expect(shadow.entries).toHaveLength(1);
expect(typeof shadow.entries[0]!.ciphertext).toBe("string");
expect(shadow.entries[0]!.iterations).toBe(600_000);
});
it("each entry roundtrips to the original content index entry", async () => {
const content = [
makeEncryptedContent("secret/page-a", "pw1", {
title: "Page A",
tags: ["secret", "alpha"],
links: ["other/page"],
}),
];
const shadow = await runEmitter(content);
const blob = shadow.entries[0]!;
const plaintext = decrypt(blob.ciphertext, "pw1", blob.iterations);
const decoded = JSON.parse(plaintext) as ShadowContentIndexEntry;
expect(decoded.slug).toBe("secret/page-a");
expect(decoded.entry.slug).toBe("secret/page-a");
expect(decoded.entry.title).toBe("Page A");
expect(decoded.entry.tags).toEqual(["secret", "alpha"]);
expect(decoded.entry.links).toEqual(["other/page"]);
expect(decoded.entry.content).toBe("");
expect(decoded.entry.description).toBe("");
});
it("skips non-encrypted pages", async () => {
const content = [
makeNonEncryptedContent("public/page"),
makeEncryptedContent("secret/page-a", "pw1"),
];
const shadow = await runEmitter(content);
expect(shadow.entries).toHaveLength(1);
});
it("skips encrypted pages that are NOT unlisted", async () => {
const content = [makeEncryptedContent("secret/page-a", "pw1", { unlisted: false })];
const shadow = await runEmitter(content);
expect(shadow.entries).toHaveLength(0);
});
it("skips pages with missing passwords", async () => {
const [tree, vfile] = makeEncryptedContent("secret/a", "pw1");
delete (vfile.data as Record<string, unknown>).frontmatter;
const shadow = await runEmitter([[tree, vfile]]);
expect(shadow.entries).toHaveLength(0);
});
it("encrypts with per-page iteration count from the tree", async () => {
const content = [makeEncryptedContent("secret/page-a", "pw1", { iterations: 1000 })];
const shadow = await runEmitter(content);
expect(shadow.entries[0]!.iterations).toBe(1000);
const plaintext = decrypt(shadow.entries[0]!.ciphertext, "pw1", 1000);
const decoded = JSON.parse(plaintext) as ShadowContentIndexEntry;
expect(decoded.slug).toBe("secret/page-a");
});
it("produces independent ciphertexts for multiple pages with the same password", async () => {
const content = [
makeEncryptedContent("secret/a", "samepw", { title: "A" }),
makeEncryptedContent("secret/b", "samepw", { title: "B" }),
];
const shadow = await runEmitter(content);
expect(shadow.entries).toHaveLength(2);
expect(shadow.entries[0]!.ciphertext).not.toBe(shadow.entries[1]!.ciphertext);
const decodedA = JSON.parse(
decrypt(shadow.entries[0]!.ciphertext, "samepw", 600_000),
) as ShadowContentIndexEntry;
const decodedB = JSON.parse(
decrypt(shadow.entries[1]!.ciphertext, "samepw", 600_000),
) as ShadowContentIndexEntry;
const titles = new Set([decodedA.entry.title, decodedB.entry.title]);
expect(titles).toEqual(new Set(["A", "B"]));
});
it("emits a valid empty shadow index when no encrypted+unlisted pages exist", async () => {
const content = [makeNonEncryptedContent("public/page")];
const shadow = await runEmitter(content);
expect(shadow.version).toBe(SHADOW_INDEX_VERSION);
expect(shadow.entries).toEqual([]);
});
});

View file

@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { EncryptedPageFilter } from "../src/filter";
import { createCtx, createProcessedContent } from "./helpers";
describe("EncryptedPageFilter", () => {
it("allows non-encrypted pages through", () => {
const ctx = createCtx();
const filter = EncryptedPageFilter();
const content = createProcessedContent({ frontmatter: { title: "Public" } });
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
it("allows encrypted pages through with default visibility (icon)", () => {
const ctx = createCtx();
const filter = EncryptedPageFilter();
const content = createProcessedContent({
frontmatter: { title: "Secret" },
});
// Simulate the transformer setting the encrypted flag
(content[1].data as Record<string, unknown>).encrypted = true;
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
it("allows encrypted pages through with visible visibility", () => {
const ctx = createCtx();
const filter = EncryptedPageFilter({ visibility: "visible" });
const content = createProcessedContent({
frontmatter: { title: "Secret" },
});
(content[1].data as Record<string, unknown>).encrypted = true;
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
it("blocks encrypted pages with hidden visibility", () => {
const ctx = createCtx();
const filter = EncryptedPageFilter({ visibility: "hidden" });
const content = createProcessedContent({
frontmatter: { title: "Secret" },
});
(content[1].data as Record<string, unknown>).encrypted = true;
expect(filter.shouldPublish(ctx, content)).toBe(false);
});
it("allows non-encrypted pages even with hidden visibility", () => {
const ctx = createCtx();
const filter = EncryptedPageFilter({ visibility: "hidden" });
const content = createProcessedContent({
frontmatter: { title: "Public" },
});
expect(filter.shouldPublish(ctx, content)).toBe(true);
});
});

View file

@ -4,7 +4,6 @@ import { createCtx } from "./helpers";
import type { Root as HastRoot, Element } from "hast";
import { VFile } from "vfile";
/** Helper: create a simple HAST tree with an article containing text. */
function createHastTree(text: string): HastRoot {
return {
type: "root",
@ -26,7 +25,6 @@ function createHastTree(text: string): HastRoot {
};
}
/** Helper: run the transformer's htmlPlugins on a tree + vfile. */
async function runTransformer(
tree: HastRoot,
vfile: VFile,
@ -36,7 +34,6 @@ async function runTransformer(
const transformer = EncryptedPages(options);
const plugins = transformer.htmlPlugins?.(ctx) ?? [];
// Each plugin is a [pluginFn] or [pluginFn, options] tuple
for (const pluginEntry of plugins) {
const pluginFn = Array.isArray(pluginEntry) ? pluginEntry[0] : pluginEntry;
const attacher = pluginFn as () => (tree: HastRoot, file: VFile) => void;
@ -55,10 +52,10 @@ describe("EncryptedPages transformer", () => {
await runTransformer(tree, vfile);
// Tree should be unchanged — still has the article
const article = tree.children[0] as Element;
expect(article.tagName).toBe("article");
expect((vfile.data as Record<string, unknown>).encrypted).toBeUndefined();
expect((vfile.data as Record<string, unknown>).unlisted).toBeUndefined();
});
it("encrypts pages with a password in frontmatter", async () => {
@ -68,16 +65,13 @@ describe("EncryptedPages transformer", () => {
await runTransformer(tree, vfile);
// Tree should now contain an encrypted-page container
const container = tree.children[0] as Element;
expect(container.tagName).toBe("div");
expect((container.properties?.className as string[]) ?? []).toContain("encrypted-page");
// Should have data attributes
expect(typeof container.properties?.["data-encrypted"]).toBe("string");
expect(container.properties?.["data-iterations"]).toBe("600000");
// VFile should be flagged
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).text).toBe("");
expect((vfile.data as Record<string, unknown>).description).toBe("");
@ -94,7 +88,6 @@ describe("EncryptedPages transformer", () => {
const encryptedData = container.properties?.["data-encrypted"] as string;
expect(encryptedData).toBeTruthy();
// Decrypt using the exported decrypt function
const decrypted = decrypt(encryptedData, "mypassword", 600_000);
expect(decrypted).toContain("Roundtrip test content");
});
@ -134,7 +127,6 @@ describe("EncryptedPages transformer", () => {
const container = tree.children[0] as Element;
expect(container.properties?.["data-iterations"]).toBe("1000");
// Verify decryption with the correct iteration count
const encryptedData = container.properties?.["data-encrypted"] as string;
const decrypted = decrypt(encryptedData, "pw", 1000);
expect(decrypted).toContain("Custom iterations");
@ -164,13 +156,57 @@ describe("EncryptedPages transformer", () => {
expect((vfile.data as Record<string, unknown>).encrypted).toBeUndefined();
});
it("sets visibility metadata on encrypted pages", async () => {
it("does not mark encrypted pages as unlisted by default", async () => {
const tree = createHastTree("Hidden content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw" } };
await runTransformer(tree, vfile, { visibility: "hidden" });
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).encryptedVisibility).toBe("hidden");
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).unlisted).toBeUndefined();
});
it("marks encrypted pages as unlisted when unlistWhenEncrypted is true", async () => {
const tree = createHastTree("Hidden content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw" } };
await runTransformer(tree, vfile, { unlistWhenEncrypted: true });
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
});
it("does not mark non-encrypted pages as unlisted even when unlistWhenEncrypted is true", async () => {
const tree = createHastTree("Public content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Public Page" } };
await runTransformer(tree, vfile, { unlistWhenEncrypted: true });
expect((vfile.data as Record<string, unknown>).encrypted).toBeUndefined();
expect((vfile.data as Record<string, unknown>).unlisted).toBeUndefined();
});
it("respects frontmatter unlisted: true override without unlistWhenEncrypted", async () => {
const tree = createHastTree("Secret");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", unlisted: true } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
});
it("respects frontmatter unlisted: false override when unlistWhenEncrypted is true", async () => {
const tree = createHastTree("Secret");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", unlisted: false } };
await runTransformer(tree, vfile, { unlistWhenEncrypted: true });
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(false);
});
});