From c7cd941e5f8c36e9d19fa44438186836bcd19fcb Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 2 Sep 2023 18:07:26 -0700 Subject: [PATCH 01/26] feat: pluralize things in lists --- quartz/components/pages/FolderContent.tsx | 3 ++- quartz/components/pages/TagContent.tsx | 5 +++-- quartz/util/lang.ts | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 quartz/util/lang.ts diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index dc076c4a1..a766d4b0b 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -7,6 +7,7 @@ import style from "../styles/listPage.scss" import { PageList } from "../PageList" import { _stripSlashes, simplifySlug } from "../../util/path" import { Root } from "hast" +import { pluralize } from "../../util/lang" function FolderContent(props: QuartzComponentProps) { const { tree, fileData, allFiles } = props @@ -36,7 +37,7 @@ function FolderContent(props: QuartzComponentProps) {

{content}

-

{allPagesInFolder.length} items under this folder.

+

{pluralize(allPagesInFolder.length, "item")} under this folder.

diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx index fb72e284b..9907e3fc3 100644 --- a/quartz/components/pages/TagContent.tsx +++ b/quartz/components/pages/TagContent.tsx @@ -6,6 +6,7 @@ import { PageList } from "../PageList" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { QuartzPluginData } from "../../plugins/vfile" import { Root } from "hast" +import { pluralize } from "../../util/lang" const numPages = 10 function TagContent(props: QuartzComponentProps) { @@ -60,7 +61,7 @@ function TagContent(props: QuartzComponentProps) { {content &&

{content}

}

- {pages.length} items with this tag.{" "} + {pluralize(pages.length, "item")} with this tag.{" "} {pages.length > numPages && `Showing first ${numPages}.`}

@@ -80,7 +81,7 @@ function TagContent(props: QuartzComponentProps) { return (
{content}
-

{pages.length} items with this tag.

+

{pluralize(pages.length, "item")} with this tag.

diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts new file mode 100644 index 000000000..eb03a2436 --- /dev/null +++ b/quartz/util/lang.ts @@ -0,0 +1,7 @@ +export function pluralize(count: number, s: string): string { + if (count === 1) { + return `1 ${s}` + } else { + return `${count} ${s}s` + } +} From f257e2a948c04698374c25adf05222be070d9ac9 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:06:05 +0200 Subject: [PATCH 02/26] fix: clipboard button visible in search (#445) --- quartz/components/styles/clipboard.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/quartz/components/styles/clipboard.scss b/quartz/components/styles/clipboard.scss index 1702a7bb4..a585c7b52 100644 --- a/quartz/components/styles/clipboard.scss +++ b/quartz/components/styles/clipboard.scss @@ -10,7 +10,6 @@ background-color: var(--light); border: 1px solid; border-radius: 5px; - z-index: 1; opacity: 0; transition: 0.2s; From 50da33ea4d0ce34536900b84c74e575766411a4c Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:32:46 +0200 Subject: [PATCH 03/26] feat(search): add arrow key navigation (#442) * feat(search): add arrow navigation * chore: format * refactor: simplify arrow navigation * chore: remove comment * feat: rework arrow navigation to work without state * feat: make pressing enter work with arrow navigation * fix: remove unused css class * chore: correct comment * refactor(search): use optional chaining --- quartz/components/scripts/search.inline.ts | 29 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 4b9e372bc..a1c3e6ca2 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -82,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => { const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const results = document.getElementById("results-container") + const resultCards = document.getElementsByClassName("result-card") const idDataMap = Object.keys(data) as FullSlug[] function hideSearch() { @@ -122,9 +123,31 @@ document.addEventListener("nav", async (e: unknown) => { // add "#" prefix for tag search if (searchBar) searchBar.value = "#" } else if (e.key === "Enter") { - const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null - if (anchor) { - anchor.click() + // If result has focus, navigate to that one, otherwise pick first result + if (results?.contains(document.activeElement)) { + const active = document.activeElement as HTMLInputElement + active.click() + } else { + const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null + anchor?.click() + } + } else if (e.key === "ArrowDown") { + e.preventDefault() + // When first pressing ArrowDown, results wont contain the active element, so focus first element + if (!results?.contains(document.activeElement)) { + const firstResult = resultCards[0] as HTMLInputElement | null + firstResult?.focus() + } else { + // If an element in results-container already has focus, focus next one + const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null + nextResult?.focus() + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (results?.contains(document.activeElement)) { + // If an element in results-container already has focus, focus previous one + const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null + prevResult?.focus() } } } From 49d5d56bf47e6f4d249a5cb6ea9eb309d48d2079 Mon Sep 17 00:00:00 2001 From: Adam Brangenberg Date: Mon, 4 Sep 2023 06:28:57 +0200 Subject: [PATCH 04/26] feat(analytics): Support for Umami (#449) --- quartz/cfg.ts | 4 ++++ quartz/plugins/emitters/componentResources.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 21e03016a..8371b5e2b 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -12,6 +12,10 @@ export type Analytics = provider: "google" tagId: string } + | { + provider: "umami" + websiteId: string + } export interface GlobalConfiguration { pageTitle: string diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index c52a3a20e..96db8aa81 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -96,6 +96,15 @@ function addGlobalPageResources( });`) } else if (cfg.analytics?.provider === "plausible") { componentResources.afterDOMLoaded.push(plausibleScript) + } else if (cfg.analytics?.provider === "umami") { + componentResources.afterDOMLoaded.push(` + const umamiScript = document.createElement("script") + umamiScript.src = "https://analytics.umami.is/script.js" + umamiScript["data-website-id"] = "${cfg.analytics.websiteId}" + umamiScript.async = true + + document.head.appendChild(umamiScript) + `) } if (cfg.enableSPA) { From b3bd6f7c01e87cdf15ba98fe96cfc2059dc3947a Mon Sep 17 00:00:00 2001 From: Dr Kim Foale Date: Mon, 4 Sep 2023 05:29:58 +0100 Subject: [PATCH 05/26] docs: Make it clearer that wikilinks go to paths not page titles (#448) --- docs/features/wikilinks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/wikilinks.md b/docs/features/wikilinks.md index 4d197157d..704a0d0cf 100644 --- a/docs/features/wikilinks.md +++ b/docs/features/wikilinks.md @@ -10,9 +10,9 @@ This is enabled as a part of [[Obsidian compatibility]] and can be configured an ## Syntax -- `[[Path to file]]`: produces a link to `Path to file` with the text `Path to file` -- `[[Path to file | Here's the title override]]`: produces a link to `Path to file` with the text `Here's the title override` -- `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file` +- `[[Path to file]]`: produces a link to `Path to file.md` (or `Path-to-file.md`) with the text `Path to file` +- `[[Path to file | Here's the title override]]`: produces a link to `Path to file.md` with the text `Here's the title override` +- `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file.md` > [!warning] > Currently, Quartz does not support block references or note embed syntax. From 25541d2e35f849b5f90b8afd0fcc8a5a4c819120 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 4 Sep 2023 07:36:30 +0200 Subject: [PATCH 06/26] docs: update `full-text-search.md` (#447) --- docs/features/full-text search.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/features/full-text search.md b/docs/features/full-text search.md index ce3d88f93..85ec03006 100644 --- a/docs/features/full-text search.md +++ b/docs/features/full-text search.md @@ -6,9 +6,11 @@ tags: Full-text search in Quartz is powered by [Flexsearch](https://github.com/nextapps-de/flexsearch). It's fast enough to return search results in under 10ms for Quartzs as large as half a million words. -It can be opened by either clicking on the search bar or pressing ⌘+K. The top 5 search results are shown on each query. Matching subterms are highlighted and the most relevant 30 words are excerpted. Clicking on a search result will navigate to that page. +It can be opened by either clicking on the search bar or pressing `⌘`/`ctrl` + `K`. The top 5 search results are shown on each query. Matching subterms are highlighted and the most relevant 30 words are excerpted. Clicking on a search result will navigate to that page. -This component is also keyboard accessible: Tab and Shift+Tab will cycle forward and backward through search results and Enter will navigate to the highlighted result (first result by default). +To search content by tags, you can either press `⌘`/`ctrl` + `shift` + `K` or start your query with `#` (e.g. `#components`). + +This component is also keyboard accessible: Tab and Shift+Tab will cycle forward and backward through search results and Enter will navigate to the highlighted result (first result by default). You are also able to navigate search results using `ArrowUp` and `ArrowDown`. > [!info] > Search requires the `ContentIndex` emitter plugin to be present in the [[configuration]]. @@ -17,7 +19,7 @@ This component is also keyboard accessible: Tab and Shift+Tab will cycle forward By default, it indexes every page on the site with **Markdown syntax removed**. This means link URLs for instance are not indexed. -It properly tokenizes Chinese, Korean, and Japenese characters and constructs separate indexes for the title and content, weighing title matches above content matches. +It properly tokenizes Chinese, Korean, and Japenese characters and constructs separate indexes for the title, content and tags, weighing title matches above content matches. ## Customization @@ -25,4 +27,4 @@ It properly tokenizes Chinese, Korean, and Japenese characters and constructs se - Component: `quartz/components/Search.tsx` - Style: `quartz/components/styles/search.scss` - Script: `quartz/components/scripts/search.inline.ts` - - You can edit `contextWindowWords` or `numSearchResults` to suit your needs + - You can edit `contextWindowWords`, `numSearchResults` or `numTagResults` to suit your needs From 7e1be1d5b249af57c42496ef4c2a294e68ac7c15 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 20:25:38 -0700 Subject: [PATCH 07/26] fix: dont transform external links --- quartz/plugins/transformers/links.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 26c4a3228..475a5e92e 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -54,7 +54,8 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") // don't process external links or intra-document anchors - if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { + const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")) + if (isInternal) { dest = node.properties.href = transformLink( file.data.slug!, dest, @@ -77,6 +78,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = // rewrite link internals if prettylinks is on if ( opts.prettyLinks && + isInternal && node.children.length === 1 && node.children[0].type === "text" && !node.children[0].value.startsWith("#") From c9ddec07aa1dd47a0dd1498e8f6b6aea0e919699 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:02:21 -0700 Subject: [PATCH 08/26] feat: 404 page emitter --- docs/features/OxHugo compatibility.md | 2 +- docs/features/upcoming features.md | 3 +- quartz.config.ts | 1 + quartz/components/index.ts | 4 +- quartz/components/pages/404.tsx | 12 ++++++ quartz/plugins/emitters/404.tsx | 56 +++++++++++++++++++++++++++ quartz/plugins/emitters/index.ts | 1 + 7 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 quartz/components/pages/404.tsx create mode 100644 quartz/plugins/emitters/404.tsx diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md index 7801f0c25..b25167f8d 100644 --- a/docs/features/OxHugo compatibility.md +++ b/docs/features/OxHugo compatibility.md @@ -3,7 +3,7 @@ tags: - plugin/transformer --- -[org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. +[org-roam](https://www.orgroam.com/) is a plain-text personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. Because the Markdown generated by ox-hugo is not pure Markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific Markdown. diff --git a/docs/features/upcoming features.md b/docs/features/upcoming features.md index fbfdbc947..76adda00e 100644 --- a/docs/features/upcoming features.md +++ b/docs/features/upcoming features.md @@ -4,15 +4,14 @@ draft: true ## high priority backlog +- static dead link detection - block links: https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note - note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files -- static dead link detection - docker support ## misc backlog - breadcrumbs component -- filetree component - cursor chat extension - https://giscus.app/ extension - sidenotes? https://github.com/capnfabs/paperesque diff --git a/quartz.config.ts b/quartz.config.ts index 31d5bcfea..f677a18f9 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -69,6 +69,7 @@ const config: QuartzConfig = { }), Plugin.Assets(), Plugin.Static(), + Plugin.NotFoundPage(), ], }, } diff --git a/quartz/components/index.ts b/quartz/components/index.ts index a83f078b0..10a43acb5 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,7 +1,8 @@ -import ArticleTitle from "./ArticleTitle" import Content from "./pages/Content" import TagContent from "./pages/TagContent" import FolderContent from "./pages/FolderContent" +import NotFound from "./pages/404" +import ArticleTitle from "./ArticleTitle" import Darkmode from "./Darkmode" import Head from "./Head" import PageTitle from "./PageTitle" @@ -36,4 +37,5 @@ export { DesktopOnly, MobileOnly, RecentNotes, + NotFound, } diff --git a/quartz/components/pages/404.tsx b/quartz/components/pages/404.tsx new file mode 100644 index 000000000..c276f568d --- /dev/null +++ b/quartz/components/pages/404.tsx @@ -0,0 +1,12 @@ +import { QuartzComponentConstructor } from "../types" + +function NotFound() { + return ( +
+

404

+

Either this page is private or doesn't exist.

+
+ ) +} + +export default (() => NotFound) satisfies QuartzComponentConstructor diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx new file mode 100644 index 000000000..785c873da --- /dev/null +++ b/quartz/plugins/emitters/404.tsx @@ -0,0 +1,56 @@ +import { QuartzEmitterPlugin } from "../types" +import { QuartzComponentProps } from "../../components/types" +import BodyConstructor from "../../components/Body" +import { pageResources, renderPage } from "../../components/renderPage" +import { FullPageLayout } from "../../cfg" +import { FilePath, FullSlug } from "../../util/path" +import { sharedPageComponents } from "../../../quartz.layout" +import { NotFound } from "../../components" +import { defaultProcessedContent } from "../vfile" + +export const NotFoundPage: QuartzEmitterPlugin = () => { + const opts: FullPageLayout = { + ...sharedPageComponents, + pageBody: NotFound(), + beforeBody: [], + left: [], + right: [], + } + + const { head: Head, pageBody, footer: Footer } = opts + const Body = BodyConstructor() + + return { + name: "404Page", + getQuartzComponents() { + return [Head, Body, pageBody, Footer] + }, + async emit(ctx, _content, resources, emit): Promise { + const cfg = ctx.cfg.configuration + const slug = "404" as FullSlug + const externalResources = pageResources(slug, resources) + const [tree, vfile] = defaultProcessedContent({ + slug, + text: "Not Found", + description: "Not Found", + frontmatter: { title: "Not Found", tags: [] }, + }) + const componentData: QuartzComponentProps = { + fileData: vfile.data, + externalResources, + cfg, + children: [], + tree, + allFiles: [], + } + + return [ + await emit({ + content: renderPage(slug, componentData, opts, externalResources), + slug, + ext: ".html", + }), + ] + }, + } +} diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index da95d4901..99a2c54d5 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases" export { Assets } from "./assets" export { Static } from "./static" export { ComponentResources } from "./componentResources" +export { NotFoundPage } from "./404" From b668b4c1a3e0a53e32ef9762b4f92268c7544c36 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:08:08 -0700 Subject: [PATCH 09/26] docs: correct field for ignorePatterns --- docs/features/private pages.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/private pages.md b/docs/features/private pages.md index 402e52c2c..1fd6acd22 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -12,7 +12,7 @@ There may be some notes you want to avoid publishing as a website. Quartz suppor If you'd like to only publish a select number of notes, you can instead use `Plugin.ExplicitPublish` which will filter out all notes except for any that have `publish: true` in the frontmatter. -## `ignoreFiles` +## `ignorePatterns` This is a field in `quartz.config.ts` under the main [[configuration]] which allows you to specify a list of patterns to effectively exclude from parsing all together. Any valid [glob](https://github.com/mrmlnc/fast-glob#pattern-syntax) pattern works here. @@ -24,4 +24,4 @@ Common examples include: - `**/private`: exclude any files or folders named `private` at any level of nesting > [!warning] -> Marking something as private via either a plugin or through the `ignoreFiles` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information. +> Marking something as private via either a plugin or through the `ignorePatterns` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information. From 86ccdccde1c82948a0e0ff0b563c9d5cde0fbdf2 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:31:01 -0700 Subject: [PATCH 10/26] fix: encodeuri for slugs in rss --- quartz/plugins/emitters/contentIndex.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 1c7feaea2..1d0af6d7e 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -29,7 +29,7 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${slug} + https://${base}/${encodeURIComponent(slug)} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -44,8 +44,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` ${content.title} - ${root}/${slug} - ${root}/${slug} + ${root}/${encodeURIComponent(slug)} + ${root}/${encodeURIComponent(slug)} ${content.description} ${content.date?.toUTCString()} ` From 32b65cd1f7ec9c68293b886578f78b926c4e31c4 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:47:59 -0700 Subject: [PATCH 11/26] fix: escape encoding for titles in rss --- quartz/plugins/emitters/contentIndex.ts | 11 ++++++----- quartz/plugins/transformers/description.ts | 10 +--------- quartz/util/escape.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 quartz/util/escape.ts diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 1d0af6d7e..f24ae6dc1 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,5 +1,6 @@ import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" +import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" @@ -29,7 +30,7 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${encodeURIComponent(slug)} + https://${base}/${encodeURI(slug)} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -43,9 +44,9 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const root = `https://${base}` const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${content.title} - ${root}/${encodeURIComponent(slug)} - ${root}/${encodeURIComponent(slug)} + ${escapeHTML(content.title)} + ${root}/${encodeURI(slug)} + ${root}/${encodeURI(slug)} ${content.description} ${content.date?.toUTCString()} ` @@ -56,7 +57,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { return ` - ${cfg.pageTitle} + ${escapeHTML(cfg.pageTitle)} ${root} Recent content on ${cfg.pageTitle} Quartz -- quartz.jzhao.xyz diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 08af5c788..884d5b189 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -1,6 +1,7 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" import { QuartzTransformerPlugin } from "../types" +import { escapeHTML } from "../../util/escape" export interface Options { descriptionLength: number @@ -10,15 +11,6 @@ const defaultOptions: Options = { descriptionLength: 150, } -const escapeHTML = (unsafe: string) => { - return unsafe - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") -} - export const Description: QuartzTransformerPlugin | undefined> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { diff --git a/quartz/util/escape.ts b/quartz/util/escape.ts new file mode 100644 index 000000000..197558c7d --- /dev/null +++ b/quartz/util/escape.ts @@ -0,0 +1,8 @@ +export const escapeHTML = (unsafe: string) => { + return unsafe + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} From 425592d09a8275745e140a2f3c74bb1b35e16b0e Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 22:24:15 -0700 Subject: [PATCH 12/26] fix: links to index not showing in graph (closes #450) --- quartz/build.ts | 1 + quartz/components/scripts/graph.inline.ts | 3 ++- quartz/plugins/transformers/links.ts | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/quartz/build.ts b/quartz/build.ts index 22288acc1..5752caa46 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -142,6 +142,7 @@ async function startServing( const parsedFiles = [...contentMap.values()] const filteredContent = filterContent(ctx, parsedFiles) + // TODO: we can probably traverse the link graph to figure out what's safe to delete here // instead of just deleting everything await rimraf(argv.output) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index d72b297bf..dc5c99dc1 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -47,11 +47,12 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const data = await fetchData const links: LinkData[] = [] + const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) for (const [src, details] of Object.entries(data)) { const source = simplifySlug(src as FullSlug) const outgoing = details.links ?? [] for (const dest of outgoing) { - if (dest in data) { + if (validLinks.has(dest)) { links.push({ source, target: dest }) } } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 475a5e92e..02ced158d 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -5,7 +5,6 @@ import { SimpleSlug, TransformOptions, _stripSlashes, - joinSegments, simplifySlug, splitAnchor, transformLink, From 49470d641a2249b701a9af3713e14a1f13825f2a Mon Sep 17 00:00:00 2001 From: Stefano Cecere Date: Thu, 7 Sep 2023 17:13:41 +0200 Subject: [PATCH 13/26] typo (it's draft, not drafts) (#456) --- docs/features/private pages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/private pages.md b/docs/features/private pages.md index 1fd6acd22..5c3940bc7 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -8,7 +8,7 @@ There may be some notes you want to avoid publishing as a website. Quartz suppor ## Filter Plugins -[[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the `Plugin.RemoveDrafts` plugin which filters out any note that has `drafts: true` in the frontmatter. +[[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the `Plugin.RemoveDrafts` plugin which filters out any note that has `draft: true` in the frontmatter. If you'd like to only publish a select number of notes, you can instead use `Plugin.ExplicitPublish` which will filter out all notes except for any that have `publish: true` in the frontmatter. From fde8608927055c02e7fc8780aaf213a75a1e3a62 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 8 Sep 2023 09:29:57 -0700 Subject: [PATCH 14/26] fix: more lenient date parsing for templates --- quartz/plugins/transformers/lastmod.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 507b58522..015c350a5 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -11,6 +11,11 @@ const defaultOptions: Options = { priority: ["frontmatter", "git", "filesystem"], } +function coerceDate(d: any): Date { + const dt = new Date(d) + return isNaN(dt.getTime()) ? new Date() : dt +} + type MaybeDate = undefined | string | number export const CreatedModifiedDate: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -49,9 +54,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und } file.data.dates = { - created: created ? new Date(created) : new Date(), - modified: modified ? new Date(modified) : new Date(), - published: published ? new Date(published) : new Date(), + created: coerceDate(created), + modified: coerceDate(modified), + published: coerceDate(published), } } }, From 17e5fbc0e679ac42b3ea292bf6139fac5dc525e2 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sun, 10 Sep 2023 23:07:17 -0700 Subject: [PATCH 15/26] ci: print bundleInfo --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 731395d38..8915143c4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,5 +43,5 @@ jobs: - name: Test run: npm test - - name: Ensure Quartz builds - run: npx quartz build + - name: Ensure Quartz builds, check bundle info + run: npx quartz build --bundleInfo From 4b37976c29bc957d0431a43d24b7352c3626c3b5 Mon Sep 17 00:00:00 2001 From: Oskar Manhart <52569953+oskardotglobal@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:11:42 +0200 Subject: [PATCH 16/26] feat: plugin for remark-breaks (#467) * feat: plugin for remark-breaks * fix: update package-lock.json * fix: styling Co-authored-by: Jacky Zhao * Update linebreaks.ts * Update index.ts --------- Co-authored-by: Jacky Zhao --- package-lock.json | 28 +++++++++++++++++++++++ package.json | 1 + quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/linebreaks.ts | 11 +++++++++ 4 files changed, 41 insertions(+) create mode 100644 quartz/plugins/transformers/linebreaks.ts diff --git a/package-lock.json b/package-lock.json index 9246cc992..a19d81c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "rehype-raw": "^6.1.1", "rehype-slug": "^5.1.0", "remark": "^14.0.2", + "remark-breaks": "^3.0.3", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", @@ -3810,6 +3811,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz", + "integrity": "sha512-491LcYv3gbGhhCrLoeALncQmega2xPh+m3gbsIhVsOX4sw85+ShLFPvPyibxc1Swx/6GtzxgVodq+cGa/47ULg==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-find-and-replace": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", @@ -4903,6 +4917,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.3.tgz", + "integrity": "sha512-C7VkvcUp1TPUc2eAYzsPdaUh8Xj4FSbQnYA5A9f80diApLZscTDeG7efiWP65W8hV2sEy3JuGVU0i6qr5D8Hug==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-newline-to-break": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-frontmatter": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz", diff --git a/package.json b/package.json index 6ed52d602..95c57cd8d 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "rehype-raw": "^6.1.1", "rehype-slug": "^5.1.0", "remark": "^14.0.2", + "remark-breaks": "^3.0.3", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index d9f2854c0..e340f10e7 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,3 +8,4 @@ export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" +export { HardLineBreaks } from "./linebreaks" diff --git a/quartz/plugins/transformers/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts new file mode 100644 index 000000000..a8a066fc1 --- /dev/null +++ b/quartz/plugins/transformers/linebreaks.ts @@ -0,0 +1,11 @@ +import { QuartzTransformerPlugin } from "../types" +import remarkBreaks from "remark-breaks" + +export const HardLineBreaks: QuartzTransformerPlugin = () => { + return { + name: "HardLineBreaks", + markdownPlugins() { + return [remarkBreaks] + }, + } +} From 1562d2d9adb8733a32413ea38fedcce0573b0211 Mon Sep 17 00:00:00 2001 From: hcplantern <38579760+HCPlantern@users.noreply.github.com> Date: Tue, 12 Sep 2023 14:00:21 +0800 Subject: [PATCH 17/26] fix: callout parsing (#469) --- quartz/plugins/transformers/ofm.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8c8da67bc..8b95126dd 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -69,6 +69,8 @@ const callouts = { const calloutMapping: Record = { note: "note", abstract: "abstract", + summary: "abstract", + tldr: "abstract", info: "info", todo: "todo", tip: "tip", @@ -96,7 +98,7 @@ const calloutMapping: Record = { function canonicalizeCallout(calloutName: string): keyof typeof callouts { let callout = calloutName.toLowerCase() as keyof typeof calloutMapping - return calloutMapping[callout] ?? calloutName + return calloutMapping[callout] ?? "note" } const capitalize = (s: string): string => { From 5e02b3fed1a3ffeb0e2761bf2e32920880f7788c Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 19:18:44 -0700 Subject: [PATCH 18/26] feat: rss limit (closes #459) --- quartz/plugins/emitters/contentIndex.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index f24ae6dc1..bcb1e3074 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -18,12 +18,14 @@ export type ContentDetails = { interface Options { enableSiteMap: boolean enableRSS: boolean + rssLimit?: number includeEmptyFiles: boolean } const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, + rssLimit: 10, includeEmptyFiles: true, } @@ -39,7 +41,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { +function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { const base = cfg.baseUrl ?? "" const root = `https://${base}` @@ -53,13 +55,17 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const items = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .slice(0, limit ?? idx.size) .join("") + return ` ${escapeHTML(cfg.pageTitle)} ${root} - Recent content on ${cfg.pageTitle} + ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${ + cfg.pageTitle + } Quartz -- quartz.jzhao.xyz ${items} @@ -102,7 +108,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { if (opts?.enableRSS) { emitted.push( await emit({ - content: generateRSSFeed(cfg, linkIndex), + content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), slug: "index" as FullSlug, ext: ".xml", }), From 4387044cc74dc26a805cda6f9456e31bfaa16ad8 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 21:29:57 -0700 Subject: [PATCH 19/26] fix: 404 page styling for nested pages (closes #458) --- quartz/components/Head.tsx | 8 ++++++-- quartz/components/renderPage.tsx | 9 +++++---- quartz/plugins/emitters/404.tsx | 5 ++++- quartz/plugins/emitters/contentPage.tsx | 4 ++-- quartz/plugins/emitters/folderPage.tsx | 3 ++- quartz/plugins/emitters/tagPage.tsx | 10 ++++++++-- quartz/util/path.ts | 5 ++++- 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 67f0c0245..2bf263817 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -1,4 +1,4 @@ -import { joinSegments, pathToRoot } from "../util/path" +import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" import { JSResourceToScriptElement } from "../util/resources" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" @@ -7,7 +7,11 @@ export default (() => { const title = fileData.frontmatter?.title ?? "Untitled" const description = fileData.description?.trim() ?? "No description provided" const { css, js } = externalResources - const baseDir = pathToRoot(fileData.slug!) + + const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) + const path = url.pathname as FullSlug + const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) + const iconPath = joinSegments(baseDir, "static/icon.png") const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index eb1291f45..25297f289 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,7 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { FullSlug, joinSegments, pathToRoot } from "../util/path" +import { FullSlug, RelativeURL, joinSegments } from "../util/path" interface RenderComponents { head: QuartzComponent @@ -15,9 +15,10 @@ interface RenderComponents { footer: QuartzComponent } -export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources { - const baseDir = pathToRoot(slug) - +export function pageResources( + baseDir: FullSlug | RelativeURL, + staticResources: StaticResources, +): StaticResources { const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())` diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index 785c873da..cd079a065 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -28,7 +28,10 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { async emit(ctx, _content, resources, emit): Promise { const cfg = ctx.cfg.configuration const slug = "404" as FullSlug - const externalResources = pageResources(slug, resources) + + const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) + const path = url.pathname as FullSlug + const externalResources = pageResources(path, resources) const [tree, vfile] = defaultProcessedContent({ slug, text: "Not Found", diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 0e510db89..4542446b0 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -4,7 +4,7 @@ import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { FilePath } from "../../util/path" +import { FilePath, pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" @@ -31,7 +31,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp const allFiles = content.map((c) => c[1].data) for (const [tree, file] of content) { const slug = file.data.slug! - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const componentData: QuartzComponentProps = { fileData: file.data, externalResources, diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 8d62f7bb4..8632eceb4 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -12,6 +12,7 @@ import { SimpleSlug, _stripSlashes, joinSegments, + pathToRoot, simplifySlug, } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" @@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => { for (const folder of folders) { const slug = joinSegments(folder, "index") as FullSlug - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const [tree, file] = folderDescriptions[folder] const componentData: QuartzComponentProps = { fileData: file.data, diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 54ad934f6..6afde2fca 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" -import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path" +import { + FilePath, + FullSlug, + getAllSegmentPrefixes, + joinSegments, + pathToRoot, +} from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" @@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => { for (const tag of tags) { const slug = joinSegments("tags", tag) as FullSlug - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const [tree, file] = tagDescriptions[tag] const componentData: QuartzComponentProps = { fileData: file.data, diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 1557c1bd5..154006374 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -123,7 +123,10 @@ export function slugTag(tag: string) { } export function joinSegments(...args: string[]): string { - return args.filter((segment) => segment !== "").join("/") + return args + .filter((segment) => segment !== "") + .join("/") + .replace(/\/\/+/g, "/") } export function getAllSegmentPrefixes(tags: string): string[] { From 64d6db3b3fbb076d94250c8f21f39c73618777cd Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 21:44:03 -0700 Subject: [PATCH 20/26] feat: rich html rss (closes #460) --- docs/features/RSS Feed.md | 2 ++ quartz/plugins/emitters/contentIndex.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index c519f8771..bfeb399c9 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -3,3 +3,5 @@ Quartz creates an RSS feed for all the content on your site by generating an `in ## Configuration - Remove RSS feed: set the `enableRSS` field of `Plugin.ContentIndex` in `quartz.config.ts` to be `false`. +- Change number of entries: set the `rssLimit` field of `Plugin.ContentIndex` to be the desired value. It defaults to latest 10 items. +- Use rich HTML output in RSS: set `rssFullHtml` field of `Plugin.ContentIndex` to be `true`. diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index bcb1e3074..102394cec 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,8 +1,10 @@ +import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" +import { toHtml } from "hast-util-to-html" import path from "path" export type ContentIndex = Map @@ -19,6 +21,7 @@ interface Options { enableSiteMap: boolean enableRSS: boolean rssLimit?: number + rssFullHtml: boolean includeEmptyFiles: boolean } @@ -26,6 +29,7 @@ const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, rssLimit: 10, + rssFullHtml: false, includeEmptyFiles: true, } @@ -49,7 +53,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${escapeHTML(content.title)} ${root}/${encodeURI(slug)} ${root}/${encodeURI(slug)} - ${content.description} + ${content.content} ${content.date?.toUTCString()} ` @@ -80,7 +84,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] const linkIndex: ContentIndex = new Map() - for (const [_tree, file] of content) { + for (const [tree, file] of content) { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { @@ -88,7 +92,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], - content: file.data.text ?? "", + content: opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : file.data.description ?? "", date: date, description: file.data.description ?? "", }) From 3844a911e5c0a69c285ec6e2238132c9f0d6cbec Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 22:55:50 -0700 Subject: [PATCH 21/26] feat: resolve block references in obsidian markdown --- quartz/plugins/transformers/ofm.ts | 91 +++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8b95126dd..b2f1dba30 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,6 +1,7 @@ import { PluggableList } from "unified" import { QuartzTransformerPlugin } from "../types" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" +import { Element, Literal } from 'hast' import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { slug as slugAnchor } from "github-slugger" import rehypeRaw from "rehype-raw" @@ -21,6 +22,7 @@ export interface Options { callouts: boolean mermaid: boolean parseTags: boolean + parseBlockReferences: boolean enableInHtmlEmbed: boolean } @@ -31,6 +33,7 @@ const defaultOptions: Options = { callouts: true, mermaid: true, parseTags: true, + parseBlockReferences: true, enableInHtmlEmbed: false, } @@ -121,6 +124,7 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") // (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores // (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") +const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -133,29 +137,29 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { - if (replace) { - visit(tree, "html", (node: HTML) => { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replaceAll(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - }) - } - - mdastFindReplace(tree, regex, replace) + if (replace) { + visit(tree, "html", (node: HTML) => { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace) + } else { + node.value = node.value.replaceAll(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args) + if (typeof replaceValue === "string") { + return replaceValue + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join("") + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue) + } else { + return substring + } + }) + } + }) } + + mdastFindReplace(tree, regex, replace) + } : mdastFindReplace return { @@ -353,9 +357,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin node.data = { hProperties: { ...(node.data?.hProperties ?? {}), - className: `callout ${collapse ? "is-collapsible" : ""} ${ - defaultState === "collapsed" ? "is-collapsed" : "" - }`, + className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : "" + }`, "data-callout": calloutType, "data-callout-fold": collapse, }, @@ -411,11 +414,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } }) } - return plugins }, htmlPlugins() { - return [rehypeRaw] + const plugins = [rehypeRaw] + + if (opts.parseBlockReferences) { + plugins.push(() => { + return (tree, file) => { + file.data.blocks = {} + const validTagTypes = new Set(["blockquote", "p", "li"]) + visit(tree, "element", (node, _index, _parent) => { + if (validTagTypes.has(node.tagName)) { + const last = node.children.at(-1) as Literal + if (last.value && typeof last.value === 'string') { + const matches = last.value.match(blockReferenceRegex) + if (matches && matches.length >= 1) { + last.value = last.value.slice(0, -matches[0].length) + const block = matches[0].slice(1) + node.properties = { + ...node.properties, + id: block + } + file.data.blocks![block] = node + } + } + } + }) + } + }) + } + + return plugins }, externalResources() { const js: JSResource[] = [] @@ -454,3 +484,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin }, } } + +declare module "vfile" { + interface DataMap { + blocks: Record + } +} + From bfabedc5f76b21249e0af69c1a628804cf6dc413 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 13 Sep 2023 09:43:14 -0700 Subject: [PATCH 22/26] fix dont show html in search when rssFullHtml is true (closes #474) --- quartz/plugins/emitters/contentIndex.ts | 8 ++-- quartz/plugins/transformers/ofm.ts | 56 ++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 102394cec..911173e1b 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -13,6 +13,7 @@ export type ContentDetails = { links: SimpleSlug[] tags: string[] content: string + richContent?: string date?: Date description?: string } @@ -53,7 +54,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${escapeHTML(content.title)} ${root}/${encodeURI(slug)} ${root}/${encodeURI(slug)} - ${content.content} + ${content.richContent ?? content.description} ${content.date?.toUTCString()} ` @@ -92,9 +93,10 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], - content: opts?.rssFullHtml + content: file.data.text ?? "", + richContent: opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : file.data.description ?? "", + : undefined, date: date, description: file.data.description ?? "", }) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index b2f1dba30..811d659e6 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,7 +1,7 @@ import { PluggableList } from "unified" import { QuartzTransformerPlugin } from "../types" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" -import { Element, Literal } from 'hast' +import { Element, Literal } from "hast" import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { slug as slugAnchor } from "github-slugger" import rehypeRaw from "rehype-raw" @@ -137,29 +137,29 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { - if (replace) { - visit(tree, "html", (node: HTML) => { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replaceAll(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - }) - } + if (replace) { + visit(tree, "html", (node: HTML) => { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace) + } else { + node.value = node.value.replaceAll(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args) + if (typeof replaceValue === "string") { + return replaceValue + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join("") + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue) + } else { + return substring + } + }) + } + }) + } - mdastFindReplace(tree, regex, replace) - } + mdastFindReplace(tree, regex, replace) + } : mdastFindReplace return { @@ -357,8 +357,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin node.data = { hProperties: { ...(node.data?.hProperties ?? {}), - className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : "" - }`, + className: `callout ${collapse ? "is-collapsible" : ""} ${ + defaultState === "collapsed" ? "is-collapsed" : "" + }`, "data-callout": calloutType, "data-callout-fold": collapse, }, @@ -427,14 +428,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin visit(tree, "element", (node, _index, _parent) => { if (validTagTypes.has(node.tagName)) { const last = node.children.at(-1) as Literal - if (last.value && typeof last.value === 'string') { + if (last.value && typeof last.value === "string") { const matches = last.value.match(blockReferenceRegex) if (matches && matches.length >= 1) { last.value = last.value.slice(0, -matches[0].length) const block = matches[0].slice(1) node.properties = { ...node.properties, - id: block + id: block, } file.data.blocks![block] = node } @@ -490,4 +491,3 @@ declare module "vfile" { blocks: Record } } - From 0a6e9c3f86250f124190119b18c5fe55ad468ee4 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 13 Sep 2023 11:28:53 -0700 Subject: [PATCH 23/26] feat: note transclusion (#475) * basic transclude * feat: note transclusion --- docs/features/wikilinks.md | 4 +-- docs/index.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- quartz/components/renderPage.tsx | 36 +++++++++++++++++++ quartz/plugins/transformers/links.ts | 1 + quartz/plugins/transformers/ofm.ts | 52 +++++++++++++++++++++++----- quartz/styles/base.scss | 6 ++++ 8 files changed, 91 insertions(+), 16 deletions(-) diff --git a/docs/features/wikilinks.md b/docs/features/wikilinks.md index 704a0d0cf..50bbb1bb6 100644 --- a/docs/features/wikilinks.md +++ b/docs/features/wikilinks.md @@ -13,6 +13,4 @@ This is enabled as a part of [[Obsidian compatibility]] and can be configured an - `[[Path to file]]`: produces a link to `Path to file.md` (or `Path-to-file.md`) with the text `Path to file` - `[[Path to file | Here's the title override]]`: produces a link to `Path to file.md` with the text `Here's the title override` - `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file.md` - -> [!warning] -> Currently, Quartz does not support block references or note embed syntax. +- `[[Path to file#^block-ref]]`: produces a link to the specific block `block-ref` in the file `Path to file.md` diff --git a/docs/index.md b/docs/index.md index e5b9dfef5..05de2bae9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ This will guide you through initializing your Quartz with content. Once you've d ## 🔧 Features -- [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box +- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box - Hot-reload for both configuration and content - Simple JSX layouts and [[creating components|page components]] - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes diff --git a/package-lock.json b/package-lock.json index a19d81c11..a87907897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.0.10", + "version": "4.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.0.10", + "version": "4.0.11", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", diff --git a/package.json b/package.json index 95c57cd8d..0a2085cef 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.0.10", + "version": "4.0.11", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 25297f289..451813b5e 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -4,6 +4,8 @@ import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { FullSlug, RelativeURL, joinSegments } from "../util/path" +import { visit } from "unist-util-visit" +import { Root, Element } from "hast" interface RenderComponents { head: QuartzComponent @@ -53,6 +55,40 @@ export function renderPage( components: RenderComponents, pageResources: StaticResources, ): string { + // process transcludes in componentData + visit(componentData.tree as Root, "element", (node, _index, _parent) => { + if (node.tagName === "blockquote") { + const classNames = (node.properties?.className ?? []) as string[] + if (classNames.includes("transclude")) { + const inner = node.children[0] as Element + const blockSlug = inner.properties?.["data-slug"] as FullSlug + const blockRef = node.properties!.dataBlock as string + + // TODO: avoid this expensive find operation and construct an index ahead of time + let blockNode = componentData.allFiles.find((f) => f.slug === blockSlug)?.blocks?.[blockRef] + if (blockNode) { + if (blockNode.tagName === "li") { + blockNode = { + type: "element", + tagName: "ul", + children: [blockNode], + } + } + + node.children = [ + blockNode, + { + type: "element", + tagName: "a", + properties: { href: inner.properties?.href, class: ["internal"] }, + children: [{ type: "text", value: `Link to original` }], + }, + ] + } + } + } + }) + const { head: Head, header, diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 02ced158d..e050e00ad 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -72,6 +72,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = simplifySlug(destCanonical as FullSlug), ) as SimpleSlug outgoing.add(simple) + node.properties["data-slug"] = simple } // rewrite link internals if prettylinks is on diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 811d659e6..8306f40d8 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -135,6 +135,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const hast = toHast(ast, { allowDangerousHtml: true })! return toHtml(hast, { allowDangerousHtml: true }) } + const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { if (replace) { @@ -238,8 +239,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin value: ``, } } else if (ext === "") { - // TODO: note embed + const block = anchor.slice(1) + return { + type: "html", + data: { hProperties: { transclude: true } }, + value: `
Transclude of block ${block}
`, + } } + // otherwise, fall through to regular link } @@ -422,22 +431,47 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin if (opts.parseBlockReferences) { plugins.push(() => { + const inlineTagTypes = new Set(["p", "li"]) + const blockTagTypes = new Set(["blockquote"]) return (tree, file) => { file.data.blocks = {} - const validTagTypes = new Set(["blockquote", "p", "li"]) - visit(tree, "element", (node, _index, _parent) => { - if (validTagTypes.has(node.tagName)) { + + visit(tree, "element", (node, index, parent) => { + if (blockTagTypes.has(node.tagName)) { + const nextChild = parent?.children.at(index! + 2) as Element + if (nextChild && nextChild.tagName === "p") { + const text = nextChild.children.at(0) as Literal + if (text && text.value && text.type === "text") { + const matches = text.value.match(blockReferenceRegex) + if (matches && matches.length >= 1) { + parent!.children.splice(index! + 2, 1) + const block = matches[0].slice(1) + + if (!Object.keys(file.data.blocks!).includes(block)) { + node.properties = { + ...node.properties, + id: block, + } + file.data.blocks![block] = node + } + } + } + } + } else if (inlineTagTypes.has(node.tagName)) { const last = node.children.at(-1) as Literal - if (last.value && typeof last.value === "string") { + if (last && last.value && typeof last.value === "string") { const matches = last.value.match(blockReferenceRegex) if (matches && matches.length >= 1) { last.value = last.value.slice(0, -matches[0].length) const block = matches[0].slice(1) - node.properties = { - ...node.properties, - id: block, + + if (!Object.keys(file.data.blocks!).includes(block)) { + node.properties = { + ...node.properties, + id: block, + } + file.data.blocks![block] = node } - file.data.blocks![block] = node } } } diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 34def8783..92c0f84d9 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -470,3 +470,9 @@ ol.overflow { background: linear-gradient(transparent 0px, var(--light)); } } + +.transclude { + ul { + padding-left: 1rem; + } +} From 4b177ed03e9f8dea502928be1735c2dea2a6fd23 Mon Sep 17 00:00:00 2001 From: Oskar Manhart <52569953+oskardotglobal@users.noreply.github.com> Date: Thu, 14 Sep 2023 05:55:59 +0200 Subject: [PATCH 24/26] feat: display tag in graph view (#466) * feat: tags in graph view * fix: revert changing graph forces * fix: run prettier --- quartz/components/Graph.tsx | 6 ++++ quartz/components/scripts/graph.inline.ts | 40 ++++++++++++++++------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index e159aa541..1b8071b93 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -13,6 +13,8 @@ export interface D3Config { linkDistance: number fontSize: number opacityScale: number + removeTags: string[] + showTags: boolean } interface GraphOptions { @@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = { linkDistance: 30, fontSize: 0.6, opacityScale: 1, + showTags: true, + removeTags: [], }, globalGraph: { drag: true, @@ -42,6 +46,8 @@ const defaultOptions: GraphOptions = { linkDistance: 30, fontSize: 0.6, opacityScale: 1, + showTags: true, + removeTags: [], }, } diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index dc5c99dc1..1aff138f2 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -42,20 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) { linkDistance, fontSize, opacityScale, + removeTags, + showTags, } = JSON.parse(graph.dataset["cfg"]!) const data = await fetchData const links: LinkData[] = [] + const tags: SimpleSlug[] = [] + const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) + for (const [src, details] of Object.entries(data)) { const source = simplifySlug(src as FullSlug) const outgoing = details.links ?? [] + for (const dest of outgoing) { if (validLinks.has(dest)) { links.push({ source, target: dest }) } } + + if (showTags) { + const localTags = details.tags + .filter((tag) => !removeTags.includes(tag)) + .map((tag) => simplifySlug(("tags/" + tag) as FullSlug)) + + tags.push(...localTags.filter((tag) => !tags.includes(tag))) + + for (const tag of localTags) { + links.push({ source, target: tag }) + } + } } const neighbourhood = new Set() @@ -76,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) { } } else { Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) + if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) } const graphData: { nodes: NodeData[]; links: LinkData[] } = { - nodes: [...neighbourhood].map((url) => ({ - id: url, - text: data[url]?.title ?? url, - tags: data[url]?.tags ?? [], - })), + nodes: [...neighbourhood].map((url) => { + const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url + return { + id: url, + text: text, + tags: data[url]?.tags ?? [], + } + }), links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), } @@ -127,7 +149,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const isCurrent = d.id === slug if (isCurrent) { return "var(--secondary)" - } else if (visited.has(d.id)) { + } else if (visited.has(d.id) || d.id.startsWith("tags/")) { return "var(--tertiary)" } else { return "var(--gray)" @@ -231,11 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { .attr("dx", 0) .attr("dy", (d) => -nodeRadius(d) + "px") .attr("text-anchor", "middle") - .text( - (d) => - data[d.id]?.title || - (d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "), - ) + .text((d) => d.text) .style("opacity", (opacityScale - 1) / 3.75) .style("pointer-events", "none") .style("font-size", fontSize + "em") From 515ac8d9a19551347c58ad1fa70e06ea50765e80 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:39:16 +0200 Subject: [PATCH 25/26] feat: implement file explorer component (closes #201) (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add basic explorer structure„ * feat: integrate new component/plugin * feat: add basic explorer structure * feat: add sort to FileNodes * style: improve style for explorer * refactor: remove unused explorer plugin * refactor: clean explorer structure, fix base (toc) * refactor: clean css, respect displayClass * style: add styling to chevron * refactor: clean up debug statements * refactor: remove unused import * fix: clicking folder icon sometimes turns invisible * refactor: clean css * feat(explorer): add config for title * feat: add config for folder click behavior * fix: `no-pointer` not being set for all elements new approach, have one `no-pointer` class, that removes pointer events and one `clickable` class on the svg and button (everything that can normally be clicked). then, find all children with `clickable` and toggle `no-pointer` * fix: bug where nested folders got incorrect height this fixes the bug where nested folders weren't calculating their total height correctly. done by adding class to main container of all children and calculating total * feat: introduce `folderDefaultState` config * feat: store depth for explorer nodes * feat: implement option for collapsed state + bug fixes folderBehavior: "link" still has bad styling, but major bugs with pointers fixed (not clean yet, but working) * fix: default folder icon rotation * fix: hitbox problem with folder links, fix style * fix: redirect url for nested folders * fix: inconsistent behavior with 'collapseFolders' opt * chore: add comments to `ExplorerNode` * feat: save explorer state to local storage (not clean) * feat: rework `getFolders()`, fix localstorage read + write * feat: set folder state from localStorage needs serious refactoring but functional (except folder icon orientation) * fix: folder icon orientation after local storage * feat: add config for `useSavedState` * refactor: clean `explorer.inline.ts` remove unused functions, comments, unused code, add types to EventHandler * refactor: clean explorer merge `isSvg` paths, remove console logs * refactor: add documentation, remove unused funcs * feat: rework folder collapse logic use grids instead of jank scuffed solution with calculating total heights * refactor: remove depth arg from insert * feat: restore collapse functionality to clicks allow folder icon + folder label to collapse folders again * refactor: remove `pointer-event` jank * feat: improve svg viewbox + remove unused props * feat: use css selector to toggle icon rework folder icon to work purely with css instead of JS manipulation * refactor: remove unused cfg * feat: move TOC to right sidebar * refactor: clean css * style: fix overflow + overflow margin * fix: use `resolveRelative` to resolve file paths * fix: `defaultFolderState` config option * refactor: rename import, rename `folderLi` + ul * fix: use `QuartzPluginData` type * docs: add explorer documentation --- docs/features/explorer.md | 41 ++++ quartz.layout.ts | 8 +- quartz/components/Explorer.tsx | 70 +++++++ quartz/components/ExplorerNode.tsx | 196 +++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/scripts/explorer.inline.ts | 141 +++++++++++++ quartz/components/styles/explorer.scss | 133 +++++++++++++ quartz/styles/base.scss | 4 +- 8 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 docs/features/explorer.md create mode 100644 quartz/components/Explorer.tsx create mode 100644 quartz/components/ExplorerNode.tsx create mode 100644 quartz/components/scripts/explorer.inline.ts create mode 100644 quartz/components/styles/explorer.scss diff --git a/docs/features/explorer.md b/docs/features/explorer.md new file mode 100644 index 000000000..17647de00 --- /dev/null +++ b/docs/features/explorer.md @@ -0,0 +1,41 @@ +--- +title: "Explorer" +tags: + - component +--- + +Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and has options for customization. + +By default, it will show all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]]. + +> [!info] +> The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages. +> +> To clear/delete the explorer state from local storage, delete the `fileTree` entry (guide on how to delete a key from local storage in chromium based browsers can be found [here](https://docs.devolutions.net/kb/general-knowledge-base/clear-browser-local-storage/clear-chrome-local-storage/)). You can disable this by passing `useSavedState: false` as an argument. + +## Customization + +Most configuration can be done by passing in options to `Component.Explorer()`. + +For example, here's what the default configuration looks like: + +```typescript title="quartz.layout.ts" +Component.Explorer({ + title: "Explorer", // title of the explorer component + folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) + folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open") + useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer +}) +``` + +When passing in your own options, you can omit any or all of these fields if you'd like to keep the default value for that field. + +Want to customize it even more? + +- Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts` + - (optional): After removing the explorer component, you can move the [[table of contents]] component back to the `left` part of the layout +- Component: + - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx` + - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx` +- Style: `quartz/components/styles/explorer.scss` +- Script: `quartz/components/scripts/explorer.inline.ts` diff --git a/quartz.layout.ts b/quartz.layout.ts index 482aba6e3..8c1c6c114 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -21,9 +21,13 @@ export const defaultContentPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), - Component.DesktopOnly(Component.TableOfContents()), + Component.DesktopOnly(Component.Explorer()), + ], + right: [ + Component.Graph(), + Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), ], - right: [Component.Graph(), Component.Backlinks()], } // components for pages that display lists of pages (e.g. tags or folders) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx new file mode 100644 index 000000000..ce69491e9 --- /dev/null +++ b/quartz/components/Explorer.tsx @@ -0,0 +1,70 @@ +import { QuartzComponentConstructor, QuartzComponentProps } from "./types" +import explorerStyle from "./styles/explorer.scss" + +// @ts-ignore +import script from "./scripts/explorer.inline" +import { ExplorerNode, FileNode, Options } from "./ExplorerNode" + +// Options interface defined in `ExplorerNode` to avoid circular dependency +const defaultOptions = (): Options => ({ + title: "Explorer", + folderClickBehavior: "collapse", + folderDefaultState: "collapsed", + useSavedState: true, +}) +export default ((userOpts?: Partial) => { + function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { + // Parse config + const opts: Options = { ...defaultOptions(), ...userOpts } + + // Construct tree from allFiles + const fileTree = new FileNode("") + allFiles.forEach((file) => fileTree.add(file, 1)) + + // Sort tree (folders first, then files (alphabetic)) + fileTree.sort() + + // Get all folders of tree. Initialize with collapsed state + const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") + + // Stringify to pass json tree as data attribute ([data-tree]) + const jsonTree = JSON.stringify(folders) + + return ( +
+ +
+
    + +
+
+
+ ) + } + Explorer.css = explorerStyle + Explorer.afterDOMLoaded = script + return Explorer +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx new file mode 100644 index 000000000..6718ec9fa --- /dev/null +++ b/quartz/components/ExplorerNode.tsx @@ -0,0 +1,196 @@ +// @ts-ignore +import { QuartzPluginData } from "vfile" +import { resolveRelative } from "../util/path" + +export interface Options { + title: string + folderDefaultState: "collapsed" | "open" + folderClickBehavior: "collapse" | "link" + useSavedState: boolean +} + +type DataWrapper = { + file: QuartzPluginData + path: string[] +} + +export type FolderState = { + path: string + collapsed: boolean +} + +// Structure to add all files into a tree +export class FileNode { + children: FileNode[] + name: string + file: QuartzPluginData | null + depth: number + + constructor(name: string, file?: QuartzPluginData, depth?: number) { + this.children = [] + this.name = name + this.file = file ?? null + this.depth = depth ?? 0 + } + + private insert(file: DataWrapper) { + if (file.path.length === 1) { + this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1)) + } else { + const next = file.path[0] + file.path = file.path.splice(1) + for (const child of this.children) { + if (child.name === next) { + child.insert(file) + return + } + } + + const newChild = new FileNode(next, undefined, this.depth + 1) + newChild.insert(file) + this.children.push(newChild) + } + } + + // Add new file to tree + add(file: QuartzPluginData, splice: number = 0) { + this.insert({ file, path: file.filePath!.split("/").splice(splice) }) + } + + // Print tree structure (for debugging) + print(depth: number = 0) { + let folderChar = "" + if (!this.file) folderChar = "|" + console.log("-".repeat(depth), folderChar, this.name, this.depth) + this.children.forEach((e) => e.print(depth + 1)) + } + + /** + * Get folder representation with state of tree. + * Intended to only be called on root node before changes to the tree are made + * @param collapsed default state of folders (collapsed by default or not) + * @returns array containing folder state for tree + */ + getFolderPaths(collapsed: boolean): FolderState[] { + const folderPaths: FolderState[] = [] + + const traverse = (node: FileNode, currentPath: string) => { + if (!node.file) { + const folderPath = currentPath + (currentPath ? "/" : "") + node.name + if (folderPath !== "") { + folderPaths.push({ path: folderPath, collapsed }) + } + node.children.forEach((child) => traverse(child, folderPath)) + } + } + + traverse(this, "") + + return folderPaths + } + + // Sort order: folders first, then files. Sort folders and files alphabetically + sort() { + this.children = this.children.sort((a, b) => { + if ((!a.file && !b.file) || (a.file && b.file)) { + return a.name.localeCompare(b.name) + } + if (a.file && !b.file) { + return 1 + } else { + return -1 + } + }) + + this.children.forEach((e) => e.sort()) + } +} + +type ExplorerNodeProps = { + node: FileNode + opts: Options + fileData: QuartzPluginData + fullPath?: string +} + +export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { + // Get options + const folderBehavior = opts.folderClickBehavior + const isDefaultOpen = opts.folderDefaultState === "open" + + // Calculate current folderPath + let pathOld = fullPath ? fullPath : "" + let folderPath = "" + if (node.name !== "") { + folderPath = `${pathOld}/${node.name}` + } + + return ( +
+ {node.file ? ( + // Single file node +
  • + + {node.file.frontmatter?.title} + +
  • + ) : ( +
    + {node.name !== "" && ( + // Node with entire folder + // Render svg button + folder name, then children + + )} + {/* Recursively render children of folder */} +
    +
      + {node.children.map((childNode, i) => ( + + ))} +
    +
    +
    + )} +
    + ) +} diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 10a43acb5..d7b6a1c5e 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -9,6 +9,7 @@ import PageTitle from "./PageTitle" import ContentMeta from "./ContentMeta" import Spacer from "./Spacer" import TableOfContents from "./TableOfContents" +import Explorer from "./Explorer" import TagList from "./TagList" import Graph from "./Graph" import Backlinks from "./Backlinks" @@ -29,6 +30,7 @@ export { ContentMeta, Spacer, TableOfContents, + Explorer, TagList, Graph, Backlinks, diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts new file mode 100644 index 000000000..807397998 --- /dev/null +++ b/quartz/components/scripts/explorer.inline.ts @@ -0,0 +1,141 @@ +import { FolderState } from "../ExplorerNode" + +// Current state of folders +let explorerState: FolderState[] + +function toggleExplorer(this: HTMLElement) { + // Toggle collapsed state of entire explorer + this.classList.toggle("collapsed") + const content = this.nextElementSibling as HTMLElement + content.classList.toggle("collapsed") + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" +} + +function toggleFolder(evt: MouseEvent) { + evt.stopPropagation() + + // Element that was clicked + const target = evt.target as HTMLElement + + // Check if target was svg icon or button + const isSvg = target.nodeName === "svg" + + // corresponding
      element relative to clicked button/folder + let childFolderContainer: HTMLElement + + //
    • element of folder (stores folder-path dataset) + let currentFolderParent: HTMLElement + + // Get correct relative container and toggle collapsed class + if (isSvg) { + childFolderContainer = target.parentElement?.nextSibling as HTMLElement + currentFolderParent = target.nextElementSibling as HTMLElement + + childFolderContainer.classList.toggle("open") + } else { + childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement + currentFolderParent = target.parentElement as HTMLElement + + childFolderContainer.classList.toggle("open") + } + if (!childFolderContainer) return + + // Collapse folder container + const isCollapsed = childFolderContainer.classList.contains("open") + setFolderState(childFolderContainer, !isCollapsed) + + // Save folder state to localStorage + const clickFolderPath = currentFolderParent.dataset.folderpath as string + + // Remove leading "/" + const fullFolderPath = clickFolderPath.substring(1) + toggleCollapsedByPath(explorerState, fullFolderPath) + + const stringifiedFileTree = JSON.stringify(explorerState) + localStorage.setItem("fileTree", stringifiedFileTree) +} + +function setupExplorer() { + // Set click handler for collapsing entire explorer + const explorer = document.getElementById("explorer") + + // Get folder state from local storage + const storageTree = localStorage.getItem("fileTree") + + // Convert to bool + const useSavedFolderState = explorer?.dataset.savestate === "true" + + if (explorer) { + // Get config + const collapseBehavior = explorer.dataset.behavior + + // Add click handlers for all folders (click handler on folder "label") + if (collapseBehavior === "collapse") { + Array.prototype.forEach.call( + document.getElementsByClassName("folder-button"), + function (item) { + item.removeEventListener("click", toggleFolder) + item.addEventListener("click", toggleFolder) + }, + ) + } + + // Add click handler to main explorer + explorer.removeEventListener("click", toggleExplorer) + explorer.addEventListener("click", toggleExplorer) + } + + // Set up click handlers for each folder (click handler on folder "icon") + Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) { + item.removeEventListener("click", toggleFolder) + item.addEventListener("click", toggleFolder) + }) + + if (storageTree && useSavedFolderState) { + // Get state from localStorage and set folder state + explorerState = JSON.parse(storageTree) + explorerState.map((folderUl) => { + // grab
    • element for matching folder path + const folderLi = document.querySelector( + `[data-folderpath='/${folderUl.path}']`, + ) as HTMLElement + + // Get corresponding content
        tag and set state + const folderUL = folderLi.parentElement?.nextElementSibling as HTMLElement + setFolderState(folderUL, folderUl.collapsed) + }) + } else { + // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset + explorerState = JSON.parse(explorer?.dataset.tree as string) + } +} + +window.addEventListener("resize", setupExplorer) +document.addEventListener("nav", () => { + setupExplorer() +}) + +/** + * Toggles the state of a given folder + * @param folderElement
        Element of folder (parent) + * @param collapsed if folder should be set to collapsed or not + */ +function setFolderState(folderElement: HTMLElement, collapsed: boolean) { + if (collapsed) { + folderElement?.classList.remove("open") + } else { + folderElement?.classList.add("open") + } +} + +/** + * Toggles visibility of a folder + * @param array array of FolderState (`fileTree`, either get from local storage or data attribute) + * @param path path to folder (e.g. 'advanced/more/more2') + */ +function toggleCollapsedByPath(array: FolderState[], path: string) { + const entry = array.find((item) => item.path === path) + if (entry) { + entry.collapsed = !entry.collapsed + } +} diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss new file mode 100644 index 000000000..4b25a55f9 --- /dev/null +++ b/quartz/components/styles/explorer.scss @@ -0,0 +1,133 @@ +button#explorer { + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding: 0; + color: var(--dark); + display: flex; + align-items: center; + + & h3 { + font-size: 1rem; + display: inline-block; + margin: 0; + } + + & .fold { + margin-left: 0.5rem; + transition: transform 0.3s ease; + opacity: 0.8; + } + + &.collapsed .fold { + transform: rotateZ(-90deg); + } +} + +.folder-outer { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-in-out; +} + +.folder-outer.open { + grid-template-rows: 1fr; +} + +.folder-outer > ul { + overflow: hidden; +} + +#explorer-content { + list-style: none; + overflow: hidden; + max-height: none; + transition: max-height 0.35s ease; + margin-top: 0.5rem; + + &.collapsed > .overflow::after { + opacity: 0; + } + + & ul { + list-style: none; + margin: 0.08rem 0; + padding: 0; + transition: + max-height 0.35s ease, + transform 0.35s ease, + opacity 0.2s ease; + & div > li > a { + color: var(--dark); + opacity: 0.75; + pointer-events: all; + } + } +} + +svg { + pointer-events: all; + + & > polyline { + pointer-events: none; + } +} + +.folder-container { + flex-direction: row; + display: flex; + align-items: center; + user-select: none; + + & li > a { + // other selector is more specific, needs important + color: var(--secondary) !important; + opacity: 1 !important; + font-size: 1.05rem !important; + } + + & li > a:hover { + // other selector is more specific, needs important + color: var(--tertiary) !important; + } + + & li > button { + color: var(--dark); + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding-left: 0; + padding-right: 0; + display: flex; + align-items: center; + + & h3 { + font-size: 0.95rem; + display: inline-block; + color: var(--secondary); + font-weight: 600; + margin: 0; + line-height: 1.5rem; + font-weight: bold; + pointer-events: none; + } + } +} + +.folder-icon { + margin-right: 5px; + color: var(--secondary); + cursor: pointer; + transition: transform 0.3s ease; + backface-visibility: visible; +} + +div:has(> .folder-outer:not(.open)) > .folder-container > svg { + transform: rotate(-90deg); +} + +.folder-icon:hover { + color: var(--tertiary); +} diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 92c0f84d9..c6925fbe5 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -446,7 +446,7 @@ video { ul.overflow, ol.overflow { - height: 300px; + max-height: 300; overflow-y: auto; // clearfix @@ -454,7 +454,7 @@ ol.overflow { clear: both; & > li:last-of-type { - margin-bottom: 50px; + margin-bottom: 30px; } &:after { From 9bfdc24161b7507345daadb8fb1f7976482264fd Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 15 Sep 2023 09:46:06 -0700 Subject: [PATCH 26/26] fix: use git dates by default, @napi/git is fast enough --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index f677a18f9..8674bc62f 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -47,7 +47,7 @@ const config: QuartzConfig = { Plugin.FrontMatter(), Plugin.TableOfContents(), Plugin.CreatedModifiedDate({ - priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower + priority: ["frontmatter", "git", "filesystem"], }), Plugin.SyntaxHighlighting(), Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),