refactor plugins to be functions instead of classes

This commit is contained in:
Jacky Zhao 2023-06-11 23:26:43 -07:00
parent b8c011410d
commit 352075ae81
20 changed files with 464 additions and 507 deletions

19
package-lock.json generated
View file

@ -14,7 +14,6 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"esbuild-sass-plugin": "^2.9.0", "esbuild-sass-plugin": "^2.9.0",
"flamethrower-router": "^0.0.0-meme.12",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^13.1.4", "globby": "^13.1.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@ -22,9 +21,12 @@
"hast-util-to-string": "^2.0.0", "hast-util-to-string": "^2.0.0",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"mdast-util-find-and-replace": "^2.2.2", "mdast-util-find-and-replace": "^2.2.2",
"mdast-util-to-string": "^3.2.0",
"micromorph": "^0.4.5",
"preact": "^10.14.1", "preact": "^10.14.1",
"preact-render-to-string": "^6.0.3", "preact-render-to-string": "^6.0.3",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^6.1.1", "rehype-autolink-headings": "^6.1.1",
"rehype-katex": "^6.0.3", "rehype-katex": "^6.0.3",
"rehype-pretty-code": "^0.9.6", "rehype-pretty-code": "^0.9.6",
@ -1523,11 +1525,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flamethrower-router": {
"version": "0.0.0-meme.12",
"resolved": "https://registry.npmjs.org/flamethrower-router/-/flamethrower-router-0.0.0-meme.12.tgz",
"integrity": "sha512-PWcNrjzItwk61RTk/SbbKJNcAgl6qCXH8xkZjGjUGV/dgKAnURci+k+Yk8emubUQWTdAd1kSqujy0VRjoeEgxg=="
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@ -3006,6 +3003,11 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/micromorph": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/micromorph/-/micromorph-0.4.5.tgz",
"integrity": "sha512-Erasr0xiDvDeEhh7B/k7RFTwwfaAX10D7BMorNpokkwDh6XsRLYWDPaWF1m5JQeMSkGdqlEtQ8s68NcdDWuGgw=="
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
@ -3268,6 +3270,11 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/reading-time": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz",
"integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="
},
"node_modules/rehype-autolink-headings": { "node_modules/rehype-autolink-headings": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-6.1.1.tgz", "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-6.1.1.tgz",

View file

@ -30,7 +30,6 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"esbuild-sass-plugin": "^2.9.0", "esbuild-sass-plugin": "^2.9.0",
"flamethrower-router": "^0.0.0-meme.12",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^13.1.4", "globby": "^13.1.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@ -38,9 +37,12 @@
"hast-util-to-string": "^2.0.0", "hast-util-to-string": "^2.0.0",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"mdast-util-find-and-replace": "^2.2.2", "mdast-util-find-and-replace": "^2.2.2",
"mdast-util-to-string": "^3.2.0",
"micromorph": "^0.4.5",
"preact": "^10.14.1", "preact": "^10.14.1",
"preact-render-to-string": "^6.0.3", "preact-render-to-string": "^6.0.3",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^6.1.1", "rehype-autolink-headings": "^6.1.1",
"rehype-katex": "^6.0.3", "rehype-katex": "^6.0.3",
"rehype-pretty-code": "^0.9.6", "rehype-pretty-code": "^0.9.6",

View file

@ -39,23 +39,23 @@ const config: QuartzConfig = {
}, },
plugins: { plugins: {
transformers: [ transformers: [
new Plugin.FrontMatter(), Plugin.FrontMatter(),
new Plugin.Description(), Plugin.Description(),
new Plugin.TableOfContents({ showByDefault: true }), Plugin.TableOfContents({ showByDefault: true }),
new Plugin.CreatedModifiedDate({ Plugin.CreatedModifiedDate({
priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower
}), }),
new Plugin.GitHubFlavoredMarkdown(), Plugin.GitHubFlavoredMarkdown(),
new Plugin.ObsidianFlavoredMarkdown(), Plugin.ObsidianFlavoredMarkdown(),
new Plugin.ResolveLinks(), Plugin.ResolveLinks(),
new Plugin.SyntaxHighlighting(), Plugin.SyntaxHighlighting(),
new Plugin.Katex(), Plugin.Katex(),
], ],
filters: [ filters: [
new Plugin.RemoveDrafts() Plugin.RemoveDrafts()
], ],
emitters: [ emitters: [
new Plugin.ContentPage({ Plugin.ContentPage({
head: Component.Head, head: Component.Head,
header: [Component.PageTitle, Component.Spacer, Component.Darkmode], header: [Component.PageTitle, Component.Spacer, Component.Darkmode],
body: [Component.ArticleTitle, Component.ReadingTime, Component.TableOfContents, Component.Content] body: [Component.ArticleTitle, Component.ReadingTime, Component.TableOfContents, Component.Content]

View file

@ -1,24 +1,19 @@
import { QuartzComponentProps } from "./types" import { QuartzComponentProps } from "./types"
import style from "./styles/toc.scss" import style from "./styles/toc.scss"
export default function TableOfContents({ fileData, position }: QuartzComponentProps) { export default function TableOfContents({ fileData }: QuartzComponentProps) {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
if (position === 'body') { return <details class="toc" open>
// TODO: animate this <summary><h3>Table of Contents</h3></summary>
return <details className="toc" open> <ul>
<summary><h3>Table of Contents</h3></summary> {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<ul> <a href={`#${tocEntry.slug}`}>{tocEntry.text}</a>
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} className={`depth-${tocEntry.depth}`}> </li>)}
<a href={`#${tocEntry.slug}`}>{tocEntry.text}</a> </ul>
</li>)} </details>
</ul>
</details>
} else if (position === 'sidebar') {
// TODO
}
} }
TableOfContents.css = style TableOfContents.css = style

View file

@ -10,7 +10,6 @@ export type QuartzComponentProps = {
cfg: GlobalConfiguration cfg: GlobalConfiguration
children: QuartzComponent[] | JSX.Element[] children: QuartzComponent[] | JSX.Element[]
tree: Node<QuartzPluginData> tree: Node<QuartzPluginData>
position?: 'sidebar' | 'header' | 'body'
} }
export type QuartzComponent = ComponentType<QuartzComponentProps> & { export type QuartzComponent = ComponentType<QuartzComponentProps> & {
@ -18,3 +17,5 @@ export type QuartzComponent = ComponentType<QuartzComponentProps> & {
beforeDOMLoaded?: string, beforeDOMLoaded?: string,
afterDOMLoaded?: string, afterDOMLoaded?: string,
} }
export type QuartzComponentConstructor<Options extends object> = (opts: Options) => QuartzComponent

View file

@ -15,66 +15,64 @@ interface Options {
body: QuartzComponent[] body: QuartzComponent[]
} }
export class ContentPage extends QuartzEmitterPlugin { export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
name = "ContentPage" if (!opts) {
opts: Options throw new Error("ContentPage must be initialized with options specifiying the components to use")
constructor(opts: Options) {
super()
this.opts = opts
} }
getQuartzComponents(): QuartzComponent[] { return {
return [this.opts.head, Header, ...this.opts.header, ...this.opts.body] name: "ContentPage",
} getQuartzComponents() {
return [opts.head, Header, ...opts.header, ...opts.body]
},
async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> {
const fps: string[] = []
async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> { const { head: Head, header, body } = opts
const fps: string[] = [] for (const [tree, file] of content) {
const baseDir = resolveToRoot(file.data.slug!)
const pageResources: StaticResources = {
css: [baseDir + "/index.css", ...resources.css],
js: [
{ src: baseDir + "/prescript.js", loadTime: "beforeDOMReady" },
...resources.js,
{ src: baseDir + "/postscript.js", loadTime: "afterDOMReady", type: 'module' }
]
}
const { head: Head, header, body } = this.opts const componentData: QuartzComponentProps = {
for (const [tree, file] of content) { fileData: file.data,
const baseDir = resolveToRoot(file.data.slug!) externalResources: pageResources,
const pageResources: StaticResources = { cfg,
css: [baseDir + "/index.css", ...resources.css], children: [],
js: [ tree
{ src: baseDir + "/prescript.js", loadTime: "beforeDOMReady" }, }
...resources.js,
{ src: baseDir + "/postscript.js", loadTime: "afterDOMReady", type: 'module' } const doc = <html>
] <Head {...componentData} />
<body>
<div id="quartz-root" class="page">
<Header {...componentData} >
{header.map(HeaderComponent => <HeaderComponent {...componentData} />)}
</Header>
<Body {...componentData}>
{body.map(BodyComponent => <BodyComponent {...componentData} />)}
</Body>
</div>
</body>
{pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} {...resource} />)}
</html>
const fp = file.data.slug + ".html"
await emit({
content: "<!DOCTYPE html>\n" + render(doc),
slug: file.data.slug!,
ext: ".html",
})
fps.push(fp)
} }
return fps
const componentData: QuartzComponentProps = {
fileData: file.data,
externalResources: pageResources,
cfg,
children: [],
tree
}
const doc = <html>
<Head {...componentData} />
<body>
<div id="quartz-root" class="page">
<Header {...componentData} >
{header.map(HeaderComponent => <HeaderComponent {...componentData} position="header" />)}
</Header>
<Body {...componentData}>
{body.map(BodyComponent => <BodyComponent {...componentData } position="body" />)}
</Body>
</div>
</body>
{pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} {...resource} />)}
</html>
const fp = file.data.slug + ".html"
await emit({
content: "<!DOCTYPE html>\n" + render(doc),
slug: file.data.slug!,
ext: ".html",
})
fps.push(fp)
} }
return fps
} }
} }

