From 3c5ecbaaf4912db8583de88641dee6788d3f07f7 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 9 Jun 2023 23:06:02 -0700 Subject: [PATCH] toc --- quartz.config.ts | 46 +++++--------- quartz/components/ArticleTitle.tsx | 11 ++++ quartz/components/Body.tsx | 7 +-- quartz/components/Content.tsx | 9 +++ quartz/components/Header.tsx | 17 ++++- quartz/components/ReadingTime.tsx | 20 ++++++ quartz/components/TableOfContents.tsx | 24 ++++++++ quartz/components/index.ts | 19 ++++++ quartz/components/styles/header.scss | 10 --- quartz/components/styles/toc.scss | 27 ++++++++ quartz/components/types.ts | 3 + quartz/path.ts | 2 +- quartz/plugins/emitters/contentPage.tsx | 19 +++--- quartz/plugins/transformers/description.ts | 2 +- quartz/plugins/transformers/frontmatter.ts | 2 +- quartz/plugins/transformers/gfm.ts | 2 +- quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/lastmod.ts | 2 +- quartz/plugins/transformers/links.ts | 2 +- quartz/plugins/transformers/ofm.ts | 2 +- quartz/plugins/transformers/toc.ts | 72 ++++++++++++++++++++++ 21 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 quartz/components/ArticleTitle.tsx create mode 100644 quartz/components/Content.tsx create mode 100644 quartz/components/ReadingTime.tsx create mode 100644 quartz/components/TableOfContents.tsx create mode 100644 quartz/components/index.ts delete mode 100644 quartz/components/styles/header.scss create mode 100644 quartz/components/styles/toc.scss create mode 100644 quartz/plugins/transformers/toc.ts diff --git a/quartz.config.ts b/quartz.config.ts index 65539a84..3a1d4334 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -1,21 +1,6 @@ import { QuartzConfig } from "./quartz/cfg" -import Body from "./quartz/components/Body" -import Darkmode from "./quartz/components/Darkmode" -import Head from "./quartz/components/Head" -import PageTitle from "./quartz/components/PageTitle" -import Spacer from "./quartz/components/Spacer" -import { - ContentPage, - CreatedModifiedDate, - Description, - FrontMatter, - GitHubFlavoredMarkdown, - Katex, - ObsidianFlavoredMarkdown, - RemoveDrafts, - ResolveLinks, - SyntaxHighlighting -} from "./quartz/plugins" +import * as Component from "./quartz/components" +import * as Plugin from "./quartz/plugins" const config: QuartzConfig = { configuration: { @@ -54,25 +39,26 @@ const config: QuartzConfig = { }, plugins: { transformers: [ - new FrontMatter(), - new Katex(), - new Description(), - new CreatedModifiedDate({ + new Plugin.FrontMatter(), + new Plugin.Description(), + new Plugin.TableOfContents({ showByDefault: true }), + new Plugin.CreatedModifiedDate({ priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower }), - new SyntaxHighlighting(), - new GitHubFlavoredMarkdown(), - new ObsidianFlavoredMarkdown(), - new ResolveLinks(), + new Plugin.GitHubFlavoredMarkdown(), + new Plugin.ObsidianFlavoredMarkdown(), + new Plugin.ResolveLinks(), + new Plugin.SyntaxHighlighting(), + new Plugin.Katex(), ], filters: [ - new RemoveDrafts() + new Plugin.RemoveDrafts() ], emitters: [ - new ContentPage({ - head: Head, - header: [PageTitle, Spacer, Darkmode], - body: Body + new Plugin.ContentPage({ + head: Component.Head, + header: [Component.PageTitle, Component.Spacer, Component.Darkmode], + body: [Component.ArticleTitle, Component.ReadingTime, Component.TableOfContents, Component.Content] }) ] }, diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx new file mode 100644 index 00000000..02725c67 --- /dev/null +++ b/quartz/components/ArticleTitle.tsx @@ -0,0 +1,11 @@ +import { QuartzComponentProps } from "./types" + +export default function ArticleTitle({ fileData }: QuartzComponentProps) { + const title = fileData.frontmatter?.title + const displayTitle = fileData.slug === "index" ? undefined : title + if (displayTitle) { + return

{displayTitle}

+ } else { + return null + } +} diff --git a/quartz/components/Body.tsx b/quartz/components/Body.tsx index 92e66828..b8ad34b6 100644 --- a/quartz/components/Body.tsx +++ b/quartz/components/Body.tsx @@ -2,13 +2,8 @@ import clipboardScript from './scripts/clipboard.inline' import clipboardStyle from './styles/clipboard.scss' import { QuartzComponentProps } from "./types" -export default function Body({ fileData, children }: QuartzComponentProps) { - const title = fileData.frontmatter?.title - const displayTitle = fileData.slug === "index" ? undefined : title +export default function Body({ children }: QuartzComponentProps) { return
-
- {displayTitle &&

{displayTitle}

} -
{children}
} diff --git a/quartz/components/Content.tsx b/quartz/components/Content.tsx new file mode 100644 index 00000000..c010f2a8 --- /dev/null +++ b/quartz/components/Content.tsx @@ -0,0 +1,9 @@ +import { QuartzComponentProps } from "./types" +import { Fragment, jsx, jsxs } from 'preact/jsx-runtime' +import { toJsxRuntime } from "hast-util-to-jsx-runtime" + +export default function Content({ tree }: QuartzComponentProps) { + // @ts-ignore (preact makes it angry) + const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' }) + return content +} diff --git a/quartz/components/Header.tsx b/quartz/components/Header.tsx index 8eb2d70d..8b068630 100644 --- a/quartz/components/Header.tsx +++ b/quartz/components/Header.tsx @@ -1,4 +1,3 @@ -import style from './styles/header.scss' import { QuartzComponentProps } from "./types" export default function Header({ children }: QuartzComponentProps) { @@ -7,4 +6,18 @@ export default function Header({ children }: QuartzComponentProps) { } -Header.css = style +Header.css = ` +header { + display: flex; + flex-direction: row; + align-items: center; + margin: 1em 0 2em 0; + & > h1 { + } +} + +header > h1 { + margin: 0; + flex: auto; +} +` diff --git a/quartz/components/ReadingTime.tsx b/quartz/components/ReadingTime.tsx new file mode 100644 index 00000000..39110f99 --- /dev/null +++ b/quartz/components/ReadingTime.tsx @@ -0,0 +1,20 @@ +import { QuartzComponentProps } from "./types" +import readingTime from "reading-time" + +export default function ReadingTime({ fileData }: QuartzComponentProps) { + const text = fileData.text + const isHomePage = fileData.slug === "index" + if (text && !isHomePage) { + const { text: timeTaken, words } = readingTime(text) + return

{words} words, {timeTaken}

+ } else { + return null + } +} + +ReadingTime.css = ` +.reading-time { + margin-top: 0; + opacity: 0.5; +} +` diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx new file mode 100644 index 00000000..8192da42 --- /dev/null +++ b/quartz/components/TableOfContents.tsx @@ -0,0 +1,24 @@ +import { QuartzComponentProps } from "./types" +import style from "./styles/toc.scss" + +export default function TableOfContents({ fileData, position }: QuartzComponentProps) { + if (!fileData.toc) { + return null + } + + if (position === 'body') { + // TODO: animate this + return
+

Table of Contents

+ +
+ } else if (position === 'sidebar') { + // TODO + } +} + +TableOfContents.css = style diff --git a/quartz/components/index.ts b/quartz/components/index.ts new file mode 100644 index 00000000..5fde7c3e --- /dev/null +++ b/quartz/components/index.ts @@ -0,0 +1,19 @@ +import ArticleTitle from "./ArticleTitle" +import Content from "./Content" +import Darkmode from "./Darkmode" +import Head from "./Head" +import PageTitle from "./PageTitle" +import ReadingTime from "./ReadingTime" +import Spacer from "./Spacer" +import TableOfContents from "./TableOfContents" + +export { + ArticleTitle, + Content, + Darkmode, + Head, + PageTitle, + ReadingTime, + Spacer, + TableOfContents +} diff --git a/quartz/components/styles/header.scss b/quartz/components/styles/header.scss deleted file mode 100644 index c3ea4878..00000000 --- a/quartz/components/styles/header.scss +++ /dev/null @@ -1,10 +0,0 @@ -header { - display: flex; - flex-direction: row; - align-items: center; - margin: 1em 0 2em 0; - & > h1 { - margin: 0; - flex: auto; - } -} diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss new file mode 100644 index 00000000..33b9cca3 --- /dev/null +++ b/quartz/components/styles/toc.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/types.ts b/quartz/components/types.ts index 8d7a79c1..93f6a4bf 100644 --- a/quartz/components/types.ts +++ b/quartz/components/types.ts @@ -2,12 +2,15 @@ import { ComponentType, JSX } from "preact" import { StaticResources } from "../resources" import { QuartzPluginData } from "../plugins/vfile" import { GlobalConfiguration } from "../cfg" +import { Node } from "hast" export type QuartzComponentProps = { externalResources: StaticResources fileData: QuartzPluginData cfg: GlobalConfiguration children: QuartzComponent[] | JSX.Element[] + tree: Node + position?: 'sidebar' | 'header' | 'body' } export type QuartzComponent = ComponentType & { diff --git a/quartz/path.ts b/quartz/path.ts index aa3870b9..bece7704 100644 --- a/quartz/path.ts +++ b/quartz/path.ts @@ -1,7 +1,7 @@ import path from 'path' import SlugAnchor from 'github-slugger' -const slugAnchor = new SlugAnchor() +export const slugAnchor = new SlugAnchor() function slugSegment(s: string): string { return s.replace(/\s/g, '-') diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 2ab914cd..d44b709d 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -1,19 +1,18 @@ -import { toJsxRuntime } from "hast-util-to-jsx-runtime" import { StaticResources } from "../../resources" import { EmitCallback, QuartzEmitterPlugin } from "../types" import { ProcessedContent } from "../vfile" -import { Fragment, jsx, jsxs } from 'preact/jsx-runtime' import { render } from "preact-render-to-string" import { GlobalConfiguration } from "../../cfg" import { QuartzComponent } from "../../components/types" import { resolveToRoot } from "../../path" import Header from "../../components/Header" import { QuartzComponentProps } from "../../components/types" +import Body from "../../components/Body" interface Options { head: QuartzComponent header: QuartzComponent[], - body: QuartzComponent + body: QuartzComponent[] } export class ContentPage extends QuartzEmitterPlugin { @@ -26,17 +25,14 @@ export class ContentPage extends QuartzEmitterPlugin { } getQuartzComponents(): QuartzComponent[] { - return [this.opts.head, Header, ...this.opts.header, this.opts.body] + return [this.opts.head, Header, ...this.opts.header, ...this.opts.body] } async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise { const fps: string[] = [] - const { head: Head, header, body: Body } = this.opts + const { head: Head, header, body } = this.opts for (const [tree, file] of content) { - // @ts-ignore (preact makes it angry) - const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' }) - const baseDir = resolveToRoot(file.data.slug!) const pageResources: StaticResources = { css: [baseDir + "/index.css", ...resources.css], @@ -51,7 +47,8 @@ export class ContentPage extends QuartzEmitterPlugin { fileData: file.data, externalResources: pageResources, cfg, - children: [content] + children: [], + tree } const doc = @@ -59,10 +56,10 @@ export class ContentPage extends QuartzEmitterPlugin {
- {header.map(HeaderComponent => )} + {header.map(HeaderComponent => )}
- {content} + {body.map(BodyComponent => )}
diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index fa597993..b24dd1c1 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -15,7 +15,7 @@ export class Description extends QuartzTransformerPlugin { name = "Description" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, ...opts } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 778faac8..0baec9e7 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -17,7 +17,7 @@ export class FrontMatter extends QuartzTransformerPlugin { name = "FrontMatter" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, ...opts } } diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts index dd6bdec3..72f98708 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/gfm.ts @@ -19,7 +19,7 @@ export class GitHubFlavoredMarkdown extends QuartzTransformerPlugin { name = "GitHubFlavoredMarkdown" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, ...opts } } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 492a9882..51aaa341 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -6,3 +6,4 @@ export { Description } from './description' export { ResolveLinks } from './links' export { ObsidianFlavoredMarkdown } from './ofm' export { SyntaxHighlighting } from './syntax' +export { TableOfContents } from './toc' diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 95b54556..ef33afea 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -16,7 +16,7 @@ export class CreatedModifiedDate extends QuartzTransformerPlugin { name = "CreatedModifiedDate" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index d8248a0f..4bcbe827 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -21,7 +21,7 @@ export class ResolveLinks extends QuartzTransformerPlugin { name = "LinkProcessing" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, ...opts } } diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 691a1329..23ed37c9 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -93,7 +93,7 @@ export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin { name = "ObsidianFlavoredMarkdown" opts: Options - constructor(opts?: Options) { + constructor(opts?: Partial) { super() this.opts = { ...defaultOptions, ...opts } } diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts new file mode 100644 index 00000000..863e3a17 --- /dev/null +++ b/quartz/plugins/transformers/toc.ts @@ -0,0 +1,72 @@ +import { PluggableList } from "unified" +import { QuartzTransformerPlugin } from "../types" +import { Root } from "mdast" +import { visit } from "unist-util-visit" +import { toString } from "mdast-util-to-string" +import { slugAnchor } from "../../path" + +export interface Options { + maxDepth: 1 | 2 | 3 | 4 | 5 | 6, + minEntries: 1, + showByDefault: boolean +} + +const defaultOptions: Options = { + maxDepth: 3, + minEntries: 1, + showByDefault: true, +} + +interface TocEntry { + depth: number, + text: string, + slug: string +} + +export class TableOfContents extends QuartzTransformerPlugin { + name = "TableOfContents" + opts: Options + + constructor(opts?: Partial) { + super() + this.opts = { ...defaultOptions, ...opts } + } + + markdownPlugins(): PluggableList { + return [() => { + return async (tree: Root, file) => { + const display = file.data.frontmatter?.enableToc ?? this.opts.showByDefault + if (display) { + const toc: TocEntry[] = [] + let highestDepth: number = this.opts.maxDepth + visit(tree, 'heading', (node) => { + if (node.depth <= this.opts.maxDepth) { + const text = toString(node) + highestDepth = Math.min(highestDepth, node.depth) + toc.push({ + depth: node.depth, + text, + slug: slugAnchor.slug(text) + }) + } + }) + + if (toc.length > this.opts.minEntries) { + file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth })) + } + } + } + }] + } + + htmlPlugins(): PluggableList { + return [] + } +} + +declare module 'vfile' { + interface DataMap { + toc: TocEntry[] + } +} +