From 917d5791acd6361c691902b445bdc4f7129ba3fc Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Sat, 17 Jun 2023 02:41:59 +0000
Subject: [PATCH] modern toc tweaks
---
quartz/plugins/types.ts | 4
quartz/components/Body.tsx | 1
quartz/components/scripts/clipboard.inline.ts | 3
index.d.ts | 15 ++
quartz/path.ts | 22 ++
quartz/components/styles/legacyToc.scss | 27 +++
quartz/components/scripts/spa.inline.ts | 9
quartz/build.ts | 1
quartz/plugins/emitters/contentIndex.ts | 72 ++++++++++
quartz/processors/emit.ts | 11
quartz/components/TableOfContents.tsx | 75 +++++++---
quartz/components/styles/toc.scss | 22 ++
quartz/plugins/emitters/index.ts | 3
quartz/plugins/emitters/contentPage.tsx | 13 +
quartz/plugins/emitters/aliases.ts | 53 +++++++
quartz/plugins/emitters/cname.ts | 25 +++
quartz.config.ts | 20 ++
17 files changed, 318 insertions(+), 58 deletions(-)
diff --git a/index.d.ts b/index.d.ts
index a74b5f5..ec4d32a 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,4 +1,17 @@
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;
+ }
+}
diff --git a/quartz.config.ts b/quartz.config.ts
index 5868449..0c77c90 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -25,7 +25,7 @@
highlight: 'rgba(143, 159, 169, 0.15)',
},
darkMode: {
- light: '#1e1e21',
+ light: '#161618',
lightgray: '#292629',
gray: '#343434',
darkgray: '#d4d4d4',
@@ -41,7 +41,7 @@
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
}),
@@ -55,11 +55,23 @@
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
]
},
}
diff --git a/quartz/build.ts b/quartz/build.ts
index 60a1a51..b96bf01 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -57,6 +57,7 @@
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,
diff --git a/quartz/components/Body.tsx b/quartz/components/Body.tsx
index 0130828..f10cf3a 100644
--- a/quartz/components/Body.tsx
+++ b/quartz/components/Body.tsx
@@ -1,3 +1,4 @@
+// @ts-ignore
import clipboardScript from './scripts/clipboard.inline'
import clipboardStyle from './styles/clipboard.scss'
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
index 19f26ef..afb8388 100644
--- a/quartz/components/TableOfContents.tsx
+++ b/quartz/components/TableOfContents.tsx
@@ -1,38 +1,65 @@
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
diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts
index efddfa6..8d0758a 100644
--- a/quartz/components/scripts/clipboard.inline.ts
+++ b/quartz/components/scripts/clipboard.inline.ts
@@ -1,6 +1,3 @@
-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 =
diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts
index 2063a15..da34700 100644
--- a/quartz/components/scripts/spa.inline.ts
+++ b/quartz/components/scripts/spa.inline.ts
@@ -29,6 +29,11 @@
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()
@@ -64,9 +69,7 @@
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
}
diff --git a/quartz/components/styles/legacyToc.scss b/quartz/components/styles/legacyToc.scss
new file mode 100644
index 0000000..33b9cca
--- /dev/null
+++ b/quartz/components/styles/legacyToc.scss
@@ -0,0 +1,27 @@
+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});
+ }
+ }
+}
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
index 33b9cca..3003f40 100644
--- a/quartz/components/styles/toc.scss
+++ b/quartz/components/styles/toc.scss
@@ -2,24 +2,36 @@
& 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});
}
diff --git a/quartz/path.ts b/quartz/path.ts
index 87f1a9d..4efd748 100644
--- a/quartz/path.ts
+++ b/quartz/path.ts
@@ -5,6 +5,21 @@
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)
@@ -19,12 +34,9 @@
// 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 "."
}
diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts
new file mode 100644
index 0000000..c9a019b
--- /dev/null
+++ b/quartz/plugins/emitters/aliases.ts
@@ -0,0 +1,53 @@
+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
+ }
+})
diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts
new file mode 100644
index 0000000..c783dfb
--- /dev/null
+++ b/quartz/plugins/emitters/cname.ts
@@ -0,0 +1,25 @@
+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"]
+ }
+})
diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
new file mode 100644
index 0000000..8ee8a9a
--- /dev/null
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -0,0 +1,72 @@
+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: () => [],
+ }
+}
diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx
index 039b5cc..b6ded54 100644
--- a/quartz/plugins/emitters/contentPage.tsx
+++ b/quartz/plugins/emitters/contentPage.tsx
@@ -1,8 +1,6 @@
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"
@@ -12,7 +10,10 @@
interface Options {
head: QuartzComponent
header: QuartzComponent[],
- body: QuartzComponent[]
+ body: QuartzComponent[],
+ left: QuartzComponent[],
+ right: QuartzComponent[],
+ footer: QuartzComponent[],
}
export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
@@ -29,7 +30,7 @@
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) {
@@ -53,7 +54,7 @@
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} />)}
diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts
index ecf3d1d..971bf19 100644
--- a/quartz/plugins/emitters/index.ts
+++ b/quartz/plugins/emitters/index.ts
@@ -1 +1,4 @@
export { ContentPage } from './contentPage'
+export { ContentIndex } from './contentIndex'
+export { AliasRedirects } from './aliases'
+export { CNAME } from './cname'
diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts
index ac386c9..c67e41d 100644
--- a/quartz/plugins/types.ts
+++ b/quartz/plugins/types.ts
@@ -28,13 +28,13 @@
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
}
diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts
index 3407de2..e1438fa 100644
--- a/quartz/processors/emit.ts
+++ b/quartz/processors/emit.ts
@@ -25,7 +25,7 @@
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) {
@@ -42,24 +42,25 @@
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)}`)
}
}
--
Gitblit v1.10.0