Compare commits

...

63 Commits

Author SHA1 Message Date
Jacky Zhao
c9ac2a7507 pkg: bump to 4.1.5 2024-01-22 10:56:58 -08:00
Jacky Zhao
7ca491bc1d fix: add polyfill for broken tabindex on mac 2024-01-22 10:55:15 -08:00
Jacky Zhao
4edd27d3f9 fix: font weight in search 2024-01-22 10:48:23 -08:00
Jacky Zhao
2c8d0f8ab6 fix: more robust ofm comment handling 2024-01-22 10:29:57 -08:00
Jacky Zhao
cd826fb477 fix: process comments at a text level rather than a markdown level 2024-01-22 10:03:59 -08:00
Jacky Zhao
273931d25c fix: breadcrumbs on non-folder pages 2024-01-21 21:14:16 -08:00
Aaron Pham
0403fa70aa fix(search): use anchor element (closes #698) (#717)
* fix(search): use anchor element

This addresses #698 to allow search title to include links for SPA

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* fix: formatter

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore: move itemTile to `a`

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore: remove nested a title

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* chore(search): remove spaNavigate

since now searchResult is an `a` item

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-21 20:50:00 -08:00
Jacky Zhao
015b4f6a15 fix: remove quartz 3 references, update font style in popovers 2024-01-21 12:39:20 -08:00
Aaron Pham
4d338cec13 feat(ofm): add options to parse arrows (#713)
* feat(ofm): add options to parse arrows

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

* feat(ofm): add options to parse arrows

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-21 11:33:32 -08:00
LUCASTUCIOUS
c11395e7bc feat: Add an option to display or not reading time from notes (#707)
* add an option to display or not reading time from notes

* Prettier (?)

* Remove ContentMeta override from quartz.layout.ts

* Make it positive ! 🌞

* Update quartz/components/ContentMeta.tsx

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-20 13:18:35 -08:00
Jacky Zhao
1f2ea96ae0 fix: allow dashes and underscores in block references (closes #712) 2024-01-20 00:33:14 -08:00
kabirgh
ce3dd0923b refactor: move emit from callback to helper file function (#704)
* Change emit from callback to helpers file function

* Update docs, remove commented code, improve type sig
2024-01-18 10:56:14 -08:00
Jacky Zhao
af811d824f style: make internal link have less visual padding (closes #706) 2024-01-17 20:03:14 -08:00
Jacky Zhao
129e0c60a9 fix: remove extra console log 2024-01-17 09:46:01 -08:00
Jacky Zhao
d7d5d8253c fix: clean up ofm code for video parsing 2024-01-17 09:45:05 -08:00
Matthew Bailin
f6299da182 feat: add ofm option to transform <img> tags with video exts into <video> (closes #463) (#664)
* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* cleaned up index validation, regex, conditional, no autoplay

* Update quartz/plugins/transformers/ofm.ts

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

* Update quartz/plugins/transformers/ofm.ts

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

* Update quartz/plugins/transformers/ofm.ts

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

* Update ofm.ts

* Update ofm.ts

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-17 09:32:02 -08:00
kabirgh
e17ff20244 fix: use joinSegments for contentIndex.json file path (#702) 2024-01-16 08:24:01 -08:00
sean
107d9b8dff fix: external link icon shouldn't be vertical aligned (#699) 2024-01-16 08:18:55 -08:00
sean
fa7d139ce5 feat: External link icons (#697) 2024-01-15 23:55:32 -08:00
Jacky Zhao
f31cabbbf9 fix: dont use default callout title if theres additional title children left (closes #693) 2024-01-15 12:37:56 -08:00
kabirgh
30640e3441 Revert "fix: rebuild errors on windows (#692)" (#695)
This reverts commit 8eec47c340.
2024-01-15 11:51:46 -08:00
kabirgh
8eec47c340 fix: rebuild errors on windows (#692) 2024-01-15 08:39:16 -08:00
Jacky Zhao
f36376503a fix: allow transcludes of notes with dots (closes #682) 2024-01-13 14:47:39 -08:00
Jacky Zhao
a40dbd55a4 fix: unbork search shortcut 2024-01-13 13:56:03 -08:00
Jacky Zhao
e70312320f feat: improve default layout 2024-01-13 09:47:56 -08:00
Jacky Zhao
4e82b0d8ce docs: add sidneys artist handbook to showcase 2024-01-13 09:37:24 -08:00
Jacky Zhao
783b9b219c fix: dont hijack handlers when search is not focused (closes #680) 2024-01-13 09:29:43 -08:00
Jacky Zhao
4014c4d6d6 fix: add another test for notes with dots 2024-01-13 09:27:00 -08:00
Jacky Zhao
6babb788ed fix: sluggify pound (closes #681) 2024-01-13 09:22:27 -08:00
ikorihn
0a8c38dc21 fix: small typos (#686) 2024-01-13 09:09:41 -08:00
ikorihn
52e6c03730 fix: broken RSS item's link, which were set to https:/${base}. (#687) 2024-01-13 09:08:21 -08:00
Jacky Zhao
1a8aedf5f5 docs: clarify git only sets modified 2024-01-07 15:39:38 -08:00
Aaron Pham
a4d6f701bf fix(showcase): markdown link (#673) 2024-01-07 11:47:53 -08:00
Aaron Pham
60017164ad chore: add my garden 😃 (#672) 2024-01-07 11:35:52 -08:00
Jacky Zhao
5ccc48a172 style: div -> li for explorer 2024-01-04 11:05:05 -08:00
Nate Silva
707124cbd6 fix: allow publish property to be a string (ExplicitPublish) (#667)
* fix: allow publish property to be a string (ExplicitPublish)

Previously, the ExplicitPublish filter would publish if the `publish`
property was truthy.

The filter expects the `publish` property to be a boolean:

```
---
publish: true
---
```

However, Obsidian only shows the above if you are viewing a page in
“Source” mode.

If you are not in Source view, and you choose Three Dots Menu (...),
“Add file property”, you will get a string, not a boolean. It seems
likely that many users will do this and get:

```
publish: "true"
```

Notice that `"true"` is a string, not the boolean value `true`. If the
user changes this to `"false"`, the page will still be published:

```
publish: "false"
```

That is because the string value `"false"` is truthy.

This PR does the following:

- Allows the `publish` property to be either a boolean or a string.
- If it’s a string, it’s considered `true` if the string is `"true"`
  (not case-sensitive; it will also work if it is `"True"`, `"TRUE"`,
  etc.)
- Guarantees that the returned value from `shouldPublish` is a `boolean`
  -- previously it could be any truthy value even though it was cast to
  `boolean`

* style: use double-quotes everywhere

* style: format according to project style guide
2024-01-02 15:19:19 -08:00
jeff
88194ac348 feat: allow embedding youtube videos with the obsidian markdown syntax (#665)
* Add option to allow embedding YouTube videos with Obsidian Markdown syntax

* Update Obsidian compatability doc page

* Switch to converting YT links as an html plugin
2024-01-02 10:49:14 -08:00
Olivér Falvai
65d75b8bdc feat: support modification date reading from parent git repo (#661)
* feat: support modification date reading from parent git repo

* Print warning

* Fix formatting

* Update quartz/plugins/transformers/lastmod.ts

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

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-02 09:23:28 -08:00
Mats Fangohr
6e34844114 feat: embed webp images (#666) 2024-01-02 08:03:05 -08:00
Jacky Zhao
b33f13ccaf fix: dont show last page if folder 2024-01-01 14:20:34 -08:00
Jimmy He
002bbc37b1 fix: Continue setup even if a file to delete is not found (#663)
* Continue setup even if a file to delete is not found

For various reasons, `.gitkeep` may be deleted already.

(In my case, even though I followed the [Getting Started](https://quartz.jzhao.xyz) instructions exactly, my first run resulted in an `fatal: 'upstream' does not appear to be a git repository`)

If we try to delete `.gitkeep` again and don't ignore `ENOENT`, then the whole setup fails.

* Use fs.existsSync
2024-01-01 14:14:37 -08:00
Jacky Zhao
e603d7396b fix: parse emoji tags in body (closes #659) 2024-01-01 08:58:25 -08:00
Jacky Zhao
40cfccdc77 style: relative back on pre 2023-12-28 15:07:59 -08:00
Jacky Zhao
e758cbe1ee pkg: bump version to 4.1.4 2023-12-28 14:00:15 -08:00
Jacky Zhao
4b6c7aeffe feat: lazyLoading specifier in link transformer 2023-12-28 13:56:20 -08:00
Jacky Zhao
e277ed5c30 fix: use joinSegment instead of joining via slash in sitemap (closes #658) 2023-12-28 08:54:09 -08:00
Olivér Falvai
68f53352e7 feat: Self-hosted Plausible support (#656)
* Self-hosted Plausible support

* Remove leftover import
2023-12-28 08:49:35 -08:00
Jacky Zhao
359484c139 fix: more robust tags parsing 2023-12-28 08:48:14 -08:00
Jacky Zhao
dafc9f318e feat: minify js scripts (closes #655) (#657) 2023-12-28 08:02:04 -08:00
Sidney
e1b6a0014c docs: add explorer example for advanced sortFn (#564)
* Added doc example to explorer sortFn

* Prettier fixed formatting

* Let Prettier fix the formatting of the entire markdown file

* Updated example

* Added extra commentary and fixed example

* Update docs/features/explorer.md

* doc fixes

* docs: remove leftover TODO

* docs: move example to `advanced`

---------

Co-authored-by: Sidney <85735034+Epicrex@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Ben Schlegel <ben5.schlegel@gmail.com>
2023-12-28 12:04:15 +01:00
Hydrophobefireman
233d4b2f2c fix: fix invalid html output (#642)
* fix: fix invalid html output

* fix: HTML structure w/ nested <li>
2023-12-28 11:20:07 +01:00
Jacky Zhao
504b447162 fix: use slugs instead of title as basis for explorer (#652)
* use slugs instead of title as basis for explorer

* fix folder persist state, better default behaviour

* use relative path instead of full path as full path is affected by -d

* dont use title in breadcrumb if it's just index lol
2023-12-27 16:44:14 -08:00
Jacky Zhao
63bf1e14b5 style: remove relative from base pre 2023-12-20 19:55:28 -08:00
migueltorrescosta
be76da9e95 docs: Add CollapsedWave to showcase.md (#643)
Thank you so much for a beautiful setup
2023-12-20 12:09:48 -08:00
Jacky Zhao
8fe37cc5e5 docs: update issue template 2023-12-20 10:05:00 -08:00
Jacky Zhao
2e9896c893 fix: deep clone before relativizing urls in transclude (closes #640) 2023-12-20 09:52:17 -08:00
Jacky Zhao
7bcf27241f fix: latex before syntax highlighting 2023-12-19 19:03:40 -08:00
Jacky Zhao
b44a79eeba fix: wikilinks should allow external links (closes #639) 2023-12-19 11:40:59 -08:00
Jacky Zhao
9b9d86474b fix: mermaid rendering fix from upstream 2023-12-19 11:01:55 -08:00
Jacky Zhao
4c83251f8e feat: -v flag should log exact error on parse failure 2023-12-19 09:07:52 -08:00
Jacky Zhao
984ab1c578 fix: change backtick to regular after making script loading less hacky 2023-12-18 23:13:37 -08:00
Jacky Zhao
443cd53a1a fix: mermaid rendering broken after rehype-pretty-code bump (closes #638) 2023-12-18 23:09:49 -08:00
Jacky Zhao
5152d32fbd pkg: bump version to 4.1.3 2023-12-18 09:50:14 -08:00
53 changed files with 621 additions and 314 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -225,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`.

View File

@@ -25,9 +25,6 @@ This will guide you through initializing your Quartz with content. Once you've d
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

View File

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

44
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@jackyzha0/quartz",
"version": "4.1.2",
"version": "4.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackyzha0/quartz",
"version": "4.1.2",
"version": "4.1.5",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.7.0",
@@ -32,7 +32,6 @@
"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.19.3",
"preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1",
@@ -41,7 +40,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0",
"rehype-mathjax": "^5.0.0",
"rehype-pretty-code": "^0.12.1",
"rehype-pretty-code": "^0.12.3",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@@ -52,9 +51,10 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"remark-smartypants": "^2.0.0",
"rfdc": "^1.3.0",
"rimraf": "^5.0.5",
"serve-handler": "^6.1.5",
"shikiji": "^0.8.7",
"shikiji": "^0.9.9",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",
@@ -4450,14 +4450,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/plausible-tracker": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.8.tgz",
"integrity": "sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==",
"engines": {
"node": ">=10"
}
},
"node_modules/preact": {
"version": "10.19.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz",
@@ -4713,9 +4705,9 @@
}
},
"node_modules/rehype-pretty-code": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/rehype-pretty-code/-/rehype-pretty-code-0.12.1.tgz",
"integrity": "sha512-QeGvAPTc31KwP52wBjXqdXXfBAhEs6wETNlOcZRkaPKZZo6Alsz0U6yY05cCKQK53xYNeDCClVYDCyVMl3SP1Q==",
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/rehype-pretty-code/-/rehype-pretty-code-0.12.3.tgz",
"integrity": "sha512-6NbIit8A3hLrkKBEbNs862jVnTLeIOM2AmM0VZ/MtyHb+OuNMeCa6UZSx6UG4zrobm5tY9efTwhih1exsGYsiw==",
"dependencies": {
"@types/hast": "^3.0.3",
"hast-util-to-string": "^3.0.0",
@@ -4728,7 +4720,7 @@
"node": ">=18"
},
"peerDependencies": {
"shikiji": "^0.7.0 || ^0.8.0"
"shikiji": "^0.7.0 || ^0.8.0 || ^0.9.0"
}
},
"node_modules/rehype-raw": {
@@ -5161,6 +5153,11 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"node_modules/rimraf": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
@@ -5314,13 +5311,18 @@
}
},
"node_modules/shikiji": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/shikiji/-/shikiji-0.8.7.tgz",
"integrity": "sha512-j5usxwI0yHkDTHOuhuSJl9+wT5CNYeYO82dJMSJBlJ/NYT5SIebGcPoL6y9QOyH15wGrJC4LOP2nz5k8mUDGRQ==",
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/shikiji/-/shikiji-0.9.9.tgz",
"integrity": "sha512-/S3unr/0mZTstNOuAmNDEufeimtqeQb8lXvPMLsYfDvqyfmG6334bO2xmDzD0kfxH2y8gnFgSWAJpdEzksmYXg==",
"dependencies": {
"hast-util-to-html": "^9.0.0"
"shikiji-core": "0.9.9"
}
},
"node_modules/shikiji-core": {
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/shikiji-core/-/shikiji-core-0.9.9.tgz",
"integrity": "sha512-qu5Qq7Co6JIMY312J9Ek6WYjXieeyJT/fIqmkcjF4MdnMNlUnhSqPo8/42g5UdPgdyTCwijS7Nhg8DfLSLodkg=="
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",

View File

@@ -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",
@@ -57,7 +57,6 @@
"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.19.3",
"preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1",
@@ -66,7 +65,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0",
"rehype-mathjax": "^5.0.0",
"rehype-pretty-code": "^0.12.1",
"rehype-pretty-code": "^0.12.3",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@@ -77,9 +76,10 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"remark-smartypants": "^2.0.0",
"rfdc": "^1.3.0",
"rimraf": "^5.0.5",
"serve-handler": "^6.1.5",
"shikiji": "^0.8.7",
"shikiji": "^0.9.9",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",

View File

@@ -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()],

View File

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

View File

@@ -148,8 +148,11 @@ 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()

View File

@@ -7,6 +7,7 @@ export type Analytics =
| null
| {
provider: "plausible"
host?: string
}
| {
provider: "google"

View File

@@ -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
@@ -447,7 +450,7 @@ export async function handleUpdate(argv) {
try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
console.log(chalk.red("An error occurred above while pulling updates."))
await popContentFolder(contentFolder)
return
}
@@ -519,7 +522,7 @@ export async function handleSync(argv) {
try {
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
console.log(chalk.red("An error occurred above while pulling updates."))
await popContentFolder(contentFolder)
return
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
import Plausible from "plausible-tracker"
const { trackPageview } = Plausible()
document.addEventListener("nav", () => trackPageview())

View File

@@ -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[]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,17 +92,30 @@ 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 });
gtag("js", new Date());
gtag("config", "${tagId}", { send_page_view: false });
document.addEventListener(\`nav\`, () => {
gtag(\`event\`, \`page_view\`, {
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")
@@ -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,

View File

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

View File

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

View File

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

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

View File

@@ -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"), {

View File

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

View File

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

View File

@@ -30,5 +30,6 @@ declare module "vfile" {
interface DataMap {
slug: FullSlug
filePath: FilePath
relativePath: FilePath
}
}

View File

@@ -49,11 +49,19 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | 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(oneLineTagDelim)
.map((tag: string) => tag.trim())
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
.map((tag: string | number) => tag.toString())
}
// slug them all!!

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
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 { 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
@@ -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,7 +125,7 @@ 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")
@@ -123,8 +133,10 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// #(...) -> capturing group, tag itself must start with #
// (?:[-_\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")
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,
@@ -139,13 +151,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
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) {
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replaceAll(calloutLineRegex, (value) => {
src = src.replace(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
@@ -157,14 +178,20 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
src = src.toString()
}
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias] = capture
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}]]`
})
}
@@ -193,7 +220,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
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)) {
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
const dims = alias ?? ""
let [width, height] = dims.split("x", 2)
width ||= "auto"
@@ -225,7 +252,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
type: "html",
value: `<iframe src="${url}"></iframe>`,
}
} else if (ext === "") {
} else {
const block = anchor
return {
type: "html",
@@ -268,13 +295,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
])
}
if (opts.comments) {
if (opts.parseArrows) {
replacements.push([
commentRegex,
arrowRegex,
(_value: string, ..._capture: string[]) => {
return {
type: "text",
value: "",
type: "html",
value: `<span>&rarr;</span>`,
}
},
])
@@ -319,7 +346,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (typeof replace === "string") {
node.value = node.value.replace(regex, replace)
} else {
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
node.value = node.value.replace(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
@@ -335,11 +362,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
})
}
mdastFindReplace(tree, replacements)
}
})
if (opts.enableVideoEmbed) {
plugins.push(() => {
return (tree: Root, _file) => {
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>`,
}
parent.children.splice(index, 1, newNode)
return SKIP
}
})
}
})
}
if (opts.callouts) {
plugins.push(() => {
return (tree: Root, _file) => {
@@ -355,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")
@@ -371,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)
@@ -443,11 +490,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
},
htmlPlugins() {
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) => {
@@ -496,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() {

View File

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

View File

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

View File

@@ -91,8 +91,9 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
}
// 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)

View File

@@ -59,6 +59,7 @@ a {
text-decoration: none;
transition: color 0.2s ease;
color: var(--secondary);
display: inline-block;
&:hover {
color: var(--tertiary) !important;
@@ -69,6 +70,7 @@ a {
background-color: var(--highlight);
padding: 0 0.1rem;
border-radius: 5px;
line-height: 1.4rem;
&:has(> img) {
background-color: none;
@@ -76,6 +78,15 @@ a {
padding: 0;
}
}
&.external .external-icon {
height: 1ex;
margin: 0 0.15em;
> path {
fill: var(--dark);
}
}
}
.desktop-only {
@@ -328,10 +339,11 @@ figure[data-rehype-pretty-code-figure] {
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;

View File

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

View File

@@ -1,5 +1,9 @@
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"
@@ -46,7 +50,9 @@ export function getFullSlug(window: Window): FullSlug {
function sluggify(s: string): string {
return s
.split("/")
.map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
.map((segment) =>
segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").replace(/#/g, ""),
) // slugify all segments
.join("/") // always use / as sep
.replace(/\/$/, "")
}
@@ -121,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) {

View File

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