modern toc tweaks

This commit is contained in:
Jacky Zhao 2023-06-16 19:41:59 -07:00
parent 9c6046a1f8
commit 09b5f4b10a
17 changed files with 318 additions and 58 deletions

13
index.d.ts vendored
View File

@ -2,3 +2,16 @@ declare module '*.scss' {
const content: string const content: string
export = content 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;
}
}

View File

@ -25,7 +25,7 @@ const config: QuartzConfig = {
highlight: 'rgba(143, 159, 169, 0.15)', highlight: 'rgba(143, 159, 169, 0.15)',
}, },
darkMode: { darkMode: {
light: '#1e1e21', light: '#161618',
lightgray: '#292629', lightgray: '#292629',
gray: '#343434', gray: '#343434',
darkgray: '#d4d4d4', darkgray: '#d4d4d4',
@ -41,7 +41,7 @@ const config: QuartzConfig = {
transformers: [ transformers: [
Plugin.FrontMatter(), Plugin.FrontMatter(),
Plugin.Description(), Plugin.Description(),
Plugin.TableOfContents({ showByDefault: true }), Plugin.TableOfContents(),
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
}), }),
@ -55,11 +55,23 @@ const config: QuartzConfig = {
Plugin.RemoveDrafts() Plugin.RemoveDrafts()
], ],
emitters: [ emitters: [
Plugin.AliasRedirects(),
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.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
] ]
}, },
} }

View File

