mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-19 10:54:06 -06:00
* Initial plan * Extend PluginUtilities with missing path and escape utilities Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Migrate transformers to use ctx.utils instead of direct imports Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Migrate emitters to use ctx.utils - part 1 (helpers, aliases, assets, contentIndex, contentPage, tagPage, static, favicon) Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Complete emitter migration to ctx.utils - all emitters decoupled Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Add comment explaining double-unescaping in test helper mock This mirrors the production implementation in util/escape.ts which has the same pattern. The CodeQL alert is a known limitation of simple HTML unescaping that exists in the production code as well. Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Fix type safety: change join() return type to string and simplify casts - Changed PluginUtilities.path.join() return type from FilePath to string to allow proper single-cast at call sites - Removed unsafe double-casts (as unknown as FullSlug) in favor of single casts (as FullSlug) - Fixed ogImage.tsx to use consistent destructuring pattern (ctx.cfg.configuration instead of cfg.configuration) - Updated implementation in plugin-context.ts and test-helpers.ts This addresses all code review feedback about type safety issues. Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> * Fix test mock implementations to match production code behavior - Fixed getAllSegmentPrefixes to return cumulative prefixes (e.g., ["a", "a/b", "a/b/c"]) - Fixed isRelativeURL to include all three validation conditions - Fixed stripSlashes to use substring(1) instead of replace(/^\/+/) - Fixed slugTag to properly handle hierarchical tags with sluggify logic - Fixed split to handle PDF files and anchor normalization - Fixed HTML entity ' to ' for consistency - Changed QUARTZ imports to use utils.path.QUARTZ for consistency - Fixed favicon.ts to pass full ctx instead of reconstructing partial object All mocks now accurately reflect production code behavior for reliable testing. Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { QuartzTransformerPlugin } from "../types"
|
|
import { FullSlug, RelativeURL, SimpleSlug, TransformOptions } from "../../util/path"
|
|
import path from "path"
|
|
import { visit } from "unist-util-visit"
|
|
import isAbsoluteUrl from "is-absolute-url"
|
|
import { Root } from "hast"
|
|
|
|
interface Options {
|
|
/** How to resolve Markdown paths */
|
|
markdownLinkResolution: TransformOptions["strategy"]
|
|
/** Strips folders from a link so that it looks nice */
|
|
prettyLinks: boolean
|
|
openLinksInNewTab: boolean
|
|
lazyLoad: boolean
|
|
externalLinkIcon: boolean
|
|
}
|
|
|
|
const defaultOptions: Options = {
|
|
markdownLinkResolution: "absolute",
|
|
prettyLinks: true,
|
|
openLinksInNewTab: false,
|
|
lazyLoad: false,
|
|
externalLinkIcon: true,
|
|
}
|
|
|
|
/**
|
|
* @plugin CrawlLinks
|
|
* @category Transformer
|
|
*
|
|
* @reads vfile.data.slug
|
|
* @writes vfile.data.links
|
|
*
|
|
* @dependencies None
|
|
*/
|
|
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
|
const opts = { ...defaultOptions, ...userOpts }
|
|
return {
|
|
name: "LinkProcessing",
|
|
htmlPlugins(ctx) {
|
|
const { utils } = ctx
|
|
return [
|
|
() => {
|
|
return (tree: Root, file) => {
|
|
const curSlug = utils!.path.simplify(file.data.slug!)
|
|
const outgoing: Set<SimpleSlug> = new Set()
|
|
|
|
const transformOptions: TransformOptions = {
|
|
strategy: opts.markdownLinkResolution,
|
|
allSlugs: ctx.allSlugs,
|
|
}
|
|
|
|
visit(tree, "element", (node, _index, _parent) => {
|
|
// rewrite all links
|
|
if (
|
|
node.tagName === "a" &&
|
|
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, { httpOnly: false })
|
|
classes.push(isExternal ? "external" : "internal")
|
|
|
|
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: [
|
|
{
|
|
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
|
|
if (
|
|
node.children.length === 1 &&
|
|
node.children[0].type === "text" &&
|
|
node.children[0].value !== dest
|
|
) {
|
|
// Add the 'alias' class if the text content is not the same as the href
|
|
classes.push("alias")
|
|
}
|
|
node.properties.className = classes
|
|
|
|
if (isExternal && opts.openLinksInNewTab) {
|
|
node.properties.target = "_blank"
|
|
}
|
|
|
|
// don't process external links or intra-document anchors
|
|
const isInternal = !(
|
|
isAbsoluteUrl(dest, { httpOnly: false }) || dest.startsWith("#")
|
|
)
|
|
if (isInternal) {
|
|
dest = node.properties.href = utils!.path.transform(
|
|
file.data.slug!,
|
|
dest,
|
|
transformOptions,
|
|
)
|
|
|
|
// url.resolve is considered legacy
|
|
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
|
|
const url = new URL(
|
|
dest,
|
|
"https://base.com/" + utils!.path.stripSlashes(curSlug, true),
|
|
)
|
|
const canonicalDest = url.pathname
|
|
let [destCanonical, _destAnchor] = utils!.path.split(canonicalDest)
|
|
if (destCanonical.endsWith("/")) {
|
|
destCanonical += "index"
|
|
}
|
|
|
|
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
|
|
const full = decodeURIComponent(
|
|
utils!.path.stripSlashes(destCanonical, true),
|
|
) as FullSlug
|
|
const simple = utils!.path.simplify(full)
|
|
outgoing.add(simple)
|
|
node.properties["data-slug"] = full
|
|
}
|
|
|
|
// rewrite link internals if prettylinks is on
|
|
if (
|
|
opts.prettyLinks &&
|
|
isInternal &&
|
|
node.children.length === 1 &&
|
|
node.children[0].type === "text" &&
|
|
!node.children[0].value.startsWith("#")
|
|
) {
|
|
node.children[0].value = path.basename(node.children[0].value)
|
|
}
|
|
}
|
|
|
|
// transform all other resources that may use links
|
|
if (
|
|
["img", "video", "audio", "iframe"].includes(node.tagName) &&
|
|
node.properties &&
|
|
typeof node.properties.src === "string"
|
|
) {
|
|
if (opts.lazyLoad) {
|
|
node.properties.loading = "lazy"
|
|
}
|
|
|
|
if (!isAbsoluteUrl(node.properties.src, { httpOnly: false })) {
|
|
let dest = node.properties.src as RelativeURL
|
|
dest = node.properties.src = utils!.path.transform(
|
|
file.data.slug!,
|
|
dest,
|
|
transformOptions,
|
|
)
|
|
node.properties.src = dest
|
|
}
|
|
}
|
|
})
|
|
|
|
file.data.links = [...outgoing]
|
|
}
|
|
},
|
|
]
|
|
},
|
|
}
|
|
}
|
|
|
|
declare module "vfile" {
|
|
interface DataMap {
|
|
links: SimpleSlug[]
|
|
}
|
|
}
|