diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 33da89d90..cbeda5230 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -45,8 +45,12 @@ This question is best answered by tracing what happens when a user (you!) runs ` 1. The browser opens a Quartz page and loads the HTML. The `` also links to page styles (emitted to `public/index.css`) and page-critical JS (emitted to `public/prescript.js`) 2. Then, once the body is loaded, the browser loads the non-critical JS (emitted to `public/postscript.js`) -3. Once the page is done loading, the page will then dispatch a custom synthetic browser event `"nav"`. This is used so client-side scripts declared by components can 'setup' anything that requires access to the page DOM. - 1. If the [[SPA Routing|enableSPA option]] is enabled in the [[configuration]], this `"nav"` event is also fired on any client-navigation to allow for components to unregister and reregister any event handlers and state. - 2. If it's not, we wire up the `"nav"` event to just be fired a single time after page load to allow for consistency across how state is setup across both SPA and non-SPA contexts. +3. Once the page is done loading, the page will dispatch two custom synthetic browser events: + 1. **`"nav"`** event: Fired when the user navigates to a new page. This is used for navigation-specific logic like updating URL-dependent state, analytics tracking, etc. + - Contains `e.detail.url` with the current page URL + - Fired on initial page load and on client-side navigation (if [[SPA Routing|enableSPA option]] is enabled) + 2. **`"render"`** event: Fired when content needs to be processed or re-rendered. This is used for DOM manipulation, setting up event listeners, and other content-specific logic. + - Contains `e.detail.htmlElement` with the DOM element that was updated + - Fired on initial page load (with `document.body`) and whenever content is dynamically updated (e.g., in popovers, search results, after decryption) The architecture and design of the plugin system was intentionally left pretty vague here as this is described in much more depth in the guide on [[making plugins|making your own plugin]]. diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md index 84e038012..8e0a0a48b 100644 --- a/docs/advanced/creating components.md +++ b/docs/advanced/creating components.md @@ -149,15 +149,46 @@ As the names suggest, the `.beforeDOMLoaded` scripts are executed _before_ the p The `.afterDOMLoaded` script executes once the page has been completely loaded. This is a good place to setup anything that should last for the duration of a site visit (e.g. getting something saved from local storage). -If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you can listen for the `"nav"` event that gets fired whenever a page loads (which may happen on navigation if [[SPA Routing]] is enabled). +If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you have two options: + +**For navigation-specific logic**, listen for the `"nav"` event that gets fired whenever the user navigates to a new page: ```ts -document.addEventListener("nav", () => { - // do page specific logic here - // e.g. attach event listeners - const toggleSwitch = document.querySelector("#switch") as HTMLInputElement - toggleSwitch.addEventListener("change", switchTheme) - window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) +document.addEventListener("nav", (e) => { + // runs only on page navigation + // e.detail.url contains the new page URL + const currentUrl = e.detail.url + console.log(`Navigated to: ${currentUrl}`) +}) +``` + +**For rendering/re-rendering content**, use the `"render"` event which is fired when content needs to be processed or updated: + +```ts +document.addEventListener("render", (e) => { + // runs when content is rendered or re-rendered + // e.detail.htmlElement contains the DOM element that was updated + const container = e.detail.htmlElement + + // attach event listeners to elements within this container + const toggleSwitch = container.querySelector("#switch") as HTMLInputElement + if (toggleSwitch) { + toggleSwitch.addEventListener("change", switchTheme) + window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) + } +}) +``` + +You can also use the utility function from `"./util"` to simplify render event handling: + +```ts +import { addRenderListener } from "./util" + +addRenderListener((container) => { + // your rendering logic here + // container is the DOM element that was updated + const elements = container.querySelectorAll(".my-component") + elements.forEach(setupElement) }) ``` diff --git a/docs/advanced/event system.md b/docs/advanced/event system.md new file mode 100644 index 000000000..d95097a7a --- /dev/null +++ b/docs/advanced/event system.md @@ -0,0 +1,175 @@ +--- +title: Event System +--- + +Quartz uses a custom event system to coordinate between navigation and content rendering. Understanding these events is crucial for creating interactive components that work correctly with [[SPA Routing]]. + +## Event Types + +### Navigation Event (`nav`) + +The `nav` event is fired when the user navigates to a new page. This should be used for logic that needs to run once per page navigation. + +```ts +document.addEventListener("nav", (e: CustomEventMap["nav"]) => { + // Access the current page URL + const currentUrl = e.detail.url + console.log(`User navigated to: ${currentUrl}`) + + // Good for: + // - Analytics tracking + // - URL-dependent state updates + // - Setting up page-level event handlers + // - Theme/mode initialization +}) +``` + +**When it fires:** + +- On initial page load +- On client-side navigation (if SPA routing is enabled) +- Does NOT fire on content re-renders + +### Render Event (`render`) + +The `render` event is fired when content needs to be processed or updated. This should be used for DOM manipulation and content-specific logic. + +```ts +document.addEventListener("render", (e: CustomEventMap["render"]) => { + // Access the container that was updated + const container = e.detail.htmlElement + + // Process elements within this container + const codeBlocks = container.querySelectorAll("pre code") + codeBlocks.forEach(addSyntaxHighlighting) + + // Good for: + // - Setting up event listeners on new content + // - Processing dynamic content (syntax highlighting, math rendering, etc.) + // - Initializing interactive components +}) +``` + +**When it fires:** + +- On initial page load (with `document.body` as the container) +- When popover content is loaded +- When search results are displayed +- After content is decrypted +- Whenever `dispatchRenderEvent()` is called + +## Utility Functions + +Quartz provides utility functions in `quartz/components/scripts/util.ts` to make working with these events easier: + +### `addRenderListener(fn)` + +A convenience function for listening to render events: + +```ts +import { addRenderListener } from "./util" + +addRenderListener((container: HTMLElement) => { + // Your rendering logic here + // container is the DOM element that was updated + const myElements = container.querySelectorAll(".my-component") + myElements.forEach(setupMyComponent) +}) +``` + +This is equivalent to manually adding a render event listener but with cleaner syntax. + +### `dispatchRenderEvent(htmlElement)` + +Triggers a render event for a specific DOM element: + +```ts +import { dispatchRenderEvent } from "./util" + +// After dynamically creating or updating content +const myContainer = document.getElementById("dynamic-content") +// ... update the container content ... +dispatchRenderEvent(myContainer) +``` + +This will cause all render event listeners to process the specified container. + +## Best Practices + +### When to use `nav` vs `render` + +- **Use `nav` for:** Page-level setup, URL tracking, global state management +- **Use `render` for:** Content processing, element-specific event handlers, DOM manipulation + +### Memory Management + +Always clean up event handlers to prevent memory leaks: + +```ts +addRenderListener((container) => { + const buttons = container.querySelectorAll(".my-button") + + const handleClick = (e) => { + /* ... */ + } + + buttons.forEach((button) => { + button.addEventListener("click", handleClick) + // Clean up when navigating away + window.addCleanup(() => { + button.removeEventListener("click", handleClick) + }) + }) +}) +``` + +The `window.addCleanup()` function ensures handlers are removed when navigating to a new page. + +### Scoped Processing + +Always scope your render logic to the provided container: + +```ts +// ✅ Good - only processes elements within the updated container +addRenderListener((container) => { + const elements = container.querySelectorAll(".my-element") + elements.forEach(process) +}) + +// ❌ Bad - processes all elements on the page +addRenderListener((container) => { + const elements = document.querySelectorAll(".my-element") + elements.forEach(process) +}) +``` + +This ensures your logic only runs on newly updated content and avoids duplicate processing. + +## Migration from Old System + +If you have existing code that used the old `rerender` flag pattern: + +```ts +// Old pattern ❌ +document.addEventListener("nav", (e) => { + if (e.detail.rerender) return // Skip rerender events + // ... setup logic +}) +``` + +You should split this into separate event handlers: + +```ts +// New pattern ✅ +document.addEventListener("nav", (e) => { + // Navigation-only logic + updateURL(e.detail.url) +}) + +addRenderListener((container) => { + // Content rendering logic + setupComponents(container) +}) +``` + +This provides cleaner separation of concerns and better performance. diff --git a/docs/plugins/Encrypt.md b/docs/plugins/Encrypt.md new file mode 100644 index 000000000..276a588c1 --- /dev/null +++ b/docs/plugins/Encrypt.md @@ -0,0 +1,106 @@ +--- +title: "Encrypt" +tags: + - plugin/transformer +encrypt: true +encryptConfig: + password: "quartz" + message: '^ Password is "quartz"' +--- + +This plugin enables content encryption for sensitive pages in your Quartz site. It uses AES encryption with password-based access control, allowing you to protect specific pages or entire folders with passwords. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +## Configuration + +```typescript +Plugin.Encrypt({ + algorithm: "aes-256-cbc", // Encryption algorithm + ttl: 3600 * 24 * 7, // Password cache TTL in seconds (7 days) + message: "This content is encrypted.", // Default message shown + encryptedFolders: { + // Simple password for a folder + "private/": "folder-password", + + // Advanced configuration for a folder + "secure/": { + password: "advanced-password", + algorithm: "aes-256-gcm", + ttl: 3600 * 24 * 30, // 30 days + message: "Authorized access only", + }, + }, +}) +``` + +> [!warning] +> Important security notes: +> +> - All non-markdown files remain unencrypted in the final build +> - Encrypted content is still visible in your source repository if it's public +> - Use this for access control, not for storing highly sensitive secrets + +### Configuration Options + +- `algorithm`: Encryption algorithm to use + - `"aes-256-cbc"` (default): AES-256 in CBC mode + - `"aes-256-gcm"`: AES-256 in GCM mode (authenticated encryption) + - Key length is automatically inferred from the algorithm (e.g., 256-bit = 32 bytes) +- `encryptedFolders`: Object mapping folder paths to passwords or configuration objects for folder-level encryption +- `ttl`: Time-to-live for cached passwords in seconds (default: 604800 = 7 days, set to 0 for session-only) +- `message`: Message to be displayed in the decryption page + +## How Configuration Works + +### Configuration Inheritance + +Settings cascade down through your folder structure: + +```typescript +encryptedFolders: { + "docs/": { + password: "docs-password", + algorithm: "aes-256-gcm" + }, + "docs/internal/": { + password: "internal-password" + // Inherits algorithm from parent folder + } +} +``` + +In this example: + +- `docs/page.md` uses `"docs-password"` with `"aes-256-gcm"` +- `docs/internal/report.md` uses `"internal-password"` but still uses `"aes-256-gcm"` (inherited) + +### Configuration Priority + +When multiple configurations apply, the priority is: + +1. **Page frontmatter** (highest priority) +2. **Deepest matching folder** +3. **Parent folders** (inherited settings) +4. **Global defaults** (lowest priority) + +## Security Features + +### Password Caching + +- Passwords are stored in browser localStorage +- Automatic expiration based on TTL settings +- Cached passwords are tried automatically when navigating + +### Protection Levels + +- **Content**: Entire page HTML is encrypted +- **Search/RSS**: Only generic descriptions are exposed +- **Navigation**: Encrypted pages appear in navigation but require passwords to view + +## API + +- Category: Transformer +- Function name: `Plugin.Encrypt()` +- Source: [`quartz/plugins/transformers/encrypt.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/encrypt.ts) diff --git a/docs/plugins/Frontmatter.md b/docs/plugins/Frontmatter.md index 9dfe53378..5fc293362 100644 --- a/docs/plugins/Frontmatter.md +++ b/docs/plugins/Frontmatter.md @@ -64,6 +64,13 @@ Quartz supports the following frontmatter: - `published` - `publishDate` - `date` +- encrypt + - `encrypt` + - `encrypted` +- encryptConfig + - Overrides for the [[plugins/Encrypt|encryptConfig]] +- password + - `password` ## API diff --git a/index.d.ts b/index.d.ts index 9011ee38f..62dcdb2b2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,9 +7,15 @@ declare module "*.scss" { interface CustomEventMap { prenav: CustomEvent<{}> nav: CustomEvent<{ url: FullSlug }> + render: CustomEvent<{ htmlElement: HTMLElement }> + decrypt: CustomEvent<{ filePath: FullSlug; password: string }> themechange: CustomEvent<{ theme: "light" | "dark" }> readermodechange: CustomEvent<{ mode: "on" | "off" }> } -type ContentIndex = Record +type DecryptedFlag = { + decrypted?: boolean +} + +type ContentIndex = Record declare const fetchData: Promise diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..8914f6363 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -70,8 +70,13 @@ const config: QuartzConfig = { Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), + Plugin.Encrypt({ + algorithm: "aes-256-cbc", + encryptedFolders: {}, + ttl: 3600 * 24 * 7, // A week + }), + Plugin.Description(), ], filters: [Plugin.RemoveDrafts()], emitters: [ diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx index 318aeb24e..7e08c8799 100644 --- a/quartz/components/ArticleTitle.tsx +++ b/quartz/components/ArticleTitle.tsx @@ -4,7 +4,12 @@ import { classNames } from "../util/lang" const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const title = fileData.frontmatter?.title if (title) { - return

{title}

+ return ( +

+ {fileData.encryptionResult && 🔒 } + {title} +

+ ) } else { return null } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index e4cbcabae..c0042a4dd 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -30,7 +30,7 @@ const defaultOptions: Options = { return node }, sortFn: (a, b) => { - // Sort order: folders first, then files. Sort folders and files alphabeticall + // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A @@ -146,7 +146,7 @@ export default ((userOpts?: Partial) => {
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index bbccf82a2..d4888887b 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -26,7 +26,7 @@ export default ((opts?: Partial) => { displayClass, cfg, }: QuartzComponentProps) => { - if (!fileData.toc) { + if (!fileData.toc || fileData.encryptionResult) { return null } @@ -75,7 +75,7 @@ export default ((opts?: Partial) => { TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { - if (!fileData.toc) { + if (!fileData.toc || fileData.encryptionResult) { return null } return ( diff --git a/quartz/components/scripts/callout.inline.ts b/quartz/components/scripts/callout.inline.ts index 242ce514e..46f0ff489 100644 --- a/quartz/components/scripts/callout.inline.ts +++ b/quartz/components/scripts/callout.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + function toggleCallout(this: HTMLElement) { const outerBlock = this.parentElement! outerBlock.classList.toggle("is-collapsed") @@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) { content.style.gridTemplateRows = collapsed ? "0fr" : "1fr" } -function setupCallout() { - const collapsible = document.getElementsByClassName( +function setupCallout(container: HTMLElement) { + const collapsible = container.getElementsByClassName( `callout is-collapsible`, ) as HTMLCollectionOf for (const div of collapsible) { @@ -24,4 +26,4 @@ function setupCallout() { } } -document.addEventListener("nav", setupCallout) +addRenderListener(setupCallout) diff --git a/quartz/components/scripts/checkbox.inline.ts b/quartz/components/scripts/checkbox.inline.ts index 50ab0425a..189df2220 100644 --- a/quartz/components/scripts/checkbox.inline.ts +++ b/quartz/components/scripts/checkbox.inline.ts @@ -1,9 +1,10 @@ import { getFullSlug } from "../../util/path" +import { addRenderListener } from "./util" const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}` -document.addEventListener("nav", () => { - const checkboxes = document.querySelectorAll( +addRenderListener((container: HTMLElement) => { + const checkboxes = container.querySelectorAll( "input.checkbox-toggle", ) as NodeListOf checkboxes.forEach((el, index) => { diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index e16c11299..be7cf192a 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -1,10 +1,12 @@ +import { addRenderListener } from "./util" + const svgCopy = '' const svgCheck = '' -document.addEventListener("nav", () => { - const els = document.getElementsByTagName("pre") +addRenderListener((container: HTMLElement) => { + const els = container.getElementsByTagName("pre") for (let i = 0; i < els.length; i++) { const codeBlock = els[i].getElementsByTagName("code")[0] if (codeBlock) { diff --git a/quartz/components/scripts/comments.inline.ts b/quartz/components/scripts/comments.inline.ts index 2b876bf6b..ef7bde01a 100644 --- a/quartz/components/scripts/comments.inline.ts +++ b/quartz/components/scripts/comments.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + const changeTheme = (e: CustomEventMap["themechange"]) => { const theme = e.detail.theme const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement @@ -59,8 +61,8 @@ type GiscusElement = Omit & { } } -document.addEventListener("nav", () => { - const giscusContainer = document.querySelector(".giscus") as GiscusElement +addRenderListener((container: HTMLElement) => { + const giscusContainer = container.querySelector(".giscus") as GiscusElement if (!giscusContainer) { return } diff --git a/quartz/components/scripts/encrypt.inline.ts b/quartz/components/scripts/encrypt.inline.ts new file mode 100644 index 000000000..c61f178f0 --- /dev/null +++ b/quartz/components/scripts/encrypt.inline.ts @@ -0,0 +1,223 @@ +import { + decryptContent, + verifyPasswordHash, + addPasswordToCache, + Hash, + CompleteCryptoConfig, + searchForValidPassword, + EncryptionResult, +} from "../../util/encryption" +import { FullSlug, getFullSlug } from "../../util/path" +import { addRenderListener, dispatchRenderEvent } from "./util" + +const showLoading = (container: Element, show: boolean) => { + const loadingDiv = container.querySelector(".decrypt-loading") as HTMLElement + const form = container.querySelector(".decrypt-form") as HTMLElement + + if (loadingDiv && form) { + if (show) { + form.style.display = "none" + loadingDiv.style.display = "flex" + } else { + form.style.display = "flex" + loadingDiv.style.display = "none" + } + } +} + +function dispatchDecryptEvent(filePath: FullSlug, password: string) { + const event = new CustomEvent("decrypt", { + detail: { + filePath, + password, + }, + }) + document.dispatchEvent(event) +} + +const decryptWithPassword = async ( + container: Element, + password: string, + showError = true, +): Promise => { + const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf + const containerElement = container as HTMLElement + + const config = JSON.parse(containerElement.dataset.config!) as CompleteCryptoConfig + const encrypted = JSON.parse(containerElement.dataset.encrypted!) as EncryptionResult + const hash = JSON.parse(containerElement.dataset.hash!) as Hash + + if (showError) errorDivs.forEach((div) => (div.style.display = "none")) + + try { + // Check if crypto.subtle is available (especially important for older iOS versions) + if (!crypto || !crypto.subtle) { + throw new Error( + "This device does not support the required encryption features. Please use a modern browser.", + ) + } + + // First verify password hash + let isValidPassword: boolean + + isValidPassword = await verifyPasswordHash(password, hash) + + if (!isValidPassword) { + if (showError) throw new Error("incorrect-password") + return false + } + + // Show loading indicator when hash passes and give UI time to update + if (showError) { + showLoading(container, true) + // Allow UI to update before starting heavy computation + await new Promise((resolve) => setTimeout(resolve, 50)) + } + + try { + let decryptedContent: string + + decryptedContent = await decryptContent(encrypted, config, password) + + if (decryptedContent) { + // Cache the password + const filePath = getFullSlug(window) + await addPasswordToCache(password, filePath, config.ttl) + + // Replace content + const contentWrapper = document.createElement("div") + contentWrapper.className = "decrypted-content-wrapper" + contentWrapper.innerHTML = decryptedContent + container.parentNode!.replaceChild(contentWrapper, container) + // set data-decrypted of the original container to true + containerElement.dataset.decrypted = "true" + + return true + } + + if (showError) throw new Error("decryption-failed") + return false + } catch (decryptError) { + console.error("Decryption failed:", decryptError) + if (showError) showLoading(container, false) + if (showError) throw new Error("decryption-failed") + return false + } + } catch (error) { + if (showError) { + showLoading(container, false) + const errorMessage = error instanceof Error ? error.message : null + + errorDivs.forEach((div) => { + if (div.dataset.error == errorMessage) { + div.style.display = "block" + } + }) + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const decryptButton = container.querySelector(".decrypt-button") as HTMLButtonElement + + // Check if we're in a popover context + const isInPopover = container.closest(".popover") !== null + + if (passwordInput) { + passwordInput.value = "" + if (isInPopover) { + // Disable input and button in popover on failure + passwordInput.disabled = true + if (decryptButton) { + decryptButton.disabled = true + } + } else { + passwordInput.focus() + } + } + } + return false + } +} + +function updateTitle(container: HTMLElement | null) { + if (container) { + const span = container.querySelector(".article-title-icon") as HTMLElement + if (span) { + span.textContent = "🔓 " + } + } +} + +const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise => { + const fullSlug = container.dataset.slug as FullSlug + const config = JSON.parse(container.dataset.config!) as CompleteCryptoConfig + const hash = JSON.parse(container.dataset.hash!) as Hash + + const password = await searchForValidPassword(fullSlug, hash, config) + + if (password && (await decryptWithPassword(container, password, false))) { + dispatchRenderEvent(parent) + dispatchDecryptEvent(fullSlug, password) + updateTitle(parent) + return true + } + return false +} + +const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => { + const fullSlug = container.dataset.slug as FullSlug + const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement + const password = passwordInput.value + + if (!password) { + passwordInput.focus() + return + } + + if (await decryptWithPassword(container, password, true)) { + dispatchRenderEvent(parent) + dispatchDecryptEvent(fullSlug, password) + updateTitle(parent) + } +} + +addRenderListener(async (element) => { + // Try auto-decryption for all encrypted content with data-decrypted="false" + const encryptedElements = element.querySelectorAll( + ".encrypted-content[data-decrypted='false']", + ) as NodeListOf + + for (const encryptedContainer of encryptedElements) { + await tryAutoDecrypt(element, encryptedContainer) + } + + // Manual decryption handlers + const buttons = element.querySelectorAll(".decrypt-button") + + buttons.forEach((button) => { + const handleClick = async function (this: HTMLElement) { + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(element, encryptedContainer as HTMLElement) + } + + button.addEventListener("click", handleClick) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => button.removeEventListener("click", handleClick)) + } + }) + + // Enter key handler + element.querySelectorAll(".decrypt-password").forEach((input) => { + const handleKeypress = async function (this: HTMLInputElement, e: Event) { + const keyEvent = e as KeyboardEvent + if (keyEvent.key === "Enter") { + const encryptedContainer = this.closest(".encrypted-content")! + await manualDecrypt(element, encryptedContainer as HTMLElement) + } + } + + input.addEventListener("keypress", handleKeypress) + // Check if window.addCleanup exists before using it + if (window.addCleanup) { + window.addCleanup(() => input.removeEventListener("keypress", handleKeypress)) + } + }) +}) diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 9c8341169..b9bbf42b4 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,6 +1,7 @@ import { FileTrieNode } from "../../util/fileTrie" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { ContentDetails } from "../../plugins/emitters/contentIndex" +import { contentDecryptedEventListener } from "../../util/encryption" type MaybeHTMLElement = HTMLElement | undefined @@ -86,7 +87,16 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen const a = li.querySelector("a") as HTMLAnchorElement a.href = resolveRelative(currentSlug, node.slug) a.dataset.for = node.slug - a.textContent = node.displayName + + if (node.data?.encryptionResult) { + a.textContent = "🔒 " + node.displayName + + contentDecryptedEventListener(node.slug, node.data.hash!, node.data.encryptionConfig!, () => { + a.textContent = "🔓 " + node.displayName + }) + } else { + a.textContent = node.displayName + } if (currentSlug === node.slug) { a.classList.add("active") @@ -111,6 +121,8 @@ function createFolderNode( const folderPath = node.slug folderContainer.dataset.folderpath = folderPath + let titleElement: HTMLElement + if (opts.folderClickBehavior === "link") { // Replace button with link for link behavior const button = titleContainer.querySelector(".folder-button") as HTMLElement @@ -118,11 +130,20 @@ function createFolderNode( a.href = resolveRelative(currentSlug, folderPath) a.dataset.for = folderPath a.className = "folder-title" - a.textContent = node.displayName + titleElement = a button.replaceWith(a) } else { - const span = titleContainer.querySelector(".folder-title") as HTMLElement - span.textContent = node.displayName + const span = titleContainer.querySelector(".folder-title-text") as HTMLElement + titleElement = span + } + + if (node.data?.encryptionResult) { + titleElement.textContent = "🔒 " + node.displayName + contentDecryptedEventListener(folderPath, node.data.hash!, node.data.encryptionConfig!, () => { + titleElement.textContent = "🔓 " + node.displayName + }) + } else { + titleElement.textContent = node.displayName } // if the saved state is collapsed or the default state is collapsed @@ -173,6 +194,7 @@ async function setupExplorer(currentSlug: FullSlug) { ) const data = await fetchData + const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] const trie = FileTrieNode.fromEntries(entries) @@ -267,6 +289,7 @@ document.addEventListener("prenav", async () => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url + await setupExplorer(currentSlug) // if mobile hamburger is visible, collapse by default diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index a669b0547..11f7365dd 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { v, ]), ) + const links: SimpleLinkData[] = [] const tags: SimpleSlug[] = [] const validLinks = new Set(data.keys()) @@ -575,6 +576,7 @@ function cleanupGlobalGraphs() { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const slug = e.detail.url + addToVisited(simplifySlug(slug)) async function renderLocalGraph() { diff --git a/quartz/components/scripts/mermaid.inline.ts b/quartz/components/scripts/mermaid.inline.ts index 10399739d..58c4a6e09 100644 --- a/quartz/components/scripts/mermaid.inline.ts +++ b/quartz/components/scripts/mermaid.inline.ts @@ -1,4 +1,4 @@ -import { registerEscapeHandler, removeAllChildren } from "./util" +import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util" interface Position { x: number @@ -185,9 +185,11 @@ const cssVars = [ ] as const let mermaidImport = undefined -document.addEventListener("nav", async () => { - const center = document.querySelector(".center") as HTMLElement - const nodes = center.querySelectorAll("code.mermaid") as NodeListOf + +addRenderListener(async (container: HTMLElement) => { + const nodes = container.querySelectorAll( + "code.mermaid:not([data-processed])", + ) as NodeListOf if (nodes.length === 0) return mermaidImport ||= await import( @@ -204,9 +206,9 @@ document.addEventListener("nav", async () => { async function renderMermaid() { // de-init any other diagrams for (const node of nodes) { - node.removeAttribute("data-processed") const oldText = textMapping.get(node) if (oldText) { + node.removeAttribute("data-processed") node.innerHTML = oldText } } diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 989af7ee8..32757041d 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -1,6 +1,6 @@ import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util" const p = new DOMParser() let activeAnchor: HTMLAnchorElement | null = null @@ -37,6 +37,8 @@ async function mouseEnterHandler( popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) } } + + dispatchRenderEvent(popoverInner) } const targetUrl = new URL(link.href) @@ -120,8 +122,9 @@ function clearActivePopover() { allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover")) } -document.addEventListener("nav", () => { - const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[] +addRenderListener((element) => { + const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[] + for (const link of links) { link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseleave", clearActivePopover) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 717f17f00..c8010f5f4 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,7 @@ import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch" -import { ContentDetails } from "../../plugins/emitters/contentIndex" -import { registerEscapeHandler, removeAllChildren } from "./util" +import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" +import { contentDecryptedEventListener, decryptContent } from "../../util/encryption" interface Item { id: number @@ -88,6 +88,7 @@ const fetchContentCache: Map = new Map() const contextWindowWords = 30 const numSearchResults = 8 const numTagResults = 5 +const RENDER_DELAY_MS = 100 const tokenizeTerm = (term: string) => { const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") @@ -309,10 +310,16 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: const formatForDisplay = (term: string, id: number) => { const slug = idDataMap[id] + let title = data[slug].title + + if (data[slug].encryptionResult) { + title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title + } + return { id, slug, - title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), + title: searchType === "tags" ? title : highlight(term, title ?? ""), content: highlight(term, data[slug].content ?? "", true), tags: highlightTags(term.substring(1), data[slug].tags), } @@ -433,6 +440,9 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: (a, b) => b.innerHTML.length - a.innerHTML.length, ) highlights[0]?.scrollIntoView({ block: "start" }) + await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY_MS)) + + dispatchRenderEvent(previewInner) } async function onType(e: HTMLElementEventMap["input"]) { @@ -514,16 +524,54 @@ async function fillDocument(data: ContentIndex) { if (indexPopulated) return let id = 0 const promises: Array> = [] - for (const [slug, fileData] of Object.entries(data)) { - promises.push( - index.addAsync(id++, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }), - ) + for (const [slug, fileData] of Object.entries(data)) { + if (fileData.encryptionResult) { + let slugId = id + promises.push( + index.addAsync(id++, { + id: id, + slug: slug as FullSlug, + title: fileData.title, + content: "", + tags: fileData.tags, + }), + ) + fileData.decrypted = false + + promises.push( + contentDecryptedEventListener( + slug, + fileData.hash!, + fileData.encryptionConfig!, + async (password) => { + const decryptedContent = await decryptContent( + fileData.encryptionResult!, + fileData.encryptionConfig!, + password, + ) + fileData.decrypted = true + + index.update(slugId++, { + id: slugId, + slug: slug as FullSlug, + title: fileData.title, + content: decryptedContent, + tags: fileData.tags, + }) + }, + ), + ) + } else { + promises.push( + index.addAsync(id++, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }), + ) + } } await Promise.all(promises) diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 2898dc419..131394460 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,6 +1,6 @@ import micromorph from "micromorph" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, dispatchRenderEvent } from "./util" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -38,6 +38,9 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined function notifyNav(url: FullSlug) { const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) document.dispatchEvent(event) + + // Also trigger render event for the whole document body + dispatchRenderEvent(document.body) } const cleanupFns: Set<(...args: any[]) => void> = new Set() diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index 4148fa235..1741d17e8 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -1,3 +1,5 @@ +import { addRenderListener } from "./util" + const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const slug = entry.target.id @@ -34,11 +36,11 @@ function setupToc() { } } -document.addEventListener("nav", () => { +addRenderListener((container: HTMLElement) => { setupToc() // update toc entry highlighting observer.disconnect() - const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") + const headers = container.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") headers.forEach((header) => observer.observe(header)) }) diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index f71790104..8068e7d94 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise { const [_, redirect] = text.match(canonicalRegex) ?? [] return redirect ? fetch(`${new URL(redirect, url)}`) : res } + +export function addRenderListener(renderFn: (container: HTMLElement) => void) { + document.addEventListener("render", (e: CustomEventMap["render"]) => { + renderFn(e.detail.htmlElement) + }) +} + +export function dispatchRenderEvent(htmlElement: HTMLElement) { + const event: CustomEventMap["render"] = new CustomEvent("render", { + detail: { htmlElement }, + }) + document.dispatchEvent(event) +} diff --git a/quartz/components/styles/encrypt.scss b/quartz/components/styles/encrypt.scss new file mode 100644 index 000000000..5ce5b99a8 --- /dev/null +++ b/quartz/components/styles/encrypt.scss @@ -0,0 +1,174 @@ +@use "../../styles/variables.scss" as *; + +.encrypted-content { + display: flex; + align-items: center; + justify-content: center; + + .encryption-notice { + /* background: color-mix(in srgb, var(--lightgray) 60%, var(--light)); + */ + border: 1px solid var(--lightgray); + padding: 2rem 1.5rem 2rem; + border-radius: 5px; + max-width: 450px; + width: 100%; + text-align: center; + box-shadow: 0 6px 36px rgba(0, 0, 0, 0.15); + margin-top: 2rem; + font-family: var(--bodyFont); + + & h3 { + margin: 0 0 0.5rem 0; + color: var(--dark); + font-size: 1.3rem; + font-weight: $semiBoldWeight; + font-family: var(--headerFont); + } + + & p { + color: var(--darkgray); + line-height: 1.5; + font-size: 0.95rem; + margin: 0; + font-style: italic; + } + + .decrypt-form { + margin: 1rem 0 0 0; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + + .decrypt-password { + width: 100%; + box-sizing: border-box; + padding: 0.75rem 1rem; + border-radius: 5px; + font-family: var(--bodyFont); + font-size: 1rem; + border: 1px solid var(--lightgray); + color: var(--dark); + transition: border-color 0.2s ease; + // background: var(--backgroundPrimary); + + &:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--secondary) 20%, transparent); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + + .decrypt-button { + background: var(--secondary); + color: var(--light); + border: none; + border-radius: 5px; + padding: 0.75rem 2rem; + font-family: var(--bodyFont); + font-size: 1rem; + font-weight: $semiBoldWeight; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; + + &:hover { + background: color-mix(in srgb, var(--secondary) 90%, var(--dark)); + transform: translateY(-1px); + box-shadow: 0 4px 12px color-mix(in srgb, var(--secondary) 30%, transparent); + } + + &:active { + transform: translateY(0); + } + + @media all and ($mobile) { + font-size: 16px; // Prevent zoom on iOS + } + } + } + + .decrypt-loading { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--secondary) 10%, var(--light)); + border: 1px solid color-mix(in srgb, var(--secondary) 30%, transparent); + border-radius: 5px; + color: var(--secondary); + font-size: 0.9rem; + text-align: center; + align-items: center; + justify-content: center; + gap: 0.5rem; + + .loading-spinner { + width: 16px; + height: 16px; + border: 2px solid color-mix(in srgb, var(--secondary) 20%, transparent); + border-top: 2px solid var(--secondary); + border-radius: 50%; + animation: spin 1s linear infinite; + } + } + + .decrypt-error { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, #ef4444 10%, var(--light)); + border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); + border-radius: 5px; + color: #dc2626; + font-size: 0.9rem; + text-align: center; + } + + @media all and ($mobile) { + padding: 1.5rem 1rem; + margin: 1rem; + } + } +} + +// Hide the decrypt form when encrypted content appears in popover and search +.search-space, +.popover { + .encrypted-content .encryption-notice .decrypt-form { + display: none; + } + + .encryption-notice { + /* + border: 0; + padding: 0; + */ + margin: 1rem 0; + } +} + +.decrypted-content-wrapper { + display: block; + margin: 0; + padding: 0; +} + +.encryption-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; + opacity: 0.7; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts index 9c5c8adef..c939e266f 100644 --- a/quartz/i18n/locales/ar-SA.ts +++ b/quartz/i18n/locales/ar-SA.ts @@ -65,6 +65,15 @@ export default { ? `دقيقتان للقراءة` : `${minutes} دقائق للقراءة`, }, + encryption: { + title: "🛡️ محتوى محدود 🛡️", + enterPassword: "ادخل كلمة المرور", + decrypt: "فك التشفير", + decrypting: "جاري فك التشفير...", + incorrectPassword: "كلمة مرور خاطئة. حاول مرة أخرى.", + decryptionFailed: "فشل فك التشفير، تحقق من السجلات", + encryptedDescription: "هذا الملف مشفر. افتحه لرؤية المحتويات.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ca-ES.ts b/quartz/i18n/locales/ca-ES.ts index 1483d9c57..17c373374 100644 --- a/quartz/i18n/locales/ca-ES.ts +++ b/quartz/i18n/locales/ca-ES.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`, }, + encryption: { + title: "🛡️ Contingut Restringit 🛡️", + enterPassword: "Introduïu la contrasenya", + decrypt: "Desxifrar", + decrypting: "Desxifrant...", + incorrectPassword: "Contrasenya incorrecta. Torneu-ho a intentar.", + decryptionFailed: "Ha fallat el desxifratge, comproveu els registres", + encryptedDescription: "Aquest fitxer està xifrat. Obriu-lo per veure els continguts.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/cs-CZ.ts b/quartz/i18n/locales/cs-CZ.ts index 87f19964f..017b1900c 100644 --- a/quartz/i18n/locales/cs-CZ.ts +++ b/quartz/i18n/locales/cs-CZ.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min čtení`, }, + encryption: { + title: "🛡️ Omezený obsah 🛡️", + enterPassword: "Zadejte heslo", + decrypt: "Dešifrovat", + decrypting: "Dešifruji...", + incorrectPassword: "Nesprávné heslo. Zkuste to znovu.", + decryptionFailed: "Dešifrování selhalo, zkontrolujte protokoly", + encryptedDescription: "Tento soubor je zašifrován. Otevřete jej pro zobrazení obsahu.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts index 19eb4f2ac..a07a56dca 100644 --- a/quartz/i18n/locales/de-DE.ts +++ b/quartz/i18n/locales/de-DE.ts @@ -59,6 +59,16 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} Min. Lesezeit`, }, + encryption: { + title: "🛡️ Eingeschränkter Inhalt 🛡️", + enterPassword: "Passwort eingeben", + decrypt: "Entschlüsseln", + decrypting: "Entschlüsselt...", + incorrectPassword: "Falsches Passwort. Bitte versuchen Sie es erneut.", + decryptionFailed: "Entschlüsselung fehlgeschlagen, überprüfen Sie die Protokolle", + encryptedDescription: + "Diese Datei ist verschlüsselt. Öffnen Sie sie, um den Inhalt zu sehen.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index f22538fca..2c8ee2815 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -62,6 +62,15 @@ export interface Translation { contentMeta: { readingTime: (variables: { minutes: number }) => string } + encryption: { + title: string + enterPassword: string + decrypt: string + decrypting: string + incorrectPassword: string + decryptionFailed: string + encryptedDescription: string + } } pages: { rss: { diff --git a/quartz/i18n/locales/en-GB.ts b/quartz/i18n/locales/en-GB.ts index 80e52bf3c..d3d506843 100644 --- a/quartz/i18n/locales/en-GB.ts +++ b/quartz/i18n/locales/en-GB.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ Restricted Content 🛡️", + enterPassword: "Enter password", + decrypt: "Decrypt", + decrypting: "Decrypting...", + incorrectPassword: "Incorrect password. Please try again.", + decryptionFailed: "Decryption failed, check logs", + encryptedDescription: "This file is encrypted. Open it to see the contents.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index e1111e9ab..6720064e5 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ Restricted Content 🛡️", + enterPassword: "Enter password", + decrypt: "Decrypt", + decrypting: "Decrypting...", + incorrectPassword: "Incorrect password. Please try again.", + decryptionFailed: "Decryption failed, check logs", + encryptedDescription: "This file is encrypted. Open it to see the contents.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts index b7e425e81..f128702b3 100644 --- a/quartz/i18n/locales/es-ES.ts +++ b/quartz/i18n/locales/es-ES.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Se lee en ${minutes} min`, }, + encryption: { + title: "🛡️ Contenido Restringido 🛡️", + enterPassword: "Ingrese contraseña", + decrypt: "Desencriptar", + decrypting: "Desencriptando...", + incorrectPassword: "Contraseña incorrecta. Intente de nuevo.", + decryptionFailed: "Desencriptación falló, revise los registros", + encryptedDescription: "Este archivo está encriptado. Ábralo para ver los contenidos.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fa-IR.ts b/quartz/i18n/locales/fa-IR.ts index ccb996fa9..ea27e697c 100644 --- a/quartz/i18n/locales/fa-IR.ts +++ b/quartz/i18n/locales/fa-IR.ts @@ -60,6 +60,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`, }, + encryption: { + title: "🛡️ محتوای محدود 🛡️", + enterPassword: "رمز عبور را وارد کنید", + decrypt: "رمزگشایی", + decrypting: "در حال رمزگشایی...", + incorrectPassword: "رمز عبور اشتباه است. دوباره تلاش کنید.", + decryptionFailed: "رمزگشایی ناموفق، لاگ‌ها را بررسی کنید", + encryptedDescription: "این فایل رمزگذاری شده است. آن را باز کنید تا محتوا را ببینید.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fi-FI.ts b/quartz/i18n/locales/fi-FI.ts index 19e102a31..9bc84061a 100644 --- a/quartz/i18n/locales/fi-FI.ts +++ b/quartz/i18n/locales/fi-FI.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min lukuaika`, }, + encryption: { + title: "🛡️ Rajoitettu Sisältö 🛡️", + enterPassword: "Anna salasana", + decrypt: "Pura salaus", + decrypting: "Puretaan salausta...", + incorrectPassword: "Väärä salasana. Yritä uudelleen.", + decryptionFailed: "Salauksen purku epäonnistui, tarkista lokit", + encryptedDescription: "Tämä tiedosto on salattu. Avaa se nähdäksesi sisällön.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts index f6df309a6..ddc9aaa07 100644 --- a/quartz/i18n/locales/fr-FR.ts +++ b/quartz/i18n/locales/fr-FR.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min de lecture`, }, + encryption: { + title: "🛡️ Contenu Restreint 🛡️", + enterPassword: "Entrez le mot de passe", + decrypt: "Déchiffrer", + decrypting: "Déchiffrement...", + incorrectPassword: "Mot de passe incorrect. Veuillez réessayer.", + decryptionFailed: "Échec du déchiffrement, vérifiez les journaux", + encryptedDescription: "Ce fichier est chiffré. Ouvrez-le pour voir le contenu.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/hu-HU.ts b/quartz/i18n/locales/hu-HU.ts index 2a4f07727..1fd7695f7 100644 --- a/quartz/i18n/locales/hu-HU.ts +++ b/quartz/i18n/locales/hu-HU.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} perces olvasás`, }, + encryption: { + title: "🛡️ Korlátozott Tartalom 🛡️", + enterPassword: "Jelszó megadása", + decrypt: "Visszafejtés", + decrypting: "Visszafejtés...", + incorrectPassword: "Helytelen jelszó. Kérjük, próbálja újra.", + decryptionFailed: "A visszafejtés sikertelen, ellenőrizze a naplókat", + encryptedDescription: "Ez a fájl titkosított. Nyissa meg a tartalom megtekintéséhez.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/id-ID.ts b/quartz/i18n/locales/id-ID.ts index 813e2bb57..42bb3f973 100644 --- a/quartz/i18n/locales/id-ID.ts +++ b/quartz/i18n/locales/id-ID.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} menit baca`, }, + encryption: { + title: "🛡️ Konten Terbatas 🛡️", + enterPassword: "Masukkan kata sandi", + decrypt: "Dekripsi", + decrypting: "Mendekripsi...", + incorrectPassword: "Kata sandi salah. Silakan coba lagi.", + decryptionFailed: "Dekripsi gagal, periksa log", + encryptedDescription: "File ini terenkripsi. Buka untuk melihat konten.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/it-IT.ts b/quartz/i18n/locales/it-IT.ts index 0be7d70d3..525237d9b 100644 --- a/quartz/i18n/locales/it-IT.ts +++ b/quartz/i18n/locales/it-IT.ts @@ -60,6 +60,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => (minutes === 1 ? "1 minuto" : `${minutes} minuti`), }, + encryption: { + title: "🛡️ Contenuto Riservato 🛡️", + enterPassword: "Inserisci password", + decrypt: "Decripta", + decrypting: "Decriptando...", + incorrectPassword: "Password errata. Riprova.", + decryptionFailed: "Decriptazione fallita, controlla i log", + encryptedDescription: "Questo file è criptato. Aprilo per vedere i contenuti.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts index 3c30124d7..85732e8c6 100644 --- a/quartz/i18n/locales/ja-JP.ts +++ b/quartz/i18n/locales/ja-JP.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ 制限されたコンテンツ 🛡️", + enterPassword: "パスワードを入力", + decrypt: "復号化", + decrypting: "復号化中...", + incorrectPassword: "パスワードが間違っています。もう一度お試しください。", + decryptionFailed: "復号化に失敗しました。ログを確認してください", + encryptedDescription: "このファイルは暗号化されています。内容を見るには開いてください。", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ko-KR.ts b/quartz/i18n/locales/ko-KR.ts index 1bca096b2..c76fe2297 100644 --- a/quartz/i18n/locales/ko-KR.ts +++ b/quartz/i18n/locales/ko-KR.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min read`, }, + encryption: { + title: "🛡️ 제한된 콘텐츠 🛡️", + enterPassword: "비밀번호 입력", + decrypt: "복호화", + decrypting: "복호화 중...", + incorrectPassword: "비밀번호가 틀렸습니다. 다시 시도해주세요.", + decryptionFailed: "복호화에 실패했습니다. 로그를 확인하세요", + encryptedDescription: "이 파일은 암호화되어 있습니다. 내용을 보려면 열어주세요.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/lt-LT.ts b/quartz/i18n/locales/lt-LT.ts index 690960c35..039877699 100644 --- a/quartz/i18n/locales/lt-LT.ts +++ b/quartz/i18n/locales/lt-LT.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min skaitymo`, }, + encryption: { + title: "🛡️ Apribotas Turinys 🛡️", + enterPassword: "Įvesti slaptažodį", + decrypt: "Iššifruoti", + decrypting: "Iššifruojama...", + incorrectPassword: "Neteisingas slaptažodis. Bandykite dar kartą.", + decryptionFailed: "Iššifravimas nepavyko, patikrinkite žurnalus", + encryptedDescription: "Šis failas yra užšifruotas. Atidarykite jį, kad pamatytumėte turinį.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/nb-NO.ts b/quartz/i18n/locales/nb-NO.ts index 0e415221e..73f76871e 100644 --- a/quartz/i18n/locales/nb-NO.ts +++ b/quartz/i18n/locales/nb-NO.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min lesning`, }, + encryption: { + title: "🛡️ Begrenset Innhold 🛡️", + enterPassword: "Skriv inn passord", + decrypt: "Dekrypter", + decrypting: "Dekrypterer...", + incorrectPassword: "Feil passord. Prøv igjen.", + decryptionFailed: "Dekryptering mislyktes, sjekk logger", + encryptedDescription: "Denne filen er kryptert. Åpne den for å se innholdet.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts index 8ab3b0722..2d73dd8d4 100644 --- a/quartz/i18n/locales/nl-NL.ts +++ b/quartz/i18n/locales/nl-NL.ts @@ -60,6 +60,15 @@ export default { readingTime: ({ minutes }) => minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`, }, + encryption: { + title: "🛡️ Beperkte Inhoud 🛡️", + enterPassword: "Voer wachtwoord in", + decrypt: "Ontsleutelen", + decrypting: "Ontsleutelen...", + incorrectPassword: "Onjuist wachtwoord. Probeer opnieuw.", + decryptionFailed: "Ontsleuteling mislukt, controleer logs", + encryptedDescription: "Dit bestand is versleuteld. Open het om de inhoud te zien.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/pl-PL.ts b/quartz/i18n/locales/pl-PL.ts index 544219ab2..ad06af52b 100644 --- a/quartz/i18n/locales/pl-PL.ts +++ b/quartz/i18n/locales/pl-PL.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} min. czytania `, }, + encryption: { + title: "🛡️ Ograniczona Treść 🛡️", + enterPassword: "Wprowadź hasło", + decrypt: "Odszyfruj", + decrypting: "Odszyfrowywanie...", + incorrectPassword: "Nieprawidłowe hasło. Spróbuj ponownie.", + decryptionFailed: "Odszyfrowanie nie powiodło się, sprawdź logi", + encryptedDescription: "Ten plik jest zaszyfrowany. Otwórz go, aby zobaczyć zawartość.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/pt-BR.ts b/quartz/i18n/locales/pt-BR.ts index e431d8ed5..4e7ebce49 100644 --- a/quartz/i18n/locales/pt-BR.ts +++ b/quartz/i18n/locales/pt-BR.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `Leitura de ${minutes} min`, }, + encryption: { + title: "🛡️ Conteúdo Restrito 🛡️", + enterPassword: "Digite a senha", + decrypt: "Descriptografar", + decrypting: "Descriptografando...", + incorrectPassword: "Senha incorreta. Tente novamente.", + decryptionFailed: "Descriptografia falhou, verifique os logs", + encryptedDescription: "Este arquivo está criptografado. Abra-o para ver o conteúdo.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts index 007d90169..76b537b61 100644 --- a/quartz/i18n/locales/ro-RO.ts +++ b/quartz/i18n/locales/ro-RO.ts @@ -60,6 +60,15 @@ export default { readingTime: ({ minutes }) => minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`, }, + encryption: { + title: "🛡️ Conținut Restricționat 🛡️", + enterPassword: "Introduceți parola", + decrypt: "Decriptați", + decrypting: "Se decriptează...", + incorrectPassword: "Parolă incorectă. Încercați din nou.", + decryptionFailed: "Decriptarea a eșuat, verificați jurnalele", + encryptedDescription: "Acest fișier este criptat. Deschideți-l pentru a vedea conținutul.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/ru-RU.ts b/quartz/i18n/locales/ru-RU.ts index 5534d140c..9996a4f12 100644 --- a/quartz/i18n/locales/ru-RU.ts +++ b/quartz/i18n/locales/ru-RU.ts @@ -60,6 +60,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`, }, + encryption: { + title: "🛡️ Ограниченный Контент 🛡️", + enterPassword: "Введите пароль", + decrypt: "Расшифровать", + decrypting: "Расшифровка...", + incorrectPassword: "Неверный пароль. Попробуйте снова.", + decryptionFailed: "Расшифровка не удалась, проверьте логи", + encryptedDescription: "Этот файл зашифрован. Откройте его, чтобы увидеть содержимое.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/th-TH.ts b/quartz/i18n/locales/th-TH.ts index 073013196..01aa47879 100644 --- a/quartz/i18n/locales/th-TH.ts +++ b/quartz/i18n/locales/th-TH.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`, }, + encryption: { + title: "🛡️ เนื้อหาถูกจำกัด 🛡️", + enterPassword: "ใส่รหัสผ่าน", + decrypt: "ถอดรหัส", + decrypting: "กำลังถอดรหัส...", + incorrectPassword: "รหัสผ่านไม่ถูกต้อง กรุณาลองใหม่", + decryptionFailed: "การถอดรหัสล้มเหลว ตรวจสอบบันทึก", + encryptedDescription: "ไฟล์นี้ถูกเข้ารหัส เปิดเพื่อดูเนื้อหา", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/tr-TR.ts b/quartz/i18n/locales/tr-TR.ts index 9c2d6d4ad..aeb287b8c 100644 --- a/quartz/i18n/locales/tr-TR.ts +++ b/quartz/i18n/locales/tr-TR.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`, }, + encryption: { + title: "🛡️ Kısıtlı İçerik 🛡️", + enterPassword: "Şifreyi girin", + decrypt: "Şifreyi Çöz", + decrypting: "Şifre çözülüyor...", + incorrectPassword: "Yanlış şifre. Tekrar deneyin.", + decryptionFailed: "Şifre çözme başarısız, logları kontrol edin", + encryptedDescription: "Bu dosya şifrelenmiş. İçeriği görmek için açın.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts index ac2a24850..75c7d6ede 100644 --- a/quartz/i18n/locales/uk-UA.ts +++ b/quartz/i18n/locales/uk-UA.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} хв читання`, }, + encryption: { + title: "🛡️ Обмежений Контент 🛡️", + enterPassword: "Введіть пароль", + decrypt: "Розшифрувати", + decrypting: "Розшифрування...", + incorrectPassword: "Неправильний пароль. Спробуйте ще раз.", + decryptionFailed: "Розшифрування не вдалося, перевірте журнали", + encryptedDescription: "Цей файл зашифрований. Відкрийте його, щоб побачити вміст.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/vi-VN.ts b/quartz/i18n/locales/vi-VN.ts index 43eda8797..f62a9a3c3 100644 --- a/quartz/i18n/locales/vi-VN.ts +++ b/quartz/i18n/locales/vi-VN.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes} phút đọc`, }, + encryption: { + title: "🛡️ Nội Dung Bị Hạn Chế 🛡️", + enterPassword: "Nhập mật khẩu", + decrypt: "Giải mã", + decrypting: "Đang giải mã...", + incorrectPassword: "Mật khẩu sai. Vui lòng thử lại.", + decryptionFailed: "Giải mã thất bại, kiểm tra nhật ký", + encryptedDescription: "Tệp này đã được mã hóa. Mở để xem nội dung.", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts index 09951bbb3..470b6eb7d 100644 --- a/quartz/i18n/locales/zh-CN.ts +++ b/quartz/i18n/locales/zh-CN.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `${minutes}分钟阅读`, }, + encryption: { + title: "🛡️ 受限内容 🛡️", + enterPassword: "输入密码", + decrypt: "解密", + decrypting: "解密中...", + incorrectPassword: "密码错误。请重试。", + decryptionFailed: "解密失败,请检查日志", + encryptedDescription: "此文件已加密。打开以查看内容。", + }, }, pages: { rss: { diff --git a/quartz/i18n/locales/zh-TW.ts b/quartz/i18n/locales/zh-TW.ts index 4e784c06d..54294585a 100644 --- a/quartz/i18n/locales/zh-TW.ts +++ b/quartz/i18n/locales/zh-TW.ts @@ -59,6 +59,15 @@ export default { contentMeta: { readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`, }, + encryption: { + title: "🛡️ 受限內容 🛡️", + enterPassword: "輸入密碼", + decrypt: "解密", + decrypting: "解密中...", + incorrectPassword: "密碼錯誤。請重試。", + decryptionFailed: "解密失敗,請檢查日誌", + encryptedDescription: "此檔案已加密。打開以檢視內容。", + }, }, pages: { rss: { diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 9c5ee186f..e5f2a6aaf 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -261,6 +261,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso window.addCleanup = () => {} const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) document.dispatchEvent(event) + const renderEvent = new CustomEvent("render", { detail: { htmlElement: document.body } }) + document.dispatchEvent(renderEvent) `) } } diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..c8d43152b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -7,8 +7,11 @@ import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption" export type ContentIndexMap = Map + +// Base content details without encryption-specific fields export type ContentDetails = { slug: FullSlug filePath: FilePath @@ -19,6 +22,9 @@ export type ContentDetails = { richContent?: string date?: Date description?: string + encryptionConfig?: CompleteCryptoConfig + hash?: Hash + encryptionResult?: EncryptionResult } interface Options { @@ -103,19 +109,25 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { - linkIndex.set(slug, { + const contentDetails: ContentDetails = { slug, filePath: file.data.relativePath!, title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", - richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : undefined, + richContent: + opts?.rssFullHtml && !file.data.encryptionResult + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : undefined, date: date, description: file.data.description ?? "", - }) + encryptionConfig: file.data.encryptionConfig, + hash: file.data.hash, + encryptionResult: file.data.encryptionResult, + } + + linkIndex.set(slug, contentDetails) } } @@ -143,6 +155,14 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed + if (content.encryptionResult) { + delete content.richContent + } else { + delete content.hash + delete content.encryptionConfig + delete content.encryptionResult + } + delete content.description delete content.date return [slug, content] diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 3f8519b32..cf7a20360 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -2,6 +2,7 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" import { QuartzTransformerPlugin } from "../types" import { escapeHTML } from "../../util/escape" +import { i18n } from "../../i18n" export interface Options { descriptionLength: number @@ -24,10 +25,17 @@ export const Description: QuartzTransformerPlugin> = (userOpts) const opts = { ...defaultOptions, ...userOpts } return { name: "Description", - htmlPlugins() { + htmlPlugins(ctx) { return [ () => { return async (tree: HTMLRoot, file) => { + if (file.data.encryptionConfig) { + file.data.description = + file.data.encryptionConfig.message || + i18n(ctx.cfg.configuration.locale).components.encryption.encryptedDescription + return + } + let frontMatterDescription = file.data.frontmatter?.description let text = escapeHTML(toString(tree)) diff --git a/quartz/plugins/transformers/encrypt.ts b/quartz/plugins/transformers/encrypt.ts new file mode 100644 index 000000000..a3541ecb1 --- /dev/null +++ b/quartz/plugins/transformers/encrypt.ts @@ -0,0 +1,351 @@ +import { QuartzTransformerPlugin } from "../types" +import { Root } from "hast" +import { toHtml } from "hast-util-to-html" +import { fromHtml } from "hast-util-from-html" +import { VFile } from "vfile" +import { i18n } from "../../i18n" +import { + SUPPORTED_ALGORITHMS, + encryptContent, + Hash, + hashString, + EncryptionResult, + CompleteCryptoConfig, + BaseCryptoConfig, + EncryptionConfig, + DirectoryConfig, +} from "../../util/encryption" + +// @ts-ignore +import encryptScript from "../../components/scripts/encrypt.inline.ts" +import encryptStyle from "../../components/styles/encrypt.scss" + +// ============================================================================= +// TYPE DEFINITIONS +// ============================================================================= +// Plugin configuration +export interface PluginConfig extends CompleteCryptoConfig { + encryptedFolders: Record +} + +// User-provided options (all optional) +export type PluginOptions = Partial + +// Internal normalized folder configuration +interface NormalizedFolderConfig extends CompleteCryptoConfig { + password: string + path: string + depth: number +} + +// ============================================================================= +// CONFIGURATION MANAGEMENT +// ============================================================================= + +const DEFAULT_CONFIG: CompleteCryptoConfig = { + algorithm: "aes-256-cbc", + ttl: 3600 * 24 * 7, // 1 week + message: "This content is encrypted.", +} + +/** + * Normalizes a directory configuration into a complete configuration object + */ +function normalizeDirectoryConfig( + dirConfig: DirectoryConfig, + defaults: CompleteCryptoConfig, +): EncryptionConfig { + if (typeof dirConfig === "string") { + return { + ...defaults, + password: dirConfig, + } + } + + return { + algorithm: dirConfig.algorithm ?? defaults.algorithm, + ttl: dirConfig.ttl ?? defaults.ttl, + message: dirConfig.message ?? defaults.message, + password: dirConfig.password, + } +} + +/** + * Creates a sorted list of folder configurations by path depth + * This ensures parent configurations are processed before children + */ +function createFolderConfigHierarchy( + folders: Record, + globalDefaults: CompleteCryptoConfig, +): NormalizedFolderConfig[] { + const configs: NormalizedFolderConfig[] = [] + + for (const [path, config] of Object.entries(folders)) { + const normalized = normalizeDirectoryConfig(config, globalDefaults) + configs.push({ + ...normalized, + path: path.endsWith("/") ? path : path + "/", + depth: path.split("/").filter(Boolean).length, + }) + } + + // Sort by depth (shallow to deep) to ensure proper inheritance + return configs.sort((a, b) => a.depth - b.depth) +} + +/** + * Merges configurations following the inheritance chain + * Deeper paths override shallower ones + */ +function mergeConfigurations( + base: CompleteCryptoConfig, + override: Partial, +): CompleteCryptoConfig { + return { + algorithm: override.algorithm ?? base.algorithm, + ttl: override.ttl ?? base.ttl, + message: override.message ?? base.message, + } +} + +/** + * Gets the encryption configuration for a specific file path + * Respects the directory hierarchy and inheritance + */ +function getConfigurationForPath( + filePath: string, + folderConfigs: NormalizedFolderConfig[], + globalDefaults: CompleteCryptoConfig, +): EncryptionConfig | undefined { + let currentConfig: CompleteCryptoConfig = globalDefaults + let password: string | undefined + + // Apply configurations in order (shallow to deep) + for (const folderConfig of folderConfigs) { + if (filePath.startsWith(folderConfig.path)) { + // Merge the configuration, inheriting from parent + currentConfig = mergeConfigurations(currentConfig, folderConfig) + password = folderConfig.password + } + } + + // If no password was found in any matching folder, return undefined + if (!password) { + return undefined + } + + return { + ...currentConfig, + password, + } +} + +/** + * Validates the plugin configuration + */ +function validateConfig(config: EncryptionConfig, file: VFile | null = null): void { + let suffixedPath = "" + + if (file && file.data && file.data.relativePath) { + suffixedPath = `(in file: ${file.data.relativePath})` + } + + if (!SUPPORTED_ALGORITHMS.includes(config.algorithm)) { + throw new Error( + `[EncryptPlugin] Unsupported encryption algorithm: ${config.algorithm}. ` + + `Supported algorithms: ${SUPPORTED_ALGORITHMS.join(", ")} ${suffixedPath}`, + ) + } + + if (config.ttl < 0) { + throw new Error(`[EncryptPlugin] TTL cannot be negative. ${suffixedPath}`) + } +} + +// ============================================================================= +// PLUGIN IMPLEMENTATION +// ============================================================================= + +export const Encrypt: QuartzTransformerPlugin = (userOpts) => { + // Merge user options with defaults + const pluginConfig: PluginConfig = { + ...DEFAULT_CONFIG, + ...userOpts, + encryptedFolders: userOpts?.encryptedFolders ?? {}, + } + + // Pre-process folder configurations for efficient lookup + const folderConfigs = createFolderConfigHierarchy(pluginConfig.encryptedFolders, pluginConfig) + + /** + * Determines the final encryption configuration for a file + * Priority: frontmatter > deepest matching folder > global defaults + */ + const getEncryptionConfig = (file: VFile): EncryptionConfig | undefined => { + const frontmatter = file.data?.frontmatter + const relativePath = file.data?.relativePath + + // Check if file should be encrypted via frontmatter + const shouldEncryptViaFrontmatter = frontmatter?.encrypt === true + const frontmatterConfig = frontmatter?.encryptConfig as + | (BaseCryptoConfig & { password?: string }) + | undefined + + // Get folder-based configuration + const folderConfig = relativePath + ? getConfigurationForPath(relativePath, folderConfigs, pluginConfig) + : undefined + + // If neither folder config nor frontmatter indicates encryption, skip + if (!folderConfig && !shouldEncryptViaFrontmatter) { + return undefined + } + + // Build final configuration with proper precedence + let finalConfig: EncryptionConfig | undefined + + if (folderConfig && frontmatterConfig) { + // Merge folder and frontmatter configs + finalConfig = { + algorithm: frontmatterConfig.algorithm ?? folderConfig.algorithm, + ttl: frontmatterConfig.ttl ?? folderConfig.ttl, + message: frontmatterConfig.message ?? folderConfig.message, + password: frontmatterConfig.password ?? folderConfig.password, + } + } else if (folderConfig) { + // Use folder config only + finalConfig = folderConfig + } else if (frontmatterConfig?.password) { + // Use frontmatter config with global defaults + finalConfig = { + algorithm: frontmatterConfig.algorithm ?? pluginConfig.algorithm, + ttl: frontmatterConfig.ttl ?? pluginConfig.ttl, + message: frontmatterConfig.message ?? pluginConfig.message, + password: frontmatterConfig.password, + } + } + + // Validate final configuration + if (!finalConfig?.password) { + console.warn(`[EncryptPlugin] No password configured for ${relativePath}`) + return undefined + } + + return finalConfig + } + + return { + name: "Encrypt", + markdownPlugins() { + return [ + () => { + return async (_, file) => { + const config = getEncryptionConfig(file) + if (!config) { + return + } + // Validate configuration + validateConfig(config, file) + + file.data.encryptionConfig = config + file.data.hash = await hashString(config.password) + } + }, + ] + }, + htmlPlugins(ctx) { + return [ + () => { + return async (tree: Root, file) => { + const config = getEncryptionConfig(file) + if (!config || !file.data.hash) { + return tree + } + + const locale = ctx.cfg.configuration.locale + const t = i18n(locale).components.encryption + + // Encrypt the content + const encryptionResult = await encryptContent(toHtml(tree), config.password, config) + + // Store for later use + file.data.encryptionResult = encryptionResult + + // Create attributes for client-side decryption + const attributes = [ + `data-config='${JSON.stringify({ + algorithm: config.algorithm, + ttl: config.ttl, + message: config.message, + })}'`, + `data-encrypted='${JSON.stringify(encryptionResult)}'`, + `data-hash='${JSON.stringify(file.data.hash)}'`, + `data-slug='${file.data.slug}'`, + `data-decrypted='false'`, + ].join(" ") + + // Create encrypted content placeholder + const encryptedTree = fromHtml( + ` +
+
+

${t.title}

+ ${config.message ? `

${config.message}

` : ""} +
+ + +
+
+
+ ${t.decrypting} +
+
+ ${t.incorrectPassword} +
+
+ ${t.decryptionFailed} +
+
+
+ `, + { fragment: true }, + ) + + // Replace the original tree + tree.children = encryptedTree.children + return tree + } + }, + ] + }, + externalResources() { + return { + js: [ + { + loadTime: "afterDOMReady", + contentType: "inline", + script: encryptScript, + }, + ], + css: [ + { + content: encryptStyle, + inline: true, + }, + ], + } + }, + } +} + +// ============================================================================= +// MODULE AUGMENTATION +// ============================================================================= + +declare module "vfile" { + interface DataMap { + encryptionConfig: EncryptionConfig + encryptionResult: EncryptionResult + hash: Hash + } +} diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index db1cf4213..04608a724 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -6,6 +6,7 @@ import toml from "toml" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" import { QuartzPluginData } from "../vfile" import { i18n } from "../../i18n" +import { EncryptionConfig } from "../../util/encryption" export interface Options { delimiters: string | [string, string] @@ -119,6 +120,22 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) if (socialImage) data.socialImage = socialImage + const encrypted = coalesceAliases(data, ["encrypted", "encrypt"]) + if (encrypted) data.encrypt = true + + const password = coalesceAliases(data, ["password"]) + if (password) data.encryptConfig = { password: password } + + const encryptConfig = coalesceAliases(data, ["encryptConfig", "encrypt_config"]) + if (encryptConfig && typeof encryptConfig === "object") { + data.encryptConfig = { + password: encryptConfig.password || password, + message: encryptConfig.message || undefined, + algorithm: encryptConfig.algorithm || undefined, + ttl: encryptConfig.ttl || undefined, + } + } + // Remove duplicate slugs const uniqueSlugs = [...new Set(allSlugs)] allSlugs.splice(0, allSlugs.length, ...uniqueSlugs) @@ -152,6 +169,8 @@ declare module "vfile" { cssclasses: string[] socialImage: string comments: boolean | string + encrypt: boolean + encryptConfig: EncryptionConfig }> } } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..5270d5f05 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,6 +8,7 @@ export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" +export { Encrypt } from "./encrypt" export { TableOfContents } from "./toc" export { HardLineBreaks } from "./linebreaks" export { RoamFlavoredMarkdown } from "./roam" diff --git a/quartz/util/encryption.ts b/quartz/util/encryption.ts new file mode 100644 index 000000000..cfcce3dcc --- /dev/null +++ b/quartz/util/encryption.ts @@ -0,0 +1,527 @@ +export const SUPPORTED_ALGORITHMS = ["aes-256-cbc", "aes-256-gcm"] as const + +export type SupportedEncryptionAlgorithm = (typeof SUPPORTED_ALGORITHMS)[number] + +// Result of hash operation +export interface Hash { + hash: string + salt: string +} + +// Result of encryption operation +export interface EncryptionResult { + encryptedContent: string + encryptionSalt: string + iv?: string + authTag?: string +} + +// Base crypto configuration without password +export interface BaseCryptoConfig { + algorithm?: SupportedEncryptionAlgorithm + ttl?: number + message?: string +} + +// Directory configuration can be partial or just a password string +export type DirectoryConfig = (BaseCryptoConfig & { password: string }) | string + +// Complete crypto configuration with all required fields +export interface CompleteCryptoConfig { + algorithm: SupportedEncryptionAlgorithm + ttl: number + message: string +} + +// Encryption configuration includes password +export interface EncryptionConfig extends CompleteCryptoConfig { + password: string +} + +const ENCRYPTION_CACHE_KEY = "quartz-encrypt-passwords" + +// ============================================================================= +// CRYPTO INITIALIZATION +// ============================================================================= + +// Unified crypto interface for both Node.js and browser environments +let crypto: Crypto +if (typeof globalThis !== "undefined" && globalThis.crypto) { + crypto = globalThis.crypto +} else if (typeof window !== "undefined" && window.crypto) { + crypto = window.crypto +} else { + // Node.js environment + try { + const { webcrypto } = require("node:crypto") + crypto = webcrypto as Crypto + } catch { + throw new Error("No crypto implementation available") + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +// Check if crypto.subtle is available and supported +function checkCryptoSupport(): void { + if (!crypto || !crypto.subtle) { + throw new Error("Web Crypto API is not supported in this environment") + } +} + +function deriveKeyLengthFromAlgorithm(algorithm: string): number { + if (algorithm.includes("256")) return 32 // 256 bits = 32 bytes + if (algorithm.includes("192")) return 24 // 192 bits = 24 bytes + if (algorithm.includes("128")) return 16 // 128 bits = 16 bytes + return 32 // Default to 256-bit +} + +// Browser-compatible base64 encoding/decoding +export function base64Encode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data).toString("base64") + } else { + // Browser environment + return btoa(encodeURIComponent(data)) + } +} + +export function base64Decode(data: string): string { + if (typeof Buffer !== "undefined") { + // Node.js environment + return Buffer.from(data, "base64").toString() + } else { + // Browser environment + return decodeURIComponent(atob(data)) + } +} + +// Utility functions for array buffer conversions +export function hexToArrayBuffer(hex: string): ArrayBuffer { + if (!hex) return new ArrayBuffer(0) + + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16) + } + return bytes.buffer +} + +export function arrayBufferToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +export function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder() + return encoder.encode(str).buffer as ArrayBuffer +} + +export function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +// ============================================================================= +// CORE CRYPTOGRAPHIC FUNCTIONS +// ============================================================================= + +export async function deriveKeyFromHash( + passwordHash: string, + algorithm: string, +): Promise { + try { + const keyLength = deriveKeyLengthFromAlgorithm(algorithm) + const hashBytes = hexToArrayBuffer(passwordHash) + + // Use only the required key length from the hash + const keyBytes = new Uint8Array(hashBytes).slice(0, keyLength) + + // For GCM mode, use AES-GCM as the algorithm name + const algorithmName = algorithm === "aes-256-gcm" ? "AES-GCM" : "AES-CBC" + + return await crypto.subtle.importKey("raw", keyBytes, { name: algorithmName }, false, [ + "encrypt", + "decrypt", + ]) + } catch (error) { + throw new Error( + `Key derivation failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +export async function hashString( + password: string, + salt: ArrayBuffer | string | undefined = undefined, +): Promise { + const passwordBytes = stringToArrayBuffer(password) + + let saltBytes: Uint8Array | null = null + + if (typeof salt === "string") { + saltBytes = new Uint8Array(hexToArrayBuffer(salt)) + } else if (salt !== undefined) { + saltBytes = new Uint8Array(salt) + } else { + saltBytes = crypto.getRandomValues(new Uint8Array(16)) + } + + const combined = new Uint8Array(passwordBytes.byteLength + saltBytes.byteLength) + combined.set(new Uint8Array(passwordBytes), 0) + combined.set(saltBytes, passwordBytes.byteLength) + + const hashBuffer = await crypto.subtle.digest("SHA-256", combined) + return { + hash: arrayBufferToHex(hashBuffer), + salt: arrayBufferToHex(saltBytes.buffer as ArrayBuffer), + } +} + +export async function verifyPasswordHash( + password: string, + passwordHashData: Hash, +): Promise { + const { hash: passwordHash } = await hashString(password, passwordHashData.salt) + return passwordHash === passwordHashData.hash +} + +export async function encryptContent( + content: string, + password: string, + config: CompleteCryptoConfig, +): Promise { + checkCryptoSupport() + + const { algorithm } = config + + if (!SUPPORTED_ALGORITHMS.includes(algorithm as SupportedEncryptionAlgorithm)) { + throw new Error( + `Unsupported encryption algorithm: ${algorithm}. Supported: ${SUPPORTED_ALGORITHMS.join(", ")}`, + ) + } + + // Generate random salt for encryption + const initializationVector = crypto.getRandomValues(new Uint8Array(16)) + + // Create encryption hash and derive key + const encryptionHashData = await hashString(password) + const key = await deriveKeyFromHash(encryptionHashData.hash, algorithm) + + // Prepare content for encryption + const contentBuffer = stringToArrayBuffer(content) + + let encryptedBuffer: ArrayBuffer + let authTag: ArrayBuffer | undefined + + try { + if (algorithm === "aes-256-gcm") { + // GCM mode - the Web Crypto API returns ciphertext with auth tag appended + const encryptedWithTag = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: initializationVector, + }, + key, + contentBuffer, + ) + + // The last 16 bytes (128 bits) are the authentication tag + const encryptedBytes = new Uint8Array(encryptedWithTag) + const ciphertext = encryptedBytes.slice(0, -16) + const authTagBytes = encryptedBytes.slice(-16) + + encryptedBuffer = ciphertext.buffer + authTag = authTagBytes.buffer + } else if (algorithm === "aes-256-cbc") { + // CBC mode + encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-CBC", + iv: initializationVector, + }, + key, + contentBuffer, + ) + } else { + throw new Error("Unsupported algorithm: " + algorithm) + } + } catch (error) { + throw new Error( + `Encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + // Build result object + const result: EncryptionResult = { + encryptedContent: arrayBufferToHex(encryptedBuffer), + encryptionSalt: encryptionHashData.salt, + iv: arrayBufferToHex(initializationVector.buffer as ArrayBuffer), + } + + if (authTag) { + result.authTag = arrayBufferToHex(authTag) + } + + return result +} + +export async function decryptContent( + encrypted: EncryptionResult, + config: CompleteCryptoConfig, + password: string, +): Promise { + checkCryptoSupport() + const { encryptedContent, encryptionSalt, iv, authTag } = encrypted + + // Create encryption hash and derive key + const encryptionSaltBuffer = hexToArrayBuffer(encryptionSalt) + const encryptionHashData = await hashString(password, encryptionSaltBuffer) + const key = await deriveKeyFromHash(encryptionHashData.hash, config.algorithm) + + // Prepare for decryption + const ciphertext = hexToArrayBuffer(encryptedContent) + let decryptedBuffer: ArrayBuffer + + try { + if (config.algorithm === "aes-256-gcm") { + // GCM mode + if (!iv) throw new Error("IV is required for GCM mode") + if (!authTag) throw new Error("Authentication tag is required for GCM mode") + + const initializationVectorBuffer = hexToArrayBuffer(iv) + const authTagBuffer = hexToArrayBuffer(authTag) + + // For GCM decryption, we need to append the auth tag to the ciphertext + const combined = new Uint8Array(ciphertext.byteLength + authTagBuffer.byteLength) + combined.set(new Uint8Array(ciphertext), 0) + combined.set(new Uint8Array(authTagBuffer), ciphertext.byteLength) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: initializationVectorBuffer, + }, + key, + combined.buffer, + ) + } else if (config.algorithm === "aes-256-cbc") { + // CBC mode + if (!iv) throw new Error("IV is required for CBC mode") + const initializationVectorBuffer = hexToArrayBuffer(iv) + + decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-CBC", + iv: initializationVectorBuffer, + }, + key, + ciphertext, + ) + } else { + throw new Error("Unsupported algorithm: " + config.algorithm) + } + } catch (error) { + throw new Error( + `Decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + return arrayBufferToString(decryptedBuffer) +} + +export async function searchForValidPassword( + filePath: string, + hash: Hash, + config: CompleteCryptoConfig, + blacklist: Set | null = null, +): Promise { + const passwords = getRelevantPasswords(filePath) + + for (const password of passwords) { + if (blacklist && blacklist.has(password)) { + continue + } + + if (await verifyPasswordHash(password, hash)) { + addPasswordToCache(password, filePath, config.ttl) + return password + } + + if (blacklist) { + blacklist.add(password) + } + } + + return undefined +} + +// ============================================================================= +// PASSWORD CACHING AND MANAGEMENT +// ============================================================================= + +// Queue to prevent race conditions in cache operations +let cacheOperationQueue: Promise = Promise.resolve() + +interface CachedPassword { + password: string + ttl: number +} + +// Helper function to execute cache operations atomically +async function executeAtomicCacheOperation(operation: () => T): Promise { + return new Promise((resolve, reject) => { + cacheOperationQueue = cacheOperationQueue + .then(() => { + try { + const result = operation() + resolve(result) + } catch (error) { + reject(error) + } + }) + .catch((error) => { + reject(error) + }) + }) +} + +export function getPasswordCache(): Record { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return {} + } + + try { + const cache = localStorage.getItem(ENCRYPTION_CACHE_KEY) + return cache ? JSON.parse(cache) : {} + } catch { + return {} + } +} + +export function savePasswordCache(cache: Record) { + // Check if we're in a browser environment + if (typeof localStorage === "undefined") { + return + } + + try { + localStorage.setItem(ENCRYPTION_CACHE_KEY, JSON.stringify(cache)) + } catch { + // Silent fail if localStorage is not available + } +} + +export async function addPasswordToCache( + password: string, + filePath: string, + ttl: number, +): Promise { + return executeAtomicCacheOperation(() => { + const cache = getPasswordCache() + const now = Date.now() + + cache[filePath] = { + password, + ttl: ttl == 0 ? 0 : now + ttl, + } + + savePasswordCache(cache) + }) +} + +export function getRelevantPasswords(filePath: string): string[] { + const cache = getPasswordCache() + const now = Date.now() + const uniquePasswords: Set = new Set() + const passwords: string[] = [] + + // Clean expired passwords (but keep infinite TTL ones) + Object.keys(cache).forEach((path) => { + if (cache[path].ttl > 0 && cache[path].ttl < now) { + delete cache[path] + } + }) + + if (cache[filePath] && (cache[filePath].ttl > now || cache[filePath].ttl === 0)) { + // If the exact file path is cached, return its password + return [cache[filePath].password] + } + + // Get passwords by directory hierarchy (closest first) + const sortedPaths = Object.keys(cache).sort((a, b) => { + const aShared = getSharedDirectoryDepth(a, filePath) + const bShared = getSharedDirectoryDepth(b, filePath) + return bShared - aShared // Descending order (most shared first) + }) + + for (const path of sortedPaths) { + if (!uniquePasswords.has(cache[path].password)) { + uniquePasswords.add(cache[path].password) + passwords.push(cache[path].password) + } + } + + return passwords +} + +export function getSharedDirectoryDepth(path1: string, path2: string): number { + const parts1 = path1.split("/") + const parts2 = path2.split("/") + let sharedDepth = 0 + + const minLength = Math.min(parts1.length, parts2.length) + for (let i = 0; i < minLength - 1; i++) { + // -1 to exclude filename + if (parts1[i] === parts2[i]) { + sharedDepth++ + } else { + break + } + } + + return sharedDepth +} + +export async function contentDecryptedEventListener( + filePath: string, + hash: Hash, + config: CompleteCryptoConfig, + callback: (password: string) => void, +) { + const blacklist = new Set() + + async function decryptionSuccessful(password: string) { + addPasswordToCache(password, filePath, config.ttl) + callback(password) + } + + async function listener(e: CustomEventMap["decrypt"]) { + const password = e.detail.password + + if (blacklist.has(password)) { + return + } + + if (await verifyPasswordHash(password, hash)) { + document.removeEventListener("decrypt", listener) + await decryptionSuccessful(password) + return + } else { + blacklist.add(password) + } + } + + const password = await searchForValidPassword(filePath, hash, config, blacklist) + if (password) { + callback(password) + } + + document.addEventListener("decrypt", listener) +}