2 files added
17 files modified
| | |
| | | "@napi-rs/simple-git": "^0.1.8", |
| | | "chalk": "^4.1.2", |
| | | "cli-spinner": "^0.2.10", |
| | | "env-paths": "^3.0.0", |
| | | "esbuild-sass-plugin": "^2.9.0", |
| | | "github-slugger": "^2.0.0", |
| | | "globby": "^13.1.4", |
| | |
| | | "url": "https://github.com/fb55/entities?sponsor=1" |
| | | } |
| | | }, |
| | | "node_modules/env-paths": { |
| | | "version": "3.0.0", |
| | | "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", |
| | | "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", |
| | | "engines": { |
| | | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" |
| | | }, |
| | | "funding": { |
| | | "url": "https://github.com/sponsors/sindresorhus" |
| | | } |
| | | }, |
| | | "node_modules/esbuild": { |
| | | "version": "0.17.19", |
| | | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", |
| | |
| | | "@napi-rs/simple-git": "^0.1.8", |
| | | "chalk": "^4.1.2", |
| | | "cli-spinner": "^0.2.10", |
| | | "env-paths": "^3.0.0", |
| | | "esbuild-sass-plugin": "^2.9.0", |
| | | "github-slugger": "^2.0.0", |
| | | "globby": "^13.1.4", |
| | |
| | | #!/usr/bin/env node |
| | | import { readFileSync } from 'fs' |
| | | import { promises, readFileSync } from 'fs' |
| | | import yargs from 'yargs' |
| | | import path from 'path' |
| | | import { hideBin } from 'yargs/helpers' |
| | | import esbuild from 'esbuild' |
| | | import chalk from 'chalk' |
| | |
| | | jsx: "automatic", |
| | | jsxImportSource: "preact", |
| | | external: ["@napi-rs/simple-git", "shiki"], |
| | | plugins: [sassPlugin({ |
| | | plugins: [ |
| | | sassPlugin({ |
| | | type: 'css-text' |
| | | })] |
| | | }), |
| | | { |
| | | name: 'inline-script-loader', |
| | | setup(build) { |
| | | build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { |
| | | let text = await promises.readFile(args.path, 'utf8') |
| | | const transpiled = await esbuild.build({ |
| | | stdin: { |
| | | contents: text, |
| | | sourcefile: path.relative(path.resolve('.'), args.path), |
| | | }, |
| | | write: false, |
| | | bundle: true, |
| | | platform: "browser", |
| | | format: "esm", |
| | | }) |
| | | const rawMod = transpiled.outputFiles[0].text |
| | | return { |
| | | contents: rawMod, |
| | | loader: 'text', |
| | | } |
| | | }) |
| | | } |
| | | } |
| | | ] |
| | | }).catch(err => { |
| | | console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) |
| | | console.log(`Reason: ${chalk.grey(err)}`) |
| | |
| | | externalResources: StaticResources |
| | | } |
| | | |
| | | export default function({ title, description, slug, externalResources }: HeadProps) { |
| | | export function Component({ title, description, slug, externalResources }: HeadProps) { |
| | | const { css, js } = externalResources |
| | | const baseDir = resolveToRoot(slug) |
| | | const stylePath = baseDir + "/index.css" |
| | | const iconPath = baseDir + "/static/icon.png" |
| | | const ogImagePath = baseDir + "/static/og-image.png" |
| | | return <head> |
| | |
| | | <meta name="generator" content="Quartz" /> |
| | | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| | | <link rel="preconnect" href="https://fonts.gstatic.com" /> |
| | | <link rel="stylesheet" type="text/css" href={stylePath} /> |
| | | {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} />)} |
| | | {js.filter(resource => resource.loadTime === "beforeDOMReady").map(resource => <script key={resource.src} {...resource} />)} |
| | | </head> |
| | | } |
| | | |
| | | export function beforeDOMLoaded() { |
| | | |
| | | } |
| | | |
| | | export function onDOMLoaded() { |
| | | |
| | | } |
| | |
| | | slug: string |
| | | } |
| | | |
| | | export default function({ title, slug }: HeaderProps) { |
| | | export function Component({ title, slug }: HeaderProps) { |
| | | const baseDir = resolveToRoot(slug) |
| | | return <header> |
| | | <h1><a href={baseDir}>{title}</a></h1> |
| | | </header> |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | export default "Darkmode" |
| | | |
| | | console.log("HELLOOOO FROM CONSOLE") |
| New file |
| | |
| | | import { ComponentType } from "preact" |
| | | |
| | | export type QuartzComponent<Props> = { |
| | | Component: ComponentType<Props> |
| | | css?: string, |
| | | beforeDOMLoaded?: string, |
| | | afterDOMLoaded?: string, |
| | | } |
| | |
| | | const parsedFiles = await parseMarkdown(processor, argv.directory, filePaths, argv.verbose) |
| | | const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose) |
| | | await emitContent(argv.directory, output, cfg, filteredContent, argv.verbose) |
| | | console.log(chalk.green(`Done in ${perf.timeSince()}`)) |
| | | console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) |
| | | |
| | | if (argv.serve) { |
| | | const server = http.createServer(async (req, res) => { |
| | |
| | | // resolve /a/b/c to ../../ |
| | | export function resolveToRoot(slug: string): string { |
| | | let fp = slug |
| | | if (fp.endsWith("/index")) { |
| | | fp = fp.slice(0, -"/index".length) |
| | | if (fp.endsWith("index")) { |
| | | fp = fp.slice(0, -"index".length) |
| | | } |
| | | |
| | | return fp |
| | | if (fp === "") { |
| | | return "." |
| | | } |
| | | |
| | | return "./" + fp |
| | | .split('/') |
| | | .filter(x => x !== '') |
| | | .map(_ => '..') |
| | |
| | | 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" |
| | | import { googleFontHref, templateThemeStyles } from "../../theme" |
| | | import { GlobalConfiguration } from "../../cfg" |
| | | import { HeaderProps } from "../../components/Header" |
| | | |
| | | import styles from '../../styles/base.scss' |
| | | import { QuartzComponent } from "../../components/types" |
| | | import { resolveToRoot } from "../../path" |
| | | |
| | | interface Options { |
| | | Head: ComponentType<HeadProps> |
| | | Header: ComponentType<HeaderProps> |
| | | Head: QuartzComponent<HeadProps> |
| | | Header: QuartzComponent<HeaderProps> |
| | | } |
| | | |
| | | export class ContentPage extends QuartzEmitterPlugin { |
| | |
| | | this.opts = opts |
| | | } |
| | | |
| | | getQuartzComponents(): QuartzComponent<any>[] { |
| | | return [...Object.values(this.opts)] |
| | | } |
| | | |
| | | async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> { |
| | | const fps: string[] = [] |
| | | |
| | | // emit styles |
| | | emit({ |
| | | slug: "index", |
| | | ext: ".css", |
| | | content: templateThemeStyles(cfg.theme, styles) |
| | | }) |
| | | fps.push("index.css") |
| | | resources.css.push(googleFontHref(cfg.theme)) |
| | | |
| | | const { Head, Header } = this.opts |
| | | for (const [tree, file] of content) { |
| | | // @ts-ignore (preact makes it angry) |
| | | const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' }) |
| | | |
| | | const baseDir = resolveToRoot(file.data.slug!) |
| | | const pageResources: StaticResources = { |
| | | css: [baseDir + "/index.css", ...resources.css,], |
| | | js: [ |
| | | { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", type: 'module' }, |
| | | ...resources.js, |
| | | { src: baseDir + "/postscript.js", loadTime: "afterDOMReady", type: 'module' } |
| | | ] |
| | | } |
| | | |
| | | const title = file.data.frontmatter?.title |
| | | const { Head, Header } = this.opts |
| | | const doc = <html> |
| | | <Head |
| | | <Head.Component |
| | | title={title ?? "Untitled"} |
| | | description={file.data.description ?? "No description provided"} |
| | | slug={file.data.slug!} |
| | | externalResources={resources} /> |
| | | externalResources={pageResources} /> |
| | | <body> |
| | | <div id="quartz-root" class="page"> |
| | | <Header title={cfg.siteTitle} slug={file.data.slug!} /> |
| | | <Header.Component title={cfg.siteTitle} slug={file.data.slug!} /> |
| | | <article> |
| | | {file.data.slug !== "index" && <h1>{title}</h1>} |
| | | {content} |
| | | </article> |
| | | </div> |
| | | </body> |
| | | {resources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} src={resource.src} />)} |
| | | {pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(resource => <script key={resource.src} {...resource} />)} |
| | | </html> |
| | | |
| | | const fp = file.data.slug + ".html" |
| | |
| | | import { GlobalConfiguration } from '../cfg' |
| | | import { QuartzComponent } from '../components/types' |
| | | import { StaticResources } from '../resources' |
| | | import { PluginTypes } from './types' |
| | | import { googleFontHref, joinStyles } from '../theme' |
| | | import { EmitCallback, PluginTypes } from './types' |
| | | import styles from '../styles/base.scss' |
| | | |
| | | export type ComponentResources = { |
| | | css: string[], |
| | | beforeDOMLoaded: string[], |
| | | afterDOMLoaded: string[] |
| | | } |
| | | |
| | | function joinScripts(scripts: string[]): string { |
| | | return scripts.join("\n") |
| | | } |
| | | |
| | | export function emitComponentResources(cfg: GlobalConfiguration, resources: StaticResources, plugins: PluginTypes, emit: EmitCallback) { |
| | | const fps: string[] = [] |
| | | const allComponents: Set<QuartzComponent<any>> = new Set() |
| | | for (const emitter of plugins.emitters) { |
| | | const components = emitter.getQuartzComponents() |
| | | for (const component of components) { |
| | | allComponents.add(component) |
| | | } |
| | | } |
| | | |
| | | const componentResources: ComponentResources = { |
| | | css: [], |
| | | beforeDOMLoaded: [], |
| | | afterDOMLoaded: [] |
| | | } |
| | | |
| | | for (const component of allComponents) { |
| | | const { css, beforeDOMLoaded, afterDOMLoaded } = component |
| | | if (css) { |
| | | componentResources.css.push(css) |
| | | } |
| | | if (beforeDOMLoaded) { |
| | | componentResources.beforeDOMLoaded.push(beforeDOMLoaded) |
| | | } |
| | | if (afterDOMLoaded) { |
| | | componentResources.beforeDOMLoaded.push(afterDOMLoaded) |
| | | } |
| | | } |
| | | |
| | | emit({ |
| | | slug: "index", |
| | | ext: ".css", |
| | | content: joinStyles(cfg.theme, styles, ...componentResources.css) |
| | | }) |
| | | emit({ |
| | | slug: "prescript", |
| | | ext: ".js", |
| | | content: joinScripts(componentResources.beforeDOMLoaded) |
| | | }) |
| | | emit({ |
| | | slug: "postscript", |
| | | ext: ".js", |
| | | content: joinScripts(componentResources.afterDOMLoaded) |
| | | }) |
| | | |
| | | fps.push("index.css", "prescript.js", "postscript.js") |
| | | resources.css.push(googleFontHref(cfg.theme)) |
| | | return fps |
| | | } |
| | | |
| | | export function getStaticResourcesFromPlugins(plugins: PluginTypes) { |
| | | const staticResources: StaticResources = { |
| | |
| | | js: [], |
| | | } |
| | | |
| | | for (const plugin of plugins.transformers) { |
| | | const res = plugin.externalResources |
| | | for (const transformer of plugins.transformers) { |
| | | const res = transformer.externalResources |
| | | if (res?.js) { |
| | | staticResources.js = staticResources.js.concat(res.js) |
| | | } |
| | |
| | | |
| | | const defaultOptions: Options = { |
| | | highlight: true, |
| | | wikilinks: true |
| | | wikilinks: true, |
| | | } |
| | | |
| | | export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin { |
| | |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => { |
| | | if (value.startsWith("!")) { |
| | | |
| | | // TODO: handle embeds |
| | | } else { |
| | | const [path, rawHeader, rawAlias] = capture |
| | | const anchor = rawHeader?.slice(1).trim() ?? "" |
| | | const anchor = rawHeader?.trim() ?? "" |
| | | const alias = rawAlias?.slice(1).trim() ?? path |
| | | const url = slugify(path.trim() + anchor) |
| | | return { |
| | |
| | | import { StaticResources } from "../resources" |
| | | import { ProcessedContent } from "./vfile" |
| | | import { GlobalConfiguration } from "../cfg" |
| | | import { QuartzComponent } from "../components/types" |
| | | |
| | | export abstract class QuartzTransformerPlugin { |
| | | abstract name: string |
| | |
| | | export abstract class QuartzEmitterPlugin { |
| | | abstract name: string |
| | | abstract emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]> |
| | | abstract getQuartzComponents(): QuartzComponent<any>[] |
| | | } |
| | | |
| | | export interface PluginTypes { |
| | |
| | | import fs from "fs" |
| | | import { QuartzConfig } from "../cfg" |
| | | import { PerfTimer } from "../perf" |
| | | import { getStaticResourcesFromPlugins } from "../plugins" |
| | | import { emitComponentResources, getStaticResourcesFromPlugins } from "../plugins" |
| | | import { EmitCallback } from "../plugins/types" |
| | | import { ProcessedContent } from "../plugins/vfile" |
| | | import { QUARTZ, slugify } from "../path" |
| | |
| | | |
| | | export async function emitContent(contentFolder: string, 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) |
| | |
| | | return pathToPage |
| | | } |
| | | |
| | | const staticResources = getStaticResourcesFromPlugins(cfg.plugins) |
| | | emitComponentResources(cfg.configuration, staticResources, cfg.plugins, emit) |
| | | |
| | | let emittedFiles = 0 |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | const emitted = await emitter.emit(cfg.configuration, content, staticResources, emit) |
| | |
| | | |
| | | const staticPath = path.join(QUARTZ, "static") |
| | | await fs.promises.cp(staticPath, path.join(output, "static"), { recursive: true }) |
| | | if (verbose) { |
| | | console.log(`[emit:Static] ${path.join(output, "static", "**")}`) |
| | | } |
| | | |
| | | // glob all non MD/MDX/HTML files in content folder and copy it over |
| | | const assetsPath = path.join("public", "assets") |
| | |
| | | } |
| | | } |
| | | |
| | | if (verbose) { |
| | | console.log(`[emit:Static] ${path.join(output, "static", "**")}`) |
| | | console.log(`Emitted ${emittedFiles} files to \`${output}\` in ${perf.timeSince()}`) |
| | | } |
| | | } |
| | |
| | | const perf = new PerfTimer() |
| | | const initialLength = content.length |
| | | for (const plugin of plugins) { |
| | | content = content.filter(plugin.shouldPublish) |
| | | } |
| | | const updatedContent = content.filter(plugin.shouldPublish) |
| | | |
| | | if (verbose) { |
| | | console.log(`Filtered out ${initialLength - content.length} files in ${perf.timeSince()}`) |
| | | const diff = content.filter(x => !updatedContent.includes(x)) |
| | | for (const file of diff) { |
| | | console.log(`[filter:${plugin.name}] ${file[1].data.slug}`) |
| | | } |
| | | } |
| | | |
| | | content = updatedContent |
| | | } |
| | | |
| | | console.log(`Filtered out ${initialLength - content.length} files in ${perf.timeSince()}`) |
| | | return content |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | if (verbose) { |
| | | console.log(`Parsed and transformed ${res.length} Markdown files in ${perf.timeSince()}`) |
| | | } |
| | | |
| | | return res |
| | | } |
| | |
| | | export interface JSResource { |
| | | src: string |
| | | loadTime: 'beforeDOMReady' | 'afterDOMReady' |
| | | type?: 'module' |
| | | } |
| | | |
| | | export interface StaticResources { |
| | |
| | | margin: 0 0.5rem; |
| | | opacity: 0; |
| | | transition: opacity 0.2s ease; |
| | | transform: translateY(-0.1rem); |
| | | display: inline-block; |
| | | font-family: var(--codeFont); |
| | | user-select: none; |
| | | } |
| | |
| | | return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap` |
| | | } |
| | | |
| | | export function templateThemeStyles(theme: Theme, stylesheet: string) { |
| | | return ` |
| | | :root { |
| | | export function joinStyles(theme: Theme, ...stylesheet: string[]) { |
| | | return `:root { |
| | | --light: ${theme.colors.lightMode.light}; |
| | | --lightgray: ${theme.colors.lightMode.lightgray}; |
| | | --gray: ${theme.colors.lightMode.gray}; |
| | |
| | | --highlight: ${theme.colors.darkMode.highlight}; |
| | | } |
| | | |
| | | ${stylesheet} |
| | | ` |
| | | ${stylesheet.join("\n\n")}` |
| | | } |