View file

@ -1,10 +1,9 @@
import { QuartzFilterPlugin } from "../types" import { QuartzFilterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
export class RemoveDrafts extends QuartzFilterPlugin { export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
name = "RemoveDrafts" name: "RemoveDrafts",
shouldPublish([_tree, vfile]: ProcessedContent): boolean { shouldPublish([_tree, vfile]) {
const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
return !draftFlag return !draftFlag
} }
} })

View file

@ -1,10 +1,9 @@
import { QuartzFilterPlugin } from "../types" import { QuartzFilterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
export class ExplicitPublish extends QuartzFilterPlugin { export const ExplicitPublish: QuartzFilterPlugin = () => ({
name = "ExplicitPublish" name: "ExplicitPublish",
shouldPublish([_tree, vfile]: ProcessedContent): boolean { shouldPublish([_tree, vfile]) {
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
return publishFlag return publishFlag
} }
} })

View file

@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { Root as HTMLRoot } from 'hast' import { Root as HTMLRoot } from 'hast'
import { toString } from "hast-util-to-string" import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
@ -11,41 +10,36 @@ const defaultOptions: Options = {
descriptionLength: 150 descriptionLength: 150
} }
export class Description extends QuartzTransformerPlugin { export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "Description" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "Description",
markdownPlugins() {
return []
},
htmlPlugins() {
return [
() => {
return async (tree: HTMLRoot, file) => {
const frontMatterDescription = file.data.frontmatter?.description
const text = toString(tree)
constructor(opts?: Partial<Options>) { const desc = frontMatterDescription ?? text
super() const sentences = desc.replace(/\s+/g, ' ').split('.')
this.opts = { ...defaultOptions, ...opts } let finalDesc = ""
} let sentenceIdx = 0
const len = opts.descriptionLength
while (finalDesc.length < len) {
finalDesc += sentences[sentenceIdx] + '.'
sentenceIdx++
}
markdownPlugins(): PluggableList { file.data.description = finalDesc
return [] file.data.text = text
}
htmlPlugins(): PluggableList {
return [
() => {
return async (tree: HTMLRoot, file) => {
const frontMatterDescription = file.data.frontmatter?.description
const text = toString(tree)
const desc = frontMatterDescription ?? text
const sentences = desc.replace(/\s+/g, ' ').split('.')
let finalDesc = ""
let sentenceIdx = 0
const len = this.opts.descriptionLength
while (finalDesc.length < len) {
finalDesc += sentences[sentenceIdx] + '.'
sentenceIdx++
} }
file.data.description = finalDesc
file.data.text = text
} }
} ]
] }
} }
} }

