diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index 584746c5..50969770 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -1,6 +1,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import style from "./styles/backlinks.scss" import { relativeToRoot } from "../path" +import { stripIndex } from "./scripts/util" function Backlinks({ fileData, allFiles }: QuartzComponentProps) { const slug = fileData.slug! @@ -9,7 +10,7 @@ function Backlinks({ fileData, allFiles }: QuartzComponentProps) {

Backlinks

diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 1c1149dd..4ff2dfe4 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -1,6 +1,6 @@ import { ContentDetails } from "../../plugins/emitters/contentIndex" import * as d3 from 'd3' -import { registerEscapeHandler } from "./handler" +import { registerEscapeHandler, relative, removeAllChildren } from "./util" type NodeData = { id: string, @@ -13,18 +13,6 @@ type LinkData = { target: string } -function relative(from: string, to: string) { - const pieces = [location.protocol, '//', location.host, location.pathname] - const url = pieces.join('').slice(0, -from.length) + to - return url -} - -function removeAllChildren(node: HTMLElement) { - while (node.firstChild) { - node.removeChild(node.firstChild) - } -} - async function renderGraph(container: string, slug: string) { const graph = document.getElementById(container) if (!graph) return @@ -117,7 +105,6 @@ async function renderGraph(container: string, slug: string) { // calculate radius const color = (d: NodeData) => { - // TODO: does this handle the index page const isCurrent = d.id === slug return isCurrent ? "var(--secondary)" : "var(--gray)" } diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index f7cd9986..655831db 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -7,10 +7,11 @@ document.addEventListener("nav", () => { link.addEventListener("mouseenter", async ({ clientX, clientY }) => { async function setPosition(popoverElement: HTMLElement) { const { x, y } = await computePosition(link, popoverElement, { - middleware: [inline({ - x: clientX, - y: clientY - }), shift(), flip()] + middleware: [ + inline({ x: clientX, y: clientY }), + shift(), + flip() + ] }) Object.assign(popoverElement.style, { left: `${x}px`, @@ -22,11 +23,17 @@ document.addEventListener("nav", () => { return setPosition(link.lastChild as HTMLElement) } - const url = link.href - const anchor = new URL(url).hash - if (anchor.startsWith("#")) return + const thisUrl = new URL(document.location.href) + thisUrl.hash = "" + thisUrl.search = "" + const targetUrl = new URL(link.href) + const hash = targetUrl.hash + targetUrl.hash = "" + targetUrl.search = "" + // prevent hover of the same page + if (thisUrl.toString() === targetUrl.toString()) return - const contents = await fetch(`${url}`) + const contents = await fetch(`${targetUrl}`) .then((res) => res.text()) .catch((err) => { console.error(err) @@ -39,7 +46,6 @@ document.addEventListener("nav", () => { const popoverElement = document.createElement("div") popoverElement.classList.add("popover") - // TODO: scroll this element if we specify a header/anchor to jump to const popoverInner = document.createElement("div") popoverInner.classList.add("popover-inner") popoverElement.appendChild(popoverInner) @@ -48,6 +54,12 @@ document.addEventListener("nav", () => { setPosition(popoverElement) link.appendChild(popoverElement) link.dataset.fetchedPopover = "true" + + const heading = popoverInner.querySelector(hash) as HTMLElement | null + if (heading) { + // leave ~12px of buffer when scrolling to a heading + popoverInner.scroll({ top: heading.offsetTop - 12, behavior: 'instant' }) + } }) } }) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index b1c62654..78517fea 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,6 +1,6 @@ import { Document } from "flexsearch" import { ContentDetails } from "../../plugins/emitters/contentIndex" -import { registerEscapeHandler } from "./handler" +import { registerEscapeHandler, relative, removeAllChildren } from "./util" interface Item { slug: string, @@ -9,16 +9,6 @@ interface Item { } let index: Document | undefined = undefined -function relative(from: string, to: string) { - const pieces = [location.protocol, '//', location.host, location.pathname] - const url = pieces.join('').slice(0, -from.length) + to - return url -} - -function removeAllChildren(node: HTMLElement) { - node.innerHTML = `` -} - const contextWindowWords = 30 function highlight(searchTerm: string, text: string, trim?: boolean) { const tokenizedTerms = searchTerm.split(/\s+/).filter(t => t !== "") diff --git a/quartz/components/scripts/handler.ts b/quartz/components/scripts/util.ts similarity index 51% rename from quartz/components/scripts/handler.ts rename to quartz/components/scripts/util.ts index c806a8b6..e94929b2 100644 --- a/quartz/components/scripts/handler.ts +++ b/quartz/components/scripts/util.ts @@ -17,3 +17,22 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: document.removeEventListener("keydown", esc) document.addEventListener('keydown', esc) } + +export function stripIndex(s: string): string { + return s.endsWith("index") ? s.slice(0, -"index".length) : s +} + +export function relative(from: string, to: string) { + from = encodeURI(stripIndex(from)) + to = encodeURI(stripIndex(to)) + const start = [location.protocol, '//', location.host, location.pathname].join('') + const trimEnd = from.length === 0 ? start.length : -from.length + const url = start.slice(0, trimEnd) + to + return url +} + +export function removeAllChildren(node: HTMLElement) { + while (node.firstChild) { + node.removeChild(node.firstChild) + } +} diff --git a/quartz/components/styles/popover.scss b/quartz/components/styles/popover.scss index 9b794476..f95dc7b7 100644 --- a/quartz/components/styles/popover.scss +++ b/quartz/components/styles/popover.scss @@ -19,6 +19,7 @@ padding: 1rem; & > .popover-inner { + position: relative; width: 30rem; height: 20rem; padding: 0 1rem 1rem 1rem; diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index ed59f824..0e41c5bc 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -14,9 +14,6 @@ export const Description: QuartzTransformerPlugin | undefined> const opts = { ...defaultOptions, ...userOpts } return { name: "Description", - markdownPlugins() { - return [] - }, htmlPlugins() { return [ () => { diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 5568463c..fd91755f 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -33,9 +33,6 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> } ] }, - htmlPlugins() { - return [] - } } } diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index b7514e47..f3a8904a 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -53,9 +53,6 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und } ] }, - htmlPlugins() { - return [] - } } } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 3083ce7b..13914523 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -1,5 +1,5 @@ import { QuartzTransformerPlugin } from "../types" -import { relative, relativeToRoot, slugify, trimPathSuffix } from "../../path" +import { relativeToRoot, slugify, trimPathSuffix } from "../../path" import path from "path" import { visit } from 'unist-util-visit' import isAbsoluteUrl from "is-absolute-url" @@ -24,9 +24,6 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = const opts = { ...defaultOptions, ...userOpts } return { name: "LinkProcessing", - markdownPlugins() { - return [] - }, htmlPlugins() { return [() => { return (tree, file) => { @@ -34,7 +31,8 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = const transformLink = (target: string) => { const targetSlug = slugify(decodeURI(target).trim()) if (opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) { - return './' + relative(curSlug, targetSlug) + // TODO + // return './' + relative(curSlug, targetSlug) } else { return './' + relativeToRoot(curSlug, targetSlug) } @@ -77,9 +75,9 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = } } - // transform all images + // transform all other resources that may use links if ( - node.tagName === 'img' && + ["img", "video", "audio", "iframe"].includes(node.tagName) && node.properties && typeof node.properties.src === 'string' ) { diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 3742d4b6..0deec4b9 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -3,6 +3,7 @@ import { QuartzTransformerPlugin } from "../types" import { Root, HTML, BlockContent, DefinitionContent, Code } from 'mdast' import { findAndReplace } from "mdast-util-find-and-replace" import { slugify } from "../../path" +import { slug as slugAnchor } from 'github-slugger' import rehypeRaw from "rehype-raw" import { visit } from "unist-util-visit" import path from "path" @@ -94,21 +95,43 @@ const capitalize = (s: string): string => { return s.substring(0, 1).toUpperCase() + s.substring(1); } +// Match wikilinks +// !? -> optional embedding +// \[\[ -> open brace +// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) +// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) +// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) +const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") + +// Match highlights +const highlightRegex = new RegExp(/==(.+)==/, "g") + +// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts +const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) + export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "ObsidianFlavoredMarkdown", + textTransform(src) { + // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) + if (opts.wikilinks) { + src = src.toString() + return src.replaceAll(backlinkRegex, (value, ...capture) => { + const [fp, rawHeader, rawAlias] = capture + const anchor = rawHeader?.trim().slice(1) + const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : "" + const displayAlias = rawAlias ?? "" + const embedDisplay = value.startsWith("!") ? "!" : "" + return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` + }) + } + return src + }, markdownPlugins() { const plugins: PluggableList = [] if (opts.wikilinks) { plugins.push(() => { - // Match wikilinks - // !? -> optional embedding - // \[\[ -> open brace - // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) - // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) - // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) - const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") return (tree: Root, _file) => { findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => { const [fp, rawHeader, rawAlias] = capture @@ -170,8 +193,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin if (opts.highlight) { plugins.push(() => { - // Match highlights - const highlightRegex = new RegExp(/==(.+)==/, "g") return (tree: Root, _file) => { findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => { const [inner] = capture @@ -186,8 +207,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin if (opts.callouts) { plugins.push(() => { - // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts - const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) return (tree: Root, _file) => { visit(tree, "blockquote", (node) => { if (node.children.length === 0) { diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/syntax.ts index 16424ec8..0f465195 100644 --- a/quartz/plugins/transformers/syntax.ts +++ b/quartz/plugins/transformers/syntax.ts @@ -3,9 +3,6 @@ import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code" export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({ name: "SyntaxHighlighting", - markdownPlugins() { - return [] - }, htmlPlugins() { return [[rehypePrettyCode, { theme: 'css-variables', @@ -15,10 +12,12 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({ } }, onVisitHighlightedLine(node) { + node.properties.className ??= [] node.properties.className.push('highlighted') }, onVisitHighlightedWord(node) { - node.properties.className = ['word'] + node.properties.className ??= [] + node.properties.className.push('word') }, } satisfies Partial]] } diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts index 172f082a..8d37def5 100644 --- a/quartz/plugins/transformers/toc.ts +++ b/quartz/plugins/transformers/toc.ts @@ -52,9 +52,6 @@ export const TableOfContents: QuartzTransformerPlugin | undefin } }] }, - htmlPlugins() { - return [] - } } } diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index 444fcffc..f74b3c9c 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -14,9 +14,10 @@ type OptionType = object | undefined export type QuartzTransformerPlugin = (opts?: Options) => QuartzTransformerPluginInstance export type QuartzTransformerPluginInstance = { name: string - markdownPlugins(): PluggableList - htmlPlugins(): PluggableList - externalResources?(): Partial + textTransform?: (src: string | Buffer) => string | Buffer + markdownPlugins?: () => PluggableList + htmlPlugins?: () => PluggableList + externalResources?: () => Partial } export type QuartzFilterPlugin = (opts?: Options) => QuartzFilterPluginInstance diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index f9377012..6560bf6c 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -21,8 +21,8 @@ export function createProcessor(transformers: QuartzTransformerPluginInstance[]) let processor = unified().use(remarkParse) // MD AST -> MD AST transforms - for (const plugin of transformers) { - processor = processor.use(plugin.markdownPlugins()) + for (const plugin of transformers.filter(p => p.markdownPlugins)) { + processor = processor.use(plugin.markdownPlugins!()) } // MD AST -> HTML AST @@ -30,8 +30,8 @@ export function createProcessor(transformers: QuartzTransformerPluginInstance[]) // HTML AST -> HTML AST transforms - for (const plugin of transformers) { - processor = processor.use(plugin.htmlPlugins()) + for (const plugin of transformers.filter(p => p.htmlPlugins)) { + processor = processor.use(plugin.htmlPlugins!()) } return processor @@ -73,13 +73,18 @@ async function transpileWorkerScript() { }) } -export function createFileParser(baseDir: string, fps: string[], verbose: boolean) { +export function createFileParser(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: string[], verbose: boolean) { return async (processor: QuartzProcessor) => { const res: ProcessedContent[] = [] for (const fp of fps) { try { const file = await read(fp) + // Text -> Text transforms + for (const plugin of transformers.filter(p => p.textTransform)) { + file.value = plugin.textTransform!(file.value) + } + // base data properties that plugins may use file.data.slug = slugify(path.relative(baseDir, file.path)) file.data.filePath = fp @@ -111,9 +116,8 @@ export async function parseMarkdown(transformers: QuartzTransformerPluginInstanc log.start(`Parsing input files using ${concurrency} threads`) if (concurrency === 1) { - // single-thread const processor = createProcessor(transformers) - const parse = createFileParser(baseDir, fps, verbose) + const parse = createFileParser(transformers, baseDir, fps, verbose) res = await parse(processor) } else { await transpileWorkerScript() diff --git a/quartz/worker.ts b/quartz/worker.ts index e2c278fa..be2c0d19 100644 --- a/quartz/worker.ts +++ b/quartz/worker.ts @@ -6,6 +6,6 @@ const processor = createProcessor(transformers) // only called from worker thread export async function parseFiles(baseDir: string, fps: string[], verbose: boolean) { - const parse = createFileParser(baseDir, fps, verbose) + const parse = createFileParser(transformers, baseDir, fps, verbose) return parse(processor) }