diff --git a/docs/plugins/CrawlLinks.md b/docs/plugins/CrawlLinks.md index 47b7bdd77..dafe39a20 100644 --- a/docs/plugins/CrawlLinks.md +++ b/docs/plugins/CrawlLinks.md @@ -19,6 +19,14 @@ This plugin accepts the following configuration options: - `openLinksInNewTab`: If `true`, configures external links to open in a new tab. Defaults to `false`. - `lazyLoad`: If `true`, adds lazy loading to resource elements (`img`, `video`, etc.) to improve page load performance. Defaults to `false`. - `externalLinkIcon`: Adds an icon next to external links when `true` (default) to visually distinguishing them from internal links. +- `substitutions`: default `[]`, a list of regex-image pairs. When you write a link's URL to match the regex, it will display the image after the link on your webpage. + - images may either be an `Image(url)`, `Emoji(text)`, or `Path({code: code, viewbox: viewbox})`. Examples: + - `Image("https://website.com/image.jpg")` + - `Image("/static/icon.png")` + - `Emoji("🪴")` + - `Path({code: "really long string like M320 0H288V64h32 82.7L201.4 265.4...", viewbox: "0 0 512 512"})` + - Example use: `substitutions: [ [/garden!(.+)/, Emoji("🪴")], ],` + - This would let you write links in Markdown like `[Someone's garden](garden!https://their-website.com)`, which would look like `Someone's Garden🪴` on the website. > [!warning] > Removing this plugin is _not_ recommended and will likely break the page. diff --git a/package-lock.json b/package-lock.json index 30d740c21..228999431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "hast-util-to-html": "^9.0.4", "hast-util-to-jsx-runtime": "^2.3.2", "hast-util-to-string": "^3.0.1", - "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", "lightningcss": "^1.29.1", "mdast-util-find-and-replace": "^3.0.2", @@ -3848,17 +3847,6 @@ "node": ">=12" } }, - "node_modules/is-absolute-url": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", - "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", diff --git a/package.json b/package.json index 726350b3b..0e57f8acc 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "hast-util-to-html": "^9.0.4", "hast-util-to-jsx-runtime": "^2.3.2", "hast-util-to-string": "^3.0.1", - "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", "lightningcss": "^1.29.1", "mdast-util-find-and-replace": "^3.0.2", diff --git a/quartz.config.ts b/quartz.config.ts index dc339d987..91aea4a8a 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -1,5 +1,6 @@ import { QuartzConfig } from "./quartz/cfg" import * as Plugin from "./quartz/plugins" +import { Image, Path, Emoji } from "./quartz/plugins/transformers/links" /** * Quartz 4.0 Configuration @@ -70,7 +71,12 @@ const config: QuartzConfig = { Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), - Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), + Plugin.CrawlLinks({ + markdownLinkResolution: "shortest", + // See https://quartz.jzhao.xyz/plugins/CrawlLinks + // Try uncommenting the below line and writing [Someone's Garden](garden!https://jzhao.xyz/) in markdown + // substitutions: [[/garden!(.+)/, Emoji("🪴")]], + }), Plugin.Description(), Plugin.Latex({ renderEngine: "katex" }), ], diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 3e8dbdede..7c54219b9 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -11,8 +11,32 @@ import { } from "../../util/path" import path from "path" import { visit } from "unist-util-visit" -import isAbsoluteUrl from "is-absolute-url" -import { Root } from "hast" +import { ElementContent, Root } from "hast" + +type Sub = [RegExp, Appendable] +type Appendable = (Image | Emoji | Path) & Tagged +type Tagged = { type: "image" | "emoji" | "path" } +type Image = { src: string } +type Emoji = { text: string } +type Path = { + code: string + viewbox: string +} +export function Image(src: string | Image): Appendable { + if (typeof src == "object") { + return src as Image & { type: "image" } + } + return { src: src, type: "image" } +} +export function Emoji(text: string | Emoji): Appendable { + if (typeof text == "object") { + return text as Emoji & { type: "emoji" } + } + return { text: text, type: "emoji" } +} +export function Path(path: Path): Appendable { + return path as Path & { type: "path" } // This errors if path is uncast. what +} interface Options { /** How to resolve Markdown paths */ @@ -22,6 +46,7 @@ interface Options { openLinksInNewTab: boolean lazyLoad: boolean externalLinkIcon: boolean + substitutions?: Sub[] } const defaultOptions: Options = { @@ -55,32 +80,90 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) node.properties && typeof node.properties.href === "string" ) { - let dest = node.properties.href as RelativeURL - const classes = (node.properties.className ?? []) as string[] - const isExternal = isAbsoluteUrl(dest) - classes.push(isExternal ? "external" : "internal") + const href = node.properties.href + let dest = href as RelativeURL + let refIcon: ElementContent | null = null + let matched = false + // bfahrenfort: the 'every' lambda is like a foreach that allows continue/break + opts.substitutions?.every(([regex, sub]) => { + const parts = href.match(regex) + if (parts == null) return true // continue - if (isExternal && opts.externalLinkIcon) { - node.children.push({ - type: "element", - tagName: "svg", - properties: { - "aria-hidden": "true", - class: "external-icon", - style: "max-width:0.8em;max-height:0.8em", - viewBox: "0 0 512 512", - }, - children: [ - { + matched = true + dest = parts.slice(1).join("") as RelativeURL + switch (sub.type) { + case "image": + refIcon = { type: "element", - tagName: "path", + tagName: "img", properties: { - d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z", + src: (sub as Image).src, + style: "max-width:1em;max-height:1em;margin:0px", }, children: [], - }, - ], - }) + } + break + case "emoji": + refIcon = { type: "text", value: (sub as Emoji).text } + break + case "path": + let vector = sub as Path + refIcon = { + type: "element", + tagName: "svg", + properties: { + "aria-hidden": "true", + class: "external-icon", + style: "max-width:1em;max-height:1em", + viewBox: vector.viewbox, + }, + children: [ + { + type: "element", + tagName: "path", + properties: { + d: vector.code, + }, + children: [], + }, + ], + } + break + } + return false // break + }) + node.properties.href = dest + const classes = (node.properties.className ?? []) as string[] + const isExternal = URL.canParse(dest) + classes.push(isExternal || matched ? "external" : "internal") + + // If the link matched a substitution, display the corresponding image afterwards; + // otherwise, if it's an external link, display the default external link icon + if ((isExternal && opts.externalLinkIcon) || matched) { + node.children.push( + refIcon != null + ? refIcon + : { + type: "element", + tagName: "svg", + properties: { + "aria-hidden": "true", + class: "external-icon", + style: "max-width:0.8em;max-height:0.8em", + viewBox: "0 0 512 512", + }, + children: [ + { + type: "element", + tagName: "path", + properties: { + d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z", + }, + children: [], + }, + ], + }, + ) } // Check if the link has alias text @@ -99,7 +182,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) } // don't process external links or intra-document anchors - const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")) + const isInternal = !(URL.canParse(dest) || dest.startsWith("#")) if (isInternal) { dest = node.properties.href = transformLink( file.data.slug!, @@ -145,7 +228,7 @@ export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) node.properties.loading = "lazy" } - if (!isAbsoluteUrl(node.properties.src)) { + if (!URL.canParse(node.properties.src)) { let dest = node.properties.src as RelativeURL dest = node.properties.src = transformLink( file.data.slug!,