View file

@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import matter from "gray-matter" import matter from "gray-matter"
import remarkFrontmatter from 'remark-frontmatter' import remarkFrontmatter from 'remark-frontmatter'
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
@ -13,35 +12,30 @@ const defaultOptions: Options = {
delims: '---' delims: '---'
} }
export class FrontMatter extends QuartzTransformerPlugin { export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "FrontMatter" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "FrontMatter",
markdownPlugins() {
return [
remarkFrontmatter,
() => {
return (_, file) => {
const { data } = matter(file.value, opts)
constructor(opts?: Partial<Options>) { // fill in frontmatter
super() file.data.frontmatter = {
this.opts = { ...defaultOptions, ...opts } title: file.stem ?? "Untitled",
} tags: [],
...data
markdownPlugins(): PluggableList { }
return [
remarkFrontmatter,
() => {
return (_, file) => {
const { data } = matter(file.value, this.opts)
// fill in frontmatter
file.data.frontmatter = {
title: file.stem ?? "Untitled",
tags: [],
...data
} }
} }
} ]
] },
} htmlPlugins() {
return []
htmlPlugins(): PluggableList { }
return []
} }
} }

View file

@ -15,27 +15,24 @@ const defaultOptions: Options = {
linkHeadings: true linkHeadings: true
} }
export class GitHubFlavoredMarkdown extends QuartzTransformerPlugin { export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "GitHubFlavoredMarkdown" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "GitHubFlavoredMarkdown",
constructor(opts?: Partial<Options>) { markdownPlugins() {
super() return opts.enableSmartyPants ? [remarkGfm] : [remarkGfm, smartypants]
this.opts = { ...defaultOptions, ...opts } },
} htmlPlugins() {
if (opts.linkHeadings) {
markdownPlugins(): PluggableList { return [rehypeSlug, [rehypeAutolinkHeadings, {
return this.opts.enableSmartyPants ? [remarkGfm] : [remarkGfm, smartypants] behavior: 'append', content: {
} type: 'text',
value: ' §'
htmlPlugins(): PluggableList { }
return this.opts.linkHeadings }]]
? [rehypeSlug, [rehypeAutolinkHeadings, { } else {
behavior: 'append', content: { return []
type: 'text', }
value: ' §' }
}
}]]
: []
} }
} }

