diff --git a/index.d.ts b/index.d.ts index a74b5f59..ec4d32aa 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(type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void): void; + dispatchEvent(ev: CustomEventMap[K]): void; + } +} diff --git a/quartz.config.ts b/quartz.config.ts index 5868449e..0c77c901 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -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 ] }, } diff --git a/quartz/build.ts b/quartz/build.ts index 60a1a516..b96bf01c 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -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, diff --git a/quartz/components/Body.tsx b/quartz/components/Body.tsx index 0130828d..f10cf3ac 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 19f26ef4..afb83887 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) => { 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
-

Table of Contents

- -
+ function TableOfContents({ fileData }: QuartzComponentProps) { + if (!fileData.toc) { + return null } - TableOfContents.css = style - return TableOfContents + return
+

Table of Contents

+ +
} + + 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 efddfa6f..8d0758a7 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 = '' const svgCheck = diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 2063a15c..da347009 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -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 } diff --git a/quartz/components/styles/legacyToc.scss b/quartz/components/styles/legacyToc.scss new file mode 100644 index 00000000..33b9cca3 --- /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 33b9cca3..3003f40f 100644 --- a/quartz/components/styles/toc.scss +++ b/quartz/components/styles/toc.scss @@ -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}); } diff --git a/quartz/path.ts b/quartz/path.ts index 87f1a9d6..4efd7480 100644 --- a/quartz/path.ts +++ b/quartz/path.ts @@ -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 "." } diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts new file mode 100644 index 00000000..c9a019ba --- /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 { + 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: ` + + + + ${ogSlug} + + + + + + + `, + 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 00000000..c783dfb6 --- /dev/null +++ b/quartz/plugins/emitters/cname.ts @@ -0,0 +1,25 @@ +import { QuartzEmitterPlugin } from "../types" + +interface Options { + domain: string +} + +export const CNAME: QuartzEmitterPlugin = (opts?: Options) => ({ + name: "CNAME", + getQuartzComponents() { + return [] + }, + async emit(_contentFolder, _cfg, _content, _resources, emit): Promise { + 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 00000000..8ee8a9ac --- /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 + +export const ContentIndex: QuartzEmitterPlugin = (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 = 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 039b5ccc..b6ded54e 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 @@ 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 = (opts) => { @@ -29,7 +30,7 @@ export const ContentPage: QuartzEmitterPlugin = (opts) => { getQuartzComponents() { return [opts.head, Header, ...opts.header, ...opts.body] }, - async emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emit: EmitCallback): Promise { + async emit(_contentDir, cfg, content, resources, emit): Promise { const fps: string[] = [] for (const [tree, file] of content) { @@ -53,7 +54,7 @@ export const ContentPage: QuartzEmitterPlugin = (opts) => { const doc = - +
{header.map(HeaderComponent => )} diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index ecf3d1d3..971bf194 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 ac386c9f..c67e41da 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -28,13 +28,13 @@ export type QuartzFilterPluginInstance = { export type QuartzEmitterPlugin = (opts?: Options) => QuartzEmitterPluginInstance export type QuartzEmitterPluginInstance = { name: string - emit(cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise + emit(contentDir: string, cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise 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 3407de21..e1438fae 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -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)}`) } }