diff --git a/package-lock.json b/package-lock.json index a879078977376..7c12e7146da5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.0.11", + "version": "4.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.0.11", + "version": "4.1.2", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", @@ -85,7 +85,8 @@ "typescript": "^5.0.4" }, "engines": { - "node": ">=18.14" + "node": ">=18.14", + "npm": ">=9.3.1" } }, "node_modules/@clack/core": { diff --git a/package.json b/package.json index aa6324366aa4e..0a746dc3f512c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.1.1", + "version": "4.1.2", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 5cb39d9ad6963..305f511fd0535 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,9 +3,10 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { FullSlug, RelativeURL, joinSegments } from "../util/path" +import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" +import { QuartzPluginData } from "../plugins/vfile" interface RenderComponents { head: QuartzComponent @@ -49,6 +50,18 @@ export function pageResources( } } +let pageIndex: Map | undefined = undefined +function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map { + if (!pageIndex) { + pageIndex = new Map() + for (const file of allFiles) { + pageIndex.set(file.slug!, file) + } + } + + return pageIndex +} + export function renderPage( slug: FullSlug, componentData: QuartzComponentProps, @@ -62,17 +75,15 @@ export function renderPage( if (classNames.includes("transclude")) { const inner = node.children[0] as Element const transcludeTarget = inner.properties?.["data-slug"] as FullSlug - - // TODO: avoid this expensive find operation and construct an index ahead of time - const page = componentData.allFiles.find((f) => f.slug === transcludeTarget) + const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget) if (!page) { return } let blockRef = node.properties?.dataBlock as string | undefined - if (blockRef?.startsWith("^")) { + if (blockRef?.startsWith("#^")) { // block transclude - blockRef = blockRef.slice(1) + blockRef = blockRef.slice("#^".length) let blockNode = page.blocks?.[blockRef] if (blockNode) { if (blockNode.tagName === "li") { @@ -84,7 +95,7 @@ export function renderPage( } node.children = [ - blockNode, + normalizeHastElement(blockNode, slug, transcludeTarget), { type: "element", tagName: "a", @@ -117,7 +128,9 @@ export function renderPage( } node.children = [ - ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]), + ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) => + normalizeHastElement(child as Element, slug, transcludeTarget), + ), { type: "element", tagName: "a", @@ -135,7 +148,9 @@ export function renderPage( { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` }, ], }, - ...(page.htmlAst.children as ElementContent[]), + ...(page.htmlAst.children as ElementContent[]).map((child) => + normalizeHastElement(child as Element, slug, transcludeTarget), + ), { type: "element", tagName: "a", diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 1aff138f2fcb8..bddcfa4c618d2 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -1,4 +1,4 @@ -import type { ContentDetails } from "../../plugins/emitters/contentIndex" +import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex" import * as d3 from "d3" import { registerEscapeHandler, removeAllChildren } from "./util" import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" @@ -46,20 +46,22 @@ async function renderGraph(container: string, fullSlug: FullSlug) { showTags, } = JSON.parse(graph.dataset["cfg"]!) - const data = await fetchData - + const data: Map = new Map( + Object.entries(await fetchData).map(([k, v]) => [ + simplifySlug(k as FullSlug), + v, + ]), + ) const links: LinkData[] = [] const tags: SimpleSlug[] = [] - const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) - - for (const [src, details] of Object.entries(data)) { - const source = simplifySlug(src as FullSlug) + const validLinks = new Set(data.keys()) + for (const [source, details] of data.entries()) { const outgoing = details.links ?? [] for (const dest of outgoing) { if (validLinks.has(dest)) { - links.push({ source, target: dest }) + links.push({ source: source, target: dest }) } } @@ -71,7 +73,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { tags.push(...localTags.filter((tag) => !tags.includes(tag))) for (const tag of localTags) { - links.push({ source, target: tag }) + links.push({ source: source, target: tag }) } } } @@ -93,17 +95,17 @@ async function renderGraph(container: string, fullSlug: FullSlug) { } } } else { - Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) + validLinks.forEach((id) => neighbourhood.add(id)) if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) } const graphData: { nodes: NodeData[]; links: LinkData[] } = { nodes: [...neighbourhood].map((url) => { - const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url + const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url return { id: url, text: text, - tags: data[url]?.tags ?? [], + tags: data.get(url)?.tags ?? [], } }), links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), @@ -200,7 +202,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { window.spaNavigate(new URL(targ, window.location.toString())) }) .on("mouseover", function (_, d) { - const neighbours: SimpleSlug[] = data[fullSlug].links ?? [] + const neighbours: SimpleSlug[] = data.get(slug)?.links ?? [] const neighbourNodes = d3 .selectAll(".node") .filter((d) => neighbours.includes(d.id)) diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index eec473c103c38..3072959df57d2 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -81,14 +81,16 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to const url = new URL(dest, `https://base.com/${curSlug}`) const canonicalDest = url.pathname - const [destCanonical, _destAnchor] = splitAnchor(canonicalDest) + let [destCanonical, _destAnchor] = splitAnchor(canonicalDest) + if (destCanonical.endsWith("/")) { + destCanonical += "index" + } // need to decodeURIComponent here as WHATWG URL percent-encodes everything - const simple = decodeURIComponent( - simplifySlug(destCanonical as FullSlug), - ) as SimpleSlug + const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug + const simple = simplifySlug(full) outgoing.add(simple) - node.properties["data-slug"] = simple + node.properties["data-slug"] = full } // rewrite link internals if prettylinks is on diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 2e47cedf56d79..4c6a6dbed0853 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -182,7 +182,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const [rawFp, rawHeader, rawAlias] = capture const fp = rawFp ?? "" const anchor = rawHeader?.trim().replace(/^#+/, "") - const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : "" + const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : "" + const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : "" const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" const embedDisplay = value.startsWith("!") ? "!" : "" return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index 8bbb58dc3071f..18edc9407cb0b 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -83,7 +83,7 @@ describe("transforms", () => { test("simplifySlug", () => { asserts( [ - ["index", ""], + ["index", "/"], ["abc", "abc"], ["abc/index", "abc/"], ["abc/def", "abc/def"], diff --git a/quartz/util/path.ts b/quartz/util/path.ts index e450339fd1923..5cf54b803d1de 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -1,4 +1,5 @@ import { slug } from "github-slugger" +import type { Element as HastElement } from "hast" // this file must be isomorphic so it can't use node libs (e.g. path) export const QUARTZ = "quartz" @@ -24,7 +25,7 @@ export function isFullSlug(s: string): s is FullSlug { /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ export type SimpleSlug = SlugLike<"simple"> export function isSimpleSlug(s: string): s is SimpleSlug { - const validStart = !(s.startsWith(".") || s.startsWith("/")) + const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/"))) const validEnding = !(s.endsWith("/index") || s === "index") return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s) } @@ -65,7 +66,8 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { } export function simplifySlug(fp: FullSlug): SimpleSlug { - return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug + const res = _stripSlashes(_trimSuffix(fp, "index"), true) + return (res.length === 0 ? "/" : res) as SimpleSlug } export function transformInternalLink(link: string): RelativeURL { @@ -86,20 +88,47 @@ export function transformInternalLink(link: string): RelativeURL { // from micromorph/src/utils.ts // https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5 +const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => { + const rebased = new URL(el.getAttribute(attr)!, newBase) + el.setAttribute(attr, rebased.pathname + rebased.hash) +} export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) { - const rebase = (el: Element, attr: string, newBase: string | URL) => { - const rebased = new URL(el.getAttribute(attr)!, newBase) - el.setAttribute(attr, rebased.pathname + rebased.hash) - } - el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => - rebase(item, "href", destination), + _rebaseHtmlElement(item, "href", destination), ) el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => - rebase(item, "src", destination), + _rebaseHtmlElement(item, "src", destination), ) } +const _rebaseHastElement = ( + el: HastElement, + attr: string, + curBase: FullSlug, + newBase: FullSlug, +) => { + if (el.properties?.[attr]) { + if (!isRelativeURL(String(el.properties[attr]))) { + return + } + + const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string) + el.properties[attr] = rel + } +} + +export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) { + _rebaseHastElement(el, "src", curBase, newBase) + _rebaseHastElement(el, "href", curBase, newBase) + if (el.children) { + el.children = el.children.map((child) => + normalizeHastElement(child as HastElement, curBase, newBase), + ) + } + + return el +} + // resolve /a/b/c to ../.. export function pathToRoot(slug: FullSlug): RelativeURL { let rootPath = slug