View file

@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import fs from "fs" import fs from "fs"
import path from 'path' import path from 'path'
import { Repository } from "@napi-rs/simple-git" import { Repository } from "@napi-rs/simple-git"
@ -12,59 +11,51 @@ const defaultOptions: Options = {
priority: ['frontmatter', 'git', 'filesystem'] priority: ['frontmatter', 'git', 'filesystem']
} }
export class CreatedModifiedDate extends QuartzTransformerPlugin { export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "CreatedModifiedDate" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "CreatedModifiedDate",
markdownPlugins() {
return [
() => {
let repo: Repository | undefined = undefined
return async (_tree, file) => {
let created: undefined | Date = undefined
let modified: undefined | Date = undefined
let published: undefined | Date = undefined
constructor(opts?: Partial<Options>) { const fp = path.join(file.cwd, file.data.filePath as string)
super() for (const source of opts.priority) {
this.opts = { if (source === "filesystem") {
...defaultOptions, const st = await fs.promises.stat(fp)
...opts, created ||= new Date(st.birthtimeMs)
} modified ||= new Date(st.mtimeMs)
} } else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date
modified ||= file.data.frontmatter.lastmod
modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate
} else if (source === "git") {
if (!repo) {
repo = new Repository(file.cwd)
}
markdownPlugins(): PluggableList { modified ||= new Date(await repo.getFileLatestModifiedDateAsync(file.data.filePath!))
return [
() => {
let repo: Repository | undefined = undefined
return async (_tree, file) => {
let created: undefined | Date = undefined
let modified: undefined | Date = undefined
let published: undefined | Date = undefined
const fp = path.join(file.cwd, file.data.filePath as string)
for (const source of this.opts.priority) {
if (source === "filesystem") {
const st = await fs.promises.stat(fp)
created ||= new Date(st.birthtimeMs)
modified ||= new Date(st.mtimeMs)
} else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date
modified ||= file.data.frontmatter.lastmod
modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate
} else if (source === "git") {
if (!repo) {
repo = new Repository(file.cwd)
} }
}
modified ||= new Date(await repo.getFileLatestModifiedDateAsync(file.data.filePath!)) file.data.dates = {
created: created ?? new Date(),
modified: modified ?? new Date(),
published: published ?? new Date()
} }
} }
file.data.dates = {
created: created ?? new Date(),
modified: modified ?? new Date(),
published: published ?? new Date()
}
} }
} ]
] },
} htmlPlugins() {
return []
htmlPlugins(): PluggableList { }
return []
} }
} }

