diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 83beff5..5387912 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -9,7 +9,6 @@ import { } from "../util/resources" import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { clone } from "../util/clone" -import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" import { GlobalConfiguration } from "../cfg" import { i18n } from "../i18n" @@ -110,173 +109,192 @@ export function pageResources( return resources } -function renderTranscludes( +/** @internal Exported for testing only. */ +export function renderTranscludes( root: Root, cfg: GlobalConfiguration, slug: FullSlug, componentData: QuartzComponentProps, visited: Set, ) { - // process transcludes in componentData - visit(root, "element", (node, _index, _parent) => { - if (node.tagName === "blockquote") { - const classNames = (node.properties?.className ?? []) as string[] - if (classNames.includes("transclude")) { - const inner = node.children[0] as Element - const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug - if (visited.has(transcludeTarget)) { - console.warn( - styleText( - "yellow", - `Warning: Skipping circular transclusion: ${slug} -> ${transcludeTarget}`, - ), - ) - node.children = [ - { - type: "element", - tagName: "p", - properties: { style: "color: var(--secondary);" }, - children: [ - { - type: "text", - value: `Circular transclusion detected: ${transcludeTarget}`, - }, - ], - }, - ] - return - } - visited.add(transcludeTarget) + // Walk the tree manually instead of using visit() so we can track the + // ancestor chain for cycle detection. visit() runs the callback before + // descending into replaced children, so a Set-based guard there falsely + // rejects sibling transclusions of the same target. + function walk(node: Element | Root) { + const children = (node as Root).children ?? [] + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (child?.type !== "element") continue + const el = child as Element - let page = componentData.allFiles.find((f) => f.slug === transcludeTarget) - if (!page) { - // Virtual pages from PageType plugins have slugs without extensions - // (e.g. "plugins/CanvasPage") but CrawlLinks resolves wikilinks like - // ![[CanvasPage.canvas]] to "plugins/CanvasPage.canvas". Fall back to - // stripping the extension from the transclude target. - const dotIdx = transcludeTarget.lastIndexOf(".") - const slashIdx = transcludeTarget.lastIndexOf("/") - if (dotIdx > slashIdx + 1) { - const stripped = transcludeTarget.slice(0, dotIdx) as FullSlug - page = componentData.allFiles.findLast((f) => f.slug === stripped) - } - } - if (!page) { - return - } + if (el.tagName !== "blockquote") { + walk(el) + continue + } - let blockRef = node.properties.dataBlock as string | undefined - if (blockRef?.startsWith("#^")) { - // block transclude - blockRef = blockRef.slice("#^".length) - let blockNode = page.blocks?.[blockRef] - if (blockNode) { - if (blockNode.tagName === "li") { - blockNode = { - type: "element", - tagName: "ul", - properties: {}, - children: [blockNode], - } - } + const classNames = (el.properties?.className ?? []) as string[] + if (!classNames.includes("transclude")) { + walk(el) + continue + } - node.children = [ - normalizeHastElement(blockNode, slug, transcludeTarget), + const inner = el.children[0] as Element + const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug + if (visited.has(transcludeTarget)) { + console.warn( + styleText( + "yellow", + `Warning: Skipping circular transclusion: ${slug} -> ${transcludeTarget}`, + ), + ) + el.children = [ + { + type: "element", + tagName: "p", + properties: { style: "color: var(--secondary);" }, + children: [ { - type: "element", - tagName: "a", - properties: { - href: inner.properties?.href, - class: ["internal", "internal-link", "transclude-src"], - }, - children: [ - { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, - ], + type: "text", + value: `Circular transclusion detected: ${transcludeTarget}`, }, - ] - } - } else if (blockRef?.startsWith("#") && page.htmlAst) { - // header transclude - blockRef = blockRef.slice(1) - let startIdx = undefined - let startDepth = undefined - let endIdx = undefined - for (const [i, el] of page.htmlAst.children.entries()) { - // skip non-headers - if (!(el.type === "element" && el.tagName.match(headerRegex))) continue - const depth = Number(el.tagName.substring(1)) + ], + }, + ] + continue + } - // lookin for our blockref - if (startIdx === undefined || startDepth === undefined) { - // skip until we find the blockref that matches - if (el.properties?.id === blockRef) { - startIdx = i - startDepth = depth - } - } else if (depth <= startDepth) { - // looking for new header that is same level or higher - endIdx = i - break - } - } + visited.add(transcludeTarget) - if (startIdx === undefined) { - return - } - - node.children = [ - ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) => - normalizeHastElement(child as Element, slug, transcludeTarget), - ), - { - type: "element", - tagName: "a", - properties: { - href: inner.properties?.href, - class: ["internal", "internal-link", "transclude-src"], - }, - children: [ - { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, - ], - }, - ] - } else if (page.htmlAst) { - // page transclude - node.children = [ - { - type: "element", - tagName: "h1", - properties: {}, - children: [ - { - type: "text", - value: - page.frontmatter?.title ?? - i18n(cfg.locale).components.transcludes.transcludeOf({ - targetSlug: page.slug!, - }), - }, - ], - }, - ...(page.htmlAst.children as ElementContent[]).map((child) => - normalizeHastElement(child as Element, slug, transcludeTarget), - ), - { - type: "element", - tagName: "a", - properties: { - href: inner.properties?.href, - class: ["internal", "internal-link", "transclude-src"], - }, - children: [ - { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, - ], - }, - ] + let page = componentData.allFiles.find((f) => f.slug === transcludeTarget) + if (!page) { + const dotIdx = transcludeTarget.lastIndexOf(".") + const slashIdx = transcludeTarget.lastIndexOf("/") + if (dotIdx > slashIdx + 1) { + const stripped = transcludeTarget.slice(0, dotIdx) as FullSlug + page = componentData.allFiles.findLast((f) => f.slug === stripped) } } + if (!page) { + visited.delete(transcludeTarget) + continue + } + + let blockRef = el.properties.dataBlock as string | undefined + if (blockRef?.startsWith("#^")) { + // block transclude + blockRef = blockRef.slice("#^".length) + let blockNode = page.blocks?.[blockRef] + if (blockNode) { + if (blockNode.tagName === "li") { + blockNode = { + type: "element", + tagName: "ul", + properties: {}, + children: [blockNode], + } + } + + el.children = [ + normalizeHastElement(blockNode, slug, transcludeTarget), + { + type: "element", + tagName: "a", + properties: { + href: inner.properties?.href, + class: ["internal", "internal-link", "transclude-src"], + }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } + } else if (blockRef?.startsWith("#") && page.htmlAst) { + // header transclude + blockRef = blockRef.slice(1) + let startIdx = undefined + let startDepth = undefined + let endIdx = undefined + for (const [i, htmlEl] of page.htmlAst.children.entries()) { + if (!(htmlEl.type === "element" && htmlEl.tagName.match(headerRegex))) continue + const depth = Number(htmlEl.tagName.substring(1)) + + if (startIdx === undefined || startDepth === undefined) { + if (htmlEl.properties?.id === blockRef) { + startIdx = i + startDepth = depth + } + } else if (depth <= startDepth) { + endIdx = i + break + } + } + + if (startIdx === undefined) { + visited.delete(transcludeTarget) + continue + } + + el.children = [ + ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((c) => + normalizeHastElement(c as Element, slug, transcludeTarget), + ), + { + type: "element", + tagName: "a", + properties: { + href: inner.properties?.href, + class: ["internal", "internal-link", "transclude-src"], + }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } else if (page.htmlAst) { + // page transclude + el.children = [ + { + type: "element", + tagName: "h1", + properties: {}, + children: [ + { + type: "text", + value: + page.frontmatter?.title ?? + i18n(cfg.locale).components.transcludes.transcludeOf({ + targetSlug: page.slug!, + }), + }, + ], + }, + ...(page.htmlAst.children as ElementContent[]).map((c) => + normalizeHastElement(c as Element, slug, transcludeTarget), + ), + { + type: "element", + tagName: "a", + properties: { + href: inner.properties?.href, + class: ["internal", "internal-link", "transclude-src"], + }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } + + // Recurse into the replaced children to resolve nested transclusions, + // then remove from visited so sibling embeds of the same target work. + walk(el) + visited.delete(transcludeTarget) } - }) + } + + walk(root) } export function renderPage(