This commit is contained in:
Yigit Colakoglu 2025-12-10 08:40:06 -05:00 committed by GitHub
commit 4ffb88a69c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2089 additions and 60 deletions

View File

@ -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 `<head>` also links to page styles (emitted to `public/index.css`) and page-critical JS (emitted to `public/prescript.js`) 1. The browser opens a Quartz page and loads the HTML. The `<head>` 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`) 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. 3. Once the page is done loading, the page will dispatch two custom synthetic browser events:
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. 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.
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. - 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]]. 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]].

View File

@ -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). 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 ```ts
document.addEventListener("nav", () => { document.addEventListener("nav", (e) => {
// do page specific logic here // runs only on page navigation
// e.g. attach event listeners // e.detail.url contains the new page URL
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement const currentUrl = e.detail.url
toggleSwitch.addEventListener("change", switchTheme) console.log(`Navigated to: ${currentUrl}`)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) })
```
**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)
}) })
``` ```

View File

@ -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.

106
docs/plugins/Encrypt.md Normal file
View File

@ -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)

View File

@ -64,6 +64,13 @@ Quartz supports the following frontmatter:
- `published` - `published`
- `publishDate` - `publishDate`
- `date` - `date`
- encrypt
- `encrypt`
- `encrypted`
- encryptConfig
- Overrides for the [[plugins/Encrypt|encryptConfig]]
- password
- `password`
## API ## API

8
index.d.ts vendored
View File

@ -7,9 +7,15 @@ declare module "*.scss" {
interface CustomEventMap { interface CustomEventMap {
prenav: CustomEvent<{}> prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug }>
render: CustomEvent<{ htmlElement: HTMLElement }>
decrypt: CustomEvent<{ filePath: FullSlug; password: string }>
themechange: CustomEvent<{ theme: "light" | "dark" }> themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }> readermodechange: CustomEvent<{ mode: "on" | "off" }>
} }
type ContentIndex = Record<FullSlug, ContentDetails> type DecryptedFlag = {
decrypted?: boolean
}
type ContentIndex = Record<FullSlug, ContentDetails & DecryptedFlag>
declare const fetchData: Promise<ContentIndex> declare const fetchData: Promise<ContentIndex>

View File

@ -70,8 +70,13 @@ const config: QuartzConfig = {
Plugin.GitHubFlavoredMarkdown(), Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(), Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }), Plugin.Latex({ renderEngine: "katex" }),
Plugin.Encrypt({
algorithm: "aes-256-cbc",
encryptedFolders: {},
ttl: 3600 * 24 * 7, // A week
}),
Plugin.Description(),
], ],
filters: [Plugin.RemoveDrafts()], filters: [Plugin.RemoveDrafts()],
emitters: [ emitters: [

View File

@ -4,7 +4,12 @@ import { classNames } from "../util/lang"
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
const title = fileData.frontmatter?.title const title = fileData.frontmatter?.title
if (title) { if (title) {
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1> return (
<h1 class={classNames(displayClass, "article-title")}>
{fileData.encryptionResult && <span className="article-title-icon">🔒 </span>}
{title}
</h1>
)
} else { } else {
return null return null
} }

View File

@ -30,7 +30,7 @@ const defaultOptions: Options = {
return node return node
}, },
sortFn: (a, b) => { 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)) { if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // 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 // 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<Options>) => {
</svg> </svg>
<div> <div>
<button class="folder-button"> <button class="folder-button">
<span class="folder-title"></span> <span class="folder-title folder-title-text"></span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -26,7 +26,7 @@ export default ((opts?: Partial<Options>) => {
displayClass, displayClass,
cfg, cfg,
}: QuartzComponentProps) => { }: QuartzComponentProps) => {
if (!fileData.toc) { if (!fileData.toc || fileData.encryptionResult) {
return null return null
} }
@ -75,7 +75,7 @@ export default ((opts?: Partial<Options>) => {
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded) TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) { if (!fileData.toc || fileData.encryptionResult) {
return null return null
} }
return ( return (

View File

@ -1,3 +1,5 @@
import { addRenderListener } from "./util"
function toggleCallout(this: HTMLElement) { function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement! const outerBlock = this.parentElement!
outerBlock.classList.toggle("is-collapsed") outerBlock.classList.toggle("is-collapsed")
@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) {
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr" content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
} }
function setupCallout() { function setupCallout(container: HTMLElement) {
const collapsible = document.getElementsByClassName( const collapsible = container.getElementsByClassName(
`callout is-collapsible`, `callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) { for (const div of collapsible) {
@ -24,4 +26,4 @@ function setupCallout() {
} }
} }
document.addEventListener("nav", setupCallout) addRenderListener(setupCallout)

