mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-30 08:14:05 -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
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Ensure Quartz builds
|
- name: Ensure Quartz builds, check bundle info
|
||||||
run: npx quartz build
|
run: npx quartz build --bundleInfo
|
||||||
|
|||||||
@ -3,7 +3,7 @@ tags:
|
|||||||
- plugin/transformer
|
- 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.
|
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
|
## Configuration
|
||||||
|
|
||||||
- Remove RSS feed: set the `enableRSS` field of `Plugin.ContentIndex` in `quartz.config.ts` to be `false`.
|
- 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.
|
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]
|
> [!info]
|
||||||
> Search requires the `ContentIndex` emitter plugin to be present in the [[configuration]].
|
> 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.
|
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
|
## Customization
|
||||||
|
|
||||||
@ -25,4 +27,4 @@ It properly tokenizes Chinese, Korean, and Japenese characters and constructs se
|
|||||||
- Component: `quartz/components/Search.tsx`
|
- Component: `quartz/components/Search.tsx`
|
||||||
- Style: `quartz/components/styles/search.scss`
|
- Style: `quartz/components/styles/search.scss`
|
||||||
- Script: `quartz/components/scripts/search.inline.ts`
|
- 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
|
## 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.
|
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.
|
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
|
- `**/private`: exclude any files or folders named `private` at any level of nesting
|
||||||
|
|
||||||
> [!warning]
|
> [!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
|
## 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
|
- 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
|
- note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files
|
||||||
- static dead link detection
|
|
||||||
- docker support
|
- docker support
|
||||||
|
|
||||||
## misc backlog
|
## misc backlog
|
||||||
|
|
||||||
- breadcrumbs component
|
- breadcrumbs component
|
||||||
- filetree component
|
|
||||||
- cursor chat extension
|
- cursor chat extension
|
||||||
- https://giscus.app/ extension
|
- https://giscus.app/ extension
|
||||||
- sidenotes? https://github.com/capnfabs/paperesque
|
- 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
|
## Syntax
|
||||||
|
|
||||||
- `[[Path to file]]`: produces a link to `Path to file` with the text `Path to file`
|
- `[[Path to file]]`: produces a link to `Path to file.md` (or `Path-to-file.md`) with the text `Path to file`
|
||||||
- `[[Path to file | Here's the title override]]`: produces a link to `Path to file` with the text `Here's the title override`
|
- `[[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`
|
- `[[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`
|
||||||
> [!warning]
|
|
||||||
> Currently, Quartz does not support block references or note embed syntax.
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ This will guide you through initializing your Quartz with content. Once you've d
|
|||||||
|
|
||||||
## 🔧 Features
|
## 🔧 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
|
- Hot-reload for both configuration and content
|
||||||
- Simple JSX layouts and [[creating components|page components]]
|
- Simple JSX layouts and [[creating components|page components]]
|
||||||
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
|
- [[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",
|
"name": "@jackyzha0/quartz",
|
||||||
"version": "4.0.10",
|
"version": "4.0.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@jackyzha0/quartz",
|
"name": "@jackyzha0/quartz",
|
||||||
"version": "4.0.10",
|
"version": "4.0.11",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^0.6.3",
|
"@clack/prompts": "^0.6.3",
|
||||||
@ -45,6 +45,7 @@
|
|||||||
"rehype-raw": "^6.1.1",
|
"rehype-raw": "^6.1.1",
|
||||||
"rehype-slug": "^5.1.0",
|
"rehype-slug": "^5.1.0",
|
||||||
"remark": "^14.0.2",
|
"remark": "^14.0.2",
|
||||||
|
"remark-breaks": "^3.0.3",
|
||||||
"remark-frontmatter": "^4.0.1",
|
"remark-frontmatter": "^4.0.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"remark-math": "^5.1.1",
|
"remark-math": "^5.1.1",
|
||||||
@ -3810,6 +3811,19 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/mdast-util-phrasing": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz",
|
||||||
@ -4903,6 +4917,20 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/remark-frontmatter": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz",
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "@jackyzha0/quartz",
|
"name": "@jackyzha0/quartz",
|
||||||
"description": "🌱 publish your digital garden and notes as a website",
|
"description": "🌱 publish your digital garden and notes as a website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.0.10",
|
"version": "4.0.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -69,6 +69,7 @@
|
|||||||
"rehype-raw": "^6.1.1",
|
"rehype-raw": "^6.1.1",
|
||||||
"rehype-slug": "^5.1.0",
|
"rehype-slug": "^5.1.0",
|
||||||
"remark": "^14.0.2",
|
"remark": "^14.0.2",
|
||||||
|
"remark-breaks": "^3.0.3",
|
||||||
"remark-frontmatter": "^4.0.1",
|
"remark-frontmatter": "^4.0.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"remark-math": "^5.1.1",
|
"remark-math": "^5.1.1",
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const config: QuartzConfig = {
|
|||||||
}),
|
}),
|
||||||
Plugin.Assets(),
|
Plugin.Assets(),
|
||||||
Plugin.Static(),
|
Plugin.Static(),
|
||||||
|
Plugin.NotFoundPage(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,9 +22,13 @@ export const defaultContentPageLayout: PageLayout = {
|
|||||||
Component.MobileOnly(Component.Spacer()),
|
Component.MobileOnly(Component.Spacer()),
|
||||||
Component.Search(),
|
Component.Search(),
|
||||||
Component.Darkmode(),
|
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)
|
// 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 parsedFiles = [...contentMap.values()]
|
||||||
const filteredContent = filterContent(ctx, parsedFiles)
|
const filteredContent = filterContent(ctx, parsedFiles)
|
||||||
|
|
||||||
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||||
// instead of just deleting everything
|
// instead of just deleting everything
|
||||||
await rimraf(argv.output)
|
await rimraf(argv.output)
|
||||||
|
|||||||
@ -12,6 +12,10 @@ export type Analytics =
|
|||||||
provider: "google"
|
provider: "google"
|
||||||
tagId: string
|
tagId: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
provider: "umami"
|
||||||
|
websiteId: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlobalConfiguration {
|
export interface GlobalConfiguration {
|
||||||
pageTitle: string
|
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
|
linkDistance: number
|
||||||
fontSize: number
|
fontSize: number
|
||||||
opacityScale: number
|
opacityScale: number
|
||||||
|
removeTags: string[]
|
||||||
|
showTags: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GraphOptions {
|
interface GraphOptions {
|
||||||
@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = {
|
|||||||
linkDistance: 30,
|
linkDistance: 30,
|
||||||
fontSize: 0.6,
|
fontSize: 0.6,
|
||||||
opacityScale: 1,
|
opacityScale: 1,
|
||||||
|
showTags: true,
|
||||||
|
removeTags: [],
|
||||||
},
|
},
|
||||||
globalGraph: {
|
globalGraph: {
|
||||||
drag: true,
|
drag: true,
|
||||||
@ -42,6 +46,8 @@ const defaultOptions: GraphOptions = {
|
|||||||
linkDistance: 30,
|
linkDistance: 30,
|
||||||
fontSize: 0.6,
|
fontSize: 0.6,
|
||||||
opacityScale: 1,
|
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 { JSResourceToScriptElement } from "../util/resources"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
@ -7,7 +7,11 @@ export default (() => {
|
|||||||
const title = fileData.frontmatter?.title ?? "Untitled"
|
const title = fileData.frontmatter?.title ?? "Untitled"
|
||||||
const description = fileData.description?.trim() ?? "No description provided"
|
const description = fileData.description?.trim() ?? "No description provided"
|
||||||
const { css, js } = externalResources
|
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 iconPath = joinSegments(baseDir, "static/icon.png")
|
||||||
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
|
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import ArticleTitle from "./ArticleTitle"
|
|
||||||
import Content from "./pages/Content"
|
import Content from "./pages/Content"
|
||||||
import TagContent from "./pages/TagContent"
|
import TagContent from "./pages/TagContent"
|
||||||
import FolderContent from "./pages/FolderContent"
|
import FolderContent from "./pages/FolderContent"
|
||||||
|
import NotFound from "./pages/404"
|
||||||
|
import ArticleTitle from "./ArticleTitle"
|
||||||
import Darkmode from "./Darkmode"
|
import Darkmode from "./Darkmode"
|
||||||
import Head from "./Head"
|
import Head from "./Head"
|
||||||
import PageTitle from "./PageTitle"
|
import PageTitle from "./PageTitle"
|
||||||
import ContentMeta from "./ContentMeta"
|
import ContentMeta from "./ContentMeta"
|
||||||
import Spacer from "./Spacer"
|
import Spacer from "./Spacer"
|
||||||
import TableOfContents from "./TableOfContents"
|
import TableOfContents from "./TableOfContents"
|
||||||
|
import Explorer from "./Explorer"
|
||||||
import TagList from "./TagList"
|
import TagList from "./TagList"
|
||||||
import Graph from "./Graph"
|
import Graph from "./Graph"
|
||||||
import Backlinks from "./Backlinks"
|
import Backlinks from "./Backlinks"
|
||||||
@ -28,6 +30,7 @@ export {
|
|||||||
ContentMeta,
|
ContentMeta,
|
||||||
Spacer,
|
Spacer,
|
||||||
TableOfContents,
|
TableOfContents,
|
||||||
|
Explorer,
|
||||||
TagList,
|
TagList,
|
||||||
Graph,
|
Graph,
|
||||||
Backlinks,
|
Backlinks,
|
||||||
@ -36,4 +39,5 @@ export {
|
|||||||
DesktopOnly,
|
DesktopOnly,
|
||||||
MobileOnly,
|
MobileOnly,
|
||||||
RecentNotes,
|
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 { PageList } from "../PageList"
|
||||||
import { _stripSlashes, simplifySlug } from "../../util/path"
|
import { _stripSlashes, simplifySlug } from "../../util/path"
|
||||||
import { Root } from "hast"
|
import { Root } from "hast"
|
||||||
|
import { pluralize } from "../../util/lang"
|
||||||
|
|
||||||
function FolderContent(props: QuartzComponentProps) {
|
function FolderContent(props: QuartzComponentProps) {
|
||||||
const { tree, fileData, allFiles } = props
|
const { tree, fileData, allFiles } = props
|
||||||
@ -36,7 +37,7 @@ function FolderContent(props: QuartzComponentProps) {
|
|||||||
<article>
|
<article>
|
||||||
<p>{content}</p>
|
<p>{content}</p>
|
||||||
</article>
|
</article>
|
||||||
<p>{allPagesInFolder.length} items under this folder.</p>
|
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
||||||
<div>
|
<div>
|
||||||
<PageList {...listProps} />
|
<PageList {...listProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { PageList } from "../PageList"
|
|||||||
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
|
||||||
import { QuartzPluginData } from "../../plugins/vfile"
|
import { QuartzPluginData } from "../../plugins/vfile"
|
||||||
import { Root } from "hast"
|
import { Root } from "hast"
|
||||||
|
import { pluralize } from "../../util/lang"
|
||||||
|
|
||||||
const numPages = 10
|
const numPages = 10
|
||||||
function TagContent(props: QuartzComponentProps) {
|
function TagContent(props: QuartzComponentProps) {
|
||||||
@ -60,7 +61,7 @@ function TagContent(props: QuartzComponentProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
{content && <p>{content}</p>}
|
{content && <p>{content}</p>}
|
||||||
<p>
|
<p>
|
||||||
{pages.length} items with this tag.{" "}
|
{pluralize(pages.length, "item")} with this tag.{" "}
|
||||||
{pages.length > numPages && `Showing first ${numPages}.`}
|
{pages.length > numPages && `Showing first ${numPages}.`}
|
||||||
</p>
|
</p>
|
||||||
<PageList limit={numPages} {...listProps} />
|
<PageList limit={numPages} {...listProps} />
|
||||||
@ -80,7 +81,7 @@ function TagContent(props: QuartzComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="popover-hint">
|
<div class="popover-hint">
|
||||||
<article>{content}</article>
|
<article>{content}</article>
|
||||||
<p>{pages.length} items with this tag.</p>
|
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
||||||
<div>
|
<div>
|
||||||
<PageList {...listProps} />
|
<PageList {...listProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
|
|||||||
import HeaderConstructor from "./Header"
|
import HeaderConstructor from "./Header"
|
||||||
import BodyConstructor from "./Body"
|
import BodyConstructor from "./Body"
|
||||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
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 {
|
interface RenderComponents {
|
||||||
head: QuartzComponent
|
head: QuartzComponent
|
||||||
@ -15,9 +17,10 @@ interface RenderComponents {
|
|||||||
footer: QuartzComponent
|
footer: QuartzComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources {
|
export function pageResources(
|
||||||
const baseDir = pathToRoot(slug)
|
baseDir: FullSlug | RelativeURL,
|
||||||
|
staticResources: StaticResources,
|
||||||
|
): StaticResources {
|
||||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||||
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
|
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
|
||||||
|
|
||||||
@ -52,6 +55,40 @@ export function renderPage(
|
|||||||
components: RenderComponents,
|
components: RenderComponents,
|
||||||
pageResources: StaticResources,
|
pageResources: StaticResources,
|
||||||
): string {
|
): 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 {
|
const {
|
||||||
head: Head,
|
head: Head,
|
||||||
header,
|
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,
|
linkDistance,
|
||||||
fontSize,
|
fontSize,
|
||||||
opacityScale,
|
opacityScale,
|
||||||
|
removeTags,
|
||||||
|
showTags,
|
||||||
} = JSON.parse(graph.dataset["cfg"]!)
|
} = JSON.parse(graph.dataset["cfg"]!)
|
||||||
|
|
||||||
const data = await fetchData
|
const data = await fetchData
|
||||||
|
|
||||||
const links: LinkData[] = []
|
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)) {
|
for (const [src, details] of Object.entries<ContentDetails>(data)) {
|
||||||
const source = simplifySlug(src as FullSlug)
|
const source = simplifySlug(src as FullSlug)
|
||||||
const outgoing = details.links ?? []
|
const outgoing = details.links ?? []
|
||||||
|
|
||||||
for (const dest of outgoing) {
|
for (const dest of outgoing) {
|
||||||
if (dest in data) {
|
if (validLinks.has(dest)) {
|
||||||
links.push({ source, target: 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>()
|
const neighbourhood = new Set<SimpleSlug>()
|
||||||
@ -75,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
|
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[] } = {
|
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||||
nodes: [...neighbourhood].map((url) => ({
|
nodes: [...neighbourhood].map((url) => {
|
||||||
id: url,
|
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
|
||||||
text: data[url]?.title ?? url,
|
return {
|
||||||
tags: data[url]?.tags ?? [],
|
id: url,
|
||||||
})),
|
text: text,
|
||||||
|
tags: data[url]?.tags ?? [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
|
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
|
const isCurrent = d.id === slug
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
return "var(--secondary)"
|
return "var(--secondary)"
|
||||||
} else if (visited.has(d.id)) {
|
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||||
return "var(--tertiary)"
|
return "var(--tertiary)"
|
||||||
} else {
|
} else {
|
||||||
return "var(--gray)"
|
return "var(--gray)"
|
||||||
@ -230,11 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
|||||||
.attr("dx", 0)
|
.attr("dx", 0)
|
||||||
.attr("dy", (d) => -nodeRadius(d) + "px")
|
.attr("dy", (d) => -nodeRadius(d) + "px")
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.text(
|
.text((d) => d.text)
|
||||||
(d) =>
|
|
||||||
data[d.id]?.title ||
|
|
||||||
(d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "),
|
|
||||||
)
|
|
||||||
.style("opacity", (opacityScale - 1) / 3.75)
|
.style("opacity", (opacityScale - 1) / 3.75)
|
||||||
.style("pointer-events", "none")
|
.style("pointer-events", "none")
|
||||||
.style("font-size", fontSize + "em")
|
.style("font-size", fontSize + "em")
|
||||||
|
|||||||
@ -82,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
const searchIcon = document.getElementById("search-icon")
|
const searchIcon = document.getElementById("search-icon")
|
||||||
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
||||||
const results = document.getElementById("results-container")
|
const results = document.getElementById("results-container")
|
||||||
|
const resultCards = document.getElementsByClassName("result-card")
|
||||||
const idDataMap = Object.keys(data) as FullSlug[]
|
const idDataMap = Object.keys(data) as FullSlug[]
|
||||||
|
|
||||||
function hideSearch() {
|
function hideSearch() {
|
||||||
@ -122,9 +123,31 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
// add "#" prefix for tag search
|
// add "#" prefix for tag search
|
||||||
if (searchBar) searchBar.value = "#"
|
if (searchBar) searchBar.value = "#"
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
// If result has focus, navigate to that one, otherwise pick first result
|
||||||
if (anchor) {
|
if (results?.contains(document.activeElement)) {
|
||||||
anchor.click()
|
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);
|
background-color: var(--light);
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
z-index: 1;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: 0.2s;
|
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") {
|
} else if (cfg.analytics?.provider === "plausible") {
|
||||||
componentResources.afterDOMLoaded.push(plausibleScript)
|
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) {
|
if (cfg.enableSPA) {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
import { Root } from "hast"
|
||||||
import { GlobalConfiguration } from "../../cfg"
|
import { GlobalConfiguration } from "../../cfg"
|
||||||
import { getDate } from "../../components/Date"
|
import { getDate } from "../../components/Date"
|
||||||
|
import { escapeHTML } from "../../util/escape"
|
||||||
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
|
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
|
||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { toHtml } from "hast-util-to-html"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||||
@ -10,6 +13,7 @@ export type ContentDetails = {
|
|||||||
links: SimpleSlug[]
|
links: SimpleSlug[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
content: string
|
content: string
|
||||||
|
richContent?: string
|
||||||
date?: Date
|
date?: Date
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@ -17,19 +21,23 @@ export type ContentDetails = {
|
|||||||
interface Options {
|
interface Options {
|
||||||
enableSiteMap: boolean
|
enableSiteMap: boolean
|
||||||
enableRSS: boolean
|
enableRSS: boolean
|
||||||
|
rssLimit?: number
|
||||||
|
rssFullHtml: boolean
|
||||||
includeEmptyFiles: boolean
|
includeEmptyFiles: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
const defaultOptions: Options = {
|
||||||
enableSiteMap: true,
|
enableSiteMap: true,
|
||||||
enableRSS: true,
|
enableRSS: true,
|
||||||
|
rssLimit: 10,
|
||||||
|
rssFullHtml: false,
|
||||||
includeEmptyFiles: true,
|
includeEmptyFiles: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||||
const base = cfg.baseUrl ?? ""
|
const base = cfg.baseUrl ?? ""
|
||||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||||
<loc>https://${base}/${slug}</loc>
|
<loc>https://${base}/${encodeURI(slug)}</loc>
|
||||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||||
</url>`
|
</url>`
|
||||||
const urls = Array.from(idx)
|
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>`
|
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 base = cfg.baseUrl ?? ""
|
||||||
const root = `https://${base}`
|
const root = `https://${base}`
|
||||||
|
|
||||||
@ -52,13 +60,17 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
|||||||
|
|
||||||
const items = Array.from(idx)
|
const items = Array.from(idx)
|
||||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
|
.slice(0, limit ?? idx.size)
|
||||||
.join("")
|
.join("")
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8" ?>
|
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>${cfg.pageTitle}</title>
|
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||||
<link>${root}</link>
|
<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>
|
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||||
${items}
|
${items}
|
||||||
</channel>
|
</channel>
|
||||||
@ -73,7 +85,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
const emitted: FilePath[] = []
|
const emitted: FilePath[] = []
|
||||||
const linkIndex: ContentIndex = new Map()
|
const linkIndex: ContentIndex = new Map()
|
||||||
for (const [_tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
@ -82,6 +94,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
links: file.data.links ?? [],
|
links: file.data.links ?? [],
|
||||||
tags: file.data.frontmatter?.tags ?? [],
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
content: file.data.text ?? "",
|
content: file.data.text ?? "",
|
||||||
|
richContent: opts?.rssFullHtml
|
||||||
|
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||||
|
: undefined,
|
||||||
date: date,
|
date: date,
|
||||||
description: file.data.description ?? "",
|
description: file.data.description ?? "",
|
||||||
})
|
})
|
||||||
@ -101,7 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
if (opts?.enableRSS) {
|
if (opts?.enableRSS) {
|
||||||
emitted.push(
|
emitted.push(
|
||||||
await emit({
|
await emit({
|
||||||
content: generateRSSFeed(cfg, linkIndex),
|
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||||
slug: "index" as FullSlug,
|
slug: "index" as FullSlug,
|
||||||
ext: ".xml",
|
ext: ".xml",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import HeaderConstructor from "../../components/Header"
|
|||||||
import BodyConstructor from "../../components/Body"
|
import BodyConstructor from "../../components/Body"
|
||||||
import { pageResources, renderPage } from "../../components/renderPage"
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
import { FullPageLayout } from "../../cfg"
|
import { FullPageLayout } from "../../cfg"
|
||||||
import { FilePath } from "../../util/path"
|
import { FilePath, pathToRoot } from "../../util/path"
|
||||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { Content } from "../../components"
|
import { Content } from "../../components"
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
const externalResources = pageResources(slug, resources)
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
externalResources,
|
externalResources,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
SimpleSlug,
|
SimpleSlug,
|
||||||
_stripSlashes,
|
_stripSlashes,
|
||||||
joinSegments,
|
joinSegments,
|
||||||
|
pathToRoot,
|
||||||
simplifySlug,
|
simplifySlug,
|
||||||
} from "../../util/path"
|
} from "../../util/path"
|
||||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const slug = joinSegments(folder, "index") as FullSlug
|
const slug = joinSegments(folder, "index") as FullSlug
|
||||||
const externalResources = pageResources(slug, resources)
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
const [tree, file] = folderDescriptions[folder]
|
const [tree, file] = folderDescriptions[folder]
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
|
|||||||
@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases"
|
|||||||
export { Assets } from "./assets"
|
export { Assets } from "./assets"
|
||||||
export { Static } from "./static"
|
export { Static } from "./static"
|
||||||
export { ComponentResources } from "./componentResources"
|
export { ComponentResources } from "./componentResources"
|
||||||
|
export { NotFoundPage } from "./404"
|
||||||
|
|||||||
@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body"
|
|||||||
import { pageResources, renderPage } from "../../components/renderPage"
|
import { pageResources, renderPage } from "../../components/renderPage"
|
||||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||||
import { FullPageLayout } from "../../cfg"
|
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 { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { TagContent } from "../../components"
|
import { TagContent } from "../../components"
|
||||||
|
|
||||||
@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
const slug = joinSegments("tags", tag) as FullSlug
|
const slug = joinSegments("tags", tag) as FullSlug
|
||||||
const externalResources = pageResources(slug, resources)
|
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||||
const [tree, file] = tagDescriptions[tag]
|
const [tree, file] = tagDescriptions[tag]
|
||||||
const componentData: QuartzComponentProps = {
|
const componentData: QuartzComponentProps = {
|
||||||
fileData: file.data,
|
fileData: file.data,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Root as HTMLRoot } from "hast"
|
import { Root as HTMLRoot } from "hast"
|
||||||
import { toString } from "hast-util-to-string"
|
import { toString } from "hast-util-to-string"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
import { escapeHTML } from "../../util/escape"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
descriptionLength: number
|
descriptionLength: number
|
||||||
@ -10,15 +11,6 @@ const defaultOptions: Options = {
|
|||||||
descriptionLength: 150,
|
descriptionLength: 150,
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapeHTML = (unsafe: string) => {
|
|
||||||
return unsafe
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -11,6 +11,11 @@ const defaultOptions: Options = {
|
|||||||
priority: ["frontmatter", "git", "filesystem"],
|
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
|
type MaybeDate = undefined | string | number
|
||||||
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
userOpts,
|
userOpts,
|
||||||
@ -49,9 +54,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
|||||||
}
|
}
|
||||||
|
|
||||||
file.data.dates = {
|
file.data.dates = {
|
||||||
created: created ? new Date(created) : new Date(),
|
created: coerceDate(created),
|
||||||
modified: modified ? new Date(modified) : new Date(),
|
modified: coerceDate(modified),
|
||||||
published: published ? new Date(published) : new Date(),
|
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,
|
SimpleSlug,
|
||||||
TransformOptions,
|
TransformOptions,
|
||||||
_stripSlashes,
|
_stripSlashes,
|
||||||
joinSegments,
|
|
||||||
simplifySlug,
|
simplifySlug,
|
||||||
splitAnchor,
|
splitAnchor,
|
||||||
transformLink,
|
transformLink,
|
||||||
@ -54,7 +53,8 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
||||||
|
|
||||||
// don't process external links or intra-document anchors
|
// 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(
|
dest = node.properties.href = transformLink(
|
||||||
file.data.slug!,
|
file.data.slug!,
|
||||||
dest,
|
dest,
|
||||||
@ -72,11 +72,13 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
simplifySlug(destCanonical as FullSlug),
|
simplifySlug(destCanonical as FullSlug),
|
||||||
) as SimpleSlug
|
) as SimpleSlug
|
||||||
outgoing.add(simple)
|
outgoing.add(simple)
|
||||||
|
node.properties["data-slug"] = simple
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewrite link internals if prettylinks is on
|
// rewrite link internals if prettylinks is on
|
||||||
if (
|
if (
|
||||||
opts.prettyLinks &&
|
opts.prettyLinks &&
|
||||||
|
isInternal &&
|
||||||
node.children.length === 1 &&
|
node.children.length === 1 &&
|
||||||
node.children[0].type === "text" &&
|
node.children[0].type === "text" &&
|
||||||
!node.children[0].value.startsWith("#")
|
!node.children[0].value.startsWith("#")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { PluggableList } from "unified"
|
import { PluggableList } from "unified"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
|
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 { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||||
import { slug as slugAnchor } from "github-slugger"
|
import { slug as slugAnchor } from "github-slugger"
|
||||||
import rehypeRaw from "rehype-raw"
|
import rehypeRaw from "rehype-raw"
|
||||||
@ -21,6 +22,7 @@ export interface Options {
|
|||||||
callouts: boolean
|
callouts: boolean
|
||||||
mermaid: boolean
|
mermaid: boolean
|
||||||
parseTags: boolean
|
parseTags: boolean
|
||||||
|
parseBlockReferences: boolean
|
||||||
enableInHtmlEmbed: boolean
|
enableInHtmlEmbed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ const defaultOptions: Options = {
|
|||||||
callouts: true,
|
callouts: true,
|
||||||
mermaid: true,
|
mermaid: true,
|
||||||
parseTags: true,
|
parseTags: true,
|
||||||
|
parseBlockReferences: true,
|
||||||
enableInHtmlEmbed: false,
|
enableInHtmlEmbed: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,6 +72,8 @@ const callouts = {
|
|||||||
const calloutMapping: Record<string, keyof typeof callouts> = {
|
const calloutMapping: Record<string, keyof typeof callouts> = {
|
||||||
note: "note",
|
note: "note",
|
||||||
abstract: "abstract",
|
abstract: "abstract",
|
||||||
|
summary: "abstract",
|
||||||
|
tldr: "abstract",
|
||||||
info: "info",
|
info: "info",
|
||||||
todo: "todo",
|
todo: "todo",
|
||||||
tip: "tip",
|
tip: "tip",
|
||||||
@ -96,7 +101,7 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
|
|||||||
|
|
||||||
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
||||||
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||||
return calloutMapping[callout] ?? calloutName
|
return calloutMapping[callout] ?? "note"
|
||||||
}
|
}
|
||||||
|
|
||||||
const capitalize = (s: string): string => {
|
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, 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 "/"
|
// (?:\/[-_\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 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> = (
|
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
userOpts,
|
userOpts,
|
||||||
@ -129,6 +135,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
const hast = toHast(ast, { allowDangerousHtml: true })!
|
const hast = toHast(ast, { allowDangerousHtml: true })!
|
||||||
return toHtml(hast, { allowDangerousHtml: true })
|
return toHtml(hast, { allowDangerousHtml: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const findAndReplace = opts.enableInHtmlEmbed
|
const findAndReplace = opts.enableInHtmlEmbed
|
||||||
? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
|
? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
|
||||||
if (replace) {
|
if (replace) {
|
||||||
@ -232,8 +239,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
value: `<iframe src="${url}"></iframe>`,
|
value: `<iframe src="${url}"></iframe>`,
|
||||||
}
|
}
|
||||||
} else if (ext === "") {
|
} 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
|
// otherwise, fall through to regular link
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,11 +424,63 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
},
|
},
|
||||||
htmlPlugins() {
|
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() {
|
externalResources() {
|
||||||
const js: JSResource[] = []
|
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,
|
ul.overflow,
|
||||||
ol.overflow {
|
ol.overflow {
|
||||||
height: 300px;
|
max-height: 300;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
// clearfix
|
// clearfix
|
||||||
@ -454,7 +454,7 @@ ol.overflow {
|
|||||||
clear: both;
|
clear: both;
|
||||||
|
|
||||||
& > li:last-of-type {
|
& > li:last-of-type {
|
||||||
margin-bottom: 50px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
@ -470,3 +470,9 @@ ol.overflow {
|
|||||||
background: linear-gradient(transparent 0px, var(--light));
|
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 {
|
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[] {
|
export function getAllSegmentPrefixes(tags: string): string[] {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user