mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-28 15:24:06 -06:00
Merge branch 'v4' of github-bfahrenfort:jackyzha0/quartz into v4
This commit is contained in:
commit
3e892a900d
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
41
docs/features/explorer.md
Normal file
41
docs/features/explorer.md
Normal file
@ -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`
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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 <j.zhao2k19@gmail.com>",
|
||||
"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",
|
||||
|
||||
@ -68,6 +68,7 @@ const config: QuartzConfig = {
|
||||
}),
|
||||
Plugin.Assets(),
|
||||
Plugin.Static(),
|
||||
Plugin.NotFoundPage(),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -12,6 +12,10 @@ export type Analytics =
|
||||
provider: "google"
|
||||
tagId: string
|
||||
}
|
||||
| {
|
||||
provider: "umami"
|
||||
websiteId: string
|
||||
}
|
||||
|
||||
export interface GlobalConfiguration {
|
||||
pageTitle: string
|
||||
|
||||
70
quartz/components/Explorer.tsx
Normal file
70
quartz/components/Explorer.tsx
Normal file
@ -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<Options>) => {
|
||||
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 (
|
||||
<div class={`explorer ${displayClass}`}>
|
||||
<button
|
||||
type="button"
|
||||
id="explorer"
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-tree={jsonTree}
|
||||
>
|
||||
<h3>{opts.title}</h3>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="fold"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="explorer-content">
|
||||
<ul class="overflow">
|
||||
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Explorer.css = explorerStyle
|
||||
Explorer.afterDOMLoaded = script
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
196
quartz/components/ExplorerNode.tsx
Normal file
196
quartz/components/ExplorerNode.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
{node.file ? (
|
||||
// Single file node
|
||||
<li key={node.file.slug}>
|
||||
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
|
||||
{node.file.frontmatter?.title}
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<div>
|
||||
{node.name !== "" && (
|
||||
// Node with entire folder
|
||||
// Render svg button + folder name, then children
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
||||
<li key={node.name} data-folderpath={folderPath}>
|
||||
{folderBehavior === "link" ? (
|
||||
<a href={`${folderPath}`} data-for={node.name} class="folder-title">
|
||||
{node.name}
|
||||
</a>
|
||||
) : (
|
||||
<button class="folder-button">
|
||||
<h3 class="folder-title">{node.name}</h3>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
)}
|
||||
{/* Recursively render children of folder */}
|
||||
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
|
||||
<ul
|
||||
// Inline style for left folder paddings
|
||||
style={{
|
||||
paddingLeft: node.name !== "" ? "1.4rem" : "0",
|
||||
}}
|
||||
class="content"
|
||||
data-folderul={folderPath}
|
||||
>
|
||||
{node.children.map((childNode, i) => (
|
||||
<ExplorerNode
|
||||
node={childNode}
|
||||
key={i}
|
||||
opts={opts}
|
||||
fullPath={folderPath}
|
||||
fileData={fileData}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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: [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
12
quartz/components/pages/404.tsx
Normal file
12
quartz/components/pages/404.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { QuartzComponentConstructor } from "../types"
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<article class="popover-hint">
|
||||
<h1>404</h1>
|
||||
<p>Either this page is private or doesn't exist.</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default (() => NotFound) satisfies QuartzComponentConstructor
|
||||
@ -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) {
|
||||
<article>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{allPagesInFolder.length} items under this folder.</p>
|
||||
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<p>
|
||||
{pages.length} items with this tag.{" "}
|
||||
{pluralize(pages.length, "item")} with this tag.{" "}
|
||||
{pages.length > numPages && `Showing first ${numPages}.`}
|
||||
</p>
|
||||
<PageList limit={numPages} {...listProps} />
|
||||
@ -80,7 +81,7 @@ function TagContent(props: QuartzComponentProps) {
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article>{content}</article>
|
||||
<p>{pages.length} items with this tag.</p>
|
||||
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
141
quartz/components/scripts/explorer.inline.ts
Normal file
141
quartz/components/scripts/explorer.inline.ts
Normal file
@ -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 <ul> element relative to clicked button/folder
|
||||
let childFolderContainer: HTMLElement
|
||||
|
||||
// <li> 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 <li> element for matching folder path
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='/${folderUl.path}']`,
|
||||
) as HTMLElement
|
||||
|
||||
// Get corresponding content <ul> 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 <div class="folder-outer"> 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
|
||||
}
|
||||
}
|
||||
@ -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<ContentDetails>(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<SimpleSlug>()
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
|
||||
133
quartz/components/styles/explorer.scss
Normal file
133
quartz/components/styles/explorer.scss
Normal file
@ -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);
|
||||
}
|
||||
59
quartz/plugins/emitters/404.tsx
Normal file
59
quartz/plugins/emitters/404.tsx
Normal file
@ -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<FilePath[]> {
|
||||
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",
|
||||
}),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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<FullSlug, ContentDetails>
|
||||
@ -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 => `<url>
|
||||
<loc>https://${base}/${slug}</loc>
|
||||
<loc>https://${base}/${encodeURI(slug)}</loc>
|
||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||
</url>`
|
||||
const urls = Array.from(idx)
|
||||
@ -38,7 +46,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||
}
|
||||
|
||||
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 `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${cfg.pageTitle}</title>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<link>${root}</link>
|
||||
<description>Recent content on ${cfg.pageTitle}</description>
|
||||
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${
|
||||
cfg.pageTitle
|
||||
}</description>
|
||||
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||
${items}
|
||||
</channel>
|
||||
@ -73,7 +85,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (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<Partial<Options>> = (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<Partial<Options>> = (opts) => {
|
||||
if (opts?.enableRSS) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
content: generateRSSFeed(cfg, linkIndex),
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".xml",
|
||||
}),
|
||||
|
||||
@ -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<Partial<FullPageLayout>> = (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,
|
||||
|
||||
@ -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<FullPageLayout> = (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,
|
||||
|
||||
@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases"
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
export { NotFoundPage } from "./404"
|
||||
|
||||
@ -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<FullPageLayout> = (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,
|
||||
|
||||
@ -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<Partial<Options> | undefined> = (userOpts) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
return {
|
||||
|
||||
@ -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<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@ -49,9 +54,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | 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),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
11
quartz/plugins/transformers/linebreaks.ts
Normal file
11
quartz/plugins/transformers/linebreaks.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
|
||||
export const HardLineBreaks: QuartzTransformerPlugin = () => {
|
||||
return {
|
||||
name: "HardLineBreaks",
|
||||
markdownPlugins() {
|
||||
return [remarkBreaks]
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ import {
|
||||
SimpleSlug,
|
||||
TransformOptions,
|
||||
_stripSlashes,
|
||||
joinSegments,
|
||||
simplifySlug,
|
||||
splitAnchor,
|
||||
transformLink,
|
||||
@ -54,7 +53,8 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | 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<Partial<Options> | 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("#")
|
||||
|
||||
@ -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<string, keyof typeof callouts> = {
|
||||
note: "note",
|
||||
abstract: "abstract",
|
||||
summary: "abstract",
|
||||
tldr: "abstract",
|
||||
info: "info",
|
||||
todo: "todo",
|
||||
tip: "tip",
|
||||
@ -96,7 +101,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||
|
||||
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<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@ -129,6 +135,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
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<Partial<Options>
|
||||
value: `<iframe src="${url}"></iframe>`,
|
||||
}
|
||||
} else if (ext === "") {
|
||||
// TODO: note embed
|
||||
const block = anchor.slice(1)
|
||||
return {
|
||||
type: "html",
|
||||
data: { hProperties: { transclude: true } },
|
||||
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
|
||||
url + anchor
|
||||
}" class="transclude-inner">Transclude of block ${block}</a></blockquote>`,
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, fall through to regular link
|
||||
}
|
||||
|
||||
@ -409,11 +424,63 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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<Partial<Options>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vfile" {
|
||||
interface DataMap {
|
||||
blocks: Record<string, Element>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
8
quartz/util/escape.ts
Normal file
8
quartz/util/escape.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const escapeHTML = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
7
quartz/util/lang.ts
Normal file
7
quartz/util/lang.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function pluralize(count: number, s: string): string {
|
||||
if (count === 1) {
|
||||
return `1 ${s}`
|
||||
} else {
|
||||
return `${count} ${s}s`
|
||||
}
|
||||
}
|
||||
@ -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[] {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user