View File

@ -1,9 +1,10 @@
import { getFullSlug } from "../../util/path" import { getFullSlug } from "../../util/path"
import { addRenderListener } from "./util"
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}` const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
document.addEventListener("nav", () => { addRenderListener((container: HTMLElement) => {
const checkboxes = document.querySelectorAll( const checkboxes = container.querySelectorAll(
"input.checkbox-toggle", "input.checkbox-toggle",
) as NodeListOf<HTMLInputElement> ) as NodeListOf<HTMLInputElement>
checkboxes.forEach((el, index) => { checkboxes.forEach((el, index) => {

View File

@ -1,10 +1,12 @@
import { addRenderListener } from "./util"
const svgCopy = const svgCopy =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>' '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
const svgCheck = const svgCheck =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>' '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
document.addEventListener("nav", () => { addRenderListener((container: HTMLElement) => {
const els = document.getElementsByTagName("pre") const els = container.getElementsByTagName("pre")
for (let i = 0; i < els.length; i++) { for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0] const codeBlock = els[i].getElementsByTagName("code")[0]
if (codeBlock) { if (codeBlock) {

View File

@ -1,3 +1,5 @@
import { addRenderListener } from "./util"
const changeTheme = (e: CustomEventMap["themechange"]) => { const changeTheme = (e: CustomEventMap["themechange"]) => {
const theme = e.detail.theme const theme = e.detail.theme
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
@ -59,8 +61,8 @@ type GiscusElement = Omit<HTMLElement, "dataset"> & {
} }
} }
document.addEventListener("nav", () => { addRenderListener((container: HTMLElement) => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement const giscusContainer = container.querySelector(".giscus") as GiscusElement
if (!giscusContainer) { if (!giscusContainer) {
return return
} }

View File

@ -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<boolean> => {
const errorDivs = container.querySelectorAll(".decrypt-error") as NodeListOf<HTMLElement>
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<boolean> => {
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<HTMLElement>
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))
}
})
})

View File

@ -1,6 +1,7 @@
import { FileTrieNode } from "../../util/fileTrie" import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { contentDecryptedEventListener } from "../../util/encryption"
type MaybeHTMLElement = HTMLElement | undefined type MaybeHTMLElement = HTMLElement | undefined
@ -86,7 +87,16 @@ function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElemen
const a = li.querySelector("a") as HTMLAnchorElement const a = li.querySelector("a") as HTMLAnchorElement
a.href = resolveRelative(currentSlug, node.slug) a.href = resolveRelative(currentSlug, node.slug)
a.dataset.for = 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) { if (currentSlug === node.slug) {
a.classList.add("active") a.classList.add("active")
@ -111,6 +121,8 @@ function createFolderNode(
const folderPath = node.slug const folderPath = node.slug
folderContainer.dataset.folderpath = folderPath folderContainer.dataset.folderpath = folderPath
let titleElement: HTMLElement
if (opts.folderClickBehavior === "link") { if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior // Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement const button = titleContainer.querySelector(".folder-button") as HTMLElement
@ -118,11 +130,20 @@ function createFolderNode(
a.href = resolveRelative(currentSlug, folderPath) a.href = resolveRelative(currentSlug, folderPath)
a.dataset.for = folderPath a.dataset.for = folderPath
a.className = "folder-title" a.className = "folder-title"
a.textContent = node.displayName titleElement = a
button.replaceWith(a) button.replaceWith(a)
} else { } else {
const span = titleContainer.querySelector(".folder-title") as HTMLElement const span = titleContainer.querySelector(".folder-title-text") as HTMLElement
span.textContent = node.displayName 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 // 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 data = await fetchData
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
const trie = FileTrieNode.fromEntries(entries) const trie = FileTrieNode.fromEntries(entries)
@ -267,6 +289,7 @@ document.addEventListener("prenav", async () => {
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url const currentSlug = e.detail.url
await setupExplorer(currentSlug) await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default // if mobile hamburger is visible, collapse by default

View File

@ -95,6 +95,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
v, v,
]), ]),
) )
const links: SimpleLinkData[] = [] const links: SimpleLinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
@ -575,6 +576,7 @@ function cleanupGlobalGraphs() {
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url const slug = e.detail.url
addToVisited(simplifySlug(slug)) addToVisited(simplifySlug(slug))
async function renderLocalGraph() { async function renderLocalGraph() {

View File

@ -1,4 +1,4 @@
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util"
interface Position { interface Position {
x: number x: number
@ -185,9 +185,11 @@ const cssVars = [
] as const ] as const
let mermaidImport = undefined let mermaidImport = undefined
document.addEventListener("nav", async () => {
const center = document.querySelector(".center") as HTMLElement addRenderListener(async (container: HTMLElement) => {
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement> const nodes = container.querySelectorAll(
"code.mermaid:not([data-processed])",
) as NodeListOf<HTMLElement>
if (nodes.length === 0) return if (nodes.length === 0) return
mermaidImport ||= await import( mermaidImport ||= await import(
@ -204,9 +206,9 @@ document.addEventListener("nav", async () => {
async function renderMermaid() { async function renderMermaid() {
// de-init any other diagrams // de-init any other diagrams
for (const node of nodes) { for (const node of nodes) {
node.removeAttribute("data-processed")
const oldText = textMapping.get(node) const oldText = textMapping.get(node)
if (oldText) { if (oldText) {
node.removeAttribute("data-processed")
node.innerHTML = oldText node.innerHTML = oldText
} }
} }

View File

@ -1,6 +1,6 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path" import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util"
const p = new DOMParser() const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null let activeAnchor: HTMLAnchorElement | null = null
@ -37,6 +37,8 @@ async function mouseEnterHandler(
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
} }
} }
dispatchRenderEvent(popoverInner)
} }
const targetUrl = new URL(link.href) const targetUrl = new URL(link.href)
@ -120,8 +122,9 @@ function clearActivePopover() {
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover")) allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
} }
document.addEventListener("nav", () => { addRenderListener((element) => {
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[] const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[]
for (const link of links) { for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseleave", clearActivePopover) link.addEventListener("mouseleave", clearActivePopover)

View File

@ -1,7 +1,7 @@
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch" import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
import { contentDecryptedEventListener, decryptContent } from "../../util/encryption"
interface Item { interface Item {
id: number id: number
@ -88,6 +88,7 @@ const fetchContentCache: Map<FullSlug, Element[]> = new Map()
const contextWindowWords = 30 const contextWindowWords = 30
const numSearchResults = 8 const numSearchResults = 8
const numTagResults = 5 const numTagResults = 5
const RENDER_DELAY_MS = 100
const tokenizeTerm = (term: string) => { const tokenizeTerm = (term: string) => {
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") 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 formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] const slug = idDataMap[id]
let title = data[slug].title
if (data[slug].encryptionResult) {
title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title
}
return { return {
id, id,
slug, 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), content: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term.substring(1), data[slug].tags), 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, (a, b) => b.innerHTML.length - a.innerHTML.length,
) )
highlights[0]?.scrollIntoView({ block: "start" }) highlights[0]?.scrollIntoView({ block: "start" })
await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY_MS))
dispatchRenderEvent(previewInner)
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
@ -514,16 +524,54 @@ async function fillDocument(data: ContentIndex) {
if (indexPopulated) return if (indexPopulated) return
let id = 0 let id = 0
const promises: Array<Promise<unknown>> = [] const promises: Array<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries(data)) {
promises.push( if (fileData.encryptionResult) {
index.addAsync(id++, { let slugId = id
id, promises.push(
slug: slug as FullSlug, index.addAsync(id++, {
title: fileData.title, id: id,
content: fileData.content, slug: slug as FullSlug,
tags: fileData.tags, 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) await Promise.all(promises)

View File

@ -1,6 +1,6 @@
import micromorph from "micromorph" import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical, dispatchRenderEvent } from "./util"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/micromorph // https://github.com/natemoo-re/micromorph
@ -38,6 +38,9 @@ const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined
function notifyNav(url: FullSlug) { function notifyNav(url: FullSlug) {
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } }) const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
document.dispatchEvent(event) document.dispatchEvent(event)
// Also trigger render event for the whole document body
dispatchRenderEvent(document.body)
} }
const cleanupFns: Set<(...args: any[]) => void> = new Set() const cleanupFns: Set<(...args: any[]) => void> = new Set()

View File

@ -1,3 +1,5 @@
import { addRenderListener } from "./util"
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const slug = entry.target.id const slug = entry.target.id
@ -34,11 +36,11 @@ function setupToc() {
} }
} }
document.addEventListener("nav", () => { addRenderListener((container: HTMLElement) => {
setupToc() setupToc()
// update toc entry highlighting // update toc entry highlighting
observer.disconnect() 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)) headers.forEach((header) => observer.observe(header))
}) })

View File

@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise<Response> {
const [_, redirect] = text.match(canonicalRegex) ?? [] const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res 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)
}

View File

@ -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);
}
}

View File

@ -65,6 +65,15 @@ export default {
? `دقيقتان للقراءة` ? `دقيقتان للقراءة`
: `${minutes} دقائق للقراءة`, : `${minutes} دقائق للقراءة`,
}, },
encryption: {
title: "🛡️ محتوى محدود 🛡️",
enterPassword: "ادخل كلمة المرور",
decrypt: "فك التشفير",
decrypting: "جاري فك التشفير...",
incorrectPassword: "كلمة مرور خاطئة. حاول مرة أخرى.",
decryptionFailed: "فشل فك التشفير، تحقق من السجلات",
encryptedDescription: "هذا الملف مشفر. افتحه لرؤية المحتويات.",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `Es llegeix en ${minutes} min`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min čtení`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,16 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} Min. Lesezeit`, 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: { pages: {
rss: { rss: {

View File

@ -62,6 +62,15 @@ export interface Translation {
contentMeta: { contentMeta: {
readingTime: (variables: { minutes: number }) => string readingTime: (variables: { minutes: number }) => string
} }
encryption: {
title: string
enterPassword: string
decrypt: string
decrypting: string
incorrectPassword: string
decryptionFailed: string
encryptedDescription: string
}
} }
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `Se lee en ${minutes} min`, 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: { pages: {
rss: { rss: {

View File

@ -60,6 +60,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`, readingTime: ({ minutes }) => `زمان تقریبی مطالعه: ${minutes} دقیقه`,
}, },
encryption: {
title: "🛡️ محتوای محدود 🛡️",
enterPassword: "رمز عبور را وارد کنید",
decrypt: "رمزگشایی",
decrypting: "در حال رمزگشایی...",
incorrectPassword: "رمز عبور اشتباه است. دوباره تلاش کنید.",
decryptionFailed: "رمزگشایی ناموفق، لاگ‌ها را بررسی کنید",
encryptedDescription: "این فایل رمزگذاری شده است. آن را باز کنید تا محتوا را ببینید.",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min lukuaika`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min de lecture`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} perces olvasás`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} menit baca`, 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: { pages: {
rss: { rss: {

View File

@ -60,6 +60,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => (minutes === 1 ? "1 minuto" : `${minutes} minuti`), 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, readingTime: ({ minutes }) => `${minutes} min read`,
}, },
encryption: {
title: "🛡️ 制限されたコンテンツ 🛡️",
enterPassword: "パスワードを入力",
decrypt: "復号化",
decrypting: "復号化中...",
incorrectPassword: "パスワードが間違っています。もう一度お試しください。",
decryptionFailed: "復号化に失敗しました。ログを確認してください",
encryptedDescription: "このファイルは暗号化されています。内容を見るには開いてください。",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`, readingTime: ({ minutes }) => `${minutes} min read`,
}, },
encryption: {
title: "🛡️ 제한된 콘텐츠 🛡️",
enterPassword: "비밀번호 입력",
decrypt: "복호화",
decrypting: "복호화 중...",
incorrectPassword: "비밀번호가 틀렸습니다. 다시 시도해주세요.",
decryptionFailed: "복호화에 실패했습니다. 로그를 확인하세요",
encryptedDescription: "이 파일은 암호화되어 있습니다. 내용을 보려면 열어주세요.",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min skaitymo`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min lesning`, 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: { pages: {
rss: { rss: {

View File

@ -60,6 +60,15 @@ export default {
readingTime: ({ minutes }) => readingTime: ({ minutes }) =>
minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} min. czytania `, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `Leitura de ${minutes} min`, 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: { pages: {
rss: { rss: {

View File

@ -60,6 +60,15 @@ export default {
readingTime: ({ minutes }) => readingTime: ({ minutes }) =>
minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`, 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: { pages: {
rss: { rss: {

View File

@ -60,6 +60,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`, readingTime: ({ minutes }) => `время чтения ~${minutes} мин.`,
}, },
encryption: {
title: "🛡️ Ограниченный Контент 🛡️",
enterPassword: "Введите пароль",
decrypt: "Расшифровать",
decrypting: "Расшифровка...",
incorrectPassword: "Неверный пароль. Попробуйте снова.",
decryptionFailed: "Расшифровка не удалась, проверьте логи",
encryptedDescription: "Этот файл зашифрован. Откройте его, чтобы увидеть содержимое.",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`, readingTime: ({ minutes }) => `อ่านราว ${minutes} นาที`,
}, },
encryption: {
title: "🛡️ เนื้อหาถูกจำกัด 🛡️",
enterPassword: "ใส่รหัสผ่าน",
decrypt: "ถอดรหัส",
decrypting: "กำลังถอดรหัส...",
incorrectPassword: "รหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
decryptionFailed: "การถอดรหัสล้มเหลว ตรวจสอบบันทึก",
encryptedDescription: "ไฟล์นี้ถูกเข้ารหัส เปิดเพื่อดูเนื้อหา",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} dakika okuma süresi`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} хв читання`, readingTime: ({ minutes }) => `${minutes} хв читання`,
}, },
encryption: {
title: "🛡️ Обмежений Контент 🛡️",
enterPassword: "Введіть пароль",
decrypt: "Розшифрувати",
decrypting: "Розшифрування...",
incorrectPassword: "Неправильний пароль. Спробуйте ще раз.",
decryptionFailed: "Розшифрування не вдалося, перевірте журнали",
encryptedDescription: "Цей файл зашифрований. Відкрийте його, щоб побачити вміст.",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes} phút đọc`, 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: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `${minutes}分钟阅读`, readingTime: ({ minutes }) => `${minutes}分钟阅读`,
}, },
encryption: {
title: "🛡️ 受限内容 🛡️",
enterPassword: "输入密码",
decrypt: "解密",
decrypting: "解密中...",
incorrectPassword: "密码错误。请重试。",
decryptionFailed: "解密失败,请检查日志",
encryptedDescription: "此文件已加密。打开以查看内容。",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -59,6 +59,15 @@ export default {
contentMeta: { contentMeta: {
readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`, readingTime: ({ minutes }) => `閱讀時間約 ${minutes} 分鐘`,
}, },
encryption: {
title: "🛡️ 受限內容 🛡️",
enterPassword: "輸入密碼",
decrypt: "解密",
decrypting: "解密中...",
incorrectPassword: "密碼錯誤。請重試。",
decryptionFailed: "解密失敗,請檢查日誌",
encryptedDescription: "此檔案已加密。打開以檢視內容。",
},
}, },
pages: { pages: {
rss: { rss: {

View File

@ -261,6 +261,8 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
window.addCleanup = () => {} window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event) document.dispatchEvent(event)
const renderEvent = new CustomEvent("render", { detail: { htmlElement: document.body } })
document.dispatchEvent(renderEvent)
`) `)
} }
} }

View File

@ -7,8 +7,11 @@ import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { Hash, EncryptionResult, CompleteCryptoConfig } from "../../util/encryption"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
// Base content details without encryption-specific fields
export type ContentDetails = { export type ContentDetails = {
slug: FullSlug slug: FullSlug
filePath: FilePath filePath: FilePath
@ -19,6 +22,9 @@ export type ContentDetails = {
richContent?: string richContent?: string
date?: Date date?: Date
description?: string description?: string
encryptionConfig?: CompleteCryptoConfig
hash?: Hash
encryptionResult?: EncryptionResult
} }
interface Options { interface Options {
@ -103,19 +109,25 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { const contentDetails: ContentDetails = {
slug, slug,
filePath: file.data.relativePath!, filePath: file.data.relativePath!,
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
links: file.data.links ?? [], links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [], tags: file.data.frontmatter?.tags ?? [],
content: file.data.text ?? "", content: file.data.text ?? "",
richContent: opts?.rssFullHtml richContent:
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) opts?.rssFullHtml && !file.data.encryptionResult
: undefined, ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined,
date: date, date: date,
description: file.data.description ?? "", 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<Partial<Options>> = (opts) => {
// remove description and from content index as nothing downstream // remove description and from content index as nothing downstream
// actually uses it. we only keep it in the index as we need it // actually uses it. we only keep it in the index as we need it
// for the RSS feed // 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.description
delete content.date delete content.date
return [slug, content] return [slug, content]

View File

@ -2,6 +2,7 @@ import { Root as HTMLRoot } from "hast"
import { toString } from "hast-util-to-string" import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { escapeHTML } from "../../util/escape" import { escapeHTML } from "../../util/escape"
import { i18n } from "../../i18n"
export interface Options { export interface Options {
descriptionLength: number descriptionLength: number
@ -24,10 +25,17 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "Description", name: "Description",
htmlPlugins() { htmlPlugins(ctx) {
return [ return [
() => { () => {
return async (tree: HTMLRoot, file) => { 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 frontMatterDescription = file.data.frontmatter?.description
let text = escapeHTML(toString(tree)) let text = escapeHTML(toString(tree))

View File

@ -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<string, DirectoryConfig>
}
// User-provided options (all optional)
export type PluginOptions = Partial<PluginConfig>
// 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<string, DirectoryConfig>,
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<BaseCryptoConfig>,
): 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<PluginOptions> = (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(
`
<div class="encrypted-content" ${attributes}>
<div class="encryption-notice">
<h3>${t.title}</h3>
${config.message ? `<p>${config.message}</p>` : ""}
<div class="decrypt-form">
<input type="password" class="decrypt-password" placeholder="${t.enterPassword}" />
<button class="decrypt-button">${t.decrypt}</button>
</div>
<div class="decrypt-loading">
<div class="loading-spinner"></div>
<span>${t.decrypting}</span>
</div>
<div class="decrypt-error" data-error="incorrect-password">
${t.incorrectPassword}
</div>
<div class="decrypt-error" data-error="decryption-failed">
${t.decryptionFailed}
</div>
</div>
</div>
`,
{ 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
}
}

View File

@ -6,6 +6,7 @@ import toml from "toml"
import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path" import { FilePath, FullSlug, getFileExtension, slugifyFilePath, slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { EncryptionConfig } from "../../util/encryption"
export interface Options { export interface Options {
delimiters: string | [string, string] delimiters: string | [string, string]
@ -119,6 +120,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
if (socialImage) data.socialImage = socialImage 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 // Remove duplicate slugs
const uniqueSlugs = [...new Set(allSlugs)] const uniqueSlugs = [...new Set(allSlugs)]
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs) allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
@ -152,6 +169,8 @@ declare module "vfile" {
cssclasses: string[] cssclasses: string[]
socialImage: string socialImage: string
comments: boolean | string comments: boolean | string
encrypt: boolean
encryptConfig: EncryptionConfig
}> }>
} }
} }

View File

@ -8,6 +8,7 @@ export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm" export { ObsidianFlavoredMarkdown } from "./ofm"
export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm"
export { SyntaxHighlighting } from "./syntax" export { SyntaxHighlighting } from "./syntax"
export { Encrypt } from "./encrypt"
export { TableOfContents } from "./toc" export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks" export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam" export { RoamFlavoredMarkdown } from "./roam"

527
quartz/util/encryption.ts Normal file
View File

@ -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<CryptoKey> {
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<Hash> {
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<boolean> {
const { hash: passwordHash } = await hashString(password, passwordHashData.salt)
return passwordHash === passwordHashData.hash
}
export async function encryptContent(
content: string,
password: string,
config: CompleteCryptoConfig,
): Promise<EncryptionResult> {
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<string> {
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<string> | null = null,
): Promise<string | undefined> {
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<void> = Promise.resolve()
interface CachedPassword {
password: string
ttl: number
}
// Helper function to execute cache operations atomically
async function executeAtomicCacheOperation<T>(operation: () => T): Promise<T> {
return new Promise<T>((resolve, reject) => {
cacheOperationQueue = cacheOperationQueue
.then(() => {
try {
const result = operation()
resolve(result)
} catch (error) {
reject(error)
}
})
.catch((error) => {
reject(error)
})
})
}
export function getPasswordCache(): Record<string, CachedPassword> {
// 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<string, CachedPassword>) {
// 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<void> {
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<string> = 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<string>()
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)
}