Jacky Zhao
2023-06-17 917d5791acd6361c691902b445bdc4f7129ba3fc
modern toc tweaks
4 files added
13 files modified
376 ■■■■ changed files
index.d.ts 15 ●●●●● patch | view | raw | blame | history
quartz.config.ts 20 ●●●● patch | view | raw | blame | history
quartz/build.ts 1 ●●●● patch | view | raw | blame | history
quartz/components/Body.tsx 1 ●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 75 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/clipboard.inline.ts 3 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/spa.inline.ts 9 ●●●●● patch | view | raw | blame | history
quartz/components/styles/legacyToc.scss 27 ●●●●● patch | view | raw | blame | history
quartz/components/styles/toc.scss 22 ●●●● patch | view | raw | blame | history
quartz/path.ts 22 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/aliases.ts 53 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/cname.ts 25 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentIndex.ts 72 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentPage.tsx 13 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/index.ts 3 ●●●●● patch | view | raw | blame | history
quartz/plugins/types.ts 4 ●●●● patch | view | raw | blame | history
quartz/processors/emit.ts 11 ●●●● patch | view | raw | blame | history
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;
  }
}
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
    ]
  },
}
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,
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"
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
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 =
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
}
quartz/components/styles/legacyToc.scss
New 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});
    }
  }
}
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});
    }
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 "."
  }
quartz/plugins/emitters/aliases.ts
New 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
  }
})
quartz/plugins/emitters/cname.ts
New 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"]
  }
})
quartz/plugins/emitters/contentIndex.ts
New 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: () => [],
  }
}
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} />)}
quartz/plugins/emitters/index.ts
@@ -1 +1,4 @@
export { ContentPage } from './contentPage'
export { ContentIndex } from './contentIndex'
export { AliasRedirects } from './aliases'
export { CNAME } from './cname'
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
}
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)}`)
    }
  }