Add a new "render" event that is triggered post-encryption

This commit is contained in:
arg3t 2025-07-31 14:47:28 +02:00
parent 54d739214a
commit 43cfbaae22
18 changed files with 304 additions and 102 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`)
2. Then, once the body is loaded, the browser loads the non-critical JS (emitted to `public/postscript.js`)
3. Once the page is done loading, the page will then dispatch a custom synthetic browser event `"nav"`. This is used so client-side scripts declared by components can 'setup' anything that requires access to the page DOM.
1. If the [[SPA Routing|enableSPA option]] is enabled in the [[configuration]], this `"nav"` event is also fired on any client-navigation to allow for components to unregister and reregister any event handlers and state.
2. If it's not, we wire up the `"nav"` event to just be fired a single time after page load to allow for consistency across how state is setup across both SPA and non-SPA contexts.
3. Once the page is done loading, the page will dispatch two custom synthetic browser events:
1. **`"nav"`** event: Fired when the user navigates to a new page. This is used for navigation-specific logic like updating URL-dependent state, analytics tracking, etc.
- Contains `e.detail.url` with the current page URL
- Fired on initial page load and on client-side navigation (if [[SPA Routing|enableSPA option]] is enabled)
2. **`"render"`** event: Fired when content needs to be processed or re-rendered. This is used for DOM manipulation, setting up event listeners, and other content-specific logic.
- Contains `e.detail.htmlElement` with the DOM element that was updated
- Fired on initial page load (with `document.body`) and whenever content is dynamically updated (e.g., in popovers, search results, after decryption)
The architecture and design of the plugin system was intentionally left pretty vague here as this is described in much more depth in the guide on [[making plugins|making your own plugin]].

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).
If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you can listen for the `"nav"` event that gets fired whenever a page loads (which may happen on navigation if [[SPA Routing]] is enabled).
If you need to create an `afterDOMLoaded` script that depends on _page specific_ elements that may change when navigating to a new page, you have two options:
**For navigation-specific logic**, listen for the `"nav"` event that gets fired whenever the user navigates to a new page:
```ts
document.addEventListener("nav", () => {
// do page specific logic here
// e.g. attach event listeners
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
document.addEventListener("nav", (e) => {
// runs only on page navigation
// e.detail.url contains the new page URL
const currentUrl = e.detail.url
console.log(`Navigated to: ${currentUrl}`)
})
```
**For rendering/re-rendering content**, use the `"render"` event which is fired when content needs to be processed or updated:
```ts
document.addEventListener("render", (e) => {
// runs when content is rendered or re-rendered
// e.detail.htmlElement contains the DOM element that was updated
const container = e.detail.htmlElement
// attach event listeners to elements within this container
const toggleSwitch = container.querySelector("#switch") as HTMLInputElement
if (toggleSwitch) {
toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
}
})
```
You can also use the utility function from `"./util"` to simplify render event handling:
```ts
import { addRenderListener } from "./util"
addRenderListener((container) => {
// your rendering logic here
// container is the DOM element that was updated
const elements = container.querySelectorAll(".my-component")
elements.forEach(setupElement)
})
```

View File

@ -0,0 +1,171 @@
---
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.

3
index.d.ts vendored
View File

@ -6,7 +6,8 @@ declare module "*.scss" {
// dom custom event
interface CustomEventMap {
prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug; rerender?: boolean }>
nav: CustomEvent<{ url: FullSlug }>
render: CustomEvent<{ htmlElement: HTMLElement }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }>
}

View File

@ -1,3 +1,5 @@
import { addRenderListener } from "./util"
function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement!
outerBlock.classList.toggle("is-collapsed")
@ -7,8 +9,8 @@ function toggleCallout(this: HTMLElement) {
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
}
function setupCallout() {
const collapsible = document.getElementsByClassName(
function setupCallout(container: HTMLElement) {
const collapsible = container.getElementsByClassName(
`callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement>
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 { addRenderListener } from "./util"
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
document.addEventListener("nav", () => {
const checkboxes = document.querySelectorAll(
addRenderListener((container: HTMLElement) => {
const checkboxes = container.querySelectorAll(
"input.checkbox-toggle",
) as NodeListOf<HTMLInputElement>
checkboxes.forEach((el, index) => {

View File

@ -1,10 +1,12 @@
import { addRenderListener } from "./util"
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>'
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>'
document.addEventListener("nav", () => {
const els = document.getElementsByTagName("pre")
addRenderListener((container: HTMLElement) => {
const els = container.getElementsByTagName("pre")
for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0]
if (codeBlock) {

View File

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

View File

@ -8,6 +8,7 @@ import {
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
@ -78,6 +79,9 @@ const decryptWithPassword = async (
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
}
@ -121,13 +125,6 @@ const decryptWithPassword = async (
}
}
const notifyNav = (url: FullSlug) => {
const event: CustomEventMap["nav"] = new CustomEvent("nav", {
detail: { url, rerender: true },
})
document.dispatchEvent(event)
}
function updateTitle(container: HTMLElement | null) {
console.log(container)
if (container) {
@ -138,32 +135,22 @@ function updateTitle(container: HTMLElement | null) {
}
}
const tryAutoDecrypt = async (container: HTMLElement): Promise<boolean> => {
const filePath = getFullSlug(window)
const tryAutoDecrypt = async (parent: HTMLElement, container: HTMLElement): Promise<boolean> => {
const fullSlug = container.dataset.slug as FullSlug
const config = JSON.parse(container.dataset.config!) as EncryptionConfig
const hash = JSON.parse(container.dataset.hash!) as Hash
const parent =
(container.closest(".popover") as HTMLElement) ||
(container.closest(".preview-inner") as HTMLElement) ||
(container.closest(".center") as HTMLElement) ||
null
const password = await searchForValidPassword(fullSlug, hash, config)
if (password && (await decryptWithPassword(container, password, false))) {
notifyNav(filePath)
dispatchRenderEvent(parent)
updateTitle(parent)
return true
}
return false
}
const manualDecrypt = async (container: HTMLElement) => {
const parent =
(container.closest(".preview-inner") as HTMLElement) ||
(container.closest(".center") as HTMLElement) ||
null
const manualDecrypt = async (parent: HTMLElement, container: HTMLElement) => {
const passwordInput = container.querySelector(".decrypt-password") as HTMLInputElement
const password = passwordInput.value
@ -173,29 +160,28 @@ const manualDecrypt = async (container: HTMLElement) => {
}
if (await decryptWithPassword(container, password, true)) {
const filePath = getFullSlug(window)
notifyNav(filePath)
dispatchRenderEvent(parent)
updateTitle(parent)
}
}
document.addEventListener("nav", async () => {
// Try auto-decryption for all encrypted content
const encryptedElements = document.querySelectorAll(
".encrypted-content",
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(encryptedContainer)
await tryAutoDecrypt(element, encryptedContainer)
}
// Manual decryption handlers
const buttons = document.querySelectorAll(".decrypt-button")
const buttons = element.querySelectorAll(".decrypt-button")
buttons.forEach((button) => {
const handleClick = async function (this: HTMLElement) {
const encryptedContainer = this.closest(".encrypted-content")!
await manualDecrypt(encryptedContainer as HTMLElement)
await manualDecrypt(element, encryptedContainer as HTMLElement)
}
button.addEventListener("click", handleClick)
@ -206,12 +192,12 @@ document.addEventListener("nav", async () => {
})
// Enter key handler
document.querySelectorAll(".decrypt-password").forEach((input) => {
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(encryptedContainer as HTMLElement)
await manualDecrypt(element, encryptedContainer as HTMLElement)
}
}

View File

@ -1,4 +1,4 @@
import { registerEscapeHandler, removeAllChildren } from "./util"
import { registerEscapeHandler, removeAllChildren, addRenderListener } from "./util"
interface Position {
x: number
@ -144,8 +144,8 @@ const cssVars = [
let mermaidImport = undefined
document.addEventListener("nav", async () => {
const nodes = document.querySelectorAll(
addRenderListener(async (container: HTMLElement) => {
const nodes = container.querySelectorAll(
"code.mermaid:not([data-processed])",
) as NodeListOf<HTMLElement>
if (nodes.length === 0) return

View File

@ -1,17 +1,10 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { FullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
import { fetchCanonical, dispatchRenderEvent, addRenderListener } from "./util"
const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null
function notifyNav(url: FullSlug) {
const event: CustomEventMap["nav"] = new CustomEvent("nav", {
detail: { url, rerender: true },
})
document.dispatchEvent(event)
}
async function mouseEnterHandler(
this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number },
@ -45,7 +38,7 @@ async function mouseEnterHandler(
}
}
notifyNav(link.getAttribute("href") as FullSlug)
dispatchRenderEvent(popoverInner)
}
const targetUrl = new URL(link.href)
@ -129,8 +122,9 @@ function clearActivePopover() {
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
}
document.addEventListener("nav", () => {
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
addRenderListener((element) => {
const links = [...element.querySelectorAll("a.internal")] as HTMLAnchorElement[]
for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseleave", clearActivePopover)

View File

@ -1,5 +1,5 @@
import FlexSearch from "flexsearch"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { registerEscapeHandler, removeAllChildren, dispatchRenderEvent } from "./util"
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
import { contentDecryptedEventListener, decryptContent } from "../../util/encryption"
@ -143,13 +143,6 @@ function highlightHTML(searchTerm: string, el: HTMLElement) {
return html.body
}
function notifyNav(url: FullSlug) {
const event: CustomEventMap["nav"] = new CustomEvent("nav", {
detail: { url, rerender: true },
})
document.dispatchEvent(event)
}
async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
const container = searchElement.querySelector(".search-container") as HTMLElement
if (!container) return
@ -274,10 +267,8 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
const slug = idDataMap[id]
let title = data[slug].title
if (data[slug].decrypted === false) {
title = "🔒 " + title
} else if (data[slug].decrypted === true) {
title = "🔓 " + title
if (data[slug].encryptionResult) {
title = (data[slug].decrypted ? "🔓 " : "🔒 ") + data[slug].title
}
return {
@ -406,7 +397,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
highlights[0]?.scrollIntoView({ block: "start" })
await new Promise((resolve) => setTimeout(resolve, 100))
notifyNav(slug)
dispatchRenderEvent(previewInner)
}
async function onType(e: HTMLElementEventMap["input"]) {
@ -544,9 +535,6 @@ async function fillDocument(data: ContentIndex) {
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
// Ignore rerender events
if (e.detail.rerender) return
const currentSlug = e.detail.url
const data = await fetchData
const searchElement = document.getElementsByClassName("search")

View File

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

View File

@ -1,3 +1,5 @@
import { addRenderListener } from "./util"
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
@ -34,11 +36,11 @@ function setupToc() {
}
}
document.addEventListener("nav", () => {
addRenderListener((container: HTMLElement) => {
setupToc()
// update toc entry highlighting
observer.disconnect()
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
const headers = container.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
headers.forEach((header) => observer.observe(header))
})

View File

@ -44,3 +44,16 @@ export async function fetchCanonical(url: URL): Promise<Response> {
const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res
}
export function addRenderListener(renderFn: (container: HTMLElement) => void) {
document.addEventListener("render", (e: CustomEventMap["render"]) => {
renderFn(e.detail.htmlElement)
})
}
export function dispatchRenderEvent(htmlElement: HTMLElement) {
const event: CustomEventMap["render"] = new CustomEvent("render", {
detail: { htmlElement },
})
document.dispatchEvent(event)
}

View File

@ -6,10 +6,11 @@
justify-content: center;
.encryption-notice {
background: color-mix(in srgb, var(--lightgray) 60%, var(--light));
/* background: color-mix(in srgb, var(--lightgray) 60%, var(--light));
*/
border: 1px solid var(--lightgray);
padding: 2rem 1.5rem 2rem;
border-radius: 5px;
padding: 2rem 1.5rem;
max-width: 450px;
width: 100%;
text-align: center;
@ -29,10 +30,12 @@
color: var(--darkgray);
line-height: 1.5;
font-size: 0.95rem;
margin: 0 0 1rem 0;
margin: 0;
font-style: italic;
}
.decrypt-form {
margin: 1rem 0 0 0;
display: flex;
flex-direction: column;
gap: 1rem;
@ -88,16 +91,6 @@
font-size: 16px; // Prevent zoom on iOS
}
}
.encrypted-message-footnote {
color: var(--gray);
font-size: 0.85rem;
opacity: 0.8;
text-align: center;
width: 100%;
font-style: italic;
margin: 0;
}
}
.decrypt-loading {
@ -144,12 +137,18 @@
}
// Hide the decrypt form when encrypted content appears in popover and search
.popover .encrypted-content .encryption-notice .decrypt-form {
display: none;
}
.search-space, .popover {
.encrypted-content .encryption-notice .decrypt-form {
display: none;
}
.search-space .encrypted-content .encryption-notice .decrypt-form {
display: none;
.encryption-notice {
/*
border: 0;
padding: 0;
*/
margin: 1rem 0;
}
}
.decrypted-content-wrapper {

View File

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

View File

@ -117,6 +117,7 @@ export const Encrypt: QuartzTransformerPlugin<Partial<Options>> = (userOpts) =>
`data-encrypted='${JSON.stringify(encryptionResult)}'`,
`data-hash='${JSON.stringify(file.data.hash)}'`,
`data-slug='${file.data.slug}'`,
`data-decrypted='false'`,
].join(" ")
// Create a new tree with encrypted content placeholder