Compare commits

..

46 Commits

Author SHA1 Message Date
Jacky Zhao
64c7851939 pkg: bump version to 4.1.3 2023-12-18 09:49:55 -08:00
Jacky Zhao
ea6208c1f0 deps: bump everything (closes #635) (#636)
* deps: bump ws

* deps: bump lightningcss

* deps: workerpool

* deps: various types

* deps: chalk

* deps: globby

* deps: preact

* deps: tsx

* deps: @floating-ui/dom

* deps: esbuild

* deps: types + prettier

* deps: rimraf, typescript

* deps: remark/rehype/unified ecosystem

* format
2023-12-18 09:48:40 -08:00
Jacky Zhao
78b33fc2fb fix: release build lock before client refresh 2023-12-17 16:46:17 -08:00
Jacky Zhao
d2be097b76 feat: include tag hierarchies in tag listing, sort tag listing 2023-12-17 15:09:51 -08:00
Jacky Zhao
ad1f964a5f docs: graph view tag options 2023-12-17 13:19:03 -08:00
Jacky Zhao
150050f379 docs: agentic computing in quartz philosophy 2023-12-17 13:01:44 -08:00
Jacky Zhao
d979331dc7 fix: remove whitespace unicode from tag regex 2023-12-17 12:54:52 -08:00
Jacky Zhao
972cf0a887 feat: support emoji tags (closes #634) 2023-12-17 12:28:28 -08:00
Jacky Zhao
14e6b13ff1 docs: dont pull on first sync 2023-12-17 09:57:46 -08:00
Jacky Zhao
3c01b92cc4 docs: note embeds and update git hint 2023-12-16 11:04:18 -08:00
Jacky Zhao
ed9bd43d9f docs: update showcase 2023-12-15 12:18:29 -08:00
Jacky Zhao
c35818c336 fix: set upstream in sync handler, cleanup docs around setting up github 2023-12-14 16:48:09 -08:00
Jacky Zhao
a464ae5029 fix: format 2023-12-13 16:47:22 -08:00
Jacky Zhao
66e297c0ea css: make article no longer relative to prevent z-fighting 2023-12-13 16:40:24 -08:00
Jacky Zhao
4442847b37 fix: internal link selector specificity 2023-12-13 16:07:44 -08:00
Jacky Zhao
e6b5ca33c9 re-add gitkeep to content 2023-12-11 15:34:21 -08:00
Jacky Zhao
1b92440009 fix: better error handling on spawnsync failures 2023-12-11 10:38:55 -08:00
Jacky Zhao
c6546903f2 fix: reland string coercion in title 2023-12-10 06:19:29 -08:00
Jacky Zhao
2c69b0c97d fix: frontmatter coercion (empty string is falsy) 2023-12-08 16:55:40 -08:00
Sam Stokes
a7e20804f5 feat: Support space-delimited tags in FrontMatter transformer (#620) 2023-12-04 18:18:47 -08:00
Jacky Zhao
5196f3b9db docs: github setup and hosting fixes 2023-12-03 23:25:40 -08:00
Jimin Kim
f0ec6c9b92 fix: tag index page (#616) 2023-12-03 14:56:30 -08:00
Jacky Zhao
9c88d5967f fix: don't show popovers on heading anchors 2023-12-03 09:22:16 -08:00
Jacky Zhao
0d8c025d6a deps: version bump 2023-12-02 17:00:06 -08:00
Jacky Zhao
54b4a5567c fix: fmt 2023-12-02 16:55:38 -08:00
Jacky Zhao
610b04406f fix: incorrect test 2023-12-02 16:54:09 -08:00
Jacky Zhao
82bd08d14a fix: transcludes and relative paths 2023-12-02 16:51:03 -08:00
mancuoj
649090de1b docs: add deploy with netlify (#613) 2023-12-01 22:59:02 -08:00
Jacky Zhao
b5fec6c87f feat: allow popovers on intrapage links (closes #243) 2023-12-01 09:00:47 -08:00
Jacky Zhao
0d314db1f8 fix(style): overflow on toc 2023-11-29 10:50:47 -08:00
Odaimoko
660aae62e0 docs: add Imk&Cc's homepage to showcase.md (#595)
* add Imk&Cc's homepage to showcase.md

* Update showcase.md

* Update showcase.md
2023-11-27 23:05:18 -08:00
Rune Antonsen
9a599aebea feat(breadcrumbs): add option to hide current page (#601)
* feat(breadcrumbs): add option to hide current page

* Remove debug lines

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

---------

Co-authored-by: ruant <ruant@ruant.net>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2023-11-20 08:28:16 -08:00
Jacky Zhao
296c1cf83f fix: spa shouldn't use popover script directly 2023-11-18 18:46:58 -08:00
Jacky Zhao
516d9a27e7 fix: explicit undefined check in header transclude 2023-11-18 18:27:44 -08:00
Jacky Zhao
6a05fa777c fix: bad transform in wikilink pre-transform (closes #598) 2023-11-17 14:00:49 -08:00
Jacky Zhao
3f0be7fbe4 fix: check content-type before applying spa patch (closes #597) 2023-11-17 10:46:23 -08:00
Jacky Zhao
ea08c0511a fix: dont run explorer scripts on non-explorer pages (closes #596) 2023-11-17 10:29:24 -08:00
Matt Vogel
727b9b5d72 feat: add class alias to aliases (#585) 2023-11-17 10:23:39 -08:00
Zijing Zhang
50f0ba29a2 feat: cname emitter (#590)
* feat: cname emitter

* feat: impl cname.ts

* Update cname.ts

* Update index.ts

* Update cname.ts

* Update cname.ts

* Update cname.ts

* Update cname.ts
2023-11-16 15:31:20 -08:00
Jacky Zhao
95b1141b9d fix: include anchor when normalizing urls for spa/popovers 2023-11-15 20:35:45 -08:00
Jacky Zhao
a26eb59392 feat: scrub link formatting from toc entries 2023-11-15 20:13:28 -08:00
Jacky Zhao
5befcf4780 fix: format 2023-11-15 19:32:25 -08:00
Jacky Zhao
f861a7c160 fix: regression where clicking anchors on the same page wouldn't set the anchor in the url 2023-11-15 19:31:18 -08:00
Jacky Zhao
06426c8f7e feat: support repeated anchor tag (closes #592) 2023-11-15 19:27:54 -08:00
Jacky Zhao
8fc7b9f4c6 feat: deref symlinks when copying static assets (closes #588) 2023-11-15 09:43:30 -08:00
Jacky Zhao
2de48b267a fix: set htmlAst after walking tree in ofm (closes #589) 2023-11-14 20:01:48 -08:00
43 changed files with 2706 additions and 2008 deletions

View File

@@ -20,6 +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
})
```

View File

@@ -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
},
})
```

View File

@@ -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`

View File

@@ -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.
@@ -167,9 +171,16 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
4. Go to the Settings tab and then click Domains in the sidebar
5. Enter your subdomain into the field and press Add
## GitLab Pages
## Netlify
You can configure GitLab CI to build and deploy a Quartz 4 project.
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`.
4. Under "Publish directory", enter `public`.
5. Press Deploy. Once it's live, you'll have a `*.netlify.app` URL to view the page.
6. To add a custom domain, check "Domain management" in the left sidebar, just like with Vercel.
## GitLab Pages
In your local Quartz, create a new file `.gitlab-ci.yaml`.
@@ -192,8 +203,6 @@ build:
- hash -r
- npm ci
script:
- npx prettier --write .
- npm run check
- npx quartz build
artifacts:
paths:
@@ -216,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 commited, 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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -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,7 +19,7 @@ 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

View File

@@ -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 dont work well for me. There is way too much upfront friction that by the time Ive thought about how to organize my thought into folders categories, Ive 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.

View 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.

View File

@@ -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/)
@@ -20,5 +20,6 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/)
- [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/)
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)!

3694
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.1.1",
"version": "4.1.3",
"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.1",
"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",
"rimraf": "^5.0.5",
"serve-handler": "^6.1.5",
"shikiji": "^0.8.7",
"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"
}
}

View File

@@ -152,10 +152,10 @@ async function startServing(
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
}
release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
release()
}
const watcher = chokidar.watch(".", {

View File

@@ -196,6 +196,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 +443,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 occured 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 +516,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 occured 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!"))

View File

@@ -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"))
}
}

View File

@@ -25,6 +25,10 @@ interface BreadcrumbOptions {
* Wether to display breadcrumbs on root `index.md`
*/
hideOnRoot: boolean
/**
* Wether to display the current page in the breadcrumbs.
*/
showCurrentPage: boolean
}
const defaultOptions: BreadcrumbOptions = {
@@ -32,6 +36,7 @@ const defaultOptions: BreadcrumbOptions = {
rootName: "Home",
resolveFrontmatterTitle: true,
hideOnRoot: true,
showCurrentPage: true,
}
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
@@ -95,10 +100,12 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
}
// Add current file to crumb (can directly use frontmatter title)
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
if (options.showCurrentPage) {
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
}
}
return (
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">

View File

@@ -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))

View File

@@ -3,9 +3,10 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { FullSlug, RelativeURL, joinSegments } from "../util/path"
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
import { QuartzPluginData } from "../plugins/vfile"
interface RenderComponents {
head: QuartzComponent
@@ -49,6 +50,18 @@ export function pageResources(
}
}
let pageIndex: Map<FullSlug, QuartzPluginData> | undefined = undefined
function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map<FullSlug, QuartzPluginData> {
if (!pageIndex) {
pageIndex = new Map()
for (const file of allFiles) {
pageIndex.set(file.slug!, file)
}
}
return pageIndex
}
export function renderPage(
slug: FullSlug,
componentData: QuartzComponentProps,
@@ -61,30 +74,29 @@ 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
// TODO: avoid this expensive find operation and construct an index ahead of time
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
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
if (blockRef?.startsWith("^")) {
let blockRef = node.properties.dataBlock as string | undefined
if (blockRef?.startsWith("#^")) {
// block transclude
blockRef = blockRef.slice(1)
blockRef = blockRef.slice("#^".length)
let blockNode = page.blocks?.[blockRef]
if (blockNode) {
if (blockNode.tagName === "li") {
blockNode = {
type: "element",
tagName: "ul",
properties: {},
children: [blockNode],
}
}
node.children = [
blockNode,
normalizeHastElement(blockNode, slug, transcludeTarget),
{
type: "element",
tagName: "a",
@@ -104,7 +116,7 @@ export function renderPage(
break
}
if (startIdx) {
if (startIdx !== undefined) {
endIdx = i
} else if (el.properties?.id === blockRef) {
startIdx = i
@@ -112,12 +124,14 @@ export function renderPage(
}
}
if (!startIdx) {
if (startIdx === undefined) {
return
}
node.children = [
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]),
...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) =>
normalizeHastElement(child as Element, slug, transcludeTarget),
),
{
type: "element",
tagName: "a",
@@ -131,11 +145,14 @@ export function renderPage(
{
type: "element",
tagName: "h1",
properties: {},
children: [
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
],
},
...(page.htmlAst.children as ElementContent[]),
...(page.htmlAst.children as ElementContent[]).map((child) =>
normalizeHastElement(child as Element, slug, transcludeTarget),
),
{
type: "element",
tagName: "a",

View File

@@ -120,9 +120,9 @@ function setupExplorer() {
}
}
})
} else {
} else if (explorer?.dataset.tree) {
// If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
explorerState = JSON.parse(explorer?.dataset.tree as string)
explorerState = JSON.parse(explorer.dataset.tree)
}
}
@@ -130,12 +130,13 @@ window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => {
setupExplorer()
const explorerContent = document.getElementById("explorer-ul")
observer.disconnect()
// select pseudo element at end of list
const lastItem = document.getElementById("explorer-end")
observer.disconnect()
observer.observe(lastItem as Element)
if (lastItem) {
observer.observe(lastItem)
}
})
/**

View File

@@ -1,4 +1,4 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
@@ -46,20 +46,22 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
showTags,
} = JSON.parse(graph.dataset["cfg"]!)
const data = await fetchData
const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
simplifySlug(k as FullSlug),
v,
]),
)
const links: LinkData[] = []
const tags: SimpleSlug[] = []
const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug)))
for (const [src, details] of Object.entries<ContentDetails>(data)) {
const source = simplifySlug(src as FullSlug)
const validLinks = new Set(data.keys())
for (const [source, details] of data.entries()) {
const outgoing = details.links ?? []
for (const dest of outgoing) {
if (validLinks.has(dest)) {
links.push({ source, target: dest })
links.push({ source: source, target: dest })
}
}
@@ -71,7 +73,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
for (const tag of localTags) {
links.push({ source, target: tag })
links.push({ source: source, target: tag })
}
}
}
@@ -93,17 +95,17 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}
}
} else {
Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
validLinks.forEach((id) => neighbourhood.add(id))
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
}
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes: [...neighbourhood].map((url) => {
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url
return {
id: url,
text: text,
tags: data[url]?.tags ?? [],
tags: data.get(url)?.tags ?? [],
}
}),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
@@ -200,7 +202,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
window.spaNavigate(new URL(targ, window.location.toString()))
})
.on("mouseover", function (_, d) {
const neighbours: SimpleSlug[] = data[fullSlug].links ?? []
const neighbours: SimpleSlug[] = data.get(slug)?.links ?? []
const neighbourNodes = d3
.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => neighbours.includes(d.id))

View File

@@ -1,16 +1,5 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
const update = (el: Element, attr: string, base: string | URL) => {
el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
}
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
}
import { normalizeRelativeURLs } from "../../util/path"
const p = new DOMParser()
async function mouseEnterHandler(
@@ -18,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()],
@@ -43,8 +36,6 @@ async function mouseEnterHandler(
const hash = targetUrl.hash
targetUrl.hash = ""
targetUrl.search = ""
// prevent hover of the same page
if (thisUrl.toString() === targetUrl.toString()) return
const contents = await fetch(`${targetUrl}`)
.then((res) => res.text())

View File

@@ -1,10 +1,8 @@
import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug } from "../../util/path"
import { normalizeRelativeURLs } from "./popover.inline"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
// adapted from `micromorph`
// https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
@@ -45,7 +43,14 @@ let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser()
const contents = await fetch(`${url}`)
.then((res) => res.text())
.then((res) => {
const contentType = res.headers.get("content-type")
if (contentType?.startsWith("text/html")) {
return res.text()
} else {
window.location.assign(url)
}
})
.catch(() => {
window.location.assign(url)
})
@@ -109,6 +114,7 @@ function createRouter() {
if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView()
history.pushState({}, "", url)
return
}

View File

@@ -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);

View File

@@ -30,6 +30,7 @@ button#toc {
overflow: hidden;
max-height: none;
transition: max-height 0.5s ease;
position: relative;
&.collapsed > .overflow::after {
opacity: 0;

View File

@@ -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 & {

View File

@@ -0,0 +1,29 @@
import { FilePath, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import chalk from "chalk"
export function extractDomainFromBaseUrl(baseUrl: string) {
const url = new URL(`https://${baseUrl}`)
return url.hostname
}
export const CNAME: QuartzEmitterPlugin = () => ({
name: "CNAME",
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []
}
const path = joinSegments(argv.output, "CNAME")
const content = extractDomainFromBaseUrl(cfg.configuration.baseUrl)
if (!content) {
return []
}
fs.writeFileSync(path, content)
return [path] as FilePath[]
},
})

View File

@@ -7,3 +7,4 @@ export { Assets } from "./assets"
export { Static } from "./static"
export { ComponentResources } from "./componentResources"
export { NotFoundPage } from "./404"
export { CNAME } from "./cname"

View File

@@ -11,7 +11,10 @@ export const Static: QuartzEmitterPlugin = () => ({
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), { recursive: true })
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
recursive: true,
dereference: true,
})
return fps.map((fp) => joinSegments(argv.output, "static", fp)) as FilePath[]
},
})

View File

@@ -40,12 +40,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({

View File

@@ -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,22 @@ 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)) {
data.tags = data.tags
.toString()
.split(",")
.split(oneLineTagDelim)
.map((tag: string) => tag.trim())
}
// 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"]
}
},
]

View File

@@ -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: " §",

View 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 {

View File

@@ -54,6 +54,16 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
node.properties.className ??= []
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
// Check if the link has alias text
if (
node.children.length === 1 &&
node.children[0].type === "text" &&
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")
}
if (opts.openLinksInNewTab) {
node.properties.target = "_blank"
}
@@ -71,14 +81,16 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, `https://base.com/${curSlug}`)
const canonicalDest = url.pathname
const [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) {
destCanonical += "index"
}
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
const simple = decodeURIComponent(
simplifySlug(destCanonical as FullSlug),
) as SimpleSlug
const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
const simple = simplifySlug(full)
outgoing.add(simple)
node.properties["data-slug"] = simple
node.properties["data-slug"] = full
}
// rewrite link internals if prettylinks is on

View File

@@ -1,8 +1,7 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Root, Html, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Element, Literal, 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"
@@ -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
@@ -110,7 +110,10 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts {
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g")
export const wikilinkRegex = new RegExp(
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
"g",
)
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
@@ -118,8 +121,8 @@ 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 "/"
// (?:[-_\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}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
@@ -133,39 +136,15 @@ 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) {
// pre-transform blockquotes
if (opts.callouts) {
src = src.toString()
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replaceAll(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
@@ -174,12 +153,16 @@ 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()
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias] = capture
const fp = rawFp ?? ""
const anchor = rawHeader?.trim().slice(1)
const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : ""
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("!") ? "!" : ""
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
@@ -190,108 +173,172 @@ 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"].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 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>`,
}
}
// 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.comments) {
replacements.push([
commentRegex,
(_value: string, ..._capture: string[]) => {
return {
type: "text",
value: "",
}
},
])
}
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.highlight) {
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>`,
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.replaceAll(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
} else if (Array.isArray(replaceValue)) {
return replaceValue.map(mdastToHtml).join("")
} else if (typeof replaceValue === "object" && replaceValue !== null) {
return mdastToHtml(replaceValue)
} else {
return substring
}
})
}
}
})
}
})
}
if (opts.comments) {
plugins.push(() => {
return (tree: Root, _file) => {
findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
return {
type: "text",
value: "",
}
})
}
})
}
mdastFindReplace(tree, replacements)
}
})
if (opts.callouts) {
plugins.push(() => {
@@ -332,7 +379,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"
@@ -392,51 +439,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) => {
file.data.blocks = {}
file.data.htmlAst = tree
visit(tree, "element", (node, index, parent) => {
if (blockTagTypes.has(node.tagName)) {
@@ -478,6 +490,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
}
})
file.data.htmlAst = tree
}
})
}

View File

@@ -8,7 +8,11 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
[
rehypePrettyCode,
{
theme: "css-variables",
keepBackground: false,
theme: {
dark: "github-dark",
light: "github-light",
},
} satisfies Partial<CodeOptions>,
],
]

View File

@@ -3,6 +3,7 @@ import { Root } from "mdast"
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger"
import { wikilinkRegex } from "./ofm"
export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6
@@ -24,6 +25,7 @@ interface TocEntry {
slug: string // this is just the anchor (#some-slug), not the canonical slug
}
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
) => {
@@ -41,7 +43,16 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) {
const text = toString(node)
let text = toString(node)
// strip link formatting from toc entries
text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
const fp = rawFp?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
return alias ?? fp
})
text = text.replace(regexMdLinks, "$1")
highestDepth = Math.min(highestDepth, node.depth)
toc.push({
depth: node.depth,

View File

@@ -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: [] }

View File

@@ -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,7 +87,7 @@ 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

View File

@@ -64,11 +64,17 @@ a {
color: var(--tertiary) !important;
}
&.internal:not(:has(> img)) {
&.internal {
text-decoration: none;
background-color: var(--highlight);
padding: 0 0.1rem;
border-radius: 5px;
&:has(> img) {
background-color: none;
border-radius: 0;
padding: 0;
}
}
}
@@ -94,8 +100,6 @@ a {
}
& article {
position: relative;
& > h1 {
font-size: 2rem;
}
@@ -300,11 +304,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,7 +322,7 @@ div[data-rehype-pretty-code-fragment] {
}
& > pre {
padding: 0.5rem 0;
padding: 0;
}
}
@@ -338,6 +344,7 @@ pre {
counter-reset: line;
counter-increment: line 0;
display: grid;
padding: 0.5rem 0;
& [data-highlighted-chars] {
background-color: var(--highlight);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -83,7 +83,7 @@ describe("transforms", () => {
test("simplifySlug", () => {
asserts(
[
["index", ""],
["index", "/"],
["abc", "abc"],
["abc/index", "abc/"],
["abc/def", "abc/def"],

View File

@@ -1,4 +1,5 @@
import { slug } from "github-slugger"
import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
@@ -24,7 +25,7 @@ export function isFullSlug(s: string): s is FullSlug {
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
export type SimpleSlug = SlugLike<"simple">
export function isSimpleSlug(s: string): s is SimpleSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/"))
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
const validEnding = !(s.endsWith("/index") || s === "index")
return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
}
@@ -42,6 +43,14 @@ 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")) // 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)
@@ -50,11 +59,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")) {
@@ -65,7 +70,8 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
}
export function simplifySlug(fp: FullSlug): SimpleSlug {
return _stripSlashes(_trimSuffix(fp, "index"), true) as SimpleSlug
const res = _stripSlashes(_trimSuffix(fp, "index"), true)
return (res.length === 0 ? "/" : res) as SimpleSlug
}
export function transformInternalLink(link: string): RelativeURL {
@@ -84,6 +90,49 @@ export function transformInternalLink(link: string): RelativeURL {
return res
}
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => {
const rebased = new URL(el.getAttribute(attr)!, newBase)
el.setAttribute(attr, rebased.pathname + rebased.hash)
}
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "href", destination),
)
el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
_rebaseHtmlElement(item, "src", destination),
)
}
const _rebaseHastElement = (
el: HastElement,
attr: string,
curBase: FullSlug,
newBase: FullSlug,
) => {
if (el.properties?.[attr]) {
if (!isRelativeURL(String(el.properties[attr]))) {
return
}
const rel = joinSegments(resolveRelative(curBase, newBase), "..", el.properties[attr] as string)
el.properties[attr] = rel
}
}
export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) {
_rebaseHastElement(el, "src", curBase, newBase)
_rebaseHastElement(el, "href", curBase, newBase)
if (el.children) {
el.children = el.children.map((child) =>
normalizeHastElement(child as HastElement, curBase, newBase),
)
}
return el
}
// resolve /a/b/c to ../..
export function pathToRoot(slug: FullSlug): RelativeURL {
let rootPath = slug
@@ -111,14 +160,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("/")
}