modern toc tweaks
This commit is contained in:
parent
9d2024b11c
commit
917d5791ac
15
index.d.ts
vendored
15
index.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ const config: QuartzConfig = {
|
||||
highlight: 'rgba(143, 159, 169, 0.15)',
|
||||
},
|
||||
darkMode: {
|
||||
light: '#1e1e21',
|
||||
light: '#161618',
|
||||
lightgray: '#292629',
|
||||
gray: '#343434',
|
||||
darkgray: '#d4d4d4',
|
||||
@ -41,7 +41,7 @@ const config: QuartzConfig = {
|
||||
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 @@ const config: QuartzConfig = {
|
||||
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
|
||||
]
|
||||
},
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ export default async function buildQuartz(argv: Argv, version: string) {
|
||||
|
||||
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,
|
||||
|
@ -1,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import clipboardScript from './scripts/clipboard.inline'
|
||||
import clipboardStyle from './styles/clipboard.scss'
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
@ -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
|
||||
|
@ -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 =
|
||||
|
@ -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 }
|
||||
}
|
||||
|
||||
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 @@ async function navigate(url: URL, isBack: boolean = false) {
|
||||
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
|
||||
}
|
||||
|
||||
|
27
quartz/components/styles/legacyToc.scss
Normal file
27
quartz/components/styles/legacyToc.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
@ -2,24 +2,36 @@ details.toc {
|
||||
& 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});
|
||||
}
|
||||
|
@ -5,6 +5,21 @@ function slugSegment(s: string): string {
|
||||
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 @@ export function slugify(s: string): string {
|
||||
|
||||
// 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 "."
|
||||
}
|
||||
|
||||
|
53
quartz/plugins/emitters/aliases.ts
Normal file
53
quartz/plugins/emitters/aliases.ts
Normal 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
|
||||
}
|
||||
})
|
25
quartz/plugins/emitters/cname.ts
Normal file
25
quartz/plugins/emitters/cname.ts
Normal 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"]
|
||||
}
|
||||
})
|
72
quartz/plugins/emitters/contentIndex.ts
Normal file
72
quartz/plugins/emitters/contentIndex.ts
Normal 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: () => [],
|
||||
}
|
||||
}
|
@ -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 @@ import BodyConstructor from "../../components/Body"
|
||||
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 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
|
||||
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 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
|
||||
|
||||
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} />)}
|
||||
|
@ -1 +1,4 @@
|
||||
export { ContentPage } from './contentPage'
|
||||
export { ContentIndex } from './contentIndex'
|
||||
export { AliasRedirects } from './aliases'
|
||||
export { CNAME } from './cname'
|
||||
|
@ -28,13 +28,13 @@ export type QuartzFilterPluginInstance = {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,7 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
|
||||
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 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user