fix: parsing wikilinks that have codeblock anchors, scroll to anchor
1 files deleted
1 files added
15 files modified
| | |
| | | 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! |
| | |
| | | <h3>Backlinks</h3> |
| | | <ul> |
| | | {backlinkFiles.length > 0 ? |
| | | backlinkFiles.map(f => <li><a href={relativeToRoot(slug, f.slug!)} class="internal">{f.frontmatter?.title}</a></li>) |
| | | backlinkFiles.map(f => <li><a href={stripIndex(relativeToRoot(slug, f.slug!))} class="internal">{f.frontmatter?.title}</a></li>) |
| | | : <li>No backlinks found</li>} |
| | | </ul> |
| | | </div> |
| | |
| | | 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, |
| | |
| | | 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 |
| | |
| | | |
| | | // calculate radius |
| | | const color = (d: NodeData) => { |
| | | // TODO: does this handle the index page |
| | | const isCurrent = d.id === slug |
| | | return isCurrent ? "var(--secondary)" : "var(--gray)" |
| | | } |
| | |
| | | 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`, |
| | |
| | | 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) |
| | |
| | | |
| | | 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) |
| | |
| | | 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' }) |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | |
| | | import { Document } from "flexsearch" |
| | | import { ContentDetails } from "../../plugins/emitters/contentIndex" |
| | | import { registerEscapeHandler } from "./handler" |
| | | import { registerEscapeHandler, relative, removeAllChildren } from "./util" |
| | | |
| | | interface Item { |
| | | slug: string, |
| | |
| | | } |
| | | let index: Document<Item> | 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 !== "") |
| New file |
| | |
| | | export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: () => void) { |
| | | if (!outsideContainer) return |
| | | function click(this: HTMLElement, e: HTMLElementEventMap["click"]) { |
| | | if (e.target !== this) return |
| | | e.preventDefault() |
| | | cb() |
| | | } |
| | | |
| | | function esc(e: HTMLElementEventMap["keydown"]) { |
| | | if (!e.key.startsWith("Esc")) return |
| | | e.preventDefault() |
| | | cb() |
| | | } |
| | | |
| | | outsideContainer?.removeEventListener("click", click) |
| | | outsideContainer?.addEventListener("click", click) |
| | | 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) |
| | | } |
| | | } |
| | |
| | | padding: 1rem; |
| | | |
| | | & > .popover-inner { |
| | | position: relative; |
| | | width: 30rem; |
| | | height: 20rem; |
| | | padding: 0 1rem 1rem 1rem; |
| | |
| | | const opts = { ...defaultOptions, ...userOpts } |
| | | return { |
| | | name: "Description", |
| | | markdownPlugins() { |
| | | return [] |
| | | }, |
| | | htmlPlugins() { |
| | | return [ |
| | | () => { |
| | |
| | | } |
| | | ] |
| | | }, |
| | | htmlPlugins() { |
| | | return [] |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | ] |
| | | }, |
| | | htmlPlugins() { |
| | | return [] |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | 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" |
| | |
| | | const opts = { ...defaultOptions, ...userOpts } |
| | | return { |
| | | name: "LinkProcessing", |
| | | markdownPlugins() { |
| | | return [] |
| | | }, |
| | | htmlPlugins() { |
| | | return [() => { |
| | | return (tree, file) => { |
| | |
| | | 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) |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | // 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' |
| | | ) { |
| | |
| | | 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" |
| | |
| | | return s.substring(0, 1).toUpperCase() + s.substring(1); |
| | | } |
| | | |
| | | export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { |
| | | const opts = { ...defaultOptions, ...userOpts } |
| | | return { |
| | | name: "ObsidianFlavoredMarkdown", |
| | | markdownPlugins() { |
| | | const plugins: PluggableList = [] |
| | | if (opts.wikilinks) { |
| | | plugins.push(() => { |
| | | // Match wikilinks |
| | | // !? -> optional embedding |
| | | // \[\[ -> open brace |
| | |
| | | // (#[^\[\]\|\#]+)? -> # 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<Partial<Options> | 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(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => { |
| | | const [fp, rawHeader, rawAlias] = capture |
| | |
| | | |
| | | 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 |
| | |
| | | |
| | | 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) { |
| | |
| | | |
| | | export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({ |
| | | name: "SyntaxHighlighting", |
| | | markdownPlugins() { |
| | | return [] |
| | | }, |
| | | htmlPlugins() { |
| | | return [[rehypePrettyCode, { |
| | | theme: 'css-variables', |
| | |
| | | } |
| | | }, |
| | | 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<CodeOptions>]] |
| | | } |
| | |
| | | } |
| | | }] |
| | | }, |
| | | htmlPlugins() { |
| | | return [] |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance |
| | | export type QuartzTransformerPluginInstance = { |
| | | name: string |
| | | markdownPlugins(): PluggableList |
| | | htmlPlugins(): PluggableList |
| | | externalResources?(): Partial<StaticResources> |
| | | textTransform?: (src: string | Buffer) => string | Buffer |
| | | markdownPlugins?: () => PluggableList |
| | | htmlPlugins?: () => PluggableList |
| | | externalResources?: () => Partial<StaticResources> |
| | | } |
| | | |
| | | export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance |
| | |
| | | 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 |
| | |
| | | |
| | | |
| | | // 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 |
| | |
| | | }) |
| | | } |
| | | |
| | | 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 |
| | |
| | | |
| | | 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() |
| | |
| | | |
| | | // 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) |
| | | } |