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