rendering, link resolution, asset copying

This commit is contained in:
Jacky Zhao 2023-05-31 17:01:23 -04:00
parent ad6ce0d73f
commit 21c007e2fc
19 changed files with 564 additions and 274 deletions

View file

@ -53,8 +53,6 @@ yargs(hideBin(process.argv))
const out = await esbuild.build({
entryPoints: [fp],
write: false,
minifySyntax: true,
minifyWhitespace: true,
bundle: true,
keepNames: true,
platform: "node",

View file

@ -1,4 +1,4 @@
import { PluginTypes } from "./plugins"
import { PluginTypes } from "./plugins/types"
export interface ColorScheme {
light: string,
@ -14,12 +14,6 @@ export interface ColorScheme {
export interface QuartzConfig {
configuration: {
siteTitle: string,
/** How to resolve Markdown paths */
markdownLinkResolution: 'absolute' | 'relative'
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
/** Whether to process and render latex (increases bundle size) */
enableLatex: boolean,
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
enableSPA: boolean,
/** Glob patterns to not search */

View file

@ -1,18 +1,22 @@
import { resolveToRoot } from "../path"
import { StaticResources } from "../resources"
interface Props {
title: string,
description: string,
externalResources: StaticResources,
baseDir: string
export interface HeadProps {
title: string
description: string
slug: string
externalResources: StaticResources
}
export default function({ title, description, externalResources, baseDir }: Props) {
export default function({ title, description, slug, externalResources }: HeadProps) {
const { css, js } = externalResources
const baseDir = resolveToRoot(slug)
const iconPath = baseDir + "/static/icon.png"
const ogImagePath = baseDir + "/static/og-image.png"
return <head>
<title>{title}</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content={title} />
<meta property="og:description" content={title} />
<meta property="og:image" content={ogImagePath} />
@ -21,8 +25,16 @@ export default function({ title, description, externalResources, baseDir }: Prop
<link rel="icon" href={iconPath} />
<meta name="description" content={description} />
<meta name="generator" content="Quartz" />
<meta charSet="UTF-8" />
<base href={slug} />
{css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" />)}
{js.filter(resource => resource.loadTime === "beforeDOMReady").map(resource => <script key={resource.src} src={resource.src} />)}
</head>
}
export function beforeDOMLoaded() {
}
export function onDOMLoaded() {
}

View file

@ -7,6 +7,8 @@ import chalk from "chalk"
import http from "http"
import serveHandler from "serve-handler"
import { createProcessor, parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
interface Argv {
directory: string
@ -21,7 +23,16 @@ export function buildQuartz(cfg: QuartzConfig) {
return async (argv: Argv, version: string) => {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
const perf = new PerfTimer()
const output = path.join(argv.directory, argv.output)
const output = argv.output
if (argv.verbose) {
const pluginCount = Object.values(cfg.plugins).flat().length
const pluginNames = (key: 'transformers' | 'filters' | 'emitters') => cfg.plugins[key].map(plugin => plugin.name)
console.log(`Loaded ${pluginCount} plugins`)
console.log(` Transformers: ${pluginNames('transformers').join(", ")}`)
console.log(` Filters: ${pluginNames('filters').join(", ")}`)
console.log(` Emitters: ${pluginNames('emitters').join(", ")}`)
}
// clean
if (argv.clean) {
@ -36,7 +47,7 @@ export function buildQuartz(cfg: QuartzConfig) {
perf.addEvent('glob')
const fps = await globby('**/*.md', {
cwd: argv.directory,
ignore: [...cfg.configuration.ignorePatterns, 'quartz/**'],
ignore: cfg.configuration.ignorePatterns,
gitignore: true,
})
@ -47,8 +58,8 @@ export function buildQuartz(cfg: QuartzConfig) {
const processor = createProcessor(cfg.plugins.transformers)
const filePaths = fps.map(fp => `${argv.directory}${path.sep}${fp}`)
const parsedFiles = await parseMarkdown(processor, argv.directory, filePaths, argv.verbose)
// const filteredContent = filterContent(cfg.plugins.filters, processedContent, argv.verbose)
// await emitContent(argv.directory, output, cfg, filteredContent, argv.verbose)
const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose)
await emitContent(output, cfg, filteredContent, argv.verbose)
console.log(chalk.green(`Done in ${perf.timeSince()}`))
if (argv.serve) {

View file

@ -1,11 +1,20 @@
import path from 'path'
// Replaces all whitespace with dashes and URI encodes the rest
export function pathToSlug(fp: string): string {
const { dir, name } = path.parse(fp)
let slug = path.join('/', dir, name)
slug = slug.replace(/\s/g, '-')
return slug
function slugSegment(s: string): string {
return s.replace(/\s/g, '-')
}
export function slugify(s: string): string {
const [fp, anchor] = s.split("#", 2)
const sluggedAnchor = anchor === undefined ? "" : "#" + slugSegment(anchor)
const withoutFileExt = fp.replace(new RegExp(path.extname(fp) + '$'), '')
const rawSlugSegments = withoutFileExt.split(path.sep)
const slugParts: string = rawSlugSegments
.map((segment) => slugSegment(segment))
.join(path.posix.sep)
// .replace(/index$/, '')
.replace(/\/$/, '')
return path.normalize(slugParts) + sluggedAnchor
}
// resolve /a/b/c to ../../
@ -15,5 +24,19 @@ export function resolveToRoot(slug: string): string {
fp = fp.slice(0, -"/index".length)
}
return "./" + path.relative(fp, path.posix.sep)
return fp
.split('/')
.filter(x => x !== '')
.map(_ => '..')
.join('/')
}
export function relativeToRoot(slug: string, fp: string): string {
return path.join(resolveToRoot(slug), fp)
}
export function relative(src: string, dest: string): string {
return path.relative(src, dest)
}
export const QUARTZ = "quartz"

View file

@ -1,26 +0,0 @@
import { resolveToRoot } from "../../path"
import { EmitCallback, QuartzEmitterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
export class ContentPage extends QuartzEmitterPlugin {
name = "ContentPage"
async emit(content: ProcessedContent[], emit: EmitCallback): Promise<string[]> {
const fps: string[] = []
for (const [tree, file] of content) {
const pathToRoot = resolveToRoot(file.data.slug!)
const fp = file.data.slug + ".html"
await emit({
title: file.data.frontmatter?.title ?? "Untitled",
description: file.data.description ?? "",
slug: file.data.slug!,
ext: ".html",
})
// TODO: process aliases
fps.push(fp)
}
return fps
}
}

View file

@ -0,0 +1,62 @@
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import { resolveToRoot } from "../../path"
import { StaticResources } from "../../resources"
import { EmitCallback, QuartzEmitterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { render } from "preact-render-to-string"
import { ComponentType } from "preact"
import { HeadProps } from "../../components/Head"
interface Options {
Head: ComponentType<HeadProps>
}
export class ContentPage extends QuartzEmitterPlugin {
name = "ContentPage"
opts: Options
constructor(opts: Options) {
super()
this.opts = opts
}
async emit(content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> {
const fps: string[] = []
for (const [tree, file] of content) {
// @ts-ignore (preact makes it angry)
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
const { Head } = this.opts
const doc = <html>
<Head
title={file.data.frontmatter?.title ?? "Untitled"}
description={file.data.description ?? "No description provided"}
slug={file.data.slug!}
externalResources={resources} />
<body>
<div id="quartz-root">
<header>
<h1>{file.data.frontmatter?.title}</h1>
</header>
<article>
{content}
</article>
</div>
</body>
{resources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} src={resource.src} />)}
</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

@ -45,12 +45,11 @@ export class CreatedModifiedDate extends QuartzTransformerPlugin {
modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate
} else if (source === "git") {
console.log(file)
if (!repo) {
repo = new Repository(file.cwd)
}
modified ||= new Date(await repo.getFileLatestModifiedDateAsync(fp))
modified ||= new Date(await repo.getFileLatestModifiedDateAsync(file.data.filePath!))
}
}

View file

@ -0,0 +1,85 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { remarkWikiLink } from "@flowershow/remark-wiki-link"
import { relative, relativeToRoot, slugify } from "../../path"
import path from "path"
import { visit } from 'unist-util-visit'
import isAbsoluteUrl from "is-absolute-url"
interface Options {
/** How to resolve Markdown paths */
markdownLinkResolution: 'absolute' | 'relative'
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
}
const defaultOptions: Options = {
markdownLinkResolution: 'absolute',
prettyLinks: true
}
export class LinkProcessing extends QuartzTransformerPlugin {
name = "LinkProcessing"
opts: Options
constructor(opts?: Options) {
super()
this.opts = { ...defaultOptions, ...opts }
}
markdownPlugins(): PluggableList {
return [[remarkWikiLink, {
pathFormat: this.opts.markdownLinkResolution === "absolute" ? 'obsidian-absolute' : 'raw'
}]]
}
htmlPlugins(): PluggableList {
return [() => {
return (tree, file) => {
const curSlug = file.data.slug!
const transformLink = (target: string) => {
const targetSlug = slugify(decodeURI(target))
if (this.opts.markdownLinkResolution === 'relative' && !path.isAbsolute(targetSlug)) {
return './' + relative(curSlug, targetSlug)
} else {
return './' + relativeToRoot(curSlug, targetSlug)
}
}
// rewrite all links
visit(tree, 'element', (node, _index, _parent) => {
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)
}
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
visit(tree, 'element', (node, _index, _parent) => {
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("/assets/" + node.properties.src) + ext
}
}
})
}
}]
}
}

View file

@ -15,20 +15,15 @@ export abstract class QuartzFilterPlugin {
}
export interface EmitOptions {
// meta
title: string
description: string
slug: string
ext: `.${string}`
// rendering related
content: string
}
export type EmitCallback = (data: EmitOptions) => Promise<void>
export type EmitCallback = (data: EmitOptions) => Promise<string>
export abstract class QuartzEmitterPlugin {
abstract name: string
abstract emit(content: ProcessedContent[], emitCallback: EmitCallback): Promise<string[]>
abstract emit(content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]>
}
export interface PluginTypes {

View file

@ -0,0 +1,61 @@
import path from "path"
import fs from "fs"
import { QuartzConfig } from "../cfg"
import { PerfTimer } from "../perf"
import { getStaticResourcesFromPlugins } from "../plugins"
import { EmitCallback } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
import { QUARTZ, slugify } from "../path"
import { globbyStream } from "globby"
export async function emitContent(output: string, cfg: QuartzConfig, content: ProcessedContent[], verbose: boolean) {
const perf = new PerfTimer()
const staticResources = getStaticResourcesFromPlugins(cfg.plugins)
const emit: EmitCallback = async ({ slug, ext, content }) => {
const pathToPage = path.join(output, slug + ext)
const dir = path.dirname(pathToPage)
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(pathToPage, content)
return pathToPage
}
let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) {
const emitted = await emitter.emit(content, staticResources, emit)
emittedFiles += emitted.length
if (verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
const staticPath = path.join(QUARTZ, "static")
await fs.promises.cp(staticPath, path.join(output, "static"), { recursive: true })
// glob all non MD/MDX/HTML files in content folder and copy it over
const assetsPath = path.join("public", "assets")
for await (const fp of globbyStream("**", {
ignore: ["**/*.{md,mdx,html}"],
cwd: "./content",
})) {
const ext = path.extname(fp as string)
const src = path.join("content", fp as string)
const dest = path.join(assetsPath, slugify(fp as string) + ext)
const dir = path.dirname(dest)
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
await fs.promises.copyFile(src, dest)
emittedFiles += 1
if (verbose) {
console.log(`[emit:Assets] ${dest}`)
}
}
if (verbose) {
console.log(`[emit:Static] ${path.join(output, "static", "**")}`)
console.log(`Emitted ${emittedFiles} files to \`${output}\` in ${perf.timeSince()}`)
}
}

View file

@ -0,0 +1,16 @@
import { PerfTimer } from "../perf"
import { QuartzFilterPlugin } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
export function filterContent(plugins: QuartzFilterPlugin[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
const perf = new PerfTimer()
const initialLength = content.length
for (const plugin of plugins) {
content = content.filter(plugin.shouldPublish)
}
if (verbose) {
console.log(`Filtered out ${initialLength - content.length} files in ${perf.timeSince()}`)
}
return content
}

View file

@ -6,7 +6,7 @@ import { Root as HTMLRoot } from 'hast'
import { ProcessedContent } from '../plugins/vfile'
import { PerfTimer } from '../perf'
import { read } from 'to-vfile'
import { pathToSlug } from '../path'
import { slugify } from '../path'
import path from 'path'
import { QuartzTransformerPlugin } from '../plugins/types'
@ -39,7 +39,7 @@ export async function parseMarkdown(processor: QuartzProcessor, baseDir: string,
const file = await read(fp)
// base data properties that plugins may use
file.data.slug = pathToSlug(path.relative(baseDir, file.path))
file.data.slug = slugify(path.relative(baseDir, file.path))
file.data.filePath = fp
const ast = processor.parse(file)