4 files added
13 files modified
| | |
| | | declare module '*.scss' { |
| | | const content: string |
| | | const content: string |
| | | export = content |
| | | } |
| | | |
| | | // dom custom event |
| | | interface CustomEventMap { |
| | | "spa_nav": CustomEvent<{ url: string }>; |
| | | } |
| | | |
| | | declare global { |
| | | interface Document { |
| | | addEventListener<K extends keyof CustomEventMap>(type: K, |
| | | listener: (this: Document, ev: CustomEventMap[K]) => void): void; |
| | | dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void; |
| | | } |
| | | } |
| | |
| | | highlight: 'rgba(143, 159, 169, 0.15)', |
| | | }, |
| | | darkMode: { |
| | | light: '#1e1e21', |
| | | light: '#161618', |
| | | lightgray: '#292629', |
| | | gray: '#343434', |
| | | darkgray: '#d4d4d4', |
| | |
| | | transformers: [ |
| | | Plugin.FrontMatter(), |
| | | Plugin.Description(), |
| | | Plugin.TableOfContents({ showByDefault: true }), |
| | | Plugin.TableOfContents(), |
| | | Plugin.CreatedModifiedDate({ |
| | | priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower |
| | | }), |
| | |
| | | Plugin.RemoveDrafts() |
| | | ], |
| | | emitters: [ |
| | | Plugin.AliasRedirects(), |
| | | Plugin.ContentPage({ |
| | | head: Component.Head(), |
| | | header: [Component.PageTitle(), Component.Spacer(), Component.Darkmode()], |
| | | body: [Component.ArticleTitle(), Component.ReadingTime(), Component.TagList(), Component.TableOfContents(), Component.Content()] |
| | | }) |
| | | body: [ |
| | | Component.ArticleTitle(), |
| | | Component.ReadingTime(), |
| | | Component.TagList(), |
| | | Component.TableOfContents(), |
| | | Component.Content() |
| | | ], |
| | | left: [], |
| | | right: [], |
| | | footer: [] |
| | | }), |
| | | Plugin.ContentIndex(), // you can exclude this if you don't plan on using popovers, graph, or backlinks, |
| | | Plugin.CNAME({ domain: "yoursite.xyz" }) // set this to your final deployed domain |
| | | ] |
| | | }, |
| | | } |
| | |
| | | |
| | | if (argv.serve) { |
| | | const server = http.createServer(async (req, res) => { |
| | | console.log(chalk.grey(`[req] ${req.url}`)) |
| | | return serveHandler(req, res, { |
| | | public: output, |
| | | directoryListing: false, |
| | |
| | | // @ts-ignore |
| | | import clipboardScript from './scripts/clipboard.inline' |
| | | import clipboardStyle from './styles/clipboard.scss' |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import style from "./styles/toc.scss" |
| | | |
| | | import legacyStyle from "./styles/legacyToc.scss" |
| | | import modernStyle from "./styles/toc.scss" |
| | | |
| | | interface Options { |
| | | layout: 'modern' | 'quartz-3' |
| | | layout: 'modern' | 'legacy' |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | layout: 'quartz-3' |
| | | layout: 'modern' |
| | | } |
| | | |
| | | export default ((opts?: Partial<Options>) => { |
| | | const layout = opts?.layout ?? defaultOptions.layout |
| | | if (layout === "modern") { |
| | | return function() { |
| | | return null // TODO (make this look like nextra) |
| | | } |
| | | } else { |
| | | function TableOfContents({ fileData }: QuartzComponentProps) { |
| | | if (!fileData.toc) { |
| | | return null |
| | | } |
| | | |
| | | return <details class="toc" open> |
| | | <summary><h3>Table of Contents</h3></summary> |
| | | <ul> |
| | | {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> |
| | | <a href={`#${tocEntry.slug}`}>{tocEntry.text}</a> |
| | | </li>)} |
| | | </ul> |
| | | </details> |
| | | function TableOfContents({ fileData }: QuartzComponentProps) { |
| | | if (!fileData.toc) { |
| | | return null |
| | | } |
| | | |
| | | TableOfContents.css = style |
| | | return TableOfContents |
| | | return <details class="toc" open> |
| | | <summary><h3>Table of Contents</h3></summary> |
| | | <ul> |
| | | {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> |
| | | <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a> |
| | | </li>)} |
| | | </ul> |
| | | </details> |
| | | } |
| | | |
| | | TableOfContents.css = layout === "modern" ? modernStyle : legacyStyle |
| | | |
| | | if (layout === "modern") { |
| | | TableOfContents.afterDOMLoaded = ` |
| | | const bufferPx = 150 |
| | | const observer = new IntersectionObserver(entries => { |
| | | for (const entry of entries) { |
| | | const slug = entry.target.id |
| | | const tocEntryElement = document.querySelector(\`a[data-for="$\{slug\}"]\`) |
| | | const windowHeight = entry.rootBounds?.height |
| | | if (windowHeight && tocEntryElement) { |
| | | if (entry.boundingClientRect.y < windowHeight) { |
| | | tocEntryElement.classList.add("in-view") |
| | | } else { |
| | | tocEntryElement.classList.remove("in-view") |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | |
| | | function init() { |
| | | const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") |
| | | headers.forEach(header => observer.observe(header)) |
| | | } |
| | | |
| | | init() |
| | | |
| | | document.addEventListener("spa_nav", (e) => { |
| | | observer.disconnect() |
| | | init() |
| | | }) |
| | | ` |
| | | } |
| | | |
| | | return TableOfContents |
| | | }) satisfies QuartzComponentConstructor |
| | |
| | | const description = "Initialize copy for codeblocks" |
| | | export default description |
| | | |
| | | const svgCopy = |
| | | '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>' |
| | | const svgCheck = |
| | |
| | | return { url: new URL(href), scroll: 'routerNoscroll' in a.dataset ? false : undefined } |
| | | } |
| | | |
| | | function notifyNav(slug: string) { |
| | | const event = new CustomEvent("spa_nav", { detail: { slug } }) |
| | | document.dispatchEvent(event) |
| | | } |
| | | |
| | | let p: DOMParser |
| | | async function navigate(url: URL, isBack: boolean = false) { |
| | | p = p || new DOMParser() |
| | |
| | | const elementsToAdd = html.head.querySelectorAll(':not([spa-preserve])') |
| | | elementsToAdd.forEach(el => document.head.appendChild(el)) |
| | | |
| | | if (!document.activeElement?.closest('[data-persist]')) { |
| | | document.body.focus() |
| | | } |
| | | notifyNav(document.body.dataset.slug!) |
| | | delete announcer.dataset.persist |
| | | } |
| | | |
| New file |
| | |
| | | details.toc { |
| | | & summary { |
| | | cursor: pointer; |
| | | |
| | | &::marker { |
| | | color: var(--dark); |
| | | } |
| | | |
| | | & > * { |
| | | padding-left: 0.25rem; |
| | | display: inline-block; |
| | | margin: 0; |
| | | } |
| | | } |
| | | |
| | | & ul { |
| | | list-style: none; |
| | | margin: 0.5rem 1.25rem; |
| | | padding: 0; |
| | | } |
| | | |
| | | @for $i from 1 through 6 { |
| | | & .depth-#{$i} { |
| | | padding-left: calc(1rem * #{$i}); |
| | | } |
| | | } |
| | | } |
| | |
| | | & summary { |
| | | cursor: pointer; |
| | | |
| | | &::marker { |
| | | color: var(--dark); |
| | | list-style: none; |
| | | &::marker, &::-webkit-details-marker { |
| | | display: none; |
| | | } |
| | | |
| | | & > * { |
| | | padding-left: 0.25rem; |
| | | display: inline-block; |
| | | margin: 0; |
| | | } |
| | | |
| | | & > h3 { |
| | | font-size: 1rem; |
| | | } |
| | | } |
| | | |
| | | & ul { |
| | | list-style: none; |
| | | margin: 0.5rem 1.25rem; |
| | | margin: 0.5rem 0; |
| | | padding: 0; |
| | | & > li > a { |
| | | color: var(--dark); |
| | | opacity: 0.35; |
| | | transition: 0.5s ease opacity; |
| | | &.in-view { |
| | | opacity: 0.75; |
| | | } |
| | | } |
| | | } |
| | | |
| | | @for $i from 1 through 6 { |
| | | @for $i from 0 through 6 { |
| | | & .depth-#{$i} { |
| | | padding-left: calc(1rem * #{$i}); |
| | | } |
| | |
| | | return s.replace(/\s/g, '-') |
| | | } |
| | | |
| | | export function trimPathSuffix(fp: string): string { |
| | | let [cleanPath, anchor] = fp.split("#", 2) |
| | | anchor = anchor === undefined ? "" : "#" + anchor |
| | | |
| | | if (cleanPath.endsWith("index")) { |
| | | cleanPath = cleanPath.slice(0, -"index".length) |
| | | } |
| | | |
| | | if (cleanPath === "") { |
| | | cleanPath = "./" |
| | | } |
| | | |
| | | return cleanPath + anchor |
| | | } |
| | | |
| | | export function slugify(s: string): string { |
| | | const [fp, anchor] = s.split("#", 2) |
| | | const sluggedAnchor = anchor === undefined ? "" : "#" + slugAnchor(anchor) |
| | |
| | | |
| | | // resolve /a/b/c to ../../ |
| | | export function resolveToRoot(slug: string): string { |
| | | let fp = slug |
| | | if (fp.endsWith("index")) { |
| | | fp = fp.slice(0, -"index".length) |
| | | } |
| | | let fp = trimPathSuffix(slug) |
| | | |
| | | if (fp === "") { |
| | | if (fp === "./") { |
| | | return "." |
| | | } |
| | | |
| New file |
| | |
| | | import { relativeToRoot } from "../../path" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import path from 'path' |
| | | |
| | | export const AliasRedirects: QuartzEmitterPlugin = () => ({ |
| | | name: "AliasRedirects", |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async emit(contentFolder, _cfg, content, _resources, emit): Promise<string[]> { |
| | | const fps: string[] = [] |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const ogSlug = file.data.slug! |
| | | const dir = path.relative(contentFolder, file.dirname ?? contentFolder) |
| | | |
| | | let aliases: string[] = [] |
| | | if (file.data.frontmatter?.aliases) { |
| | | aliases = file.data.frontmatter?.aliases |
| | | } else if (file.data.frontmatter?.alias) { |
| | | aliases = [file.data.frontmatter?.alias] |
| | | } |
| | | |
| | | for (const alias of aliases) { |
| | | const slug = alias.startsWith("/") |
| | | ? alias |
| | | : path.posix.join(dir, alias) |
| | | |
| | | const fp = slug + ".html" |
| | | const redirUrl = relativeToRoot(slug, ogSlug) |
| | | await emit({ |
| | | content: ` |
| | | <!DOCTYPE html> |
| | | <html lang="en-us"> |
| | | <head> |
| | | <title>${ogSlug}</title> |
| | | <link rel="canonical" href="${redirUrl}"> |
| | | <meta name="robots" content="noindex"> |
| | | <meta charset="utf-8"> |
| | | <meta http-equiv="refresh" content="0; url=${redirUrl}"> |
| | | </head> |
| | | </html> |
| | | `, |
| | | slug, |
| | | ext: ".html", |
| | | }) |
| | | |
| | | fps.push(fp) |
| | | } |
| | | } |
| | | return fps |
| | | } |
| | | }) |
| New file |
| | |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | |
| | | interface Options { |
| | | domain: string |
| | | } |
| | | |
| | | export const CNAME: QuartzEmitterPlugin<Options> = (opts?: Options) => ({ |
| | | name: "CNAME", |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async emit(_contentFolder, _cfg, _content, _resources, emit): Promise<string[]> { |
| | | const slug = "CNAME" |
| | | |
| | | if (opts?.domain) { |
| | | await emit({ |
| | | content: opts?.domain, |
| | | slug, |
| | | ext: "", |
| | | }) |
| | | } |
| | | |
| | | return ["CNAME"] |
| | | } |
| | | }) |
| New file |
| | |
| | | import { visit } from "unist-util-visit" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import { Element } from "hast" |
| | | import path from "path" |
| | | import { trimPathSuffix } from "../../path" |
| | | |
| | | interface Options { |
| | | indexAnchorLinks: boolean, |
| | | indexExternalLinks: boolean, |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | indexAnchorLinks: false, |
| | | indexExternalLinks: false, |
| | | } |
| | | |
| | | type ContentIndex = Map<string, { |
| | | title: string, |
| | | links?: string[], |
| | | tags?: string[], |
| | | content: string, |
| | | }> |
| | | |
| | | export const ContentIndex: QuartzEmitterPlugin<Options> = (userOpts) => { |
| | | const opts = { ...userOpts, ...defaultOptions } |
| | | return { |
| | | name: "ContentIndex", |
| | | async emit(_contentDir, _cfg, content, _resources, emit) { |
| | | const fp = "contentIndex" |
| | | const linkIndex: ContentIndex = new Map() |
| | | for (const [tree, file] of content) { |
| | | let slug = trimPathSuffix(file.data.slug!) |
| | | |
| | | const outgoing: Set<string> = new Set() |
| | | visit(tree, 'element', (node: Element) => { |
| | | if (node.tagName === 'a' && node.properties && typeof node.properties.href === 'string') { |
| | | let dest = node.properties.href |
| | | if (dest.startsWith(".")) { |
| | | const normalizedPath = path.normalize(path.join(slug, dest)) |
| | | dest = trimPathSuffix(normalizedPath) |
| | | outgoing.add(dest) |
| | | } else if (dest.startsWith("#")) { |
| | | if (opts.indexAnchorLinks) { |
| | | outgoing.add(dest) |
| | | } |
| | | } else { |
| | | if (opts.indexExternalLinks) { |
| | | outgoing.add(dest) |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | |
| | | linkIndex.set(slug, { |
| | | title: file.data.frontmatter?.title!, |
| | | links: [...outgoing], |
| | | tags: file.data.frontmatter?.tags, |
| | | content: file.data.text ?? "" |
| | | }) |
| | | } |
| | | |
| | | await emit({ |
| | | content: JSON.stringify(Object.fromEntries(linkIndex)), |
| | | slug: fp, |
| | | ext: ".json", |
| | | }) |
| | | |
| | | return [`${fp}.json`] |
| | | }, |
| | | getQuartzComponents: () => [], |
| | | } |
| | | } |
| | |
| | | import { JSResourceToScriptElement, StaticResources } from "../../resources" |
| | | import { EmitCallback, QuartzEmitterPlugin } from "../types" |
| | | import { ProcessedContent } from "../vfile" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import { render } from "preact-render-to-string" |
| | | import { GlobalConfiguration } from "../../cfg" |
| | | import { QuartzComponent } from "../../components/types" |
| | | import { resolveToRoot } from "../../path" |
| | | import HeaderConstructor from "../../components/Header" |
| | |
| | | interface Options { |
| | | head: QuartzComponent |
| | | header: QuartzComponent[], |
| | | body: QuartzComponent[] |
| | | body: QuartzComponent[], |
| | | left: QuartzComponent[], |
| | | right: QuartzComponent[], |
| | | footer: QuartzComponent[], |
| | | } |
| | | |
| | | export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => { |
| | |
| | | getQuartzComponents() { |
| | | return [opts.head, Header, ...opts.header, ...opts.body] |
| | | }, |
| | | async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise<string[]> { |
| | | async emit(_contentDir, cfg, content, resources, emit): Promise<string[]> { |
| | | const fps: string[] = [] |
| | | |
| | | for (const [tree, file] of content) { |
| | |
| | | |
| | | const doc = <html> |
| | | <Head {...componentData} /> |
| | | <body> |
| | | <body data-slug={file.data.slug}> |
| | | <div id="quartz-root" class="page"> |
| | | <Header {...componentData} > |
| | | {header.map(HeaderComponent => <HeaderComponent {...componentData} />)} |
| | |
| | | export { ContentPage } from './contentPage' |
| | | export { ContentIndex } from './contentIndex' |
| | | export { AliasRedirects } from './aliases' |
| | | export { CNAME } from './cname' |
| | |
| | | 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[]> |
| | | emit(contentDir: string, cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<string[]> |
| | | getQuartzComponents(): QuartzComponent[] |
| | | } |
| | | |
| | | export interface EmitOptions { |
| | | slug: string |
| | | ext: `.${string}` |
| | | ext: `.${string}` | "" |
| | | content: string |
| | | } |
| | | |
| | |
| | | let emittedFiles = 0 |
| | | for (const emitter of cfg.plugins.emitters) { |
| | | try { |
| | | const emitted = await emitter.emit(cfg.configuration, content, staticResources, emit) |
| | | const emitted = await emitter.emit(contentFolder, cfg.configuration, content, staticResources, emit) |
| | | emittedFiles += emitted.length |
| | | |
| | | if (verbose) { |
| | |
| | | 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", "**")}`) |
| | | console.log(`[emit:Static] ${path.join("static", "**")}`) |
| | | } |
| | | |
| | | // glob all non MD/MDX/HTML files in content folder and copy it over |
| | | const assetsPath = path.join("public", "assets") |
| | | const assetsPath = path.join(output, "assets") |
| | | for await (const fp of globbyStream("**", { |
| | | ignore: ["**/*.md"], |
| | | cwd: contentFolder, |
| | | })) { |
| | | const ext = path.extname(fp as string) |
| | | const src = path.join(contentFolder, fp as string) |
| | | const dest = path.join(assetsPath, slugify(fp as string) + ext) |
| | | const name = slugify(fp as string) + ext |
| | | const dest = path.join(assetsPath, name) |
| | | 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}`) |
| | | console.log(`[emit:Assets] ${path.join("assets", name)}`) |
| | | } |
| | | } |
| | | |