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 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/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/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/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 diff --git a/docs/features/private pages.md b/docs/features/private pages.md index 402e52c2c..5c3940bc7 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -8,11 +8,11 @@ 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. -## `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. 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/docs/features/wikilinks.md b/docs/features/wikilinks.md index 4d197157d..50bbb1bb6 100644 --- a/docs/features/wikilinks.md +++ b/docs/features/wikilinks.md @@ -10,9 +10,7 @@ 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` - -> [!warning] -> Currently, Quartz does not support block references or note embed syntax. +- `[[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` +- `[[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 9246cc992..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", @@ -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..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", @@ -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.config.ts b/quartz.config.ts index 87a4a3b2b..fbe4b0d28 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -68,6 +68,7 @@ const config: QuartzConfig = { }), Plugin.Assets(), Plugin.Static(), + Plugin.NotFoundPage(), ], }, } diff --git a/quartz.layout.ts b/quartz.layout.ts index 663dfdedf..e90d4c1a3 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -22,9 +22,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/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/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/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/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/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/index.ts b/quartz/components/index.ts index a83f078b0..d7b6a1c5e 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,13 +1,15 @@ -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" 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" @@ -28,6 +30,7 @@ export { ContentMeta, Spacer, TableOfContents, + Explorer, TagList, Graph, Backlinks, @@ -36,4 +39,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/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/components/renderPage.tsx b/quartz/components/renderPage.tsx index eb1291f45..451813b5e 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,9 @@ 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" +import { visit } from "unist-util-visit" +import { Root, Element } from "hast" interface RenderComponents { head: QuartzComponent @@ -15,9 +17,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())` @@ -52,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/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/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index d72b297bf..1aff138f2 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -42,19 +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 (dest in data) { + 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() @@ -75,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)), } @@ -126,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)" @@ -230,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") 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() } } } 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; 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/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx new file mode 100644 index 000000000..cd079a065 --- /dev/null +++ b/quartz/plugins/emitters/404.tsx @@ -0,0 +1,59 @@ +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 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", + 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/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) { diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 0d5207672..713c5757b 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,7 +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 @@ -10,6 +13,7 @@ export type ContentDetails = { links: SimpleSlug[] tags: string[] content: string + richContent?: string date?: Date description?: string } @@ -17,19 +21,23 @@ export type ContentDetails = { interface Options { enableSiteMap: boolean enableRSS: boolean + rssLimit?: number + rssFullHtml: boolean includeEmptyFiles: boolean } const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, + rssLimit: 10, + rssFullHtml: false, includeEmptyFiles: true, } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${slug} + https://${base}/${encodeURI(slug)} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -38,7 +46,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}` @@ -52,13 +60,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 ` - ${cfg.pageTitle} + ${escapeHTML(cfg.pageTitle)} ${root} - Recent content on ${cfg.pageTitle} + ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${ + cfg.pageTitle + } Quartz -- quartz.jzhao.xyz ${items} @@ -73,7 +85,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 !== "")) { @@ -82,6 +94,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], content: file.data.text ?? "", + richContent: opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : undefined, date: date, description: file.data.description ?? "", }) @@ -101,7 +116,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", }), 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/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" 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/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/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), } } }, 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] + }, + } +} diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 26c4a3228..e050e00ad 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, @@ -54,7 +53,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, @@ -72,11 +72,13 @@ 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 if ( opts.prettyLinks && + isInternal && node.children.length === 1 && node.children[0].type === "text" && !node.children[0].value.startsWith("#") diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8c8da67bc..8306f40d8 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, } @@ -69,6 +72,8 @@ const callouts = { const calloutMapping: Record = { note: "note", abstract: "abstract", + summary: "abstract", + tldr: "abstract", info: "info", todo: "todo", tip: "tip", @@ -96,7 +101,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 => { @@ -119,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, @@ -129,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) { @@ -232,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 } @@ -409,11 +424,63 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } }) } - return plugins }, htmlPlugins() { - return [rehypeRaw] + const plugins = [rehypeRaw] + + if (opts.parseBlockReferences) { + plugins.push(() => { + const inlineTagTypes = new Set(["p", "li"]) + const blockTagTypes = new Set(["blockquote"]) + return (tree, file) => { + file.data.blocks = {} + + 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 && 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) + + if (!Object.keys(file.data.blocks!).includes(block)) { + node.properties = { + ...node.properties, + id: block, + } + file.data.blocks![block] = node + } + } + } + } + }) + } + }) + } + + return plugins }, externalResources() { const js: JSResource[] = [] @@ -452,3 +519,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin }, } } + +declare module "vfile" { + interface DataMap { + blocks: Record + } +} diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 34def8783..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 { @@ -470,3 +470,9 @@ ol.overflow { background: linear-gradient(transparent 0px, var(--light)); } } + +.transclude { + ul { + padding-left: 1rem; + } +} 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("'", "'") +} 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` + } +} 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[] {