View file

@ -1,24 +1,20 @@
import { PluggableList } from "unified"
import remarkMath from "remark-math" import remarkMath from "remark-math"
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import { StaticResources } from "../../resources"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
export class Katex extends QuartzTransformerPlugin { export const Katex: QuartzTransformerPlugin = () => ({
name = "Katex" name: "Katex",
markdownPlugins(): PluggableList { markdownPlugins() {
return [remarkMath] return [remarkMath]
} },
htmlPlugins() {
htmlPlugins(): PluggableList {
return [ return [
[rehypeKatex, { [rehypeKatex, {
output: 'html', output: 'html',
}] }]
] ]
} },
externalResources: {
externalResources: Partial<StaticResources> = {
css: [ css: [
// base css // base css
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
@ -31,4 +27,4 @@ export class Katex extends QuartzTransformerPlugin {
} }
] ]
} }
} })

View file

@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { relative, relativeToRoot, slugify } from "../../path" import { relative, relativeToRoot, slugify } from "../../path"
import path from "path" import path from "path"
@ -17,65 +16,60 @@ const defaultOptions: Options = {
prettyLinks: true prettyLinks: true
} }
export class ResolveLinks extends QuartzTransformerPlugin { export const ResolveLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "LinkProcessing" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "LinkProcessing",
constructor(opts?: Partial<Options>) { markdownPlugins() {
super() return []
this.opts = { ...defaultOptions, ...opts } },
} htmlPlugins() {
return [() => {
markdownPlugins(): PluggableList { return (tree, file) => {
return [] const curSlug = file.data.slug!
} const transformLink = (target: string) => {
const targetSlug = slugify(decodeURI(target).trim())
htmlPlugins(): PluggableList { if (opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
return [() => { return './' + relative(curSlug, targetSlug)
return (tree, file) => { } else {
const curSlug = file.data.slug! return './' + relativeToRoot(curSlug, targetSlug)
const transformLink = (target: string) => { }
const targetSlug = slugify(decodeURI(target).trim())
if (this.opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
return './' + relative(curSlug, targetSlug)
} else {
return './' + relativeToRoot(curSlug, targetSlug)
} }
visit(tree, 'element', (node, _index, _parent) => {
// rewrite all links
if (
node.tagName === 'a' &&
node.properties &&
typeof node.properties.href === 'string'
) {
node.properties.className = isAbsoluteUrl(node.properties.href) ? "external" : "internal"
// don't process external links or intra-document anchors
if (!(isAbsoluteUrl(node.properties.href) || node.properties.href.startsWith("#"))) {
node.properties.href = transformLink(node.properties.href)
}
// rewrite link internals if prettylinks is on
if (opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
node.children[0].value = path.basename(node.children[0].value)
}
}
// transform all images
if (
node.tagName === 'img' &&
node.properties &&
typeof node.properties.src === 'string'
) {
if (!isAbsoluteUrl(node.properties.src)) {
const ext = path.extname(node.properties.src)
node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
}
}
})
} }
}]
visit(tree, 'element', (node, _index, _parent) => { }
// rewrite all links
if (
node.tagName === 'a' &&
node.properties &&
typeof node.properties.href === 'string'
) {
node.properties.className = isAbsoluteUrl(node.properties.href) ? "external" : "internal"
// don't process external links or intra-document anchors
if (!(isAbsoluteUrl(node.properties.href) || node.properties.href.startsWith("#"))) {
node.properties.href = transformLink(node.properties.href)
}
// rewrite link internals if prettylinks is on
if (this.opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
node.children[0].value = path.basename(node.children[0].value)
}
}
// transform all images
if (
node.tagName === 'img' &&
node.properties &&
typeof node.properties.src === 'string'
) {
if (!isAbsoluteUrl(node.properties.src)) {
const ext = path.extname(node.properties.src)
node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
}
}
})
}
}]
} }
} }