@ -57,6 +57,7 @@ export default async function buildQuartz(argv: Argv, version: string) {
if (argv.serve) { if (argv.serve) {
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
console.log(chalk.grey(`[req] ${req.url}`))
return serveHandler(req, res, { return serveHandler(req, res, {
public: output, public: output,
directoryListing: false, directoryListing: false,

View File

@ -1,3 +1,4 @@
// @ts-ignore
import clipboardScript from './scripts/clipboard.inline' import clipboardScript from './scripts/clipboard.inline'
import clipboardStyle from './styles/clipboard.scss' import clipboardStyle from './styles/clipboard.scss'
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"

View File

@ -1,38 +1,65 @@
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 { interface Options {
layout: 'modern' | 'quartz-3' layout: 'modern' | 'legacy'
} }
const defaultOptions: Options = { const defaultOptions: Options = {
layout: 'quartz-3' layout: 'modern'
} }
export default ((opts?: Partial<Options>) => { export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout const layout = opts?.layout ?? defaultOptions.layout
if (layout === "modern") { function TableOfContents({ fileData }: QuartzComponentProps) {
return function() { if (!fileData.toc) {
return null // TODO (make this look like nextra) return null
}
} 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>
} }
TableOfContents.css = style return <details class="toc" open>
return TableOfContents <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 }) satisfies QuartzComponentConstructor

View File

@ -1,6 +1,3 @@
const description = "Initialize copy for codeblocks"
export default description
const svgCopy = 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>' '<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 = const svgCheck =

View File

@ -29,6 +29,11 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined
return { url: new URL(href), scroll: 'routerNoscroll' in a.dataset ? false : undefined } 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 let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) { async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser() p = p || new DOMParser()
@ -64,9 +69,7 @@ async function navigate(url: URL, isBack: boolean = false) {
const elementsToAdd = html.head.querySelectorAll(':not([spa-preserve])') const elementsToAdd = html.head.querySelectorAll(':not([spa-preserve])')
elementsToAdd.forEach(el => document.head.appendChild(el)) elementsToAdd.forEach(el => document.head.appendChild(el))
if (!document.activeElement?.closest('[data-persist]')) { notifyNav(document.body.dataset.slug!)
document.body.focus()
}
delete announcer.dataset.persist delete announcer.dataset.persist
} }

View File

@ -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});
}
}
}

View File

@ -2,24 +2,36 @@ details.toc {
& summary { & summary {
cursor: pointer; cursor: pointer;
&::marker { list-style: none;
color: var(--dark); &::marker, &::-webkit-details-marker {
display: none;
} }
& > * { & > * {
padding-left: 0.25rem;
display: inline-block; display: inline-block;
margin: 0; margin: 0;
} }
& > h3 {
font-size: 1rem;
}
} }
& ul { & ul {
list-style: none; list-style: none;
margin: 0.5rem 1.25rem; margin: 0.5rem 0;
padding: 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} { & .depth-#{$i} {
padding-left: calc(1rem * #{$i}); padding-left: calc(1rem * #{$i});
} }

View File

@ -5,6 +5,21 @@ function slugSegment(s: string): string {
return s.replace(/\s/g, '-') 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 { export function slugify(s: string): string {
const [fp, anchor] = s.split("#", 2) const [fp, anchor] = s.split("#", 2)
const sluggedAnchor = anchor === undefined ? "" : "#" + slugAnchor(anchor) const sluggedAnchor = anchor === undefined ? "" : "#" + slugAnchor(anchor)
@ -19,12 +34,9 @@ export function slugify(s: string): string {
// resolve /a/b/c to ../../ // resolve /a/b/c to ../../
export function resolveToRoot(slug: string): string { export function resolveToRoot(slug: string): string {
let fp = slug let fp = trimPathSuffix(slug)
if (fp.endsWith("index")) {
fp = fp.slice(0, -"index".length)
}
if (fp === "") { if (fp === "./") {
return "." return "."
} }

View File

@ -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
}
})

View File

@ -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"]
}
})

View File

@ -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: () => [],
}
}

View File

@ -1,8 +1,6 @@
import { JSResourceToScriptElement, StaticResources } from "../../resources" import { JSResourceToScriptElement, StaticResources } from "../../resources"
import { EmitCallback, QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { ProcessedContent } from "../vfile"
import { render } from "preact-render-to-string" import { render } from "preact-render-to-string"
import { GlobalConfiguration } from "../../cfg"
import { QuartzComponent } from "../../components/types" import { QuartzComponent } from "../../components/types"
import { resolveToRoot } from "../../path" import { resolveToRoot } from "../../path"
import HeaderConstructor from "../../components/Header" import HeaderConstructor from "../../components/Header"
@ -12,7 +10,10 @@ import BodyConstructor from "../../components/Body"
interface Options { interface Options {
head: QuartzComponent head: QuartzComponent
header: QuartzComponent[], header: QuartzComponent[],
body: QuartzComponent[] body: QuartzComponent[],
left: QuartzComponent[],
right: QuartzComponent[],
footer: QuartzComponent[],
} }
export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => { export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
@ -29,7 +30,7 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
getQuartzComponents() { getQuartzComponents() {
return [opts.head, Header, ...opts.header, ...opts.body] 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[] = [] const fps: string[] = []
for (const [tree, file] of content) { for (const [tree, file] of content) {
@ -53,7 +54,7 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
const doc = <html> const doc = <html>
<Head {...componentData} /> <Head {...componentData} />
<body> <body data-slug={file.data.slug}>
<div id="quartz-root" class="page"> <div id="quartz-root" class="page">
<Header {...componentData} > <Header {...componentData} >
{header.map(HeaderComponent => <HeaderComponent {...componentData} />)} {header.map(HeaderComponent => <HeaderComponent {...componentData} />)}

View File

@ -1 +1,4 @@
export { ContentPage } from './contentPage' export { ContentPage } from './contentPage'
export { ContentIndex } from './contentIndex'
export { AliasRedirects } from './aliases'
export { CNAME } from './cname'

View File

@ -28,13 +28,13 @@ export type QuartzFilterPluginInstance = {
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = { export type QuartzEmitterPluginInstance = {
name: string 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[] getQuartzComponents(): QuartzComponent[]
} }
export interface EmitOptions { export interface EmitOptions {
slug: string slug: string
ext: `.${string}` ext: `.${string}` | ""
content: string content: string
} }

View File

@ -25,7 +25,7 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
let emittedFiles = 0 let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) { for (const emitter of cfg.plugins.emitters) {
try { 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 emittedFiles += emitted.length
if (verbose) { if (verbose) {
@ -42,24 +42,25 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
const staticPath = path.join(QUARTZ, "static") const staticPath = path.join(QUARTZ, "static")
await fs.promises.cp(staticPath, path.join(output, "static"), { recursive: true }) await fs.promises.cp(staticPath, path.join(output, "static"), { recursive: true })
if (verbose) { 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 // 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("**", { for await (const fp of globbyStream("**", {
ignore: ["**/*.md"], ignore: ["**/*.md"],
cwd: contentFolder, cwd: contentFolder,
})) { })) {
const ext = path.extname(fp as string) const ext = path.extname(fp as string)
const src = path.join(contentFolder, 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) const dir = path.dirname(dest)
await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
await fs.promises.copyFile(src, dest) await fs.promises.copyFile(src, dest)
emittedFiles += 1 emittedFiles += 1
if (verbose) { if (verbose) {
console.log(`[emit:Assets] ${dest}`) console.log(`[emit:Assets] ${path.join("assets", name)}`)
} }
} }