forked from github/quartz
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ac2a7507 | ||
|
|
7ca491bc1d | ||
|
|
4edd27d3f9 | ||
|
|
2c8d0f8ab6 | ||
|
|
cd826fb477 | ||
|
|
273931d25c | ||
|
|
0403fa70aa | ||
|
|
015b4f6a15 | ||
|
|
4d338cec13 | ||
|
|
c11395e7bc | ||
|
|
1f2ea96ae0 | ||
|
|
ce3dd0923b | ||
|
|
af811d824f | ||
|
|
129e0c60a9 | ||
|
|
d7d5d8253c | ||
|
|
f6299da182 | ||
|
|
e17ff20244 | ||
|
|
107d9b8dff | ||
|
|
fa7d139ce5 | ||
|
|
f31cabbbf9 | ||
|
|
30640e3441 | ||
|
|
8eec47c340 | ||
|
|
f36376503a | ||
|
|
a40dbd55a4 | ||
|
|
e70312320f | ||
|
|
4e82b0d8ce | ||
|
|
783b9b219c | ||
|
|
4014c4d6d6 | ||
|
|
6babb788ed | ||
|
|
0a8c38dc21 | ||
|
|
52e6c03730 | ||
|
|
1a8aedf5f5 | ||
|
|
a4d6f701bf | ||
|
|
60017164ad | ||
|
|
5ccc48a172 | ||
|
|
707124cbd6 | ||
|
|
88194ac348 | ||
|
|
65d75b8bdc | ||
|
|
6e34844114 | ||
|
|
b33f13ccaf | ||
|
|
002bbc37b1 | ||
|
|
e603d7396b | ||
|
|
40cfccdc77 | ||
|
|
e758cbe1ee | ||
|
|
4b6c7aeffe | ||
|
|
e277ed5c30 | ||
|
|
68f53352e7 | ||
|
|
359484c139 | ||
|
|
dafc9f318e | ||
|
|
e1b6a0014c | ||
|
|
233d4b2f2c | ||
|
|
504b447162 | ||
|
|
63bf1e14b5 | ||
|
|
be76da9e95 | ||
|
|
8fe37cc5e5 | ||
|
|
2e9896c893 | ||
|
|
7bcf27241f | ||
|
|
b44a79eeba | ||
|
|
9b9d86474b | ||
|
|
4c83251f8e | ||
|
|
984ab1c578 | ||
|
|
443cd53a1a | ||
|
|
5152d32fbd | ||
|
|
ea6208c1f0 | ||
|
|
78b33fc2fb | ||
|
|
d2be097b76 | ||
|
|
ad1f964a5f | ||
|
|
150050f379 | ||
|
|
d979331dc7 | ||
|
|
972cf0a887 | ||
|
|
14e6b13ff1 | ||
|
|
3c01b92cc4 | ||
|
|
ed9bd43d9f | ||
|
|
c35818c336 | ||
|
|
a464ae5029 | ||
|
|
66e297c0ea | ||
|
|
4442847b37 | ||
|
|
e6b5ca33c9 | ||
|
|
1b92440009 | ||
|
|
c6546903f2 | ||
|
|
2c69b0c97d | ||
|
|
a7e20804f5 | ||
|
|
5196f3b9db | ||
|
|
f0ec6c9b92 | ||
|
|
9c88d5967f |
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -20,12 +20,19 @@ Steps to reproduce the behavior:
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
**Screenshots and Source**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
You can help speed up fixing the problem by either
|
||||
|
||||
1. providing a simple reproduction
|
||||
2. linking to your Quartz repository where the problem can be observed
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- Quartz Version: [e.g. v4.1.2]
|
||||
- `node` Version: [e.g. v18.16]
|
||||
- `npm` version: [e.g. v10.1.0]
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
|
||||
Quartz v4 features a from-the-ground rewrite focusing on end-user extensibility and ease-of-use.
|
||||
|
||||
**If you are looking for Quartz v3, you can find it on the [`hugo` branch](https://github.com/jackyzha0/quartz/tree/hugo).**
|
||||
|
||||
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
|
||||
|
||||
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
||||
|
||||
@@ -216,22 +216,19 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit(
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
emitCallback: EmitCallback,
|
||||
): Promise<FilePath[]>
|
||||
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||
}
|
||||
```
|
||||
|
||||
An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
|
||||
An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
|
||||
|
||||
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. Its interface looks something like this:
|
||||
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
|
||||
|
||||
```ts
|
||||
export type EmitCallback = (data: {
|
||||
export type WriteOptions = (data: {
|
||||
// the build context
|
||||
ctx: BuildCtx
|
||||
// the name of the file to emit (not including the file extension)
|
||||
slug: ServerSlug
|
||||
// the file extension
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Authoring Content
|
||||
---
|
||||
|
||||
All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initailized. Any Markdown in this folder will get processed by Quartz.
|
||||
All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initialized. Any Markdown in this folder will get processed by Quartz.
|
||||
|
||||
It is recommended that you use [Obsidian](https://obsidian.md/) as a way to edit and maintain your Quartz. It comes with a nice editor and graphical interface to preview, edit, and link your local files and attachments.
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ Finally, Quartz also provides `Plugin.CrawlLinks` which allows you to customize
|
||||
- `callouts`: whether to enable [[callouts]]. Defaults to `true`
|
||||
- `mermaid`: whether to enable [[Mermaid diagrams]]. Defaults to `true`
|
||||
- `parseTags`: whether to try and parse tags in the content body. Defaults to `true`
|
||||
- `parseArrows`: whether to try and parse arrows in the content body. Defaults to `true`.
|
||||
- `enableInHtmlEmbed`: whether to try and parse Obsidian flavoured markdown in raw HTML. Defaults to `false`
|
||||
- `enableYouTubeEmbed`: whether to enable embedded YouTube videos using external image Markdown syntax. Defaults to `false`
|
||||
- Link resolution behaviour:
|
||||
- Disabling: remove all instances of `Plugin.CrawlLinks()` from `quartz.config.ts`
|
||||
- Changing link resolution preference: set `markdownLinkResolution` to one of `absolute`, `relative` or `shortest`
|
||||
|
||||
@@ -20,7 +20,7 @@ Component.Breadcrumbs({
|
||||
rootName: "Home", // name of first/root element
|
||||
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
||||
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
|
||||
showCurrentPage: true, // wether to display the current page in the breadcrumbs
|
||||
showCurrentPage: true, // whether to display the current page in the breadcrumbs
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ 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
|
||||
useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
sortFn: (a, b) => {
|
||||
... // default implementation shown later
|
||||
@@ -179,6 +179,34 @@ Component.Explorer({
|
||||
|
||||
## Advanced examples
|
||||
|
||||
> [!tip]
|
||||
> When writing more complicated functions, the `layout` file can start to look very cramped.
|
||||
> You can fix this by defining your functions in another file.
|
||||
>
|
||||
> ```ts title="functions.ts"
|
||||
> import { Options } from "./quartz/components/ExplorerNode"
|
||||
> export const mapFn: Options["mapFn"] = (node) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> export const filterFn: Options["filterFn"] = (node) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> export const sortFn: Options["sortFn"] = (a, b) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> You can then import them like this:
|
||||
>
|
||||
> ```ts title="quartz.layout.ts"
|
||||
> import { mapFn, filterFn, sortFn } from "./functions.ts"
|
||||
> Component.Explorer({
|
||||
> mapFn: mapFn,
|
||||
> filterFn: filterFn,
|
||||
> sortFn: sortFn,
|
||||
> })
|
||||
> ```
|
||||
|
||||
### Add emoji prefix
|
||||
|
||||
To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this:
|
||||
@@ -216,30 +244,63 @@ Notice how we customized the `order` array here. This is done because the defaul
|
||||
|
||||
To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function.
|
||||
|
||||
> [!tip]
|
||||
> When writing more complicated functions, the `layout` file can start to look very cramped.
|
||||
> You can fix this by defining your functions in another file.
|
||||
>
|
||||
> ```ts title="functions.ts"
|
||||
> import { Options } from "./quartz/components/ExplorerNode"
|
||||
> export const mapFn: Options["mapFn"] = (node) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> export const filterFn: Options["filterFn"] = (node) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> export const sortFn: Options["sortFn"] = (a, b) => {
|
||||
> // implement your function here
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> You can then import them like this:
|
||||
>
|
||||
> ```ts title="quartz.layout.ts"
|
||||
> import { mapFn, filterFn, sortFn } from "./functions.ts"
|
||||
> Component.Explorer({
|
||||
> mapFn: mapFn,
|
||||
> filterFn: filterFn,
|
||||
> sortFn: sortFn,
|
||||
> })
|
||||
> ```
|
||||
### Use `sort` with pre-defined sort order
|
||||
|
||||
Here's another example where a map containing file/folder names (as slugs) is used to define the sort order of the explorer in quartz. All files/folders that aren't listed inside of `nameOrderMap` will appear at the top of that folders hierarchy level.
|
||||
|
||||
It's also worth mentioning, that the smaller the number set in `nameOrderMap`, the higher up the entry will be in the explorer. Incrementing every folder/file by 100, makes ordering files in their folders a lot easier. Lastly, this example still allows you to use a `mapFn` or frontmatter titles to change display names, as it uses slugs for `nameOrderMap` (which is unaffected by display name changes).
|
||||
|
||||
```ts title="quartz.layout.ts"
|
||||
Component.Explorer({
|
||||
sortFn: (a, b) => {
|
||||
const nameOrderMap: Record<string, number> = {
|
||||
"poetry-folder": 100,
|
||||
"essay-folder": 200,
|
||||
"research-paper-file": 201,
|
||||
"dinosaur-fossils-file": 300,
|
||||
"other-folder": 400,
|
||||
}
|
||||
|
||||
let orderA = 0
|
||||
let orderB = 0
|
||||
|
||||
if (a.file && a.file.slug) {
|
||||
orderA = nameOrderMap[a.file.slug] || 0
|
||||
} else if (a.name) {
|
||||
orderA = nameOrderMap[a.name] || 0
|
||||
}
|
||||
|
||||
if (b.file && b.file.slug) {
|
||||
orderB = nameOrderMap[b.file.slug] || 0
|
||||
} else if (b.name) {
|
||||
orderB = nameOrderMap[b.name] || 0
|
||||
}
|
||||
|
||||
return orderA - orderB
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For reference, this is how the quartz explorer window would look like with that example:
|
||||
|
||||
```
|
||||
📖 Poetry Folder
|
||||
📑 Essay Folder
|
||||
⚗️ Research Paper File
|
||||
🦴 Dinosaur Fossils File
|
||||
🔮 Other Folder
|
||||
```
|
||||
|
||||
And this is how the file structure would look like:
|
||||
|
||||
```
|
||||
index.md
|
||||
poetry-folder
|
||||
index.md
|
||||
essay-folder
|
||||
index.md
|
||||
research-paper-file.md
|
||||
dinosaur-fossils-file.md
|
||||
other-folder
|
||||
index.md
|
||||
```
|
||||
|
||||
@@ -34,6 +34,8 @@ Component.Graph({
|
||||
linkDistance: 30, // how long should the links be by default?
|
||||
fontSize: 0.6, // what size should the node labels be?
|
||||
opacityScale: 1, // how quickly do we fade out the labels when zooming out?
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
@@ -45,6 +47,8 @@ Component.Graph({
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -14,3 +14,11 @@ This is enabled as a part of [[Obsidian compatibility]] and can be configured an
|
||||
- `[[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`
|
||||
|
||||
### Embeds
|
||||
|
||||
- `![[Path to image]]`: embeds an image into the page
|
||||
- `![[Path to image|100x145]]`: embeds an image into the page with dimensions 100px by 145px
|
||||
- `![[Path to file]]`: transclude an entire page
|
||||
- `![[Path to file#Anchor]]`: transclude everything under the header `Anchor`
|
||||
- `![[Path to file#^b15695]]`: transclude block with ID `^b15695`
|
||||
|
||||
@@ -4,7 +4,10 @@ title: Hosting
|
||||
|
||||
Quartz effectively turns your Markdown files and other resources into a bundle of HTML, JS, and CSS files (a website!).
|
||||
|
||||
However, if you'd like to publish your site to the world, you need a way to host it online. This guide will detail how to deploy with either GitHub Pages or Cloudflare pages but any service that allows you to deploy static HTML should work as well (e.g. Netlify, Replit, etc.)
|
||||
However, if you'd like to publish your site to the world, you need a way to host it online. This guide will detail how to deploy with common hosting providers but any service that allows you to deploy static HTML should work as well.
|
||||
|
||||
> [!warning]
|
||||
> The rest of this guide assumes that you've already created your own GitHub repository for Quartz. If you haven't already, [[setting up your GitHub repository|make sure you do so]].
|
||||
|
||||
> [!hint]
|
||||
> Some Quartz features (like [[RSS Feed]] and sitemap generation) require `baseUrl` to be configured properly in your [[configuration]] to work properly. Make sure you set this before deploying!
|
||||
@@ -26,12 +29,10 @@ Press "Save and deploy" and Cloudflare should have a deployed version of your si
|
||||
|
||||
To add a custom domain, check out [Cloudflare's documentation](https://developers.cloudflare.com/pages/platform/custom-domains/).
|
||||
|
||||
## GitHub Pages
|
||||
|
||||
Like Quartz 3, you can deploy the site generated by Quartz 4 via GitHub Pages.
|
||||
|
||||
> [!warning]
|
||||
> Quartz generates files in the format of `file.html` instead of `file/index.html` which means the trailing slashes for _non-folder paths_ are dropped. As GitHub pages does not do this redirect, this may cause existing links to your site that use trailing slashes to break. If not breaking existing links is important to you, consider using [[#Cloudflare Pages]].
|
||||
> Cloudflare Pages only allows shallow `git` clones so if you rely on `git` for timestamps, it is recommended you either add dates to your frontmatter (see [[authoring content#Syntax]]) or use another hosting provider.
|
||||
|
||||
## GitHub Pages
|
||||
|
||||
In your local Quartz, create a new file `quartz/.github/workflows/deploy.yml`.
|
||||
|
||||
@@ -93,6 +94,9 @@ Then:
|
||||
>
|
||||
> You can do this by going to your Settings page on your GitHub fork and going to the Environments tab and pressing the trash icon. The GitHub action will recreate the environment for you correctly the next time you sync your Quartz.
|
||||
|
||||
> [!info]
|
||||
> Quartz generates files in the format of `file.html` instead of `file/index.html` which means the trailing slashes for _non-folder paths_ are dropped. As GitHub pages does not do this redirect, this may cause existing links to your site that use trailing slashes to break. If not breaking existing links is important to you (e.g. you are migrating from Quartz 3), consider using [[#Cloudflare Pages]].
|
||||
|
||||
### Custom Domain
|
||||
|
||||
Here's how to add a custom domain to your GitHub pages deployment.
|
||||
@@ -169,8 +173,6 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
|
||||
|
||||
## Netlify
|
||||
|
||||
Like Vercel, you can also deploy the site generated by Quartz 4 via Netlify.
|
||||
|
||||
1. Log in to the [Netlify dashboard](https://app.netlify.com/) and click "Add new site".
|
||||
2. Select your Git provider and repository containing your Quartz project.
|
||||
3. Under "Build command", enter `npx quartz build`.
|
||||
@@ -180,8 +182,6 @@ Like Vercel, you can also deploy the site generated by Quartz 4 via Netlify.
|
||||
|
||||
## GitLab Pages
|
||||
|
||||
You can configure GitLab CI to build and deploy a Quartz 4 project.
|
||||
|
||||
In your local Quartz, create a new file `.gitlab-ci.yaml`.
|
||||
|
||||
```yaml title=".gitlab-ci.yaml"
|
||||
@@ -203,8 +203,6 @@ build:
|
||||
- hash -r
|
||||
- npm ci
|
||||
script:
|
||||
- npx prettier --write .
|
||||
- npm run check
|
||||
- npx quartz build
|
||||
artifacts:
|
||||
paths:
|
||||
@@ -227,6 +225,6 @@ pages:
|
||||
- public
|
||||
```
|
||||
|
||||
When `.gitlab-ci.yaml` is commited, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy` -> `Pages` in the sidebar.
|
||||
When `.gitlab-ci.yaml` is committed, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy > Pages` in the sidebar.
|
||||
|
||||
By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`.
|
||||
|
||||
BIN
docs/images/github-init-repo-options.png
Normal file
BIN
docs/images/github-init-repo-options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/images/github-quick-setup.png
Normal file
BIN
docs/images/github-quick-setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@@ -2,7 +2,7 @@
|
||||
title: Welcome to Quartz 4
|
||||
---
|
||||
|
||||
Quartz is a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites. Thousands of students, developers, and teachers are [[showcase|already using Quartz]] to publish personal notes, wikis, and [digital gardens](https://jzhao.xyz/posts/networked-thought) to the web.
|
||||
Quartz is a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites. Thousands of students, developers, and teachers are [[showcase|already using Quartz]] to publish personal notes, websites, and [digital gardens](https://jzhao.xyz/posts/networked-thought) to the web.
|
||||
|
||||
## 🪴 Get Started
|
||||
|
||||
@@ -19,15 +19,12 @@ npx quartz create
|
||||
|
||||
This will guide you through initializing your Quartz with content. Once you've done so, see how to:
|
||||
|
||||
1. [[authoring content|Author content]] in Quartz
|
||||
1. [[authoring content|Writing content]] in Quartz
|
||||
2. [[configuration|Configure]] Quartz's behaviour
|
||||
3. Change Quartz's [[layout]]
|
||||
4. [[build|Build and preview]] Quartz
|
||||
5. [[hosting|Host]] Quartz online
|
||||
|
||||
> [!info]
|
||||
> Coming from Quartz 3? See the [[migrating from Quartz 3|migration guide]] for the differences between Quartz 3 and Quartz 4 and how to migrate.
|
||||
|
||||
## 🔧 Features
|
||||
|
||||
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], and [many more](./features) right out of the box
|
||||
|
||||
@@ -8,7 +8,9 @@ title: Philosophy of Quartz
|
||||
>
|
||||
> _(The Garden and the Stream)_
|
||||
|
||||
The problem with the file cabinet is that it focuses on efficiency of access and interoperability rather than generativity and creativity. Thinking is not linear, nor is it hierarchical. In fact, not many things are linear or hierarchical at all. Then why is it that most tools and thinking strategies assume a nice chronological or hierarchical order for my thought processes? The ideal tool for thought for me would embrace the messiness of my mind, and organically help insights emerge from chaos instead of forcing an artificial order. A rhizomatic, not arboresecent, form of note taking.
|
||||
The problem with the file cabinet is that it focuses on efficiency of access and interoperability rather than generativity and creativity. Thinking is not linear, nor is it hierarchical. In fact, not many things are linear or hierarchical at all. Then why is it that most tools and thinking strategies assume a nice chronological or hierarchical order for my thought processes?
|
||||
|
||||
The ideal tool for thought for me would embrace the messiness of my mind, and organically help insights emerge from chaos instead of forcing an artificial order. A rhizomatic, not arboresecent, form of note taking.
|
||||
|
||||
My goal with a digital garden is not purely as an organizing system and information store (though it works nicely for that). I want my digital garden to be a playground for new ways ideas can connect together. As a result, existing formal organizing systems like Zettelkasten or the hierarchical folder structures of Notion don’t work well for me. There is way too much upfront friction that by the time I’ve thought about how to organize my thought into folders categories, I’ve lost it.
|
||||
|
||||
@@ -25,4 +27,21 @@ Quartz is designed first and foremost as a tool for publishing [digital gardens]
|
||||
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.”
|
||||
> — Richard Hamming
|
||||
|
||||
**The goal of Quartz is to make sharing your digital garden free and simple.** At its core, Quartz is designed to be easy to use enough for non-technical people to get going but also powerful enough that senior developers can tweak it to work how they'd like it to work.
|
||||
**The goal of Quartz is to make sharing your digital garden free and simple.**
|
||||
|
||||
---
|
||||
|
||||
## A garden should be your own
|
||||
|
||||
At its core, Quartz is designed to be easy to use enough for non-technical people to get going but also powerful enough that senior developers can tweak it to work how they'd like it to work.
|
||||
|
||||
1. If you like the default configuration of Quartz and just want to change the content, the only thing that you need to change is the contents of the `content` folder.
|
||||
2. If you'd like to make basic configuration tweaks but don't want to edit source code, one can tweak the plugins and components in `quartz.config.ts` and `quartz.layout.ts` in a guided manner to their liking.
|
||||
3. If you'd like to tweak the actual source code of the underlying plugins, components, or even build process, Quartz purposefully ships its full source code to the end user to allow customization at this level too.
|
||||
|
||||
Most software either confines you to either
|
||||
|
||||
1. Makes it easy to tweak content but not the presentation
|
||||
2. Gives you too many knobs to tune the presentation without good opinionated defaults
|
||||
|
||||
**Quartz should feel powerful but ultimately be an intuitive tool fully within your control.** It should be a piece of [agentic software](https://jzhao.xyz/posts/agentic-computing). Ultimately, it should have the right affordances to nudge users towards good defaults but never dictate what the 'correct' way of using it is.
|
||||
|
||||
39
docs/setting up your GitHub repository.md
Normal file
39
docs/setting up your GitHub repository.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Setting up your GitHub repository
|
||||
---
|
||||
|
||||
First, make sure you have Quartz [[index#🪴 Get Started|cloned and setup locally]].
|
||||
|
||||
Then, create a new repository on GitHub.com. Do **not** initialize the new repository with `README`, license, or `gitignore` files.
|
||||
|
||||
![[github-init-repo-options.png]]
|
||||
|
||||
At the top of your repository on GitHub.com's Quick Setup page, click the clipboard to copy the remote repository URL.
|
||||
|
||||
![[github-quick-setup.png]]
|
||||
|
||||
In your terminal of choice, navigate to the root of your Quartz folder. Then, run the following commands, replacing `REMOTE-URL` with the URL you just copied from the previous step.
|
||||
|
||||
```bash
|
||||
# add your repository
|
||||
git remote add origin REMOTE-URL
|
||||
|
||||
# track the main quartz repository for updates
|
||||
git remote add upstream https://github.com/jackyzha0/quartz.git
|
||||
```
|
||||
|
||||
To verify that you set the remote URL correctly, run the following command.
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Then, you can sync the content to upload it to your repository.
|
||||
|
||||
```bash
|
||||
npx quartz sync --no-pull
|
||||
```
|
||||
|
||||
> [!hint]
|
||||
> If `npx quartz sync` fails with `fatal: --[no-]autostash option is only valid with --rebase`, you
|
||||
> may have an outdated version of `git`. Updating `git` should fix this issue.
|
||||
@@ -6,9 +6,9 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
||||
|
||||
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
|
||||
- [Jacky Zhao's Garden](https://jzhao.xyz/)
|
||||
- [Socratica Toolbox](https://toolbox.socratica.info/)
|
||||
- [Brandon Boswell's Garden](https://brandonkboswell.com)
|
||||
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
|
||||
- [AWAGMI Intern Notes](https://notes.awagmi.xyz/)
|
||||
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
|
||||
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
|
||||
- [oldwinter の数字花园](https://garden.oldwinter.top/)
|
||||
@@ -21,5 +21,8 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
||||
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
|
||||
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
|
||||
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
|
||||
- [🌊 Collapsed Wave](https://collapsedwave.com/)
|
||||
- [Aaron Pham's Garden](https://aarnphm.xyz/)
|
||||
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
|
||||
|
||||
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!
|
||||
|
||||
3711
package-lock.json
generated
3711
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
101
package.json
101
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@jackyzha0/quartz",
|
||||
"description": "🌱 publish your digital garden and notes as a website",
|
||||
"private": true,
|
||||
"version": "4.1.2",
|
||||
"version": "4.1.5",
|
||||
"type": "module",
|
||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||
"license": "MIT",
|
||||
@@ -34,76 +34,77 @@
|
||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.6.3",
|
||||
"@floating-ui/dom": "^1.4.0",
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@floating-ui/dom": "^1.5.3",
|
||||
"@napi-rs/simple-git": "0.1.9",
|
||||
"async-mutex": "^0.4.0",
|
||||
"chalk": "^4.1.2",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.8.5",
|
||||
"esbuild-sass-plugin": "^2.12.0",
|
||||
"esbuild-sass-plugin": "^2.16.0",
|
||||
"flexsearch": "0.7.21",
|
||||
"github-slugger": "^2.0.0",
|
||||
"globby": "^13.1.4",
|
||||
"globby": "^14.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hast-util-to-html": "^8.0.4",
|
||||
"hast-util-to-jsx-runtime": "^1.2.0",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"hast-util-to-html": "^9.0.0",
|
||||
"hast-util-to-jsx-runtime": "^2.3.0",
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"is-absolute-url": "^4.0.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lightningcss": "1.21.7",
|
||||
"mdast-util-find-and-replace": "^2.2.2",
|
||||
"mdast-util-to-hast": "^12.3.0",
|
||||
"mdast-util-to-string": "^3.2.0",
|
||||
"lightningcss": "^1.22.1",
|
||||
"mdast-util-find-and-replace": "^3.0.1",
|
||||
"mdast-util-to-hast": "^13.0.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromorph": "^0.4.5",
|
||||
"plausible-tracker": "^0.3.8",
|
||||
"preact": "^10.14.1",
|
||||
"preact-render-to-string": "^6.0.3",
|
||||
"pretty-bytes": "^6.1.0",
|
||||
"preact": "^10.19.3",
|
||||
"preact-render-to-string": "^6.3.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"pretty-time": "^1.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"rehype-mathjax": "^4.0.3",
|
||||
"rehype-pretty-code": "^0.10.0",
|
||||
"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",
|
||||
"remark-parse": "^10.0.1",
|
||||
"remark-rehype": "^10.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-mathjax": "^5.0.0",
|
||||
"rehype-pretty-code": "^0.12.3",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.0.0",
|
||||
"remark-smartypants": "^2.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"rfdc": "^1.3.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"serve-handler": "^6.1.5",
|
||||
"shikiji": "^0.9.9",
|
||||
"source-map-support": "^0.5.21",
|
||||
"to-vfile": "^7.2.4",
|
||||
"to-vfile": "^8.0.0",
|
||||
"toml": "^3.0.0",
|
||||
"unified": "^10.1.2",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"vfile": "^5.3.7",
|
||||
"workerpool": "^6.4.0",
|
||||
"ws": "^8.13.0",
|
||||
"unified": "^11.0.4",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.1",
|
||||
"workerpool": "^8.0.0",
|
||||
"ws": "^8.15.1",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-spinner": "^0.2.1",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/cli-spinner": "^0.2.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/hast": "^2.3.4",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/hast": "^3.0.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.1.2",
|
||||
"@types/pretty-time": "^1.1.2",
|
||||
"@types/source-map-support": "^0.5.6",
|
||||
"@types/workerpool": "^6.4.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"esbuild": "0.19.2",
|
||||
"prettier": "^3.0.0",
|
||||
"tsx": "^3.12.7",
|
||||
"typescript": "^5.0.4"
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/workerpool": "^6.4.7",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"esbuild": "^0.19.9",
|
||||
"prettier": "^3.1.1",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,15 @@ const config: QuartzConfig = {
|
||||
Plugin.FrontMatter(),
|
||||
Plugin.TableOfContents(),
|
||||
Plugin.CreatedModifiedDate({
|
||||
priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
|
||||
// you can add 'git' here for last modified from Git
|
||||
// if you do rely on git for dates, ensure defaultDateType is 'modified'
|
||||
priority: ["frontmatter", "filesystem"],
|
||||
}),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
Plugin.SyntaxHighlighting(),
|
||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||
Plugin.GitHubFlavoredMarkdown(),
|
||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||
Plugin.Latex({ renderEngine: "katex" }),
|
||||
Plugin.Description(),
|
||||
],
|
||||
filters: [Plugin.RemoveDrafts()],
|
||||
|
||||
@@ -37,12 +37,13 @@ export const defaultContentPageLayout: PageLayout = {
|
||||
|
||||
// components for pages that display lists of pages (e.g. tags or folders)
|
||||
export const defaultListPageLayout: PageLayout = {
|
||||
beforeBody: [Component.ArticleTitle()],
|
||||
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
|
||||
left: [
|
||||
Component.PageTitle(),
|
||||
Component.MobileOnly(Component.Spacer()),
|
||||
Component.Search(),
|
||||
Component.Darkmode(),
|
||||
Component.DesktopOnly(Component.Explorer()),
|
||||
],
|
||||
right: [],
|
||||
}
|
||||
|
||||
@@ -148,14 +148,17 @@ async function startServing(
|
||||
await rimraf(argv.output)
|
||||
await emitContent(ctx, filteredContent)
|
||||
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||
if (argv.verbose) {
|
||||
console.log(chalk.red(err))
|
||||
}
|
||||
}
|
||||
|
||||
release()
|
||||
clientRefresh()
|
||||
toRebuild.clear()
|
||||
toRemove.clear()
|
||||
release()
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(".", {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Analytics =
|
||||
| null
|
||||
| {
|
||||
provider: "plausible"
|
||||
host?: string
|
||||
}
|
||||
| {
|
||||
provider: "google"
|
||||
|
||||
@@ -113,7 +113,10 @@ export async function handleCreate(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
|
||||
const gitkeepPath = path.join(contentFolder, ".gitkeep")
|
||||
if (fs.existsSync(gitkeepPath)) {
|
||||
await fs.promises.unlink(gitkeepPath)
|
||||
}
|
||||
if (setupStrategy === "copy" || setupStrategy === "symlink") {
|
||||
let originalFolder = sourceDirectory
|
||||
|
||||
@@ -196,6 +199,11 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
)
|
||||
await fs.promises.writeFile(configFilePath, configContent)
|
||||
|
||||
// setup remote
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
)
|
||||
|
||||
outro(`You're all set! Not sure what to do next? Try:
|
||||
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
||||
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
||||
@@ -438,11 +446,23 @@ export async function handleUpdate(argv) {
|
||||
console.log(
|
||||
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
)
|
||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
|
||||
try {
|
||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
console.log("Ensuring dependencies are up to date")
|
||||
spawnSync("npm", ["i"], { stdio: "inherit" })
|
||||
console.log(chalk.green("Done!"))
|
||||
const res = spawnSync("npm", ["i"], { stdio: "inherit" })
|
||||
if (res.status === 0) {
|
||||
console.log(chalk.green("Done!"))
|
||||
} else {
|
||||
console.log(chalk.red("An error occurred above while installing dependencies."))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,13 +519,25 @@ export async function handleSync(argv) {
|
||||
console.log(
|
||||
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
)
|
||||
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
try {
|
||||
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||
} catch {
|
||||
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||
await popContentFolder(contentFolder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
if (argv.push) {
|
||||
console.log("Pushing your changes")
|
||||
spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
|
||||
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (res.status !== 0) {
|
||||
console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green("Done!"))
|
||||
|
||||
@@ -36,7 +36,9 @@ export function gitPull(origin, branch) {
|
||||
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
|
||||
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
|
||||
if (out.stderr) {
|
||||
throw new Error(`Error while pulling updates: ${out.stderr}`)
|
||||
throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`))
|
||||
} else if (out.status !== 0) {
|
||||
throw new Error(chalk.red("Error while pulling updates"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,15 @@ interface BreadcrumbOptions {
|
||||
*/
|
||||
rootName: string
|
||||
/**
|
||||
* wether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Wether to display breadcrumbs on root `index.md`
|
||||
* Whether to display breadcrumbs on root `index.md`
|
||||
*/
|
||||
hideOnRoot: boolean
|
||||
/**
|
||||
* Wether to display the current page in the breadcrumbs.
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
}
|
||||
@@ -68,9 +68,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
// construct the index for the first time
|
||||
for (const file of allFiles) {
|
||||
if (file.slug?.endsWith("index")) {
|
||||
const folderParts = file.filePath?.split("/")
|
||||
if (folderParts) {
|
||||
const folderName = folderParts[folderParts?.length - 2]
|
||||
const folderParts = file.slug?.split("/")
|
||||
// 2nd last to exclude the /index
|
||||
const folderName = folderParts?.at(-2)
|
||||
if (folderName) {
|
||||
folderIndex.set(folderName, file)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +89,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
// Try to resolve frontmatter folder title
|
||||
const currentFile = folderIndex?.get(curPathSegment)
|
||||
if (currentFile) {
|
||||
curPathSegment = currentFile.frontmatter!.title
|
||||
const title = currentFile.frontmatter!.title
|
||||
if (title !== "index") {
|
||||
curPathSegment = title
|
||||
}
|
||||
}
|
||||
|
||||
// Add current slug to full path
|
||||
@@ -100,13 +104,14 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
}
|
||||
|
||||
// Add current file to crumb (can directly use frontmatter title)
|
||||
if (options.showCurrentPage) {
|
||||
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||
crumbs.push({
|
||||
displayName: fileData.frontmatter!.title,
|
||||
path: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
|
||||
{crumbs.map((crumb, index) => (
|
||||
|
||||
@@ -2,18 +2,37 @@ import { formatDate, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
|
||||
export default (() => {
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: string[] = []
|
||||
const { text: timeTaken, words: _words } = readingTime(text)
|
||||
|
||||
if (fileData.dates) {
|
||||
segments.push(formatDate(getDate(cfg, fileData)!))
|
||||
}
|
||||
|
||||
segments.push(timeTaken)
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { text: timeTaken, words: _words } = readingTime(text)
|
||||
segments.push(timeTaken)
|
||||
}
|
||||
|
||||
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
|
||||
} else {
|
||||
return null
|
||||
|
||||
@@ -18,7 +18,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 35 35"
|
||||
style="enable-background:new 0 0 35 35;"
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<title>Light mode</title>
|
||||
@@ -34,7 +34,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background='new 0 0 100 100'"
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<title>Dark mode</title>
|
||||
|
||||
@@ -12,6 +12,9 @@ const defaultOptions = {
|
||||
folderClickBehavior: "collapse",
|
||||
folderDefaultState: "collapsed",
|
||||
useSavedState: true,
|
||||
mapFn: (node) => {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||
if ((!a.file && !b.file) || (a.file && b.file)) {
|
||||
@@ -22,6 +25,7 @@ const defaultOptions = {
|
||||
sensitivity: "base",
|
||||
})
|
||||
}
|
||||
|
||||
if (a.file && !b.file) {
|
||||
return 1
|
||||
} else {
|
||||
@@ -41,46 +45,34 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
let jsonTree: string
|
||||
|
||||
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||
if (!fileTree) {
|
||||
// Construct tree from allFiles
|
||||
fileTree = new FileNode("")
|
||||
allFiles.forEach((file) => fileTree.add(file, 1))
|
||||
if (fileTree) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of this object must match corresponding function name of `FileNode`,
|
||||
* while values must be the argument that will be passed to the function.
|
||||
*
|
||||
* e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
|
||||
*/
|
||||
const functions = {
|
||||
map: opts.mapFn,
|
||||
sort: opts.sortFn,
|
||||
filter: opts.filterFn,
|
||||
}
|
||||
// Construct tree from allFiles
|
||||
fileTree = new FileNode("")
|
||||
allFiles.forEach((file) => fileTree.add(file))
|
||||
|
||||
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
||||
if (opts.order) {
|
||||
// Order is important, use loop with index instead of order.map()
|
||||
for (let i = 0; i < opts.order.length; i++) {
|
||||
const functionName = opts.order[i]
|
||||
if (functions[functionName]) {
|
||||
// for every entry in order, call matching function in FileNode and pass matching argument
|
||||
// e.g. i = 0; functionName = "filter"
|
||||
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
|
||||
|
||||
// @ts-ignore
|
||||
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
|
||||
fileTree[functionName].call(fileTree, functions[functionName])
|
||||
}
|
||||
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
||||
if (opts.order) {
|
||||
// Order is important, use loop with index instead of order.map()
|
||||
for (let i = 0; i < opts.order.length; i++) {
|
||||
const functionName = opts.order[i]
|
||||
if (functionName === "map") {
|
||||
fileTree.map(opts.mapFn)
|
||||
} else if (functionName === "sort") {
|
||||
fileTree.sort(opts.sortFn)
|
||||
} else if (functionName === "filter") {
|
||||
fileTree.filter(opts.filterFn)
|
||||
}
|
||||
}
|
||||
|
||||
// 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])
|
||||
jsonTree = JSON.stringify(folders)
|
||||
}
|
||||
|
||||
// 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])
|
||||
jsonTree = JSON.stringify(folders)
|
||||
}
|
||||
|
||||
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
|
||||
@@ -120,6 +112,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = explorerStyle
|
||||
Explorer.afterDOMLoaded = script
|
||||
return Explorer
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// @ts-ignore
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { resolveRelative } from "../util/path"
|
||||
import {
|
||||
joinSegments,
|
||||
resolveRelative,
|
||||
clone,
|
||||
simplifySlug,
|
||||
SimpleSlug,
|
||||
FilePath,
|
||||
} from "../util/path"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
@@ -10,9 +17,9 @@ export interface Options {
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileNode, b: FileNode) => number
|
||||
filterFn?: (node: FileNode) => boolean
|
||||
mapFn?: (node: FileNode) => void
|
||||
order?: OrderEntries[]
|
||||
filterFn: (node: FileNode) => boolean
|
||||
mapFn: (node: FileNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
type DataWrapper = {
|
||||
@@ -25,59 +32,74 @@ export type FolderState = {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
|
||||
if (!fp) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return fp.split("/").at(idx)
|
||||
}
|
||||
|
||||
// Structure to add all files into a tree
|
||||
export class FileNode {
|
||||
children: FileNode[]
|
||||
name: string
|
||||
children: Array<FileNode>
|
||||
name: string // this is the slug segment
|
||||
displayName: string
|
||||
file: QuartzPluginData | null
|
||||
depth: number
|
||||
|
||||
constructor(name: string, file?: QuartzPluginData, depth?: number) {
|
||||
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
|
||||
this.children = []
|
||||
this.name = name
|
||||
this.displayName = name
|
||||
this.file = file ? structuredClone(file) : null
|
||||
this.name = slugSegment
|
||||
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
|
||||
this.file = file ? clone(file) : null
|
||||
this.depth = depth ?? 0
|
||||
}
|
||||
|
||||
private insert(file: DataWrapper) {
|
||||
if (file.path.length === 1) {
|
||||
if (file.path[0] !== "index.md") {
|
||||
this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
|
||||
} else {
|
||||
const title = file.file.frontmatter?.title
|
||||
if (title && title !== "index" && file.path[0] === "index.md") {
|
||||
private insert(fileData: DataWrapper) {
|
||||
if (fileData.path.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSegment = fileData.path[0]
|
||||
|
||||
// base case, insert here
|
||||
if (fileData.path.length === 1) {
|
||||
if (nextSegment === "") {
|
||||
// index case (we are the root and we just found index.md), set our data appropriately
|
||||
const title = fileData.file.frontmatter?.title
|
||||
if (title && title !== "index") {
|
||||
this.displayName = title
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
} else {
|
||||
// direct child
|
||||
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
|
||||
}
|
||||
|
||||
const newChild = new FileNode(next, undefined, this.depth + 1)
|
||||
newChild.insert(file)
|
||||
this.children.push(newChild)
|
||||
return
|
||||
}
|
||||
|
||||
// find the right child to insert into
|
||||
fileData.path = fileData.path.splice(1)
|
||||
const child = this.children.find((c) => c.name === nextSegment)
|
||||
if (child) {
|
||||
child.insert(fileData)
|
||||
return
|
||||
}
|
||||
|
||||
const newChild = new FileNode(
|
||||
nextSegment,
|
||||
getPathSegment(fileData.file.relativePath, this.depth),
|
||||
undefined,
|
||||
this.depth + 1,
|
||||
)
|
||||
newChild.insert(fileData)
|
||||
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))
|
||||
add(file: QuartzPluginData) {
|
||||
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +117,6 @@ export class FileNode {
|
||||
*/
|
||||
map(mapFn: (node: FileNode) => void) {
|
||||
mapFn(this)
|
||||
|
||||
this.children.forEach((child) => child.map(mapFn))
|
||||
}
|
||||
|
||||
@@ -110,16 +131,16 @@ export class FileNode {
|
||||
|
||||
const traverse = (node: FileNode, currentPath: string) => {
|
||||
if (!node.file) {
|
||||
const folderPath = currentPath + (currentPath ? "/" : "") + node.name
|
||||
const folderPath = joinSegments(currentPath, node.name)
|
||||
if (folderPath !== "") {
|
||||
folderPaths.push({ path: folderPath, collapsed })
|
||||
}
|
||||
|
||||
node.children.forEach((child) => traverse(child, folderPath))
|
||||
}
|
||||
}
|
||||
|
||||
traverse(this, "")
|
||||
|
||||
return folderPaths
|
||||
}
|
||||
|
||||
@@ -147,14 +168,13 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
||||
const isDefaultOpen = opts.folderDefaultState === "open"
|
||||
|
||||
// Calculate current folderPath
|
||||
let pathOld = fullPath ? fullPath : ""
|
||||
let folderPath = ""
|
||||
if (node.name !== "") {
|
||||
folderPath = `${pathOld}/${node.name}`
|
||||
folderPath = joinSegments(fullPath ?? "", node.name)
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<>
|
||||
{node.file ? (
|
||||
// Single file node
|
||||
<li key={node.file.slug}>
|
||||
@@ -163,7 +183,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<div>
|
||||
<li>
|
||||
{node.name !== "" && (
|
||||
// Node with entire folder
|
||||
// Render svg button + folder name, then children
|
||||
@@ -185,12 +205,16 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
||||
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
||||
<div key={node.name} data-folderpath={folderPath}>
|
||||
{folderBehavior === "link" ? (
|
||||
<a href={`${folderPath}`} data-for={node.name} class="folder-title">
|
||||
<a
|
||||
href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
|
||||
data-for={node.name}
|
||||
class="folder-title"
|
||||
>
|
||||
{node.displayName}
|
||||
</a>
|
||||
) : (
|
||||
<button class="folder-button">
|
||||
<p class="folder-title">{node.displayName}</p>
|
||||
<span class="folder-title">{node.displayName}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -217,8 +241,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ function TagContent(props: QuartzComponentProps) {
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
|
||||
if (tag === "") {
|
||||
const tags = [...new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))]
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b))
|
||||
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
|
||||
@@ -23,7 +23,7 @@ export function pageResources(
|
||||
staticResources: StaticResources,
|
||||
): StaticResources {
|
||||
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())`
|
||||
|
||||
return {
|
||||
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
|
||||
@@ -74,13 +74,13 @@ export function renderPage(
|
||||
const classNames = (node.properties?.className ?? []) as string[]
|
||||
if (classNames.includes("transclude")) {
|
||||
const inner = node.children[0] as Element
|
||||
const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
|
||||
const transcludeTarget = inner.properties["data-slug"] as FullSlug
|
||||
const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
let blockRef = node.properties?.dataBlock as string | undefined
|
||||
let blockRef = node.properties.dataBlock as string | undefined
|
||||
if (blockRef?.startsWith("#^")) {
|
||||
// block transclude
|
||||
blockRef = blockRef.slice("#^".length)
|
||||
@@ -90,6 +90,7 @@ export function renderPage(
|
||||
blockNode = {
|
||||
type: "element",
|
||||
tagName: "ul",
|
||||
properties: {},
|
||||
children: [blockNode],
|
||||
}
|
||||
}
|
||||
@@ -144,6 +145,7 @@ export function renderPage(
|
||||
{
|
||||
type: "element",
|
||||
tagName: "h1",
|
||||
properties: {},
|
||||
children: [
|
||||
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
|
||||
],
|
||||
|
||||
@@ -59,8 +59,7 @@ function toggleFolder(evt: MouseEvent) {
|
||||
// Save folder state to localStorage
|
||||
const clickFolderPath = currentFolderParent.dataset.folderpath as string
|
||||
|
||||
// Remove leading "/"
|
||||
const fullFolderPath = clickFolderPath.substring(1)
|
||||
const fullFolderPath = clickFolderPath
|
||||
toggleCollapsedByPath(explorerState, fullFolderPath)
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(explorerState)
|
||||
@@ -108,9 +107,7 @@ function setupExplorer() {
|
||||
explorerState = JSON.parse(storageTree)
|
||||
explorerState.map((folderUl) => {
|
||||
// grab <li> element for matching folder path
|
||||
const folderLi = document.querySelector(
|
||||
`[data-folderpath='/${folderUl.path}']`,
|
||||
) as HTMLElement
|
||||
const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
|
||||
|
||||
// Get corresponding content <ul> tag and set state
|
||||
if (folderLi) {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import Plausible from "plausible-tracker"
|
||||
const { trackPageview } = Plausible()
|
||||
document.addEventListener("nav", () => trackPageview())
|
||||
@@ -7,6 +7,10 @@ async function mouseEnterHandler(
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
) {
|
||||
const link = this
|
||||
if (link.dataset.noPopover === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||
|
||||
@@ -35,12 +35,12 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
if (trim) {
|
||||
const includesCheck = (tok: string) =>
|
||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||
const occurencesIndices = tokenizedText.map(includesCheck)
|
||||
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||
|
||||
let bestSum = 0
|
||||
let bestIndex = 0
|
||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||
const window = occurencesIndices.slice(i, i + contextWindowWords)
|
||||
const window = occurrencesIndices.slice(i, i + contextWindowWords)
|
||||
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||
if (windowSum >= bestSum) {
|
||||
bestSum = windowSum
|
||||
@@ -122,7 +122,10 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
|
||||
// add "#" prefix for tag search
|
||||
if (searchBar) searchBar.value = "#"
|
||||
} else if (e.key === "Enter") {
|
||||
}
|
||||
|
||||
if (!container?.classList.contains("active")) return
|
||||
else if (e.key === "Enter") {
|
||||
// If result has focus, navigate to that one, otherwise pick first result
|
||||
if (results?.contains(document.activeElement)) {
|
||||
const active = document.activeElement as HTMLInputElement
|
||||
@@ -131,7 +134,14 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||
anchor?.click()
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
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()
|
||||
}
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
// When first pressing ArrowDown, results wont contain the active element, so focus first element
|
||||
if (!results?.contains(document.activeElement)) {
|
||||
@@ -142,13 +152,6 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +199,7 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
const termLower = term.toLowerCase()
|
||||
let matching = tags.filter((str) => str.includes(termLower))
|
||||
|
||||
// Substract matching from original tags, then push difference
|
||||
// Subtract matching from original tags, then push difference
|
||||
if (matching.length > 0) {
|
||||
let difference = tags.filter((x) => !matching.includes(x))
|
||||
|
||||
@@ -219,16 +222,16 @@ document.addEventListener("nav", async (e: unknown) => {
|
||||
|
||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
||||
const button = document.createElement("button")
|
||||
button.classList.add("result-card")
|
||||
button.id = slug
|
||||
button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
||||
button.addEventListener("click", () => {
|
||||
const targ = resolveRelative(currentSlug, slug)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
const itemTile = document.createElement("a")
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString()
|
||||
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
})
|
||||
return button
|
||||
return itemTile
|
||||
}
|
||||
|
||||
function displayResults(finalResults: Item[]) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
float: right;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
margin: -0.2rem 0.3rem;
|
||||
margin: 0.3rem;
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
|
||||
@@ -106,7 +106,7 @@ svg {
|
||||
align-items: center;
|
||||
font-family: var(--headerFont);
|
||||
|
||||
& p {
|
||||
& span {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
@@ -126,7 +126,7 @@ svg {
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
div:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
max-height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
font-style: initial;
|
||||
line-height: normal;
|
||||
font-size: initial;
|
||||
font-family: var(--bodyFont);
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
|
||||
// normalize button props
|
||||
// normalize card props
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
@@ -104,6 +104,7 @@
|
||||
text-align: left;
|
||||
background: var(--light);
|
||||
outline: none;
|
||||
font-weight: inherit;
|
||||
|
||||
& .highlight {
|
||||
color: var(--secondary);
|
||||
|
||||
@@ -9,7 +9,7 @@ export type QuartzComponentProps = {
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
children: (QuartzComponent | JSX.Element)[]
|
||||
tree: Node<QuartzPluginData>
|
||||
tree: Node
|
||||
allFiles: QuartzPluginData[]
|
||||
displayClass?: "mobile-only" | "desktop-only"
|
||||
} & JSX.IntrinsicAttributes & {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FilePath, FullSlug } from "../../util/path"
|
||||
import { sharedPageComponents } from "../../../quartz.layout"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
const opts: FullPageLayout = {
|
||||
@@ -25,7 +26,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Body, pageBody, Footer]
|
||||
},
|
||||
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, _content, resources): Promise<FilePath[]> {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const slug = "404" as FullSlug
|
||||
|
||||
@@ -48,7 +49,8 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
}
|
||||
|
||||
return [
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: renderPage(slug, componentData, opts, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import path from "path"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
name: "AliasRedirects",
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, _resources): Promise<FilePath[]> {
|
||||
const { argv } = ctx
|
||||
const fps: FilePath[] = []
|
||||
|
||||
for (const [_tree, file] of content) {
|
||||
@@ -32,7 +34,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
}
|
||||
|
||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||
const fp = await emit({
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-us">
|
||||
|
||||
@@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
// glob all non MD/MDX/HTML files in content folder and copy it over
|
||||
const assetsPath = argv.output
|
||||
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
if (!cfg.configuration.baseUrl) {
|
||||
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
||||
return []
|
||||
|
||||
@@ -4,8 +4,6 @@ import { QuartzEmitterPlugin } from "../types"
|
||||
// @ts-ignore
|
||||
import spaRouterScript from "../../components/scripts/spa.inline"
|
||||
// @ts-ignore
|
||||
import plausibleScript from "../../components/scripts/plausible.inline"
|
||||
// @ts-ignore
|
||||
import popoverScript from "../../components/scripts/popover.inline"
|
||||
import styles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
@@ -14,6 +12,8 @@ import { StaticResources } from "../../util/resources"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { googleFontHref, joinStyles } from "../../util/theme"
|
||||
import { Features, transform } from "lightningcss"
|
||||
import { transform as transpile } from "esbuild"
|
||||
import { write } from "./helpers"
|
||||
|
||||
type ComponentResources = {
|
||||
css: string[]
|
||||
@@ -56,9 +56,16 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
|
||||
}
|
||||
}
|
||||
|
||||
function joinScripts(scripts: string[]): string {
|
||||
async function joinScripts(scripts: string[]): Promise<string> {
|
||||
// wrap with iife to prevent scope collision
|
||||
return scripts.map((script) => `(function () {${script}})();`).join("\n")
|
||||
const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
|
||||
|
||||
// minify with esbuild
|
||||
const res = await transpile(script, {
|
||||
minify: true,
|
||||
})
|
||||
|
||||
return res.code
|
||||
}
|
||||
|
||||
function addGlobalPageResources(
|
||||
@@ -85,24 +92,37 @@ function addGlobalPageResources(
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag(\`js\`, new Date());
|
||||
gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
|
||||
|
||||
document.addEventListener(\`nav\`, () => {
|
||||
gtag(\`event\`, \`page_view\`, {
|
||||
gtag("js", new Date());
|
||||
gtag("config", "${tagId}", { send_page_view: false });
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
gtag("event", "page_view", {
|
||||
page_title: document.title,
|
||||
page_location: location.href,
|
||||
});
|
||||
});`)
|
||||
} else if (cfg.analytics?.provider === "plausible") {
|
||||
componentResources.afterDOMLoaded.push(plausibleScript)
|
||||
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const plausibleScript = document.createElement("script")
|
||||
plausibleScript.src = "${plausibleHost}/js/script.manual.js"
|
||||
plausibleScript.setAttribute("data-domain", location.hostname)
|
||||
plausibleScript.defer = true
|
||||
document.head.appendChild(plausibleScript)
|
||||
|
||||
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
plausible("pageview")
|
||||
})
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "umami") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const umamiScript = document.createElement("script")
|
||||
umamiScript.src = "https://analytics.umami.is/script.js"
|
||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||
umamiScript.async = true
|
||||
|
||||
|
||||
document.head.appendChild(umamiScript)
|
||||
`)
|
||||
}
|
||||
@@ -149,7 +169,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, _content, resources): Promise<FilePath[]> {
|
||||
// component specific scripts and styles
|
||||
const componentResources = getComponentResources(ctx)
|
||||
// important that this goes *after* component scripts
|
||||
@@ -165,10 +185,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
addGlobalPageResources(ctx, resources, componentResources)
|
||||
|
||||
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
|
||||
const prescript = joinScripts(componentResources.beforeDOMLoaded)
|
||||
const postscript = joinScripts(componentResources.afterDOMLoaded)
|
||||
const [prescript, postscript] = await Promise.all([
|
||||
joinScripts(componentResources.beforeDOMLoaded),
|
||||
joinScripts(componentResources.afterDOMLoaded),
|
||||
])
|
||||
|
||||
const fps = await Promise.all([
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".css",
|
||||
content: transform({
|
||||
@@ -185,12 +209,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
||||
include: Features.MediaQueries,
|
||||
}).code.toString(),
|
||||
}),
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "prescript" as FullSlug,
|
||||
ext: ".js",
|
||||
content: prescript,
|
||||
}),
|
||||
emit({
|
||||
write({
|
||||
ctx,
|
||||
slug: "postscript" as FullSlug,
|
||||
ext: ".js",
|
||||
content: postscript,
|
||||
|
||||
@@ -2,10 +2,11 @@ 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 { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import path from "path"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||
export type ContentDetails = {
|
||||
@@ -37,7 +38,7 @@ const defaultOptions: Options = {
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${base}/${encodeURI(slug)}</loc>
|
||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||
</url>`
|
||||
const urls = Array.from(idx)
|
||||
@@ -48,12 +49,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
|
||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const root = `https://${base}`
|
||||
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||
<title>${escapeHTML(content.title)}</title>
|
||||
<link>${root}/${encodeURI(slug)}</link>
|
||||
<guid>${root}/${encodeURI(slug)}</guid>
|
||||
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||
<description>${content.richContent ?? content.description}</description>
|
||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||
</item>`
|
||||
@@ -78,7 +78,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<link>${root}</link>
|
||||
<link>https://${base}</link>
|
||||
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
||||
cfg.pageTitle,
|
||||
)}</description>
|
||||
@@ -92,7 +92,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
opts = { ...defaultOptions, ...opts }
|
||||
return {
|
||||
name: "ContentIndex",
|
||||
async emit(ctx, content, _resources, emit) {
|
||||
async emit(ctx, content, _resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const emitted: FilePath[] = []
|
||||
const linkIndex: ContentIndex = new Map()
|
||||
@@ -116,7 +116,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
|
||||
if (opts?.enableSiteMap) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: generateSiteMap(cfg, linkIndex),
|
||||
slug: "sitemap" as FullSlug,
|
||||
ext: ".xml",
|
||||
@@ -126,7 +127,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
|
||||
if (opts?.enableRSS) {
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".xml",
|
||||
@@ -134,7 +136,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
)
|
||||
}
|
||||
|
||||
const fp = path.join("static", "contentIndex") as FullSlug
|
||||
const fp = joinSegments("static", "contentIndex") as FullSlug
|
||||
const simplifiedIndex = Object.fromEntries(
|
||||
Array.from(linkIndex).map(([slug, content]) => {
|
||||
// remove description and from content index as nothing downstream
|
||||
@@ -147,7 +149,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
)
|
||||
|
||||
emitted.push(
|
||||
await emit({
|
||||
await write({
|
||||
ctx,
|
||||
content: JSON.stringify(simplifiedIndex),
|
||||
slug: fp,
|
||||
ext: ".json",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilePath, pathToRoot } from "../../util/path"
|
||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { Content } from "../../components"
|
||||
import chalk from "chalk"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
@@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
@@ -49,7 +50,8 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { FolderContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
@@ -35,7 +36,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
@@ -82,7 +83,8 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
|
||||
19
quartz/plugins/emitters/helpers.ts
Normal file
19
quartz/plugins/emitters/helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { FilePath, FullSlug, joinSegments } from "../../util/path"
|
||||
|
||||
type WriteOptions = {
|
||||
ctx: BuildCtx
|
||||
slug: FullSlug
|
||||
ext: `.${string}` | ""
|
||||
content: string
|
||||
}
|
||||
|
||||
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
|
||||
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
|
||||
const dir = path.dirname(pathToPage)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(pathToPage, content)
|
||||
return pathToPage
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
||||
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||
const staticPath = joinSegments(QUARTZ, "static")
|
||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { TagContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
|
||||
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
@@ -32,7 +33,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
getQuartzComponents() {
|
||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
||||
},
|
||||
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
|
||||
async emit(ctx, content, resources): Promise<FilePath[]> {
|
||||
const fps: FilePath[] = []
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
@@ -40,12 +41,13 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
const tags: Set<string> = new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
)
|
||||
|
||||
// add base tag
|
||||
tags.add("index")
|
||||
|
||||
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||
[...tags].map((tag) => {
|
||||
const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
|
||||
const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
|
||||
return [
|
||||
tag,
|
||||
defaultProcessedContent({
|
||||
@@ -80,7 +82,8 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
||||
}
|
||||
|
||||
const content = renderPage(slug, componentData, opts, externalResources)
|
||||
const fp = await emit({
|
||||
const fp = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug: file.data.slug!,
|
||||
ext: ".html",
|
||||
|
||||
@@ -3,7 +3,11 @@ import { QuartzFilterPlugin } from "../types"
|
||||
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
||||
name: "ExplicitPublish",
|
||||
shouldPublish(_ctx, [_tree, vfile]) {
|
||||
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
|
||||
const publishProperty = vfile.data?.frontmatter?.publish ?? false
|
||||
const publishFlag =
|
||||
typeof publishProperty === "string"
|
||||
? publishProperty.toLowerCase() === "true"
|
||||
: Boolean(publishProperty)
|
||||
return publishFlag
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,5 +30,6 @@ declare module "vfile" {
|
||||
interface DataMap {
|
||||
slug: FullSlug
|
||||
filePath: FilePath
|
||||
relativePath: FilePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,18 @@ import { QuartzTransformerPlugin } from "../types"
|
||||
import yaml from "js-yaml"
|
||||
import toml from "toml"
|
||||
import { slugTag } from "../../util/path"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
|
||||
export interface Options {
|
||||
delims: string | string[]
|
||||
language: "yaml" | "toml"
|
||||
oneLineTagDelim: string
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
delims: "---",
|
||||
language: "yaml",
|
||||
oneLineTagDelim: ",",
|
||||
}
|
||||
|
||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
@@ -20,11 +23,13 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
return {
|
||||
name: "FrontMatter",
|
||||
markdownPlugins() {
|
||||
const { oneLineTagDelim } = opts
|
||||
|
||||
return [
|
||||
[remarkFrontmatter, ["yaml", "toml"]],
|
||||
() => {
|
||||
return (_, file) => {
|
||||
const { data } = matter(file.value, {
|
||||
const { data } = matter(Buffer.from(file.value), {
|
||||
...opts,
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||
@@ -40,24 +45,30 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
||||
// coerce title to string
|
||||
if (data.title) {
|
||||
data.title = data.title.toString()
|
||||
} else if (data.title === null || data.title === undefined) {
|
||||
data.title = file.stem ?? "Untitled"
|
||||
}
|
||||
|
||||
if (data.tags && !Array.isArray(data.tags)) {
|
||||
if (data.tags) {
|
||||
// coerce to array
|
||||
if (!Array.isArray(data.tags)) {
|
||||
data.tags = data.tags
|
||||
.toString()
|
||||
.split(oneLineTagDelim)
|
||||
.map((tag: string) => tag.trim())
|
||||
}
|
||||
|
||||
// remove all non-string tags
|
||||
data.tags = data.tags
|
||||
.toString()
|
||||
.split(",")
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
|
||||
.map((tag: string | number) => tag.toString())
|
||||
}
|
||||
|
||||
// slug them all!!
|
||||
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
|
||||
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
|
||||
|
||||
// fill in frontmatter
|
||||
file.data.frontmatter = {
|
||||
title: file.stem ?? "Untitled",
|
||||
tags: [],
|
||||
...data,
|
||||
}
|
||||
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -31,6 +31,11 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "append",
|
||||
properties: {
|
||||
ariaHidden: true,
|
||||
tabIndex: -1,
|
||||
"data-no-popover": true,
|
||||
},
|
||||
content: {
|
||||
type: "text",
|
||||
value: " §",
|
||||
|
||||
@@ -57,10 +57,22 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
||||
published ||= file.data.frontmatter.publishDate
|
||||
} else if (source === "git") {
|
||||
if (!repo) {
|
||||
repo = new Repository(file.cwd)
|
||||
// Get a reference to the main git repo.
|
||||
// It's either the same as the workdir,
|
||||
// or 1+ level higher in case of a submodule/subtree setup
|
||||
repo = Repository.discover(file.cwd)
|
||||
}
|
||||
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||
try {
|
||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nWarning: ${file.data
|
||||
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import remarkMath from "remark-math"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import rehypeMathjax from "rehype-mathjax/svg.js"
|
||||
import rehypeMathjax from "rehype-mathjax/svg"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
|
||||
interface Options {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import path from "path"
|
||||
import { visit } from "unist-util-visit"
|
||||
import isAbsoluteUrl from "is-absolute-url"
|
||||
import { Root } from "hast"
|
||||
|
||||
interface Options {
|
||||
/** How to resolve Markdown paths */
|
||||
@@ -19,12 +20,16 @@ interface Options {
|
||||
/** Strips folders from a link so that it looks nice */
|
||||
prettyLinks: boolean
|
||||
openLinksInNewTab: boolean
|
||||
lazyLoad: boolean
|
||||
externalLinkIcon: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
markdownLinkResolution: "absolute",
|
||||
prettyLinks: true,
|
||||
openLinksInNewTab: false,
|
||||
lazyLoad: false,
|
||||
externalLinkIcon: true,
|
||||
}
|
||||
|
||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||
@@ -34,7 +39,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
htmlPlugins(ctx) {
|
||||
return [
|
||||
() => {
|
||||
return (tree, file) => {
|
||||
return (tree: Root, file) => {
|
||||
const curSlug = simplifySlug(file.data.slug!)
|
||||
const outgoing: Set<SimpleSlug> = new Set()
|
||||
|
||||
@@ -51,8 +56,30 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
typeof node.properties.href === "string"
|
||||
) {
|
||||
let dest = node.properties.href as RelativeURL
|
||||
node.properties.className ??= []
|
||||
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
||||
const classes = (node.properties.className ?? []) as string[]
|
||||
const isExternal = isAbsoluteUrl(dest)
|
||||
classes.push(isExternal ? "external" : "internal")
|
||||
|
||||
if (isExternal && opts.externalLinkIcon) {
|
||||
node.children.push({
|
||||
type: "element",
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
class: "external-icon",
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the link has alias text
|
||||
if (
|
||||
@@ -61,8 +88,9 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
node.children[0].value !== dest
|
||||
) {
|
||||
// Add the 'alias' class if the text content is not the same as the href
|
||||
node.properties.className.push("alias")
|
||||
classes.push("alias")
|
||||
}
|
||||
node.properties.className = classes
|
||||
|
||||
if (opts.openLinksInNewTab) {
|
||||
node.properties.target = "_blank"
|
||||
@@ -111,6 +139,10 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
||||
node.properties &&
|
||||
typeof node.properties.src === "string"
|
||||
) {
|
||||
if (opts.lazyLoad) {
|
||||
node.properties.loading = "lazy"
|
||||
}
|
||||
|
||||
if (!isAbsoluteUrl(node.properties.src)) {
|
||||
let dest = node.properties.src as RelativeURL
|
||||
dest = node.properties.src = transformLink(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { PluggableList } from "unified"
|
||||
import { QuartzTransformerPlugin } from "../types"
|
||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
|
||||
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
|
||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
||||
import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
import { visit } from "unist-util-visit"
|
||||
import { SKIP, visit } from "unist-util-visit"
|
||||
import path from "path"
|
||||
import { JSResource } from "../../util/resources"
|
||||
// @ts-ignore
|
||||
@@ -15,6 +14,7 @@ import { toHast } from "mdast-util-to-hast"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
||||
import { capitalize } from "../../util/lang"
|
||||
import { PluggableList } from "unified"
|
||||
|
||||
export interface Options {
|
||||
comments: boolean
|
||||
@@ -23,8 +23,11 @@ export interface Options {
|
||||
callouts: boolean
|
||||
mermaid: boolean
|
||||
parseTags: boolean
|
||||
parseArrows: boolean
|
||||
parseBlockReferences: boolean
|
||||
enableInHtmlEmbed: boolean
|
||||
enableYouTubeEmbed: boolean
|
||||
enableVideoEmbed: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
@@ -34,8 +37,11 @@ const defaultOptions: Options = {
|
||||
callouts: true,
|
||||
mermaid: true,
|
||||
parseTags: true,
|
||||
parseArrows: true,
|
||||
parseBlockReferences: true,
|
||||
enableInHtmlEmbed: false,
|
||||
enableYouTubeEmbed: true,
|
||||
enableVideoEmbed: true,
|
||||
}
|
||||
|
||||
const icons = {
|
||||
@@ -105,6 +111,10 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
||||
return calloutMapping[callout] ?? "note"
|
||||
}
|
||||
|
||||
export const externalLinkRegex = /^https?:\/\//i
|
||||
|
||||
export const arrowRegex = new RegExp(/-{1,2}>/, "g")
|
||||
|
||||
// !? -> optional embedding
|
||||
// \[\[ -> open brace
|
||||
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
||||
@@ -115,16 +125,18 @@ export const wikilinkRegex = new RegExp(
|
||||
"g",
|
||||
)
|
||||
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
|
||||
const commentRegex = new RegExp(/%%(.+)%%/, "g")
|
||||
const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g")
|
||||
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
|
||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
||||
// #(...) -> capturing group, tag itself must start with #
|
||||
// (?:[-_\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")
|
||||
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
|
||||
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
|
||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
|
||||
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
|
||||
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
|
||||
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
|
||||
|
||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||
userOpts,
|
||||
@@ -136,40 +148,25 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
return toHtml(hast, { allowDangerousHtml: true })
|
||||
}
|
||||
|
||||
const findAndReplace = opts.enableInHtmlEmbed
|
||||
? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => {
|
||||
if (replace) {
|
||||
visit(tree, "html", (node: HTML) => {
|
||||
if (typeof replace === "string") {
|
||||
node.value = node.value.replace(regex, replace)
|
||||
} else {
|
||||
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
|
||||
const replaceValue = replace(substring, ...args)
|
||||
if (typeof replaceValue === "string") {
|
||||
return replaceValue
|
||||
} else if (Array.isArray(replaceValue)) {
|
||||
return replaceValue.map(mdastToHtml).join("")
|
||||
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
||||
return mdastToHtml(replaceValue)
|
||||
} else {
|
||||
return substring
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mdastFindReplace(tree, regex, replace)
|
||||
}
|
||||
: mdastFindReplace
|
||||
|
||||
return {
|
||||
name: "ObsidianFlavoredMarkdown",
|
||||
textTransform(_ctx, src) {
|
||||
// do comments at text level
|
||||
if (opts.comments) {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(commentRegex, "")
|
||||
}
|
||||
|
||||
// pre-transform blockquotes
|
||||
if (opts.callouts) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(calloutLineRegex, (value) => {
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(calloutLineRegex, (value) => {
|
||||
// force newline after title of callout
|
||||
return value + "\n> "
|
||||
})
|
||||
@@ -177,15 +174,24 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
|
||||
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
||||
if (opts.wikilinks) {
|
||||
src = src.toString()
|
||||
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
|
||||
const [rawFp, rawHeader, rawAlias] = capture
|
||||
if (src instanceof Buffer) {
|
||||
src = src.toString()
|
||||
}
|
||||
|
||||
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
||||
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||
|
||||
const fp = rawFp ?? ""
|
||||
const anchor = rawHeader?.trim().replace(/^#+/, "")
|
||||
const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
|
||||
const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
|
||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||
|
||||
if (rawFp?.match(externalLinkRegex)) {
|
||||
return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
|
||||
}
|
||||
|
||||
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
||||
})
|
||||
}
|
||||
@@ -194,103 +200,184 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
},
|
||||
markdownPlugins() {
|
||||
const plugins: PluggableList = []
|
||||
if (opts.wikilinks) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => {
|
||||
let [rawFp, rawHeader, rawAlias] = capture
|
||||
const fp = rawFp?.trim() ?? ""
|
||||
const anchor = rawHeader?.trim() ?? ""
|
||||
const alias = rawAlias?.slice(1).trim()
|
||||
|
||||
// embed cases
|
||||
if (value.startsWith("!")) {
|
||||
const ext: string = path.extname(fp).toLowerCase()
|
||||
const url = slugifyFilePath(fp as FilePath)
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
|
||||
const dims = alias ?? ""
|
||||
let [width, height] = dims.split("x", 2)
|
||||
width ||= "auto"
|
||||
height ||= "auto"
|
||||
return {
|
||||
type: "image",
|
||||
url,
|
||||
data: {
|
||||
hProperties: {
|
||||
width,
|
||||
height,
|
||||
// regex replacements
|
||||
plugins.push(() => {
|
||||
return (tree: Root, file) => {
|
||||
const replacements: [RegExp, string | ReplaceFunction][] = []
|
||||
const base = pathToRoot(file.data.slug!)
|
||||
|
||||
if (opts.wikilinks) {
|
||||
replacements.push([
|
||||
wikilinkRegex,
|
||||
(value: string, ...capture: string[]) => {
|
||||
let [rawFp, rawHeader, rawAlias] = capture
|
||||
const fp = rawFp?.trim() ?? ""
|
||||
const anchor = rawHeader?.trim() ?? ""
|
||||
const alias = rawAlias?.slice(1).trim()
|
||||
|
||||
// embed cases
|
||||
if (value.startsWith("!")) {
|
||||
const ext: string = path.extname(fp).toLowerCase()
|
||||
const url = slugifyFilePath(fp as FilePath)
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
|
||||
const dims = alias ?? ""
|
||||
let [width, height] = dims.split("x", 2)
|
||||
width ||= "auto"
|
||||
height ||= "auto"
|
||||
return {
|
||||
type: "image",
|
||||
url,
|
||||
data: {
|
||||
hProperties: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<video src="${url}" controls></video>`,
|
||||
}
|
||||
} else if (
|
||||
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
||||
) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<audio src="${url}" controls></audio>`,
|
||||
}
|
||||
} else if ([".pdf"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<iframe src="${url}"></iframe>`,
|
||||
}
|
||||
} else if (ext === "") {
|
||||
const block = anchor
|
||||
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 ${url}${block}</a></blockquote>`,
|
||||
}
|
||||
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<video src="${url}" controls></video>`,
|
||||
}
|
||||
} else if (
|
||||
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
||||
) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<audio src="${url}" controls></audio>`,
|
||||
}
|
||||
} else if ([".pdf"].includes(ext)) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<iframe src="${url}"></iframe>`,
|
||||
}
|
||||
} else {
|
||||
const block = anchor
|
||||
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 ${url}${block}</a></blockquote>`,
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, fall through to regular link
|
||||
}
|
||||
|
||||
// otherwise, fall through to regular link
|
||||
}
|
||||
// internal link
|
||||
const url = fp + anchor
|
||||
return {
|
||||
type: "link",
|
||||
url,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: alias ?? fp,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
// internal link
|
||||
const url = fp + anchor
|
||||
return {
|
||||
type: "link",
|
||||
url,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: alias ?? fp,
|
||||
if (opts.highlight) {
|
||||
replacements.push([
|
||||
highlightRegex,
|
||||
(_value: string, ...capture: string[]) => {
|
||||
const [inner] = capture
|
||||
return {
|
||||
type: "html",
|
||||
value: `<span class="text-highlight">${inner}</span>`,
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.parseArrows) {
|
||||
replacements.push([
|
||||
arrowRegex,
|
||||
(_value: string, ..._capture: string[]) => {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<span>→</span>`,
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.parseTags) {
|
||||
replacements.push([
|
||||
tagRegex,
|
||||
(_value: string, tag: string) => {
|
||||
// Check if the tag only includes numbers
|
||||
if (/^\d+$/.test(tag)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tag = slugTag(tag)
|
||||
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
|
||||
file.data.frontmatter.tags.push(tag)
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
url: base + `/tags/${tag}`,
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["tag-link"],
|
||||
},
|
||||
},
|
||||
],
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: `#${tag}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (opts.enableInHtmlEmbed) {
|
||||
visit(tree, "html", (node: Html) => {
|
||||
for (const [regex, replace] of replacements) {
|
||||
if (typeof replace === "string") {
|
||||
node.value = node.value.replace(regex, replace)
|
||||
} else {
|
||||
node.value = node.value.replace(regex, (substring: string, ...args) => {
|
||||
const replaceValue = replace(substring, ...args)
|
||||
if (typeof replaceValue === "string") {
|
||||
return replaceValue
|
||||
} else if (Array.isArray(replaceValue)) {
|
||||
return replaceValue.map(mdastToHtml).join("")
|
||||
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
||||
return mdastToHtml(replaceValue)
|
||||
} else {
|
||||
return substring
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
mdastFindReplace(tree, replacements)
|
||||
}
|
||||
})
|
||||
|
||||
if (opts.highlight) {
|
||||
if (opts.enableVideoEmbed) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
|
||||
const [inner] = capture
|
||||
return {
|
||||
type: "html",
|
||||
value: `<span class="text-highlight">${inner}</span>`,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
visit(tree, "image", (node, index, parent) => {
|
||||
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
|
||||
const newNode: Html = {
|
||||
type: "html",
|
||||
value: `<video controls src="${node.url}"></video>`,
|
||||
}
|
||||
|
||||
if (opts.comments) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, _file) => {
|
||||
findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
|
||||
return {
|
||||
type: "text",
|
||||
value: "",
|
||||
parent.children.splice(index, 1, newNode)
|
||||
return SKIP
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -312,7 +399,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
}
|
||||
|
||||
const text = firstChild.children[0].value
|
||||
const restChildren = firstChild.children.slice(1)
|
||||
const restOfTitle = firstChild.children.slice(1)
|
||||
const [firstLine, ...remainingLines] = text.split("\n")
|
||||
const remainingText = remainingLines.join("\n")
|
||||
|
||||
@@ -328,7 +415,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
|
||||
const titleNode: Paragraph = {
|
||||
type: "paragraph",
|
||||
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
|
||||
children:
|
||||
restOfTitle.length === 0
|
||||
? [{ type: "text", value: titleContent + " " }]
|
||||
: restOfTitle,
|
||||
}
|
||||
const title = mdastToHtml(titleNode)
|
||||
|
||||
@@ -336,7 +426,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>`
|
||||
|
||||
const titleHtml: HTML = {
|
||||
const titleHtml: Html = {
|
||||
type: "html",
|
||||
value: `<div
|
||||
class="callout-title"
|
||||
@@ -396,49 +486,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.parseTags) {
|
||||
plugins.push(() => {
|
||||
return (tree: Root, file) => {
|
||||
const base = pathToRoot(file.data.slug!)
|
||||
findAndReplace(tree, tagRegex, (_value: string, tag: string) => {
|
||||
// Check if the tag only includes numbers
|
||||
if (/^\d+$/.test(tag)) {
|
||||
return false
|
||||
}
|
||||
tag = slugTag(tag)
|
||||
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
|
||||
file.data.frontmatter.tags.push(tag)
|
||||
}
|
||||
|
||||
return {
|
||||
type: "link",
|
||||
url: base + `/tags/${tag}`,
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["tag-link"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: `#${tag}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return plugins
|
||||
},
|
||||
htmlPlugins() {
|
||||
const plugins = [rehypeRaw]
|
||||
const plugins: PluggableList = [rehypeRaw]
|
||||
|
||||
if (opts.parseBlockReferences) {
|
||||
plugins.push(() => {
|
||||
const inlineTagTypes = new Set(["p", "li"])
|
||||
const blockTagTypes = new Set(["blockquote"])
|
||||
return (tree, file) => {
|
||||
return (tree: HtmlRoot, file) => {
|
||||
file.data.blocks = {}
|
||||
|
||||
visit(tree, "element", (node, index, parent) => {
|
||||
@@ -487,6 +544,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.enableYouTubeEmbed) {
|
||||
plugins.push(() => {
|
||||
return (tree: HtmlRoot) => {
|
||||
visit(tree, "element", (node) => {
|
||||
if (node.tagName === "img" && typeof node.properties.src === "string") {
|
||||
const match = node.properties.src.match(ytLinkRegex)
|
||||
const videoId = match && match[2].length == 11 ? match[2] : null
|
||||
if (videoId) {
|
||||
node.tagName = "iframe"
|
||||
node.properties = {
|
||||
class: "external-embed",
|
||||
allow: "fullscreen",
|
||||
frameborder: 0,
|
||||
width: "600px",
|
||||
height: "350px",
|
||||
src: `https://www.youtube.com/embed/${videoId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
externalResources() {
|
||||
|
||||
@@ -8,7 +8,11 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
|
||||
[
|
||||
rehypePrettyCode,
|
||||
{
|
||||
theme: "css-variables",
|
||||
keepBackground: false,
|
||||
theme: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
} satisfies Partial<CodeOptions>,
|
||||
],
|
||||
]
|
||||
|
||||
@@ -36,19 +36,6 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
) => QuartzEmitterPluginInstance
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit(
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
emitCallback: EmitCallback,
|
||||
): Promise<FilePath[]>
|
||||
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||
}
|
||||
|
||||
export interface EmitOptions {
|
||||
slug: FullSlug
|
||||
ext: `.${string}` | ""
|
||||
content: string
|
||||
}
|
||||
|
||||
export type EmitCallback = (data: EmitOptions) => Promise<FilePath>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Node, Parent } from "hast"
|
||||
import { Data, VFile } from "vfile"
|
||||
|
||||
export type QuartzPluginData = Data
|
||||
export type ProcessedContent = [Node<QuartzPluginData>, VFile]
|
||||
export type ProcessedContent = [Node, VFile]
|
||||
|
||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
||||
const root: Parent = { type: "root", children: [] }
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { PerfTimer } from "../util/perf"
|
||||
import { getStaticResourcesFromPlugins } from "../plugins"
|
||||
import { EmitCallback } from "../plugins/types"
|
||||
import { ProcessedContent } from "../plugins/vfile"
|
||||
import { FilePath, joinSegments } from "../util/path"
|
||||
import { QuartzLogger } from "../util/log"
|
||||
import { trace } from "../util/trace"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
@@ -15,19 +11,12 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
||||
const log = new QuartzLogger(ctx.argv.verbose)
|
||||
|
||||
log.start(`Emitting output files`)
|
||||
const emit: EmitCallback = async ({ slug, ext, content }) => {
|
||||
const pathToPage = joinSegments(argv.output, slug + ext) as FilePath
|
||||
const dir = path.dirname(pathToPage)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(pathToPage, content)
|
||||
return pathToPage
|
||||
}
|
||||
|
||||
let emittedFiles = 0
|
||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
try {
|
||||
const emitted = await emitter.emit(ctx, content, staticResources, emit)
|
||||
const emitted = await emitter.emit(ctx, content, staticResources)
|
||||
emittedFiles += emitted.length
|
||||
|
||||
if (ctx.argv.verbose) {
|
||||
|
||||
@@ -14,27 +14,25 @@ import { QuartzLogger } from "../util/log"
|
||||
import { trace } from "../util/trace"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
|
||||
export type QuartzProcessor = Processor<MDRoot, MDRoot, HTMLRoot>
|
||||
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
||||
const transformers = ctx.cfg.plugins.transformers
|
||||
|
||||
// base Markdown -> MD AST
|
||||
let processor = unified().use(remarkParse)
|
||||
|
||||
// MD AST -> MD AST transforms
|
||||
for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
|
||||
processor = processor.use(plugin.markdownPlugins!(ctx))
|
||||
}
|
||||
|
||||
// MD AST -> HTML AST
|
||||
processor = processor.use(remarkRehype, { allowDangerousHtml: true })
|
||||
|
||||
// HTML AST -> HTML AST transforms
|
||||
for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
|
||||
processor = processor.use(plugin.htmlPlugins!(ctx))
|
||||
}
|
||||
|
||||
return processor
|
||||
return (
|
||||
unified()
|
||||
// base Markdown -> MD AST
|
||||
.use(remarkParse)
|
||||
// MD AST -> MD AST transforms
|
||||
.use(
|
||||
transformers
|
||||
.filter((p) => p.markdownPlugins)
|
||||
.flatMap((plugin) => plugin.markdownPlugins!(ctx)),
|
||||
)
|
||||
// MD AST -> HTML AST
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
// HTML AST -> HTML AST transforms
|
||||
.use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
|
||||
)
|
||||
}
|
||||
|
||||
function* chunks<T>(arr: T[], n: number) {
|
||||
@@ -89,12 +87,13 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
||||
|
||||
// Text -> Text transforms
|
||||
for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) {
|
||||
file.value = plugin.textTransform!(ctx, file.value)
|
||||
file.value = plugin.textTransform!(ctx, file.value.toString())
|
||||
}
|
||||
|
||||
// base data properties that plugins may use
|
||||
file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
|
||||
file.data.filePath = fp
|
||||
file.data.filePath = file.path as FilePath
|
||||
file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
|
||||
file.data.slug = slugifyFilePath(file.data.relativePath)
|
||||
|
||||
const ast = processor.parse(file)
|
||||
const newAst = await processor.run(ast, file)
|
||||
|
||||
@@ -59,16 +59,33 @@ a {
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--secondary);
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: var(--tertiary) !important;
|
||||
}
|
||||
|
||||
&.internal:not(:has(> img)) {
|
||||
&.internal {
|
||||
text-decoration: none;
|
||||
background-color: var(--highlight);
|
||||
padding: 0 0.1rem;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4rem;
|
||||
|
||||
&:has(> img) {
|
||||
background-color: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.external .external-icon {
|
||||
height: 1ex;
|
||||
margin: 0 0.15em;
|
||||
|
||||
> path {
|
||||
fill: var(--dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +111,6 @@ a {
|
||||
}
|
||||
|
||||
& article {
|
||||
position: relative;
|
||||
|
||||
& > h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -300,11 +315,13 @@ h6 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
div[data-rehype-pretty-code-fragment] {
|
||||
figure[data-rehype-pretty-code-figure] {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
line-height: 1.6rem;
|
||||
position: relative;
|
||||
|
||||
& > div[data-rehype-pretty-code-title] {
|
||||
& > [data-rehype-pretty-code-title] {
|
||||
font-family: var(--codeFont);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
@@ -316,16 +333,17 @@ div[data-rehype-pretty-code-fragment] {
|
||||
}
|
||||
|
||||
& > pre {
|
||||
padding: 0.5rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: var(--codeFont);
|
||||
padding: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--lightgray);
|
||||
position: relative;
|
||||
|
||||
&:has(> code.mermaid) {
|
||||
border: none;
|
||||
@@ -338,6 +356,7 @@ pre {
|
||||
counter-reset: line;
|
||||
counter-increment: line 0;
|
||||
display: grid;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
& [data-highlighted-chars] {
|
||||
background-color: var(--highlight);
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-light.json
|
||||
:root {
|
||||
--shiki-color-text: #24292e;
|
||||
--shiki-color-background: #f8f8f8;
|
||||
--shiki-token-constant: #005cc5;
|
||||
--shiki-token-string: #032f62;
|
||||
--shiki-token-comment: #6a737d;
|
||||
--shiki-token-keyword: #d73a49;
|
||||
--shiki-token-parameter: #24292e;
|
||||
--shiki-token-function: #24292e;
|
||||
--shiki-token-string-expression: #22863a;
|
||||
--shiki-token-punctuation: #24292e;
|
||||
--shiki-token-link: #24292e;
|
||||
code[data-theme*=" "] {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-bg);
|
||||
}
|
||||
|
||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-dark.json
|
||||
[saved-theme="dark"] {
|
||||
--shiki-color-text: #e1e4e8 !important;
|
||||
--shiki-color-background: #24292e !important;
|
||||
--shiki-token-constant: #79b8ff !important;
|
||||
--shiki-token-string: #9ecbff !important;
|
||||
--shiki-token-comment: #6a737d !important;
|
||||
--shiki-token-keyword: #f97583 !important;
|
||||
--shiki-token-parameter: #e1e4e8 !important;
|
||||
--shiki-token-function: #e1e4e8 !important;
|
||||
--shiki-token-string-expression: #85e89d !important;
|
||||
--shiki-token-punctuation: #e1e4e8 !important;
|
||||
--shiki-token-link: #e1e4e8 !important;
|
||||
code[data-theme*=" "] span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
[saved-theme="dark"] code[data-theme*=" "] {
|
||||
color: var(--shiki-dark);
|
||||
background-color: var(--shiki-dark-bg);
|
||||
}
|
||||
|
||||
[saved-theme="dark"] code[data-theme*=" "] span {
|
||||
color: var(--shiki-dark);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { Node, Root } from "hast"
|
||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
|
||||
import { trace } from "./trace"
|
||||
@@ -13,7 +12,7 @@ const customComponents: Components = {
|
||||
),
|
||||
}
|
||||
|
||||
export function htmlToJsx(fp: FilePath, tree: Node<QuartzPluginData>) {
|
||||
export function htmlToJsx(fp: FilePath, tree: Node) {
|
||||
try {
|
||||
return toJsxRuntime(tree as Root, {
|
||||
Fragment,
|
||||
|
||||
@@ -105,6 +105,9 @@ describe("transforms", () => {
|
||||
["index.md", "index"],
|
||||
["test.mp4", "test.mp4"],
|
||||
["note with spaces.md", "note-with-spaces"],
|
||||
["notes.with.dots.md", "notes.with.dots"],
|
||||
["test/special chars?.md", "test/special-chars-q"],
|
||||
["test/special chars #3.md", "test/special-chars-3"],
|
||||
],
|
||||
path.slugifyFilePath,
|
||||
path.isFilePath,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { slug } from "github-slugger"
|
||||
import { slug as slugAnchor } from "github-slugger"
|
||||
import type { Element as HastElement } from "hast"
|
||||
import rfdc from "rfdc"
|
||||
|
||||
export const clone = rfdc()
|
||||
|
||||
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||
|
||||
export const QUARTZ = "quartz"
|
||||
@@ -43,6 +47,16 @@ export function getFullSlug(window: Window): FullSlug {
|
||||
return res
|
||||
}
|
||||
|
||||
function sluggify(s: string): string {
|
||||
return s
|
||||
.split("/")
|
||||
.map((segment) =>
|
||||
segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").replace(/#/g, ""),
|
||||
) // slugify all segments
|
||||
.join("/") // always use / as sep
|
||||
.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
||||
fp = _stripSlashes(fp) as FilePath
|
||||
let ext = _getFileExtension(fp)
|
||||
@@ -51,11 +65,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
||||
ext = ""
|
||||
}
|
||||
|
||||
let slug = withoutFileExt
|
||||
.split("/")
|
||||
.map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
|
||||
.join("/") // always use / as sep
|
||||
.replace(/\/$/, "") // remove trailing slash
|
||||
let slug = sluggify(withoutFileExt)
|
||||
|
||||
// treat _index as index
|
||||
if (_endsWith(slug, "_index")) {
|
||||
@@ -117,7 +127,8 @@ const _rebaseHastElement = (
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) {
|
||||
export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
|
||||
const el = clone(rawEl) // clone so we dont modify the original page
|
||||
_rebaseHastElement(el, "src", curBase, newBase)
|
||||
_rebaseHastElement(el, "href", curBase, newBase)
|
||||
if (el.children) {
|
||||
@@ -156,14 +167,10 @@ export function splitAnchor(link: string): [string, string] {
|
||||
return [fp, anchor]
|
||||
}
|
||||
|
||||
export function slugAnchor(anchor: string) {
|
||||
return slug(anchor)
|
||||
}
|
||||
|
||||
export function slugTag(tag: string) {
|
||||
return tag
|
||||
.split("/")
|
||||
.map((tagSegment) => slug(tagSegment))
|
||||
.map((tagSegment) => sluggify(tagSegment))
|
||||
.join("/")
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
|
||||
} else {
|
||||
const content = resource.script
|
||||
return (
|
||||
<script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
|
||||
{content}
|
||||
</script>
|
||||
<script
|
||||
key={randomUUID()}
|
||||
type={scriptType}
|
||||
spa-preserve={spaPreserve}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></script>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user