View file

@ -89,174 +89,168 @@ const capitalize = (s: string): string => {
return s.substring(0, 1).toUpperCase() + s.substring(1); return s.substring(0, 1).toUpperCase() + s.substring(1);
} }
export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin { export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "ObsidianFlavoredMarkdown" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "ObsidianFlavoredMarkdown",
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
const anchor = rawHeader?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
constructor(opts?: Partial<Options>) { // embed cases
super() if (value.startsWith("!")) {
this.opts = { ...defaultOptions, ...opts } const ext = path.extname(fp).toLowerCase()
} const url = slugify(fp.trim()) + ext
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
markdownPlugins(): PluggableList { const dims = alias ?? ""
const plugins: PluggableList = [] let [width, height] = dims.split("x", 2)
width ||= "auto"
if (this.opts.wikilinks) { height ||= "auto"
plugins.push(() => { return {
// Match wikilinks type: 'image',
// !? -> optional embedding url,
// \[\[ -> open brace data: {
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) hProperties: {
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) width, height
// (|[^\[\]\|\#]+)? -> | 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
const anchor = rawHeader?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
// embed cases
if (value.startsWith("!")) {
const ext = path.extname(fp).toLowerCase()
const url = slugify(fp.trim()) + ext
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const dims = alias ?? ""
let [width, height] = dims.split("x", 2)
width ||= "auto"
height ||= "auto"
return {
type: 'image',
url,
data: {
hProperties: {
width, height
} }
} }
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
return {
type: 'html',
value: `<video src="${url}" controls></video>`
}
} else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
return {
type: 'html',
value: `<audio src="${url}" controls></audio>`
}
} else if ([".pdf"].includes(ext)) {
return {
type: 'html',
value: `<iframe src="${url}"></iframe>`
}
} }
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { // otherwise, fall through to regular link
return {
type: 'html',
value: `<video src="${url}" controls></video>`
}
} else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
return {
type: 'html',
value: `<audio src="${url}" controls></audio>`
}
} else if ([".pdf"].includes(ext)) {
return {
type: 'html',
value: `<iframe src="${url}"></iframe>`
}
} }
// otherwise, fall through to regular link
}
// internal link // internal link
const url = slugify(fp.trim() + anchor) const url = slugify(fp.trim() + anchor)
return { return {
type: 'link', type: 'link',
url, url,
children: [{ children: [{
type: 'text', type: 'text',
value: alias ?? fp value: alias ?? fp
}] }]
} }
}) })
}
} }
)
} }
)
}
if (this.opts.highlight) { if (opts.highlight) {
plugins.push(() => { plugins.push(() => {
// Match highlights // Match highlights
const highlightRegex = new RegExp(/==(.+)==/, "g") const highlightRegex = new RegExp(/==(.+)==/, "g")
return (tree: Root, _file) => { return (tree: Root, _file) => {
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => { findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
const [inner] = capture const [inner] = capture
return { return {
type: 'html', type: 'html',
value: `<span class="text-highlight">${inner}</span>` value: `<span class="text-highlight">${inner}</span>`
} }
}) })
} }
}) })
} }
if (this.opts.callouts) { if (opts.callouts) {
plugins.push(() => { plugins.push(() => {
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
return (tree: Root, _file) => { return (tree: Root, _file) => {
visit(tree, "blockquote", (node) => { visit(tree, "blockquote", (node) => {
if (node.children.length === 0) { if (node.children.length === 0) {
return return
} }
// find first line // find first line
const firstChild = node.children[0] const firstChild = node.children[0]
if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") {
return return
} }
const text = firstChild.children[0].value const text = firstChild.children[0].value
const [firstLine, ...remainingLines] = text.split("\n") const [firstLine, ...remainingLines] = text.split("\n")
const remainingText = remainingLines.join("\n") const remainingText = remainingLines.join("\n")
const match = firstLine.match(calloutRegex) const match = firstLine.match(calloutRegex)
if (match && match.input) { if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match const [calloutDirective, typeString, collapseChar] = match
const calloutType = typeString.toLowerCase() as keyof typeof callouts const calloutType = typeString.toLowerCase() as keyof typeof callouts
const collapse = collapseChar === "+" || collapseChar === "-" const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded" const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const titleNode: HTML = { const titleNode: HTML = {
type: "html", type: "html",
value: `<div value: `<div
class="callout-title" class="callout-title"
> >
<div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div> <div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div>
<div class="callout-title-inner">${title}</div> <div class="callout-title-inner">${title}</div>
</div>` </div>`
} }
const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode] const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode]
if (remainingText.length > 0) { if (remainingText.length > 0) {
blockquoteContent.push({ blockquoteContent.push({
type: 'paragraph', type: 'paragraph',
children: [{ children: [{
type: 'text', type: 'text',
value: remainingText, value: remainingText,
}] }]
}) })
} }
// replace first line of blockquote with title and rest of the paragraph text // replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent) node.children.splice(0, 1, ...blockquoteContent)
// add properties to base blockquote // add properties to base blockquote
node.data = { node.data = {
hProperties: { hProperties: {
...(node.data?.hProperties ?? {}), ...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`, className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
"data-callout": calloutType, "data-callout": calloutType,
"data-callout-fold": collapse, "data-callout-fold": collapse,
}
} }
} }
} })
}) }
} })
}) }
return plugins
},
htmlPlugins() {
return [rehypeRaw]
} }
return plugins
}
htmlPlugins(): PluggableList {
return [rehypeRaw]
} }
} }

View file

@ -1,15 +1,12 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code" import rehypePrettyCode, { Options as CodeOptions } from "rehype-pretty-code"
export class SyntaxHighlighting extends QuartzTransformerPlugin { export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
name = "SyntaxHighlighting" name: "SyntaxHighlighting",
markdownPlugins() {
markdownPlugins(): PluggableList {
return [] return []
} },
htmlPlugins() {
htmlPlugins(): PluggableList {
return [[rehypePrettyCode, { return [[rehypePrettyCode, {
theme: 'css-variables', theme: 'css-variables',
onVisitLine(node) { onVisitLine(node) {
@ -25,4 +22,4 @@ export class SyntaxHighlighting extends QuartzTransformerPlugin {
}, },
} satisfies Partial<CodeOptions>]] } satisfies Partial<CodeOptions>]]
} }
} })

View file

@ -1,4 +1,3 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { Root } from "mdast" import { Root } from "mdast"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
@ -23,44 +22,39 @@ interface TocEntry {
slug: string slug: string
} }
export class TableOfContents extends QuartzTransformerPlugin { export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
name = "TableOfContents" const opts = { ...defaultOptions, ...userOpts }
opts: Options return {
name: "TableOfContents",
markdownPlugins() {
return [() => {
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) {
const toc: TocEntry[] = []
let highestDepth: number = opts.maxDepth
visit(tree, 'heading', (node) => {
if (node.depth <= opts.maxDepth) {
const text = toString(node)
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,
text,
slug: slugAnchor.slug(text)
})
}
})
constructor(opts?: Partial<Options>) { if (toc.length > opts.minEntries) {
super() file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
this.opts = { ...defaultOptions, ...opts }
}
markdownPlugins(): PluggableList {
return [() => {
return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? this.opts.showByDefault
if (display) {
const toc: TocEntry[] = []
let highestDepth: number = this.opts.maxDepth
visit(tree, 'heading', (node) => {
if (node.depth <= this.opts.maxDepth) {
const text = toString(node)
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,
text,
slug: slugAnchor.slug(text)
})
} }
})
if (toc.length > this.opts.minEntries) {
file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
} }
} }
} }]
}] },
} htmlPlugins() {
return []
htmlPlugins(): PluggableList { }
return []
} }
} }

View file

@ -4,16 +4,32 @@ import { ProcessedContent } from "./vfile"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { QuartzComponent } from "../components/types" import { QuartzComponent } from "../components/types"
export abstract class QuartzTransformerPlugin { export interface PluginTypes {
abstract name: string transformers: QuartzTransformerPluginInstance[],
abstract markdownPlugins(): PluggableList filters: QuartzFilterPluginInstance[],
abstract htmlPlugins(): PluggableList emitters: QuartzEmitterPluginInstance[],
}
type OptionType = object | undefined
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
name: string
markdownPlugins(): PluggableList
htmlPlugins(): PluggableList
externalResources?: Partial<StaticResources> externalResources?: Partial<StaticResources>
} }
export abstract class QuartzFilterPlugin { export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
abstract name: string export type QuartzFilterPluginInstance = {
abstract shouldPublish(content: ProcessedContent): boolean name: string
shouldPublish(content: ProcessedContent): boolean
}
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]>
getQuartzComponents(): QuartzComponent[]
} }
export interface EmitOptions { export interface EmitOptions {
@ -23,14 +39,3 @@ export interface EmitOptions {
} }
export type EmitCallback = (data: EmitOptions) => Promise<string> export type EmitCallback = (data: EmitOptions) => Promise<string>
export abstract class QuartzEmitterPlugin {
abstract name: string
abstract emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]>
abstract getQuartzComponents(): QuartzComponent[]
}
export interface PluginTypes {
transformers: QuartzTransformerPlugin[],
filters: QuartzFilterPlugin[],
emitters: QuartzEmitterPlugin[],
}

View file

@ -1,8 +1,8 @@
import { PerfTimer } from "../perf" import { PerfTimer } from "../perf"
import { QuartzFilterPlugin } from "../plugins/types" import { QuartzFilterPluginInstance } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile" import { ProcessedContent } from "../plugins/vfile"
export function filterContent(plugins: QuartzFilterPlugin[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] { export function filterContent(plugins: QuartzFilterPluginInstance[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
const perf = new PerfTimer() const perf = new PerfTimer()
const initialLength = content.length const initialLength = content.length
for (const plugin of plugins) { for (const plugin of plugins) {

View file

@ -11,12 +11,12 @@ import { slugify } from '../path'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import workerpool, { Promise as WorkerPromise } from 'workerpool' import workerpool, { Promise as WorkerPromise } from 'workerpool'
import { QuartzTransformerPlugin } from '../plugins/types' import { QuartzTransformerPluginInstance } from '../plugins/types'
import { QuartzLogger } from '../log' import { QuartzLogger } from '../log'
import chalk from 'chalk' import chalk from 'chalk'
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void> export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
export function createProcessor(transformers: QuartzTransformerPlugin[]): QuartzProcessor { export function createProcessor(transformers: QuartzTransformerPluginInstance[]): QuartzProcessor {
// base Markdown -> MD AST // base Markdown -> MD AST
let processor = unified().use(remarkParse) let processor = unified().use(remarkParse)
@ -101,7 +101,7 @@ export function createFileParser(baseDir: string, fps: string[], verbose: boolea
} }
} }
export async function parseMarkdown(transformers: QuartzTransformerPlugin[], baseDir: string, fps: string[], verbose: boolean): Promise<ProcessedContent[]> { export async function parseMarkdown(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: string[], verbose: boolean): Promise<ProcessedContent[]> {
const perf = new PerfTimer() const perf = new PerfTimer()
const log = new QuartzLogger(verbose) const log = new QuartzLogger(verbose)