mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-01 02:07:55 +01:00
Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a8dfcd55 | ||
|
|
44da82467e | ||
|
|
3231ce6e79 | ||
|
|
a0b927da4a | ||
|
|
5ab922f316 | ||
|
|
d11a0e71a8 | ||
|
|
2b57a68e1f | ||
|
|
18cd58617d | ||
|
|
ee868b2d79 | ||
|
|
5a36e5b68d | ||
|
|
0416c03ae6 | ||
|
|
3b596c9311 | ||
|
|
970a30a139 | ||
|
|
dc62aeb213 | ||
|
|
9b8e0c9d1a | ||
|
|
45b93a80f4 | ||
|
|
e9fb0ecb96 | ||
|
|
c0c0b24138 | ||
|
|
c00089bd57 | ||
|
|
8a6ebd1939 | ||
|
|
f78b512436 | ||
|
|
295b8fc914 | ||
|
|
756acc7f97 | ||
|
|
9aa6a18be2 | ||
|
|
444e05ee21 | ||
|
|
1c175b2d09 | ||
|
|
7b2ce8b4a3 | ||
|
|
f2e93c3314 | ||
|
|
25e6869d38 | ||
|
|
bfd877133b | ||
|
|
422986c98b | ||
|
|
75d64eac91 | ||
|
|
355aa22318 | ||
|
|
7cb1c291c8 | ||
|
|
22de92f6c4 | ||
|
|
e1f12e6cb7 | ||
|
|
50bb1ffd8a | ||
|
|
fee3ef9b3a | ||
|
|
a29fadb046 | ||
|
|
4e5643fb49 | ||
|
|
072ee64127 | ||
|
|
90043cd582 | ||
|
|
e21d50c711 | ||
|
|
f3c7211bf0 | ||
|
|
ead7ee2f50 | ||
|
|
6ba138b4fa | ||
|
|
6ce754bda2 | ||
|
|
8df74185e9 | ||
|
|
37c6231e79 | ||
|
|
9555407f65 | ||
|
|
fbb4d7e399 | ||
|
|
5f624edb38 | ||
|
|
b8ddf53aa8 | ||
|
|
b85a3543f4 | ||
|
|
ebf429a9c6 | ||
|
|
2d727443b3 | ||
|
|
76be137283 | ||
|
|
f68872c09f | ||
|
|
b7152f743b | ||
|
|
603c181ad2 | ||
|
|
16adbd3011 | ||
|
|
b014d060f3 | ||
|
|
85f05ea99b | ||
|
|
bf5a556cc1 | ||
|
|
c4b756c817 | ||
|
|
211f95c527 | ||
|
|
ba40516c54 | ||
|
|
a70078ccdc | ||
|
|
2b62e29282 | ||
|
|
efdce070e1 | ||
|
|
2739457c86 | ||
|
|
7695df69e5 | ||
|
|
319dec4245 | ||
|
|
bebd6320b7 | ||
|
|
0a2d746e38 | ||
|
|
b11fefbbbe | ||
|
|
42ee069c1c | ||
|
|
b211d49922 | ||
|
|
af3a4ff9cd | ||
|
|
448ba008e0 | ||
|
|
8fa1a1e7b9 | ||
|
|
b87c6cd5c7 | ||
|
|
a8e1c4abc2 | ||
|
|
d90199c8db | ||
|
|
d5b40279bd | ||
|
|
b22bcd17b4 | ||
|
|
fa6c02d321 | ||
|
|
5fb203a6df | ||
|
|
0a76707062 | ||
|
|
1ce12fc1fc | ||
|
|
eb302c05b8 | ||
|
|
c9ac2a7507 | ||
|
|
7ca491bc1d | ||
|
|
4edd27d3f9 | ||
|
|
2c8d0f8ab6 | ||
|
|
cd826fb477 | ||
|
|
273931d25c | ||
|
|
0403fa70aa | ||
|
|
015b4f6a15 | ||
|
|
4d338cec13 | ||
|
|
c11395e7bc | ||
|
|
1f2ea96ae0 | ||
|
|
ce3dd0923b | ||
|
|
af811d824f | ||
|
|
129e0c60a9 | ||
|
|
d7d5d8253c | ||
|
|
f6299da182 | ||
|
|
e17ff20244 | ||
|
|
107d9b8dff | ||
|
|
fa7d139ce5 | ||
|
|
f31cabbbf9 | ||
|
|
30640e3441 | ||
|
|
8eec47c340 | ||
|
|
f36376503a | ||
|
|
a40dbd55a4 | ||
|
|
e70312320f | ||
|
|
4e82b0d8ce | ||
|
|
783b9b219c | ||
|
|
4014c4d6d6 | ||
|
|
6babb788ed | ||
|
|
0a8c38dc21 | ||
|
|
52e6c03730 | ||
|
|
1a8aedf5f5 | ||
|
|
a4d6f701bf | ||
|
|
60017164ad | ||
|
|
5ccc48a172 | ||
|
|
707124cbd6 | ||
|
|
88194ac348 | ||
|
|
65d75b8bdc | ||
|
|
6e34844114 | ||
|
|
b33f13ccaf | ||
|
|
002bbc37b1 | ||
|
|
e603d7396b | ||
|
|
40cfccdc77 | ||
|
|
e758cbe1ee | ||
|
|
4b6c7aeffe | ||
|
|
e277ed5c30 | ||
|
|
68f53352e7 | ||
|
|
359484c139 | ||
|
|
dafc9f318e | ||
|
|
e1b6a0014c | ||
|
|
233d4b2f2c | ||
|
|
504b447162 | ||
|
|
63bf1e14b5 | ||
|
|
be76da9e95 | ||
|
|
8fe37cc5e5 | ||
|
|
2e9896c893 | ||
|
|
7bcf27241f | ||
|
|
b44a79eeba | ||
|
|
9b9d86474b | ||
|
|
4c83251f8e | ||
|
|
984ab1c578 | ||
|
|
443cd53a1a | ||
|
|
5152d32fbd | ||
|
|
ea6208c1f0 | ||
|
|
78b33fc2fb | ||
|
|
d2be097b76 | ||
|
|
ad1f964a5f | ||
|
|
150050f379 | ||
|
|
d979331dc7 | ||
|
|
972cf0a887 | ||
|
|
14e6b13ff1 | ||
|
|
3c01b92cc4 | ||
|
|
ed9bd43d9f | ||
|
|
c35818c336 | ||
|
|
a464ae5029 | ||
|
|
66e297c0ea | ||
|
|
4442847b37 | ||
|
|
e6b5ca33c9 | ||
|
|
1b92440009 | ||
|
|
c6546903f2 | ||
|
|
2c69b0c97d | ||
|
|
a7e20804f5 | ||
|
|
5196f3b9db | ||
|
|
f0ec6c9b92 | ||
|
|
9c88d5967f |
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -20,12 +20,19 @@ Steps to reproduce the behavior:
|
|||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots and Source**
|
||||||
If applicable, add screenshots to help explain your problem.
|
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):**
|
**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]
|
- OS: [e.g. iOS]
|
||||||
- Browser [e.g. chrome, safari]
|
- Browser [e.g. chrome, safari]
|
||||||
|
|
||||||
|
|||||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -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 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.
|
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/
|
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
|
||||||
|
|
||||||
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
[Join the Discord Community](https://discord.gg/cRFFHYye7t)
|
||||||
|
|||||||
@@ -156,12 +156,13 @@ document.addEventListener("nav", () => {
|
|||||||
// do page specific logic here
|
// do page specific logic here
|
||||||
// e.g. attach event listeners
|
// e.g. attach event listeners
|
||||||
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
|
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
|
||||||
toggleSwitch.removeEventListener("change", switchTheme)
|
|
||||||
toggleSwitch.addEventListener("change", switchTheme)
|
toggleSwitch.addEventListener("change", switchTheme)
|
||||||
|
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
It is best practice to also unmount any existing event handlers to prevent memory leaks.
|
It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
|
||||||
|
This will get called on page navigation.
|
||||||
|
|
||||||
#### Importing Code
|
#### Importing Code
|
||||||
|
|
||||||
|
|||||||
@@ -216,22 +216,19 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
|||||||
|
|
||||||
export type QuartzEmitterPluginInstance = {
|
export type QuartzEmitterPluginInstance = {
|
||||||
name: string
|
name: string
|
||||||
emit(
|
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||||
ctx: BuildCtx,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
resources: StaticResources,
|
|
||||||
emitCallback: EmitCallback,
|
|
||||||
): Promise<FilePath[]>
|
|
||||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
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
|
```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)
|
// the name of the file to emit (not including the file extension)
|
||||||
slug: ServerSlug
|
slug: ServerSlug
|
||||||
// the file extension
|
// the file extension
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
title: Authoring Content
|
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.
|
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.
|
||||||
|
|
||||||
@@ -28,21 +28,13 @@ The rest of your content lives here. You can use **Markdown** here :)
|
|||||||
Some common frontmatter fields that are natively supported by Quartz:
|
Some common frontmatter fields that are natively supported by Quartz:
|
||||||
|
|
||||||
- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title.
|
- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title.
|
||||||
|
- `description`: Description of the page used for link previews.
|
||||||
- `aliases`: Other names for this note. This is a list of strings.
|
- `aliases`: Other names for this note. This is a list of strings.
|
||||||
|
- `tags`: Tags for this note.
|
||||||
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.
|
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.
|
||||||
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
|
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
|
||||||
|
|
||||||
## Syncing your Content
|
## Syncing your Content
|
||||||
|
|
||||||
When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`.
|
When your Quartz is at a point you're happy with, you can save your changes to GitHub.
|
||||||
|
First, make sure you've [[setting up your GitHub repository|already setup your GitHub repository]] and then do `npx quartz sync`.
|
||||||
> [!hint] Flags and options
|
|
||||||
> For full help options, you can run `npx quartz sync --help`.
|
|
||||||
>
|
|
||||||
> Most of these have sensible defaults but you can override them if you have a custom setup:
|
|
||||||
>
|
|
||||||
> - `-d` or `--directory`: the content folder. This is normally just `content`
|
|
||||||
> - `-v` or `--verbose`: print out extra logging information
|
|
||||||
> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes
|
|
||||||
> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz
|
|
||||||
> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ Finally, Quartz also provides `Plugin.CrawlLinks` which allows you to customize
|
|||||||
- `callouts`: whether to enable [[callouts]]. Defaults to `true`
|
- `callouts`: whether to enable [[callouts]]. Defaults to `true`
|
||||||
- `mermaid`: whether to enable [[Mermaid diagrams]]. 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`
|
- `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`
|
- `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:
|
- Link resolution behaviour:
|
||||||
- Disabling: remove all instances of `Plugin.CrawlLinks()` from `quartz.config.ts`
|
- Disabling: remove all instances of `Plugin.CrawlLinks()` from `quartz.config.ts`
|
||||||
- Changing link resolution preference: set `markdownLinkResolution` to one of `absolute`, `relative` or `shortest`
|
- Changing link resolution preference: set `markdownLinkResolution` to one of `absolute`, `relative` or `shortest`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Component.Breadcrumbs({
|
|||||||
rootName: "Home", // name of first/root element
|
rootName: "Home", // name of first/root element
|
||||||
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
|
||||||
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
|
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
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,32 @@ See [documentation on supported types and syntax here](https://help.obsidian.md
|
|||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
- Disable callouts: simply pass `callouts: false` to the plugin: `Plugin.ObsidianFlavoredMarkdown({ callouts: false })`
|
- Disable callouts: simply pass `callouts: false` to the plugin: `Plugin.ObsidianFlavoredMarkdown({ callouts: false })`
|
||||||
- Editing icons: `quartz/plugins/transformers/ofm.ts`
|
- Editing icons: `quartz/styles/callouts.scss`
|
||||||
|
|
||||||
|
### Add custom callouts
|
||||||
|
|
||||||
|
By default, custom callouts are handled by applying the `note` style. To make fancy ones, you have to add these lines to `custom.scss`.
|
||||||
|
|
||||||
|
```scss title="quartz/styles/custom.scss"
|
||||||
|
.callout {
|
||||||
|
&[data-callout="custom"] {
|
||||||
|
--color: #customcolor;
|
||||||
|
--border: #custombordercolor;
|
||||||
|
--bg: #custombg;
|
||||||
|
--callout-icon: url("data:image/svg+xml; utf8, <custom formatted svg>"); //SVG icon code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning]
|
||||||
|
> Don't forget to ensure that the SVG is URL encoded before putting it in the CSS. You can use tools like [this one](https://yoksel.github.io/url-encoder/) to help you do that.
|
||||||
|
|
||||||
## Showcase
|
## Showcase
|
||||||
|
|
||||||
> [!info]
|
> [!info]
|
||||||
> Default title
|
> Default title
|
||||||
|
|
||||||
> [!question]+ Can callouts be nested?
|
> [!question]+ Can callouts be _nested_?
|
||||||
>
|
>
|
||||||
> > [!todo]- Yes!, they can. And collapsed!
|
> > [!todo]- Yes!, they can. And collapsed!
|
||||||
> >
|
> >
|
||||||
|
|||||||
@@ -12,3 +12,12 @@ Quartz supports darkmode out of the box that respects the user's theme preferenc
|
|||||||
- Component: `quartz/components/Darkmode.tsx`
|
- Component: `quartz/components/Darkmode.tsx`
|
||||||
- Style: `quartz/components/styles/darkmode.scss`
|
- Style: `quartz/components/styles/darkmode.scss`
|
||||||
- Script: `quartz/components/scripts/darkmode.inline.ts`
|
- Script: `quartz/components/scripts/darkmode.inline.ts`
|
||||||
|
|
||||||
|
You can also listen to the `themechange` event to perform any custom logic when the theme changes.
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.addEventListener("themechange", (e) => {
|
||||||
|
console.log("Theme changed to " + e.detail.theme) // either "light" or "dark"
|
||||||
|
// your logic here
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Component.Explorer({
|
|||||||
title: "Explorer", // title of the explorer component
|
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)
|
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")
|
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
|
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||||
sortFn: (a, b) => {
|
sortFn: (a, b) => {
|
||||||
... // default implementation shown later
|
... // default implementation shown later
|
||||||
@@ -179,6 +179,34 @@ Component.Explorer({
|
|||||||
|
|
||||||
## Advanced examples
|
## 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
|
### Add emoji prefix
|
||||||
|
|
||||||
To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this:
|
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.
|
To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function.
|
||||||
|
|
||||||
> [!tip]
|
### Use `sort` with pre-defined sort order
|
||||||
> 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.
|
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.
|
||||||
>
|
|
||||||
> ```ts title="functions.ts"
|
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).
|
||||||
> import { Options } from "./quartz/components/ExplorerNode"
|
|
||||||
> export const mapFn: Options["mapFn"] = (node) => {
|
```ts title="quartz.layout.ts"
|
||||||
> // implement your function here
|
Component.Explorer({
|
||||||
> }
|
sortFn: (a, b) => {
|
||||||
> export const filterFn: Options["filterFn"] = (node) => {
|
const nameOrderMap: Record<string, number> = {
|
||||||
> // implement your function here
|
"poetry-folder": 100,
|
||||||
> }
|
"essay-folder": 200,
|
||||||
> export const sortFn: Options["sortFn"] = (a, b) => {
|
"research-paper-file": 201,
|
||||||
> // implement your function here
|
"dinosaur-fossils-file": 300,
|
||||||
> }
|
"other-folder": 400,
|
||||||
> ```
|
}
|
||||||
>
|
|
||||||
> You can then import them like this:
|
let orderA = 0
|
||||||
>
|
let orderB = 0
|
||||||
> ```ts title="quartz.layout.ts"
|
|
||||||
> import { mapFn, filterFn, sortFn } from "./functions.ts"
|
if (a.file && a.file.slug) {
|
||||||
> Component.Explorer({
|
orderA = nameOrderMap[a.file.slug] || 0
|
||||||
> mapFn: mapFn,
|
} else if (a.name) {
|
||||||
> filterFn: filterFn,
|
orderA = nameOrderMap[a.name] || 0
|
||||||
> sortFn: sortFn,
|
}
|
||||||
> })
|
|
||||||
> ```
|
if (b.file && b.file.slug) {
|
||||||
|
orderB = nameOrderMap[b.file.slug] || 0
|
||||||
|
} else if (b.name) {
|
||||||
|
orderB = nameOrderMap[b.name] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderA - orderB
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For reference, this is how the quartz explorer window would look like with that example:
|
||||||
|
|
||||||
|
```
|
||||||
|
📖 Poetry Folder
|
||||||
|
📑 Essay Folder
|
||||||
|
⚗️ Research Paper File
|
||||||
|
🦴 Dinosaur Fossils File
|
||||||
|
🔮 Other Folder
|
||||||
|
```
|
||||||
|
|
||||||
|
And this is how the file structure would look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
index.md
|
||||||
|
poetry-folder
|
||||||
|
index.md
|
||||||
|
essay-folder
|
||||||
|
index.md
|
||||||
|
research-paper-file.md
|
||||||
|
dinosaur-fossils-file.md
|
||||||
|
other-folder
|
||||||
|
index.md
|
||||||
|
```
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ Component.Graph({
|
|||||||
linkDistance: 30, // how long should the links be by default?
|
linkDistance: 30, // how long should the links be by default?
|
||||||
fontSize: 0.6, // what size should the node labels be?
|
fontSize: 0.6, // what size should the node labels be?
|
||||||
opacityScale: 1, // how quickly do we fade out the labels when zooming out?
|
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: {
|
globalGraph: {
|
||||||
drag: true,
|
drag: true,
|
||||||
@@ -45,6 +47,8 @@ Component.Graph({
|
|||||||
linkDistance: 30,
|
linkDistance: 30,
|
||||||
fontSize: 0.6,
|
fontSize: 0.6,
|
||||||
opacityScale: 1,
|
opacityScale: 1,
|
||||||
|
removeTags: [], // what tags to remove from the graph
|
||||||
|
showTags: true, // whether to show tags in the graph
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Recent Notes
|
|||||||
tags: component
|
tags: component
|
||||||
---
|
---
|
||||||
|
|
||||||
Quartz can generate a list of recent notes for based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes`.
|
Quartz can generate a list of recent notes based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes` in `quartz.layout.ts`.
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 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#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`
|
- `[[Path to file#^block-ref]]`: produces a link to the specific block `block-ref` in the file `Path to file.md`
|
||||||
|
|
||||||
|
### Embeds
|
||||||
|
|
||||||
|
- `![[Path to image]]`: embeds an image into the page
|
||||||
|
- `![[Path to image|100x145]]`: embeds an image into the page with dimensions 100px by 145px
|
||||||
|
- `![[Path to file]]`: transclude an entire page
|
||||||
|
- `![[Path to file#Anchor]]`: transclude everything under the header `Anchor`
|
||||||
|
- `![[Path to file#^b15695]]`: transclude block with ID `^b15695`
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ title: Hosting
|
|||||||
|
|
||||||
Quartz effectively turns your Markdown files and other resources into a bundle of HTML, JS, and CSS files (a website!).
|
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]
|
> [!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!
|
> 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/).
|
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]
|
> [!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`.
|
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.
|
> 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
|
### Custom Domain
|
||||||
|
|
||||||
Here's how to add a custom domain to your GitHub pages deployment.
|
Here's how to add a custom domain to your GitHub pages deployment.
|
||||||
@@ -169,8 +173,6 @@ Using `docs.example.com` is an example of a subdomain. They're a simple way of c
|
|||||||
|
|
||||||
## Netlify
|
## Netlify
|
||||||
|
|
||||||
Like Vercel, you can also deploy the site generated by Quartz 4 via Netlify.
|
|
||||||
|
|
||||||
1. Log in to the [Netlify dashboard](https://app.netlify.com/) and click "Add new site".
|
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.
|
2. Select your Git provider and repository containing your Quartz project.
|
||||||
3. Under "Build command", enter `npx quartz build`.
|
3. Under "Build command", enter `npx quartz build`.
|
||||||
@@ -180,8 +182,6 @@ Like Vercel, you can also deploy the site generated by Quartz 4 via Netlify.
|
|||||||
|
|
||||||
## GitLab Pages
|
## GitLab Pages
|
||||||
|
|
||||||
You can configure GitLab CI to build and deploy a Quartz 4 project.
|
|
||||||
|
|
||||||
In your local Quartz, create a new file `.gitlab-ci.yaml`.
|
In your local Quartz, create a new file `.gitlab-ci.yaml`.
|
||||||
|
|
||||||
```yaml title=".gitlab-ci.yaml"
|
```yaml title=".gitlab-ci.yaml"
|
||||||
@@ -203,8 +203,6 @@ build:
|
|||||||
- hash -r
|
- hash -r
|
||||||
- npm ci
|
- npm ci
|
||||||
script:
|
script:
|
||||||
- npx prettier --write .
|
|
||||||
- npm run check
|
|
||||||
- npx quartz build
|
- npx quartz build
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
@@ -227,6 +225,6 @@ pages:
|
|||||||
- public
|
- 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`.
|
By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`.
|
||||||
|
|||||||
BIN
docs/images/github-init-repo-options.png
Normal file
BIN
docs/images/github-init-repo-options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/images/github-quick-setup.png
Normal file
BIN
docs/images/github-quick-setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
@@ -2,7 +2,7 @@
|
|||||||
title: Welcome to Quartz 4
|
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
|
## 🪴 Get Started
|
||||||
|
|
||||||
@@ -19,14 +19,15 @@ npx quartz create
|
|||||||
|
|
||||||
This will guide you through initializing your Quartz with content. Once you've done so, see how to:
|
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
|
2. [[configuration|Configure]] Quartz's behaviour
|
||||||
3. Change Quartz's [[layout]]
|
3. Change Quartz's [[layout]]
|
||||||
4. [[build|Build and preview]] Quartz
|
4. [[build|Build and preview]] Quartz
|
||||||
5. [[hosting|Host]] Quartz online
|
5. Sync your changes with [[setting up your GitHub repository|GitHub]]
|
||||||
|
6. [[hosting|Host]] Quartz online
|
||||||
|
|
||||||
> [!info]
|
If you prefer instructions in a video format you can try following Nicole van der Hoeven's
|
||||||
> 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.
|
[video guide on how to set up Quartz!](https://www.youtube.com/watch?v=6s6DT1yN4dw&t=227s)
|
||||||
|
|
||||||
## 🔧 Features
|
## 🔧 Features
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ title: Philosophy of Quartz
|
|||||||
>
|
>
|
||||||
> _(The Garden and the Stream)_
|
> _(The Garden and the Stream)_
|
||||||
|
|
||||||
The problem with the file cabinet is that it focuses on efficiency of access and interoperability rather than generativity and creativity. Thinking is not linear, nor is it hierarchical. In fact, not many things are linear or hierarchical at all. Then why is it that most tools and thinking strategies assume a nice chronological or hierarchical order for my thought processes? The ideal tool for thought for me would embrace the messiness of my mind, and organically help insights emerge from chaos instead of forcing an artificial order. A rhizomatic, not arboresecent, form of note taking.
|
The problem with the file cabinet is that it focuses on efficiency of access and interoperability rather than generativity and creativity. Thinking is not linear, nor is it hierarchical. In fact, not many things are linear or hierarchical at all. Then why is it that most tools and thinking strategies assume a nice chronological or hierarchical order for my thought processes?
|
||||||
|
|
||||||
|
The ideal tool for thought for me would embrace the messiness of my mind, and organically help insights emerge from chaos instead of forcing an artificial order. A rhizomatic, not arboresecent, form of note taking.
|
||||||
|
|
||||||
My goal with a digital garden is not purely as an organizing system and information store (though it works nicely for that). I want my digital garden to be a playground for new ways ideas can connect together. As a result, existing formal organizing systems like Zettelkasten or the hierarchical folder structures of Notion don’t work well for me. There is way too much upfront friction that by the time I’ve thought about how to organize my thought into folders categories, I’ve lost it.
|
My goal with a digital garden is not purely as an organizing system and information store (though it works nicely for that). I want my digital garden to be a playground for new ways ideas can connect together. As a result, existing formal organizing systems like Zettelkasten or the hierarchical folder structures of Notion don’t work well for me. There is way too much upfront friction that by the time I’ve thought about how to organize my thought into folders categories, I’ve lost it.
|
||||||
|
|
||||||
@@ -25,4 +27,21 @@ Quartz is designed first and foremost as a tool for publishing [digital gardens]
|
|||||||
> “[One] who works with the door open gets all kinds of interruptions, but [they] also occasionally gets clues as to what the world is and what might be important.”
|
> “[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
|
> — 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.
|
||||||
|
|||||||
48
docs/setting up your GitHub repository.md
Normal file
48
docs/setting up your GitHub repository.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
# list all the repositories that are tracked
|
||||||
|
git remote -v
|
||||||
|
|
||||||
|
# if the origin doesn't match your own repository, set your repository as the origin
|
||||||
|
git remote set-url origin REMOTE-URL
|
||||||
|
|
||||||
|
# if you don't have upstream as a remote, add it so updates work
|
||||||
|
git remote add upstream https://github.com/jackyzha0/quartz.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, you can sync the content to upload it to your repository. This is a helper command that will do the initial push of your content to your repository.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx quartz sync --no-pull
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
|
||||||
|
> You may have an outdated version of `git`. Updating `git` should fix this issue.
|
||||||
|
|
||||||
|
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
|
||||||
|
|
||||||
|
> [!hint] Flags and options
|
||||||
|
> For full help options, you can run `npx quartz sync --help`.
|
||||||
|
>
|
||||||
|
> Most of these have sensible defaults but you can override them if you have a custom setup:
|
||||||
|
>
|
||||||
|
> - `-d` or `--directory`: the content folder. This is normally just `content`
|
||||||
|
> - `-v` or `--verbose`: print out extra logging information
|
||||||
|
> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes
|
||||||
|
> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz
|
||||||
|
> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing
|
||||||
@@ -6,14 +6,11 @@ Want to see what Quartz can do? Here are some cool community gardens:
|
|||||||
|
|
||||||
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
|
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
|
||||||
- [Jacky Zhao's Garden](https://jzhao.xyz/)
|
- [Jacky Zhao's Garden](https://jzhao.xyz/)
|
||||||
- [Brandon Boswell's Garden](https://brandonkboswell.com)
|
- [Socratica Toolbox](https://toolbox.socratica.info/)
|
||||||
- [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/)
|
- [oldwinter の数字花园](https://garden.oldwinter.top/)
|
||||||
|
- [Aaron Pham's Garden](https://aarnphm.xyz/)
|
||||||
|
- [The Quantum Garden](https://quantumgardener.blog/)
|
||||||
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/)
|
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/)
|
||||||
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
|
|
||||||
- [Matt Dunn's Second Brain](https://mattdunn.info/)
|
- [Matt Dunn's Second Brain](https://mattdunn.info/)
|
||||||
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
|
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
|
||||||
- [Vince Imbat's Talahardin](https://vinceimbat.com/)
|
- [Vince Imbat's Talahardin](https://vinceimbat.com/)
|
||||||
@@ -21,5 +18,12 @@ 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/)
|
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
|
||||||
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
|
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
|
||||||
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
|
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
|
||||||
|
- [🌊 Collapsed Wave](https://collapsedwave.com/)
|
||||||
|
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
|
||||||
|
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
|
||||||
|
- [Brandon Boswell's Garden](https://brandonkboswell.com)
|
||||||
|
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
|
||||||
|
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
|
||||||
|
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
|
||||||
|
|
||||||
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)!
|
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)!
|
||||||
|
|||||||
3
globals.d.ts
vendored
3
globals.d.ts
vendored
@@ -4,9 +4,10 @@ export declare global {
|
|||||||
type: K,
|
type: K,
|
||||||
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
listener: (this: Document, ev: CustomEventMap[K]) => void,
|
||||||
): void
|
): void
|
||||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
|
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
|
||||||
}
|
}
|
||||||
interface Window {
|
interface Window {
|
||||||
spaNavigate(url: URL, isBack: boolean = false)
|
spaNavigate(url: URL, isBack: boolean = false)
|
||||||
|
addCleanup(fn: (...args: any[]) => void)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
index.d.ts
vendored
1
index.d.ts
vendored
@@ -6,6 +6,7 @@ declare module "*.scss" {
|
|||||||
// dom custom event
|
// dom custom event
|
||||||
interface CustomEventMap {
|
interface CustomEventMap {
|
||||||
nav: CustomEvent<{ url: FullSlug }>
|
nav: CustomEvent<{ url: FullSlug }>
|
||||||
|
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const fetchData: Promise<ContentIndex>
|
declare const fetchData: Promise<ContentIndex>
|
||||||
|
|||||||
3631
package-lock.json
generated
3631
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
109
package.json
109
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "@jackyzha0/quartz",
|
"name": "@jackyzha0/quartz",
|
||||||
"description": "🌱 publish your digital garden and notes as a website",
|
"description": "🌱 publish your digital garden and notes as a website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.1.2",
|
"version": "4.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -34,76 +34,75 @@
|
|||||||
"quartz": "./quartz/bootstrap-cli.mjs"
|
"quartz": "./quartz/bootstrap-cli.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^0.6.3",
|
"@clack/prompts": "^0.7.0",
|
||||||
"@floating-ui/dom": "^1.4.0",
|
"@floating-ui/dom": "^1.6.1",
|
||||||
"@napi-rs/simple-git": "0.1.9",
|
"@napi-rs/simple-git": "0.1.14",
|
||||||
"async-mutex": "^0.4.0",
|
"async-mutex": "^0.4.1",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^5.3.0",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"cli-spinner": "^0.2.10",
|
"cli-spinner": "^0.2.10",
|
||||||
"d3": "^7.8.5",
|
"d3": "^7.8.5",
|
||||||
"esbuild-sass-plugin": "^2.12.0",
|
"esbuild-sass-plugin": "^2.16.0",
|
||||||
"flexsearch": "0.7.21",
|
"flexsearch": "0.7.43",
|
||||||
"github-slugger": "^2.0.0",
|
"github-slugger": "^2.0.0",
|
||||||
"globby": "^13.1.4",
|
"globby": "^14.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hast-util-to-html": "^8.0.4",
|
"hast-util-to-html": "^9.0.0",
|
||||||
"hast-util-to-jsx-runtime": "^1.2.0",
|
"hast-util-to-jsx-runtime": "^2.3.0",
|
||||||
"hast-util-to-string": "^2.0.0",
|
"hast-util-to-string": "^3.0.0",
|
||||||
"is-absolute-url": "^4.0.1",
|
"is-absolute-url": "^4.0.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lightningcss": "1.21.7",
|
"lightningcss": "^1.23.0",
|
||||||
"mdast-util-find-and-replace": "^2.2.2",
|
"mdast-util-find-and-replace": "^3.0.1",
|
||||||
"mdast-util-to-hast": "^12.3.0",
|
"mdast-util-to-hast": "^13.1.0",
|
||||||
"mdast-util-to-string": "^3.2.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"micromorph": "^0.4.5",
|
"micromorph": "^0.4.5",
|
||||||
"plausible-tracker": "^0.3.8",
|
"preact": "^10.19.3",
|
||||||
"preact": "^10.14.1",
|
"preact-render-to-string": "^6.3.1",
|
||||||
"preact-render-to-string": "^6.0.3",
|
"pretty-bytes": "^6.1.1",
|
||||||
"pretty-bytes": "^6.1.0",
|
|
||||||
"pretty-time": "^1.1.0",
|
"pretty-time": "^1.1.0",
|
||||||
"reading-time": "^1.5.0",
|
"reading-time": "^1.5.0",
|
||||||
"rehype-autolink-headings": "^6.1.1",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-katex": "^6.0.3",
|
"rehype-katex": "^7.0.0",
|
||||||
"rehype-mathjax": "^4.0.3",
|
"rehype-mathjax": "^6.0.0",
|
||||||
"rehype-pretty-code": "^0.10.0",
|
"rehype-pretty-code": "^0.12.6",
|
||||||
"rehype-raw": "^6.1.1",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-slug": "^5.1.0",
|
"rehype-slug": "^6.0.0",
|
||||||
"remark": "^14.0.2",
|
"remark": "^15.0.1",
|
||||||
"remark-breaks": "^3.0.3",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-frontmatter": "^4.0.1",
|
"remark-frontmatter": "^5.0.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^5.1.1",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^10.0.1",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^10.1.0",
|
"remark-rehype": "^11.1.0",
|
||||||
"remark-smartypants": "^2.0.0",
|
"remark-smartypants": "^2.0.0",
|
||||||
"rimraf": "^5.0.1",
|
"rfdc": "^1.3.1",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
"serve-handler": "^6.1.5",
|
"serve-handler": "^6.1.5",
|
||||||
|
"shikiji": "^0.10.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"to-vfile": "^7.2.4",
|
"to-vfile": "^8.0.0",
|
||||||
"toml": "^3.0.0",
|
"toml": "^3.0.0",
|
||||||
"unified": "^10.1.2",
|
"unified": "^11.0.4",
|
||||||
"unist-util-visit": "^4.1.2",
|
"unist-util-visit": "^5.0.0",
|
||||||
"vfile": "^5.3.7",
|
"vfile": "^6.0.1",
|
||||||
"workerpool": "^6.4.0",
|
"workerpool": "^9.1.0",
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.15.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cli-spinner": "^0.2.1",
|
"@types/cli-spinner": "^0.2.3",
|
||||||
"@types/d3": "^7.4.0",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/flexsearch": "^0.7.3",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/hast": "^2.3.4",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/js-yaml": "^4.0.5",
|
"@types/node": "^20.11.14",
|
||||||
"@types/node": "^20.1.2",
|
"@types/pretty-time": "^1.1.5",
|
||||||
"@types/pretty-time": "^1.1.2",
|
"@types/source-map-support": "^0.5.10",
|
||||||
"@types/source-map-support": "^0.5.6",
|
"@types/ws": "^8.5.10",
|
||||||
"@types/workerpool": "^6.4.0",
|
"@types/yargs": "^17.0.32",
|
||||||
"@types/ws": "^8.5.5",
|
"esbuild": "^0.19.9",
|
||||||
"@types/yargs": "^17.0.24",
|
"prettier": "^3.2.4",
|
||||||
"esbuild": "0.19.2",
|
"tsx": "^4.7.0",
|
||||||
"prettier": "^3.0.0",
|
"typescript": "^5.3.3"
|
||||||
"tsx": "^3.12.7",
|
|
||||||
"typescript": "^5.0.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,15 @@ const config: QuartzConfig = {
|
|||||||
Plugin.FrontMatter(),
|
Plugin.FrontMatter(),
|
||||||
Plugin.TableOfContents(),
|
Plugin.TableOfContents(),
|
||||||
Plugin.CreatedModifiedDate({
|
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.SyntaxHighlighting(),
|
||||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||||
Plugin.GitHubFlavoredMarkdown(),
|
Plugin.GitHubFlavoredMarkdown(),
|
||||||
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
|
||||||
Plugin.Latex({ renderEngine: "katex" }),
|
|
||||||
Plugin.Description(),
|
Plugin.Description(),
|
||||||
],
|
],
|
||||||
filters: [Plugin.RemoveDrafts()],
|
filters: [Plugin.RemoveDrafts()],
|
||||||
|
|||||||
@@ -37,12 +37,13 @@ export const defaultContentPageLayout: PageLayout = {
|
|||||||
|
|
||||||
// components for pages that display lists of pages (e.g. tags or folders)
|
// components for pages that display lists of pages (e.g. tags or folders)
|
||||||
export const defaultListPageLayout: PageLayout = {
|
export const defaultListPageLayout: PageLayout = {
|
||||||
beforeBody: [Component.ArticleTitle()],
|
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
|
||||||
left: [
|
left: [
|
||||||
Component.PageTitle(),
|
Component.PageTitle(),
|
||||||
Component.MobileOnly(Component.Spacer()),
|
Component.MobileOnly(Component.Spacer()),
|
||||||
Component.Search(),
|
Component.Search(),
|
||||||
Component.Darkmode(),
|
Component.Darkmode(),
|
||||||
|
Component.DesktopOnly(Component.Explorer()),
|
||||||
],
|
],
|
||||||
right: [],
|
right: [],
|
||||||
}
|
}
|
||||||
|
|||||||
196
quartz/build.ts
196
quartz/build.ts
@@ -3,13 +3,13 @@ sourceMapSupport.install(options)
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { PerfTimer } from "./util/perf"
|
import { PerfTimer } from "./util/perf"
|
||||||
import { rimraf } from "rimraf"
|
import { rimraf } from "rimraf"
|
||||||
import { isGitIgnored } from "globby"
|
import { GlobbyFilterFunction, isGitIgnored } from "globby"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
import { parseMarkdown } from "./processors/parse"
|
import { parseMarkdown } from "./processors/parse"
|
||||||
import { filterContent } from "./processors/filter"
|
import { filterContent } from "./processors/filter"
|
||||||
import { emitContent } from "./processors/emit"
|
import { emitContent } from "./processors/emit"
|
||||||
import cfg from "../quartz.config"
|
import cfg from "../quartz.config"
|
||||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
|
||||||
import chokidar from "chokidar"
|
import chokidar from "chokidar"
|
||||||
import { ProcessedContent } from "./plugins/vfile"
|
import { ProcessedContent } from "./plugins/vfile"
|
||||||
import { Argv, BuildCtx } from "./util/ctx"
|
import { Argv, BuildCtx } from "./util/ctx"
|
||||||
@@ -18,6 +18,19 @@ import { trace } from "./util/trace"
|
|||||||
import { options } from "./util/sourcemap"
|
import { options } from "./util/sourcemap"
|
||||||
import { Mutex } from "async-mutex"
|
import { Mutex } from "async-mutex"
|
||||||
|
|
||||||
|
type BuildData = {
|
||||||
|
ctx: BuildCtx
|
||||||
|
ignored: GlobbyFilterFunction
|
||||||
|
mut: Mutex
|
||||||
|
initialSlugs: FullSlug[]
|
||||||
|
// TODO merge contentMap and trackedAssets
|
||||||
|
contentMap: Map<FilePath, ProcessedContent>
|
||||||
|
trackedAssets: Set<FilePath>
|
||||||
|
toRebuild: Set<FilePath>
|
||||||
|
toRemove: Set<FilePath>
|
||||||
|
lastBuildMs: number
|
||||||
|
}
|
||||||
|
|
||||||
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||||
const ctx: BuildCtx = {
|
const ctx: BuildCtx = {
|
||||||
argv,
|
argv,
|
||||||
@@ -73,89 +86,22 @@ async function startServing(
|
|||||||
) {
|
) {
|
||||||
const { argv } = ctx
|
const { argv } = ctx
|
||||||
|
|
||||||
const ignored = await isGitIgnored()
|
|
||||||
const contentMap = new Map<FilePath, ProcessedContent>()
|
const contentMap = new Map<FilePath, ProcessedContent>()
|
||||||
for (const content of initialContent) {
|
for (const content of initialContent) {
|
||||||
const [_tree, vfile] = content
|
const [_tree, vfile] = content
|
||||||
contentMap.set(vfile.data.filePath!, content)
|
contentMap.set(vfile.data.filePath!, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialSlugs = ctx.allSlugs
|
const buildData: BuildData = {
|
||||||
let lastBuildMs = 0
|
ctx,
|
||||||
const toRebuild: Set<FilePath> = new Set()
|
mut,
|
||||||
const toRemove: Set<FilePath> = new Set()
|
contentMap,
|
||||||
const trackedAssets: Set<FilePath> = new Set()
|
ignored: await isGitIgnored(),
|
||||||
async function rebuild(fp: string, action: "add" | "change" | "delete") {
|
initialSlugs: ctx.allSlugs,
|
||||||
// don't do anything for gitignored files
|
toRebuild: new Set<FilePath>(),
|
||||||
if (ignored(fp)) {
|
toRemove: new Set<FilePath>(),
|
||||||
return
|
trackedAssets: new Set<FilePath>(),
|
||||||
}
|
lastBuildMs: 0,
|
||||||
|
|
||||||
// dont bother rebuilding for non-content files, just track and refresh
|
|
||||||
fp = toPosixPath(fp)
|
|
||||||
const filePath = joinSegments(argv.directory, fp) as FilePath
|
|
||||||
if (path.extname(fp) !== ".md") {
|
|
||||||
if (action === "add" || action === "change") {
|
|
||||||
trackedAssets.add(filePath)
|
|
||||||
} else if (action === "delete") {
|
|
||||||
trackedAssets.delete(filePath)
|
|
||||||
}
|
|
||||||
clientRefresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "add" || action === "change") {
|
|
||||||
toRebuild.add(filePath)
|
|
||||||
} else if (action === "delete") {
|
|
||||||
toRemove.add(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// debounce rebuilds every 250ms
|
|
||||||
|
|
||||||
const buildStart = new Date().getTime()
|
|
||||||
lastBuildMs = buildStart
|
|
||||||
const release = await mut.acquire()
|
|
||||||
if (lastBuildMs > buildStart) {
|
|
||||||
release()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const perf = new PerfTimer()
|
|
||||||
console.log(chalk.yellow("Detected change, rebuilding..."))
|
|
||||||
try {
|
|
||||||
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
|
||||||
|
|
||||||
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
|
|
||||||
.filter((fp) => !toRemove.has(fp))
|
|
||||||
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
|
|
||||||
|
|
||||||
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
|
|
||||||
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
|
|
||||||
for (const content of parsedContent) {
|
|
||||||
const [_tree, vfile] = content
|
|
||||||
contentMap.set(vfile.data.filePath!, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const fp of toRemove) {
|
|
||||||
contentMap.delete(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedFiles = [...contentMap.values()]
|
|
||||||
const filteredContent = filterContent(ctx, parsedFiles)
|
|
||||||
|
|
||||||
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
|
||||||
// instead of just deleting everything
|
|
||||||
await rimraf(argv.output)
|
|
||||||
await emitContent(ctx, filteredContent)
|
|
||||||
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
|
||||||
} catch {
|
|
||||||
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
|
||||||
}
|
|
||||||
|
|
||||||
clientRefresh()
|
|
||||||
toRebuild.clear()
|
|
||||||
toRemove.clear()
|
|
||||||
release()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const watcher = chokidar.watch(".", {
|
const watcher = chokidar.watch(".", {
|
||||||
@@ -165,15 +111,101 @@ async function startServing(
|
|||||||
})
|
})
|
||||||
|
|
||||||
watcher
|
watcher
|
||||||
.on("add", (fp) => rebuild(fp, "add"))
|
.on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
|
||||||
.on("change", (fp) => rebuild(fp, "change"))
|
.on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
|
||||||
.on("unlink", (fp) => rebuild(fp, "delete"))
|
.on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await watcher.close()
|
await watcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rebuildFromEntrypoint(
|
||||||
|
fp: string,
|
||||||
|
action: "add" | "change" | "delete",
|
||||||
|
clientRefresh: () => void,
|
||||||
|
buildData: BuildData, // note: this function mutates buildData
|
||||||
|
) {
|
||||||
|
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
|
||||||
|
buildData
|
||||||
|
|
||||||
|
const { argv } = ctx
|
||||||
|
|
||||||
|
// don't do anything for gitignored files
|
||||||
|
if (ignored(fp)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dont bother rebuilding for non-content files, just track and refresh
|
||||||
|
fp = toPosixPath(fp)
|
||||||
|
const filePath = joinSegments(argv.directory, fp) as FilePath
|
||||||
|
if (path.extname(fp) !== ".md") {
|
||||||
|
if (action === "add" || action === "change") {
|
||||||
|
trackedAssets.add(filePath)
|
||||||
|
} else if (action === "delete") {
|
||||||
|
trackedAssets.delete(filePath)
|
||||||
|
}
|
||||||
|
clientRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "add" || action === "change") {
|
||||||
|
toRebuild.add(filePath)
|
||||||
|
} else if (action === "delete") {
|
||||||
|
toRemove.add(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStart = new Date().getTime()
|
||||||
|
buildData.lastBuildMs = buildStart
|
||||||
|
const release = await mut.acquire()
|
||||||
|
|
||||||
|
// there's another build after us, release and let them do it
|
||||||
|
if (buildData.lastBuildMs > buildStart) {
|
||||||
|
release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const perf = new PerfTimer()
|
||||||
|
console.log(chalk.yellow("Detected change, rebuilding..."))
|
||||||
|
try {
|
||||||
|
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
|
||||||
|
|
||||||
|
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
|
||||||
|
.filter((fp) => !toRemove.has(fp))
|
||||||
|
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
|
||||||
|
|
||||||
|
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
|
||||||
|
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
|
||||||
|
for (const content of parsedContent) {
|
||||||
|
const [_tree, vfile] = content
|
||||||
|
contentMap.set(vfile.data.filePath!, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fp of toRemove) {
|
||||||
|
contentMap.delete(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedFiles = [...contentMap.values()]
|
||||||
|
const filteredContent = filterContent(ctx, parsedFiles)
|
||||||
|
|
||||||
|
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
|
||||||
|
// instead of just deleting everything
|
||||||
|
await rimraf(argv.output)
|
||||||
|
await emitContent(ctx, filteredContent)
|
||||||
|
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
|
||||||
|
} catch (err) {
|
||||||
|
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
|
||||||
|
if (argv.verbose) {
|
||||||
|
console.log(chalk.red(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
clientRefresh()
|
||||||
|
toRebuild.clear()
|
||||||
|
toRemove.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
|
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
|
||||||
try {
|
try {
|
||||||
return await buildQuartz(argv, mut, clientRefresh)
|
return await buildQuartz(argv, mut, clientRefresh)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type Analytics =
|
|||||||
| null
|
| null
|
||||||
| {
|
| {
|
||||||
provider: "plausible"
|
provider: "plausible"
|
||||||
|
host?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
provider: "google"
|
provider: "google"
|
||||||
@@ -15,6 +16,7 @@ export type Analytics =
|
|||||||
| {
|
| {
|
||||||
provider: "umami"
|
provider: "umami"
|
||||||
websiteId: string
|
websiteId: string
|
||||||
|
host?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalConfiguration {
|
export interface GlobalConfiguration {
|
||||||
@@ -34,6 +36,12 @@ export interface GlobalConfiguration {
|
|||||||
*/
|
*/
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
/**
|
||||||
|
* The locale to use for date formatting. Default to "en-US"
|
||||||
|
* Allow to translate the date in the language of your choice.
|
||||||
|
* Need to be formated following the IETF language tag format (https://en.wikipedia.org/wiki/IETF_language_tag)
|
||||||
|
*/
|
||||||
|
locale?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuartzConfig {
|
export interface QuartzConfig {
|
||||||
|
|||||||
@@ -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") {
|
if (setupStrategy === "copy" || setupStrategy === "symlink") {
|
||||||
let originalFolder = sourceDirectory
|
let originalFolder = sourceDirectory
|
||||||
|
|
||||||
@@ -165,22 +168,20 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
|||||||
// get a preferred link resolution strategy
|
// get a preferred link resolution strategy
|
||||||
linkResolutionStrategy = exitIfCancel(
|
linkResolutionStrategy = exitIfCancel(
|
||||||
await select({
|
await select({
|
||||||
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
|
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`,
|
||||||
options: [
|
options: [
|
||||||
{
|
|
||||||
value: "absolute",
|
|
||||||
label: "Treat links as absolute path",
|
|
||||||
hint: "for content made for Quartz 3 and Hugo",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "shortest",
|
value: "shortest",
|
||||||
label: "Treat links as shortest path",
|
label: "Treat links as shortest path",
|
||||||
hint: "for most Obsidian vaults",
|
hint: "(default)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "absolute",
|
||||||
|
label: "Treat links as absolute path",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "relative",
|
value: "relative",
|
||||||
label: "Treat links as relative paths",
|
label: "Treat links as relative paths",
|
||||||
hint: "for just normal Markdown files",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -196,6 +197,12 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
|||||||
)
|
)
|
||||||
await fs.promises.writeFile(configFilePath, configContent)
|
await fs.promises.writeFile(configFilePath, configContent)
|
||||||
|
|
||||||
|
// setup remote
|
||||||
|
execSync(
|
||||||
|
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
)
|
||||||
|
|
||||||
outro(`You're all set! Not sure what to do next? Try:
|
outro(`You're all set! Not sure what to do next? Try:
|
||||||
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
||||||
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
||||||
@@ -250,6 +257,7 @@ export async function handleBuild(argv) {
|
|||||||
},
|
},
|
||||||
write: false,
|
write: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: "esm",
|
format: "esm",
|
||||||
})
|
})
|
||||||
@@ -339,7 +347,7 @@ export async function handleBuild(argv) {
|
|||||||
directoryListing: false,
|
directoryListing: false,
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
source: "**/*.html",
|
source: "**/*.*",
|
||||||
headers: [{ key: "Content-Disposition", value: "inline" }],
|
headers: [{ key: "Content-Disposition", value: "inline" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -438,11 +446,23 @@ export async function handleUpdate(argv) {
|
|||||||
console.log(
|
console.log(
|
||||||
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||||
)
|
)
|
||||||
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
|
||||||
|
try {
|
||||||
|
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
|
||||||
|
} catch {
|
||||||
|
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||||
|
await popContentFolder(contentFolder)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await popContentFolder(contentFolder)
|
await popContentFolder(contentFolder)
|
||||||
console.log("Ensuring dependencies are up to date")
|
console.log("Ensuring dependencies are up to date")
|
||||||
spawnSync("npm", ["i"], { stdio: "inherit" })
|
const res = spawnSync("npm", ["i"], { stdio: "inherit" })
|
||||||
console.log(chalk.green("Done!"))
|
if (res.status === 0) {
|
||||||
|
console.log(chalk.green("Done!"))
|
||||||
|
} else {
|
||||||
|
console.log(chalk.red("An error occurred above while installing dependencies."))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,13 +519,25 @@ export async function handleSync(argv) {
|
|||||||
console.log(
|
console.log(
|
||||||
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
"Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||||
)
|
)
|
||||||
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
try {
|
||||||
|
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
|
||||||
|
} catch {
|
||||||
|
console.log(chalk.red("An error occurred above while pulling updates."))
|
||||||
|
await popContentFolder(contentFolder)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await popContentFolder(contentFolder)
|
await popContentFolder(contentFolder)
|
||||||
if (argv.push) {
|
if (argv.push) {
|
||||||
console.log("Pushing your changes")
|
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!"))
|
console.log(chalk.green("Done!"))
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function gitPull(origin, branch) {
|
|||||||
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
|
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
|
||||||
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
|
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
|
||||||
if (out.stderr) {
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
|
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
|
||||||
const title = fileData.frontmatter?.title
|
const title = fileData.frontmatter?.title
|
||||||
if (title) {
|
if (title) {
|
||||||
return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
|
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import style from "./styles/backlinks.scss"
|
import style from "./styles/backlinks.scss"
|
||||||
import { resolveRelative, simplifySlug } from "../util/path"
|
import { resolveRelative, simplifySlug } from "../util/path"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
|
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
|
||||||
const slug = simplifySlug(fileData.slug!)
|
const slug = simplifySlug(fileData.slug!)
|
||||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||||
return (
|
return (
|
||||||
<div class={`backlinks ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "backlinks")}>
|
||||||
<h3>Backlinks</h3>
|
<h3>Backlinks</h3>
|
||||||
<ul class="overflow">
|
<ul class="overflow">
|
||||||
{backlinkFiles.length > 0 ? (
|
{backlinkFiles.length > 0 ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|||||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
type CrumbData = {
|
type CrumbData = {
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -18,15 +19,15 @@ interface BreadcrumbOptions {
|
|||||||
*/
|
*/
|
||||||
rootName: string
|
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
|
resolveFrontmatterTitle: boolean
|
||||||
/**
|
/**
|
||||||
* Wether to display breadcrumbs on root `index.md`
|
* Whether to display breadcrumbs on root `index.md`
|
||||||
*/
|
*/
|
||||||
hideOnRoot: boolean
|
hideOnRoot: boolean
|
||||||
/**
|
/**
|
||||||
* Wether to display the current page in the breadcrumbs.
|
* Whether to display the current page in the breadcrumbs.
|
||||||
*/
|
*/
|
||||||
showCurrentPage: boolean
|
showCurrentPage: boolean
|
||||||
}
|
}
|
||||||
@@ -68,9 +69,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
|||||||
// construct the index for the first time
|
// construct the index for the first time
|
||||||
for (const file of allFiles) {
|
for (const file of allFiles) {
|
||||||
if (file.slug?.endsWith("index")) {
|
if (file.slug?.endsWith("index")) {
|
||||||
const folderParts = file.filePath?.split("/")
|
const folderParts = file.slug?.split("/")
|
||||||
if (folderParts) {
|
// 2nd last to exclude the /index
|
||||||
const folderName = folderParts[folderParts?.length - 2]
|
const folderName = folderParts?.at(-2)
|
||||||
|
if (folderName) {
|
||||||
folderIndex.set(folderName, file)
|
folderIndex.set(folderName, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
|||||||
// Try to resolve frontmatter folder title
|
// Try to resolve frontmatter folder title
|
||||||
const currentFile = folderIndex?.get(curPathSegment)
|
const currentFile = folderIndex?.get(curPathSegment)
|
||||||
if (currentFile) {
|
if (currentFile) {
|
||||||
curPathSegment = currentFile.frontmatter!.title
|
const title = currentFile.frontmatter!.title
|
||||||
|
if (title !== "index") {
|
||||||
|
curPathSegment = title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add current slug to full path
|
// Add current slug to full path
|
||||||
@@ -100,15 +105,16 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add current file to crumb (can directly use frontmatter title)
|
// Add current file to crumb (can directly use frontmatter title)
|
||||||
if (options.showCurrentPage) {
|
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
displayName: fileData.frontmatter!.title,
|
displayName: fileData.frontmatter!.title,
|
||||||
path: "",
|
path: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
|
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
||||||
{crumbs.map((crumb, index) => (
|
{crumbs.map((crumb, index) => (
|
||||||
<div class="breadcrumb-element">
|
<div class="breadcrumb-element">
|
||||||
<a href={crumb.path}>{crumb.displayName}</a>
|
<a href={crumb.path}>{crumb.displayName}</a>
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
import { formatDate, getDate } from "./Date"
|
import { formatDate, getDate } from "./Date"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import readingTime from "reading-time"
|
import readingTime from "reading-time"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||||
const text = fileData.text
|
const text = fileData.text
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
const segments: string[] = []
|
const segments: string[] = []
|
||||||
const { text: timeTaken, words: _words } = readingTime(text)
|
|
||||||
|
|
||||||
if (fileData.dates) {
|
if (fileData.dates) {
|
||||||
segments.push(formatDate(getDate(cfg, fileData)!))
|
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
|
||||||
}
|
}
|
||||||
|
|
||||||
segments.push(timeTaken)
|
// Display reading time if enabled
|
||||||
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
|
if (options.showReadingTime) {
|
||||||
|
const { text: timeTaken, words: _words } = readingTime(text)
|
||||||
|
segments.push(timeTaken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
import darkmodeScript from "./scripts/darkmode.inline"
|
import darkmodeScript from "./scripts/darkmode.inline"
|
||||||
import styles from "./styles/darkmode.scss"
|
import styles from "./styles/darkmode.scss"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function Darkmode({ displayClass }: QuartzComponentProps) {
|
function Darkmode({ displayClass }: QuartzComponentProps) {
|
||||||
return (
|
return (
|
||||||
<div class={`darkmode ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "darkmode")}>
|
||||||
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
|
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
|
||||||
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
|
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
|
||||||
<svg
|
<svg
|
||||||
@@ -18,7 +19,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
|||||||
x="0px"
|
x="0px"
|
||||||
y="0px"
|
y="0px"
|
||||||
viewBox="0 0 35 35"
|
viewBox="0 0 35 35"
|
||||||
style="enable-background:new 0 0 35 35;"
|
style="enable-background:new 0 0 35 35"
|
||||||
xmlSpace="preserve"
|
xmlSpace="preserve"
|
||||||
>
|
>
|
||||||
<title>Light mode</title>
|
<title>Light mode</title>
|
||||||
@@ -34,7 +35,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
|
|||||||
x="0px"
|
x="0px"
|
||||||
y="0px"
|
y="0px"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
style="enable-background='new 0 0 100 100'"
|
style="enable-background:new 0 0 100 100"
|
||||||
xmlSpace="preserve"
|
xmlSpace="preserve"
|
||||||
>
|
>
|
||||||
<title>Dark mode</title>
|
<title>Dark mode</title>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { QuartzPluginData } from "../plugins/vfile"
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
date: Date
|
date: Date
|
||||||
|
locale?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||||
@@ -16,14 +17,14 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date
|
|||||||
return data.dates?.[cfg.defaultDateType]
|
return data.dates?.[cfg.defaultDateType]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(d: Date): string {
|
export function formatDate(d: Date, locale = "en-US"): string {
|
||||||
return d.toLocaleDateString("en-US", {
|
return d.toLocaleDateString(locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Date({ date }: Props) {
|
export function Date({ date, locale }: Props) {
|
||||||
return <>{formatDate(date)}</>
|
return <>{formatDate(date, locale)}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import explorerStyle from "./styles/explorer.scss"
|
|||||||
import script from "./scripts/explorer.inline"
|
import script from "./scripts/explorer.inline"
|
||||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
@@ -12,6 +13,9 @@ const defaultOptions = {
|
|||||||
folderClickBehavior: "collapse",
|
folderClickBehavior: "collapse",
|
||||||
folderDefaultState: "collapsed",
|
folderDefaultState: "collapsed",
|
||||||
useSavedState: true,
|
useSavedState: true,
|
||||||
|
mapFn: (node) => {
|
||||||
|
return node
|
||||||
|
},
|
||||||
sortFn: (a, b) => {
|
sortFn: (a, b) => {
|
||||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
// Sort order: folders first, then files. Sort folders and files alphabetically
|
||||||
if ((!a.file && !b.file) || (a.file && b.file)) {
|
if ((!a.file && !b.file) || (a.file && b.file)) {
|
||||||
@@ -22,6 +26,7 @@ const defaultOptions = {
|
|||||||
sensitivity: "base",
|
sensitivity: "base",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.file && !b.file) {
|
if (a.file && !b.file) {
|
||||||
return 1
|
return 1
|
||||||
} else {
|
} else {
|
||||||
@@ -41,52 +46,39 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
let jsonTree: string
|
let jsonTree: string
|
||||||
|
|
||||||
function constructFileTree(allFiles: QuartzPluginData[]) {
|
function constructFileTree(allFiles: QuartzPluginData[]) {
|
||||||
if (!fileTree) {
|
if (fileTree) {
|
||||||
// Construct tree from allFiles
|
return
|
||||||
fileTree = new FileNode("")
|
}
|
||||||
allFiles.forEach((file) => fileTree.add(file, 1))
|
|
||||||
|
|
||||||
/**
|
// Construct tree from allFiles
|
||||||
* Keys of this object must match corresponding function name of `FileNode`,
|
fileTree = new FileNode("")
|
||||||
* while values must be the argument that will be passed to the function.
|
allFiles.forEach((file) => fileTree.add(file))
|
||||||
*
|
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
|
||||||
if (opts.order) {
|
if (opts.order) {
|
||||||
// Order is important, use loop with index instead of order.map()
|
// Order is important, use loop with index instead of order.map()
|
||||||
for (let i = 0; i < opts.order.length; i++) {
|
for (let i = 0; i < opts.order.length; i++) {
|
||||||
const functionName = opts.order[i]
|
const functionName = opts.order[i]
|
||||||
if (functions[functionName]) {
|
if (functionName === "map") {
|
||||||
// for every entry in order, call matching function in FileNode and pass matching argument
|
fileTree.map(opts.mapFn)
|
||||||
// e.g. i = 0; functionName = "filter"
|
} else if (functionName === "sort") {
|
||||||
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
|
fileTree.sort(opts.sortFn)
|
||||||
|
} else if (functionName === "filter") {
|
||||||
// @ts-ignore
|
fileTree.filter(opts.filterFn)
|
||||||
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
|
|
||||||
fileTree[functionName].call(fileTree, functions[functionName])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
// Stringify to pass json tree as data attribute ([data-tree])
|
||||||
|
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
||||||
|
jsonTree = JSON.stringify(folders)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
|
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
|
||||||
constructFileTree(allFiles)
|
constructFileTree(allFiles)
|
||||||
return (
|
return (
|
||||||
<div class={`explorer ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "explorer")}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="explorer"
|
id="explorer"
|
||||||
@@ -120,6 +112,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Explorer.css = explorerStyle
|
Explorer.css = explorerStyle
|
||||||
Explorer.afterDOMLoaded = script
|
Explorer.afterDOMLoaded = script
|
||||||
return Explorer
|
return Explorer
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
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"
|
type OrderEntries = "sort" | "filter" | "map"
|
||||||
|
|
||||||
@@ -10,9 +17,9 @@ export interface Options {
|
|||||||
folderClickBehavior: "collapse" | "link"
|
folderClickBehavior: "collapse" | "link"
|
||||||
useSavedState: boolean
|
useSavedState: boolean
|
||||||
sortFn: (a: FileNode, b: FileNode) => number
|
sortFn: (a: FileNode, b: FileNode) => number
|
||||||
filterFn?: (node: FileNode) => boolean
|
filterFn: (node: FileNode) => boolean
|
||||||
mapFn?: (node: FileNode) => void
|
mapFn: (node: FileNode) => void
|
||||||
order?: OrderEntries[]
|
order: OrderEntries[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type DataWrapper = {
|
type DataWrapper = {
|
||||||
@@ -25,59 +32,74 @@ export type FolderState = {
|
|||||||
collapsed: boolean
|
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
|
// Structure to add all files into a tree
|
||||||
export class FileNode {
|
export class FileNode {
|
||||||
children: FileNode[]
|
children: Array<FileNode>
|
||||||
name: string
|
name: string // this is the slug segment
|
||||||
displayName: string
|
displayName: string
|
||||||
file: QuartzPluginData | null
|
file: QuartzPluginData | null
|
||||||
depth: number
|
depth: number
|
||||||
|
|
||||||
constructor(name: string, file?: QuartzPluginData, depth?: number) {
|
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
|
||||||
this.children = []
|
this.children = []
|
||||||
this.name = name
|
this.name = slugSegment
|
||||||
this.displayName = name
|
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
|
||||||
this.file = file ? structuredClone(file) : null
|
this.file = file ? clone(file) : null
|
||||||
this.depth = depth ?? 0
|
this.depth = depth ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private insert(file: DataWrapper) {
|
private insert(fileData: DataWrapper) {
|
||||||
if (file.path.length === 1) {
|
if (fileData.path.length === 0) {
|
||||||
if (file.path[0] !== "index.md") {
|
return
|
||||||
this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
|
}
|
||||||
} else {
|
|
||||||
const title = file.file.frontmatter?.title
|
const nextSegment = fileData.path[0]
|
||||||
if (title && title !== "index" && file.path[0] === "index.md") {
|
|
||||||
|
// 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
|
this.displayName = title
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// direct child
|
||||||
const next = file.path[0]
|
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
|
||||||
file.path = file.path.splice(1)
|
|
||||||
for (const child of this.children) {
|
|
||||||
if (child.name === next) {
|
|
||||||
child.insert(file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newChild = new FileNode(next, undefined, this.depth + 1)
|
return
|
||||||
newChild.insert(file)
|
|
||||||
this.children.push(newChild)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 new file to tree
|
||||||
add(file: QuartzPluginData, splice: number = 0) {
|
add(file: QuartzPluginData) {
|
||||||
this.insert({ file, path: file.filePath!.split("/").splice(splice) })
|
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +117,6 @@ export class FileNode {
|
|||||||
*/
|
*/
|
||||||
map(mapFn: (node: FileNode) => void) {
|
map(mapFn: (node: FileNode) => void) {
|
||||||
mapFn(this)
|
mapFn(this)
|
||||||
|
|
||||||
this.children.forEach((child) => child.map(mapFn))
|
this.children.forEach((child) => child.map(mapFn))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +131,16 @@ export class FileNode {
|
|||||||
|
|
||||||
const traverse = (node: FileNode, currentPath: string) => {
|
const traverse = (node: FileNode, currentPath: string) => {
|
||||||
if (!node.file) {
|
if (!node.file) {
|
||||||
const folderPath = currentPath + (currentPath ? "/" : "") + node.name
|
const folderPath = joinSegments(currentPath, node.name)
|
||||||
if (folderPath !== "") {
|
if (folderPath !== "") {
|
||||||
folderPaths.push({ path: folderPath, collapsed })
|
folderPaths.push({ path: folderPath, collapsed })
|
||||||
}
|
}
|
||||||
|
|
||||||
node.children.forEach((child) => traverse(child, folderPath))
|
node.children.forEach((child) => traverse(child, folderPath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
traverse(this, "")
|
traverse(this, "")
|
||||||
|
|
||||||
return folderPaths
|
return folderPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +168,13 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
|||||||
const isDefaultOpen = opts.folderDefaultState === "open"
|
const isDefaultOpen = opts.folderDefaultState === "open"
|
||||||
|
|
||||||
// Calculate current folderPath
|
// Calculate current folderPath
|
||||||
let pathOld = fullPath ? fullPath : ""
|
|
||||||
let folderPath = ""
|
let folderPath = ""
|
||||||
if (node.name !== "") {
|
if (node.name !== "") {
|
||||||
folderPath = `${pathOld}/${node.name}`
|
folderPath = joinSegments(fullPath ?? "", node.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<>
|
||||||
{node.file ? (
|
{node.file ? (
|
||||||
// Single file node
|
// Single file node
|
||||||
<li key={node.file.slug}>
|
<li key={node.file.slug}>
|
||||||
@@ -163,7 +183,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<li>
|
||||||
{node.name !== "" && (
|
{node.name !== "" && (
|
||||||
// Node with entire folder
|
// Node with entire folder
|
||||||
// Render svg button + folder name, then children
|
// 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 */}
|
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
||||||
<div key={node.name} data-folderpath={folderPath}>
|
<div key={node.name} data-folderpath={folderPath}>
|
||||||
{folderBehavior === "link" ? (
|
{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}
|
{node.displayName}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<button class="folder-button">
|
<button class="folder-button">
|
||||||
<p class="folder-title">{node.displayName}</p>
|
<span class="folder-title">{node.displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,8 +241,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
)}
|
)}
|
||||||
</li>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/graph.inline"
|
import script from "./scripts/graph.inline"
|
||||||
import style from "./styles/graph.scss"
|
import style from "./styles/graph.scss"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
export interface D3Config {
|
export interface D3Config {
|
||||||
drag: boolean
|
drag: boolean
|
||||||
@@ -56,7 +57,7 @@ export default ((opts?: GraphOptions) => {
|
|||||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||||
return (
|
return (
|
||||||
<div class={`graph ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "graph")}>
|
||||||
<h3>Graph View</h3>
|
<h3>Graph View</h3>
|
||||||
<div class="graph-outer">
|
<div class="graph-outer">
|
||||||
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function PageList({ cfg, fileData, allFiles, limit }: Props) {
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
{page.dates && (
|
{page.dates && (
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
<Date date={getDate(cfg, page)!} />
|
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div class="desc">
|
<div class="desc">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { pathToRoot } from "../util/path"
|
import { pathToRoot } from "../util/path"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
|
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
|
||||||
const title = cfg?.pageTitle ?? "Untitled Quartz"
|
const title = cfg?.pageTitle ?? "Untitled Quartz"
|
||||||
const baseDir = pathToRoot(fileData.slug!)
|
const baseDir = pathToRoot(fileData.slug!)
|
||||||
return (
|
return (
|
||||||
<h1 class={`page-title ${displayClass ?? ""}`}>
|
<h1 class={classNames(displayClass, "page-title")}>
|
||||||
<a href={baseDir}>{title}</a>
|
<a href={baseDir}>{title}</a>
|
||||||
</h1>
|
</h1>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { byDateAndAlphabetical } from "./PageList"
|
|||||||
import style from "./styles/recentNotes.scss"
|
import style from "./styles/recentNotes.scss"
|
||||||
import { Date, getDate } from "./Date"
|
import { Date, getDate } from "./Date"
|
||||||
import { GlobalConfiguration } from "../cfg"
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
title: string
|
title: string
|
||||||
@@ -28,7 +29,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||||
const remaining = Math.max(0, pages.length - opts.limit)
|
const remaining = Math.max(0, pages.length - opts.limit)
|
||||||
return (
|
return (
|
||||||
<div class={`recent-notes ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "recent-notes")}>
|
||||||
<h3>{opts.title}</h3>
|
<h3>{opts.title}</h3>
|
||||||
<ul class="recent-ul">
|
<ul class="recent-ul">
|
||||||
{pages.slice(0, opts.limit).map((page) => {
|
{pages.slice(0, opts.limit).map((page) => {
|
||||||
@@ -47,7 +48,7 @@ export default ((userOpts?: Partial<Options>) => {
|
|||||||
</div>
|
</div>
|
||||||
{page.dates && (
|
{page.dates && (
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
<Date date={getDate(cfg, page)!} />
|
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul class="tags">
|
<ul class="tags">
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|||||||
import style from "./styles/search.scss"
|
import style from "./styles/search.scss"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/search.inline"
|
import script from "./scripts/search.inline"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
export default (() => {
|
export interface SearchOptions {
|
||||||
|
enablePreview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: SearchOptions = {
|
||||||
|
enablePreview: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||||
function Search({ displayClass }: QuartzComponentProps) {
|
function Search({ displayClass }: QuartzComponentProps) {
|
||||||
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`search ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "search")}>
|
||||||
<div id="search-icon">
|
<div id="search-icon">
|
||||||
<p>Search</p>
|
<p>Search</p>
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -35,7 +46,7 @@ export default (() => {
|
|||||||
aria-label="Search for something"
|
aria-label="Search for something"
|
||||||
placeholder="Search for something"
|
placeholder="Search for something"
|
||||||
/>
|
/>
|
||||||
<div id="results-container"></div>
|
<div id="search-layout" data-preview={opts.enablePreview}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function Spacer({ displayClass }: QuartzComponentProps) {
|
function Spacer({ displayClass }: QuartzComponentProps) {
|
||||||
return <div class={`spacer ${displayClass ?? ""}`}></div>
|
return <div class={classNames(displayClass, "spacer")}></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (() => Spacer) satisfies QuartzComponentConstructor
|
export default (() => Spacer) satisfies QuartzComponentConstructor
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
import legacyStyle from "./styles/legacyToc.scss"
|
import legacyStyle from "./styles/legacyToc.scss"
|
||||||
import modernStyle from "./styles/toc.scss"
|
import modernStyle from "./styles/toc.scss"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/toc.inline"
|
import script from "./scripts/toc.inline"
|
||||||
@@ -19,7 +20,7 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`toc ${displayClass ?? ""}`}>
|
<div class={classNames(displayClass, "toc")}>
|
||||||
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
|
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||||
<h3>Table of Contents</h3>
|
<h3>Table of Contents</h3>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { pathToRoot, slugTag } from "../util/path"
|
import { pathToRoot, slugTag } from "../util/path"
|
||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
import { classNames } from "../util/lang"
|
||||||
|
|
||||||
function TagList({ fileData, displayClass }: QuartzComponentProps) {
|
function TagList({ fileData, displayClass }: QuartzComponentProps) {
|
||||||
const tags = fileData.frontmatter?.tags
|
const tags = fileData.frontmatter?.tags
|
||||||
const baseDir = pathToRoot(fileData.slug!)
|
const baseDir = pathToRoot(fileData.slug!)
|
||||||
if (tags && tags.length > 0) {
|
if (tags && tags.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ul class={`tags ${displayClass ?? ""}`}>
|
<ul class={classNames(displayClass, "tags")}>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const display = `#${tag}`
|
const display = `#${tag}`
|
||||||
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
const linkDest = baseDir + `/tags/${slugTag(tag)}`
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
|||||||
|
|
||||||
function Content({ fileData, tree }: QuartzComponentProps) {
|
function Content({ fileData, tree }: QuartzComponentProps) {
|
||||||
const content = htmlToJsx(fileData.filePath!, tree)
|
const content = htmlToJsx(fileData.filePath!, tree)
|
||||||
return <article class="popover-hint">{content}</article>
|
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||||
|
const classString = ["popover-hint", ...classes].join(" ")
|
||||||
|
return <article class={classString}>{content}</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (() => Content) satisfies QuartzComponentConstructor
|
export default (() => Content) satisfies QuartzComponentConstructor
|
||||||
|
|||||||
@@ -8,40 +8,60 @@ import { Root } from "hast"
|
|||||||
import { pluralize } from "../../util/lang"
|
import { pluralize } from "../../util/lang"
|
||||||
import { htmlToJsx } from "../../util/jsx"
|
import { htmlToJsx } from "../../util/jsx"
|
||||||
|
|
||||||
function FolderContent(props: QuartzComponentProps) {
|
interface FolderContentOptions {
|
||||||
const { tree, fileData, allFiles } = props
|
/**
|
||||||
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
|
* Whether to display number of folders
|
||||||
const allPagesInFolder = allFiles.filter((file) => {
|
*/
|
||||||
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
|
showFolderCount: boolean
|
||||||
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
|
||||||
const folderParts = folderSlug.split(path.posix.sep)
|
|
||||||
const fileParts = fileSlug.split(path.posix.sep)
|
|
||||||
const isDirectChild = fileParts.length === folderParts.length + 1
|
|
||||||
return prefixed && isDirectChild
|
|
||||||
})
|
|
||||||
|
|
||||||
const listProps = {
|
|
||||||
...props,
|
|
||||||
allFiles: allPagesInFolder,
|
|
||||||
}
|
|
||||||
|
|
||||||
const content =
|
|
||||||
(tree as Root).children.length === 0
|
|
||||||
? fileData.description
|
|
||||||
: htmlToJsx(fileData.filePath!, tree)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="popover-hint">
|
|
||||||
<article>
|
|
||||||
<p>{content}</p>
|
|
||||||
</article>
|
|
||||||
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
|
||||||
<div>
|
|
||||||
<PageList {...listProps} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderContent.css = style + PageList.css
|
const defaultOptions: FolderContentOptions = {
|
||||||
export default (() => FolderContent) satisfies QuartzComponentConstructor
|
showFolderCount: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||||
|
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||||
|
|
||||||
|
function FolderContent(props: QuartzComponentProps) {
|
||||||
|
const { tree, fileData, allFiles } = props
|
||||||
|
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
|
||||||
|
const allPagesInFolder = allFiles.filter((file) => {
|
||||||
|
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
|
||||||
|
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
|
||||||
|
const folderParts = folderSlug.split(path.posix.sep)
|
||||||
|
const fileParts = fileSlug.split(path.posix.sep)
|
||||||
|
const isDirectChild = fileParts.length === folderParts.length + 1
|
||||||
|
return prefixed && isDirectChild
|
||||||
|
})
|
||||||
|
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||||
|
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||||
|
const listProps = {
|
||||||
|
...props,
|
||||||
|
allFiles: allPagesInFolder,
|
||||||
|
}
|
||||||
|
|
||||||
|
const content =
|
||||||
|
(tree as Root).children.length === 0
|
||||||
|
? fileData.description
|
||||||
|
: htmlToJsx(fileData.filePath!, tree)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={classes}>
|
||||||
|
<article>
|
||||||
|
<p>{content}</p>
|
||||||
|
</article>
|
||||||
|
<div class="page-listing">
|
||||||
|
{options.showFolderCount && (
|
||||||
|
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<PageList {...listProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderContent.css = style + PageList.css
|
||||||
|
return FolderContent
|
||||||
|
}) satisfies QuartzComponentConstructor
|
||||||
|
|||||||
@@ -26,16 +26,20 @@ function TagContent(props: QuartzComponentProps) {
|
|||||||
(tree as Root).children.length === 0
|
(tree as Root).children.length === 0
|
||||||
? fileData.description
|
? fileData.description
|
||||||
: htmlToJsx(fileData.filePath!, tree)
|
: htmlToJsx(fileData.filePath!, tree)
|
||||||
|
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||||
if (tag === "") {
|
const classes = ["popover-hint", ...cssClasses].join(" ")
|
||||||
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()
|
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="popover-hint">
|
<div class={classes}>
|
||||||
<article>
|
<article>
|
||||||
<p>{content}</p>
|
<p>{content}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -58,11 +62,13 @@ function TagContent(props: QuartzComponentProps) {
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
{content && <p>{content}</p>}
|
{content && <p>{content}</p>}
|
||||||
<p>
|
<div class="page-listing">
|
||||||
{pluralize(pages.length, "item")} with this tag.{" "}
|
<p>
|
||||||
{pages.length > numPages && `Showing first ${numPages}.`}
|
{pluralize(pages.length, "item")} with this tag.{" "}
|
||||||
</p>
|
{pages.length > numPages && `Showing first ${numPages}.`}
|
||||||
<PageList limit={numPages} {...listProps} />
|
</p>
|
||||||
|
<PageList limit={numPages} {...listProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -77,11 +83,13 @@ function TagContent(props: QuartzComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="popover-hint">
|
<div class={classes}>
|
||||||
<article>{content}</article>
|
<article>{content}</article>
|
||||||
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
<div class="page-listing">
|
||||||
<div>
|
<p>{pluralize(pages.length, "item")} with this tag.</p>
|
||||||
<PageList {...listProps} />
|
<div>
|
||||||
|
<PageList {...listProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function pageResources(
|
|||||||
staticResources: StaticResources,
|
staticResources: StaticResources,
|
||||||
): StaticResources {
|
): StaticResources {
|
||||||
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
|
||||||
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
|
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
|
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],
|
||||||
@@ -74,13 +74,13 @@ export function renderPage(
|
|||||||
const classNames = (node.properties?.className ?? []) as string[]
|
const classNames = (node.properties?.className ?? []) as string[]
|
||||||
if (classNames.includes("transclude")) {
|
if (classNames.includes("transclude")) {
|
||||||
const inner = node.children[0] as Element
|
const inner = node.children[0] as Element
|
||||||
const transcludeTarget = inner.properties?.["data-slug"] as FullSlug
|
const transcludeTarget = inner.properties["data-slug"] as FullSlug
|
||||||
const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
|
const page = getOrComputeFileIndex(componentData.allFiles).get(transcludeTarget)
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockRef = node.properties?.dataBlock as string | undefined
|
let blockRef = node.properties.dataBlock as string | undefined
|
||||||
if (blockRef?.startsWith("#^")) {
|
if (blockRef?.startsWith("#^")) {
|
||||||
// block transclude
|
// block transclude
|
||||||
blockRef = blockRef.slice("#^".length)
|
blockRef = blockRef.slice("#^".length)
|
||||||
@@ -90,6 +90,7 @@ export function renderPage(
|
|||||||
blockNode = {
|
blockNode = {
|
||||||
type: "element",
|
type: "element",
|
||||||
tagName: "ul",
|
tagName: "ul",
|
||||||
|
properties: {},
|
||||||
children: [blockNode],
|
children: [blockNode],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +145,7 @@ export function renderPage(
|
|||||||
{
|
{
|
||||||
type: "element",
|
type: "element",
|
||||||
tagName: "h1",
|
tagName: "h1",
|
||||||
|
properties: {},
|
||||||
children: [
|
children: [
|
||||||
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
|
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
function toggleCallout(this: HTMLElement) {
|
function toggleCallout(this: HTMLElement) {
|
||||||
const outerBlock = this.parentElement!
|
const outerBlock = this.parentElement!
|
||||||
outerBlock.classList.toggle(`is-collapsed`)
|
outerBlock.classList.toggle("is-collapsed")
|
||||||
const collapsed = outerBlock.classList.contains(`is-collapsed`)
|
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||||
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
|
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
|
||||||
outerBlock.style.maxHeight = height + `px`
|
outerBlock.style.maxHeight = height + "px"
|
||||||
|
|
||||||
// walk and adjust height of all parents
|
// walk and adjust height of all parents
|
||||||
let current = outerBlock
|
let current = outerBlock
|
||||||
let parent = outerBlock.parentElement
|
let parent = outerBlock.parentElement
|
||||||
while (parent) {
|
while (parent) {
|
||||||
if (!parent.classList.contains(`callout`)) {
|
if (!parent.classList.contains("callout")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapsed = parent.classList.contains(`is-collapsed`)
|
const collapsed = parent.classList.contains("is-collapsed")
|
||||||
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
|
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
|
||||||
parent.style.maxHeight = height + `px`
|
parent.style.maxHeight = height + "px"
|
||||||
|
|
||||||
current = parent
|
current = parent
|
||||||
parent = parent.parentElement
|
parent = parent.parentElement
|
||||||
@@ -30,15 +30,15 @@ function setupCallout() {
|
|||||||
const title = div.firstElementChild
|
const title = div.firstElementChild
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
title.removeEventListener(`click`, toggleCallout)
|
title.addEventListener("click", toggleCallout)
|
||||||
title.addEventListener(`click`, toggleCallout)
|
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||||
|
|
||||||
const collapsed = div.classList.contains(`is-collapsed`)
|
const collapsed = div.classList.contains("is-collapsed")
|
||||||
const height = collapsed ? title.scrollHeight : div.scrollHeight
|
const height = collapsed ? title.scrollHeight : div.scrollHeight
|
||||||
div.style.maxHeight = height + `px`
|
div.style.maxHeight = height + "px"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener(`nav`, setupCallout)
|
document.addEventListener("nav", setupCallout)
|
||||||
window.addEventListener(`resize`, setupCallout)
|
window.addEventListener("resize", setupCallout)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ document.addEventListener("nav", () => {
|
|||||||
button.type = "button"
|
button.type = "button"
|
||||||
button.innerHTML = svgCopy
|
button.innerHTML = svgCopy
|
||||||
button.ariaLabel = "Copy source"
|
button.ariaLabel = "Copy source"
|
||||||
button.addEventListener("click", () => {
|
function onClick() {
|
||||||
navigator.clipboard.writeText(source).then(
|
navigator.clipboard.writeText(source).then(
|
||||||
() => {
|
() => {
|
||||||
button.blur()
|
button.blur()
|
||||||
@@ -26,7 +26,9 @@ document.addEventListener("nav", () => {
|
|||||||
},
|
},
|
||||||
(error) => console.error(error),
|
(error) => console.error(error),
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
button.addEventListener("click", onClick)
|
||||||
|
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||||
els[i].prepend(button)
|
els[i].prepend(button)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,39 @@ const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "l
|
|||||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||||
|
|
||||||
|
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||||
|
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||||
|
detail: { theme },
|
||||||
|
})
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", () => {
|
||||||
const switchTheme = (e: any) => {
|
const switchTheme = (e: Event) => {
|
||||||
if (e.target.checked) {
|
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
|
||||||
document.documentElement.setAttribute("saved-theme", "dark")
|
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||||
localStorage.setItem("theme", "dark")
|
localStorage.setItem("theme", newTheme)
|
||||||
} else {
|
emitThemeChangeEvent(newTheme)
|
||||||
document.documentElement.setAttribute("saved-theme", "light")
|
}
|
||||||
localStorage.setItem("theme", "light")
|
|
||||||
}
|
const themeChange = (e: MediaQueryListEvent) => {
|
||||||
|
const newTheme = e.matches ? "dark" : "light"
|
||||||
|
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||||
|
localStorage.setItem("theme", newTheme)
|
||||||
|
toggleSwitch.checked = e.matches
|
||||||
|
emitThemeChangeEvent(newTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Darkmode toggle
|
// Darkmode toggle
|
||||||
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
|
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
|
||||||
toggleSwitch.removeEventListener("change", switchTheme)
|
|
||||||
toggleSwitch.addEventListener("change", switchTheme)
|
toggleSwitch.addEventListener("change", switchTheme)
|
||||||
|
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
|
||||||
if (currentTheme === "dark") {
|
if (currentTheme === "dark") {
|
||||||
toggleSwitch.checked = true
|
toggleSwitch.checked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for changes in prefers-color-scheme
|
// Listen for changes in prefers-color-scheme
|
||||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
colorSchemeMediaQuery.addEventListener("change", (e) => {
|
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||||
const newTheme = e.matches ? "dark" : "light"
|
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
|
||||||
localStorage.setItem("theme", newTheme)
|
|
||||||
toggleSwitch.checked = e.matches
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,135 +1,106 @@
|
|||||||
import { FolderState } from "../ExplorerNode"
|
import { FolderState } from "../ExplorerNode"
|
||||||
|
|
||||||
// Current state of folders
|
type MaybeHTMLElement = HTMLElement | undefined
|
||||||
let explorerState: FolderState[]
|
let currentExplorerState: FolderState[]
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
// If last element is observed, remove gradient of "overflow" class so element is visible
|
||||||
const explorer = document.getElementById("explorer-ul")
|
const explorerUl = document.getElementById("explorer-ul")
|
||||||
|
if (!explorerUl) return
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
explorer?.classList.add("no-background")
|
explorerUl.classList.add("no-background")
|
||||||
} else {
|
} else {
|
||||||
explorer?.classList.remove("no-background")
|
explorerUl.classList.remove("no-background")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleExplorer(this: HTMLElement) {
|
function toggleExplorer(this: HTMLElement) {
|
||||||
// Toggle collapsed state of entire explorer
|
|
||||||
this.classList.toggle("collapsed")
|
this.classList.toggle("collapsed")
|
||||||
const content = this.nextElementSibling as HTMLElement
|
const content = this.nextElementSibling as MaybeHTMLElement
|
||||||
|
if (!content) return
|
||||||
|
|
||||||
content.classList.toggle("collapsed")
|
content.classList.toggle("collapsed")
|
||||||
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFolder(evt: MouseEvent) {
|
function toggleFolder(evt: MouseEvent) {
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
|
const target = evt.target as MaybeHTMLElement
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
// Element that was clicked
|
|
||||||
const target = evt.target as HTMLElement
|
|
||||||
|
|
||||||
// Check if target was svg icon or button
|
|
||||||
const isSvg = target.nodeName === "svg"
|
const isSvg = target.nodeName === "svg"
|
||||||
|
const childFolderContainer = (
|
||||||
|
isSvg
|
||||||
|
? target.parentElement?.nextSibling
|
||||||
|
: target.parentElement?.parentElement?.nextElementSibling
|
||||||
|
) as MaybeHTMLElement
|
||||||
|
const currentFolderParent = (
|
||||||
|
isSvg ? target.nextElementSibling : target.parentElement
|
||||||
|
) as MaybeHTMLElement
|
||||||
|
if (!(childFolderContainer && currentFolderParent)) return
|
||||||
|
|
||||||
// corresponding <ul> element relative to clicked button/folder
|
childFolderContainer.classList.toggle("open")
|
||||||
let childFolderContainer: HTMLElement
|
|
||||||
|
|
||||||
// <li> element of folder (stores folder-path dataset)
|
|
||||||
let currentFolderParent: HTMLElement
|
|
||||||
|
|
||||||
// Get correct relative container and toggle collapsed class
|
|
||||||
if (isSvg) {
|
|
||||||
childFolderContainer = target.parentElement?.nextSibling as HTMLElement
|
|
||||||
currentFolderParent = target.nextElementSibling as HTMLElement
|
|
||||||
|
|
||||||
childFolderContainer.classList.toggle("open")
|
|
||||||
} else {
|
|
||||||
childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
|
|
||||||
currentFolderParent = target.parentElement as HTMLElement
|
|
||||||
|
|
||||||
childFolderContainer.classList.toggle("open")
|
|
||||||
}
|
|
||||||
if (!childFolderContainer) return
|
|
||||||
|
|
||||||
// Collapse folder container
|
|
||||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
const isCollapsed = childFolderContainer.classList.contains("open")
|
||||||
setFolderState(childFolderContainer, !isCollapsed)
|
setFolderState(childFolderContainer, !isCollapsed)
|
||||||
|
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
||||||
// Save folder state to localStorage
|
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
||||||
const clickFolderPath = currentFolderParent.dataset.folderpath as string
|
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||||
|
|
||||||
// Remove leading "/"
|
|
||||||
const fullFolderPath = clickFolderPath.substring(1)
|
|
||||||
toggleCollapsedByPath(explorerState, fullFolderPath)
|
|
||||||
|
|
||||||
const stringifiedFileTree = JSON.stringify(explorerState)
|
|
||||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupExplorer() {
|
function setupExplorer() {
|
||||||
// Set click handler for collapsing entire explorer
|
|
||||||
const explorer = document.getElementById("explorer")
|
const explorer = document.getElementById("explorer")
|
||||||
|
if (!explorer) return
|
||||||
|
|
||||||
|
if (explorer.dataset.behavior === "collapse") {
|
||||||
|
for (const item of document.getElementsByClassName(
|
||||||
|
"folder-button",
|
||||||
|
) as HTMLCollectionOf<HTMLElement>) {
|
||||||
|
item.addEventListener("click", toggleFolder)
|
||||||
|
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
explorer.addEventListener("click", toggleExplorer)
|
||||||
|
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
||||||
|
|
||||||
|
// Set up click handlers for each folder (click handler on folder "icon")
|
||||||
|
for (const item of document.getElementsByClassName(
|
||||||
|
"folder-icon",
|
||||||
|
) as HTMLCollectionOf<HTMLElement>) {
|
||||||
|
item.addEventListener("click", toggleFolder)
|
||||||
|
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
||||||
|
}
|
||||||
|
|
||||||
// Get folder state from local storage
|
// Get folder state from local storage
|
||||||
const storageTree = localStorage.getItem("fileTree")
|
const storageTree = localStorage.getItem("fileTree")
|
||||||
|
|
||||||
// Convert to bool
|
|
||||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
||||||
|
const oldExplorerState: FolderState[] =
|
||||||
|
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
||||||
|
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
||||||
|
const newExplorerState: FolderState[] = explorer.dataset.tree
|
||||||
|
? JSON.parse(explorer.dataset.tree)
|
||||||
|
: []
|
||||||
|
currentExplorerState = []
|
||||||
|
for (const { path, collapsed } of newExplorerState) {
|
||||||
|
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
|
||||||
|
}
|
||||||
|
|
||||||
if (explorer) {
|
currentExplorerState.map((folderState) => {
|
||||||
// Get config
|
const folderLi = document.querySelector(
|
||||||
const collapseBehavior = explorer.dataset.behavior
|
`[data-folderpath='${folderState.path}']`,
|
||||||
|
) as MaybeHTMLElement
|
||||||
// Add click handlers for all folders (click handler on folder "label")
|
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
||||||
if (collapseBehavior === "collapse") {
|
if (folderUl) {
|
||||||
Array.prototype.forEach.call(
|
setFolderState(folderUl, folderState.collapsed)
|
||||||
document.getElementsByClassName("folder-button"),
|
|
||||||
function (item) {
|
|
||||||
item.removeEventListener("click", toggleFolder)
|
|
||||||
item.addEventListener("click", toggleFolder)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler to main explorer
|
|
||||||
explorer.removeEventListener("click", toggleExplorer)
|
|
||||||
explorer.addEventListener("click", toggleExplorer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up click handlers for each folder (click handler on folder "icon")
|
|
||||||
Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
|
|
||||||
item.removeEventListener("click", toggleFolder)
|
|
||||||
item.addEventListener("click", toggleFolder)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (storageTree && useSavedFolderState) {
|
|
||||||
// Get state from localStorage and set folder state
|
|
||||||
explorerState = JSON.parse(storageTree)
|
|
||||||
explorerState.map((folderUl) => {
|
|
||||||
// grab <li> element for matching folder path
|
|
||||||
const folderLi = document.querySelector(
|
|
||||||
`[data-folderpath='/${folderUl.path}']`,
|
|
||||||
) as HTMLElement
|
|
||||||
|
|
||||||
// Get corresponding content <ul> tag and set state
|
|
||||||
if (folderLi) {
|
|
||||||
const folderUL = folderLi.parentElement?.nextElementSibling
|
|
||||||
if (folderUL) {
|
|
||||||
setFolderState(folderUL as HTMLElement, folderUl.collapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", setupExplorer)
|
window.addEventListener("resize", setupExplorer)
|
||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", () => {
|
||||||
setupExplorer()
|
setupExplorer()
|
||||||
|
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
|
|
||||||
// select pseudo element at end of list
|
// select pseudo element at end of list
|
||||||
@@ -145,11 +116,7 @@ document.addEventListener("nav", () => {
|
|||||||
* @param collapsed if folder should be set to collapsed or not
|
* @param collapsed if folder should be set to collapsed or not
|
||||||
*/
|
*/
|
||||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||||
if (collapsed) {
|
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||||
folderElement?.classList.remove("open")
|
|
||||||
} else {
|
|
||||||
folderElement?.classList.add("open")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -319,12 +319,12 @@ function renderGlobalGraph() {
|
|||||||
registerEscapeHandler(container, hideGlobalGraph)
|
registerEscapeHandler(container, hideGlobalGraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("nav", async (e: unknown) => {
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
const slug = (e as CustomEventMap["nav"]).detail.url
|
const slug = e.detail.url
|
||||||
addToVisited(slug)
|
addToVisited(slug)
|
||||||
await renderGraph("graph-container", slug)
|
await renderGraph("graph-container", slug)
|
||||||
|
|
||||||
const containerIcon = document.getElementById("global-graph-icon")
|
const containerIcon = document.getElementById("global-graph-icon")
|
||||||
containerIcon?.removeEventListener("click", renderGlobalGraph)
|
|
||||||
containerIcon?.addEventListener("click", renderGlobalGraph)
|
containerIcon?.addEventListener("click", renderGlobalGraph)
|
||||||
|
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import Plausible from "plausible-tracker"
|
|
||||||
const { trackPageview } = Plausible()
|
|
||||||
document.addEventListener("nav", () => trackPageview())
|
|
||||||
@@ -7,6 +7,10 @@ async function mouseEnterHandler(
|
|||||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
) {
|
) {
|
||||||
const link = this
|
const link = this
|
||||||
|
if (link.dataset.noPopover === "true") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
async function setPosition(popoverElement: HTMLElement) {
|
async function setPosition(popoverElement: HTMLElement) {
|
||||||
const { x, y } = await computePosition(link, popoverElement, {
|
const { x, y } = await computePosition(link, popoverElement, {
|
||||||
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
|
||||||
@@ -72,7 +76,7 @@ async function mouseEnterHandler(
|
|||||||
document.addEventListener("nav", () => {
|
document.addEventListener("nav", () => {
|
||||||
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
|
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
link.removeEventListener("mouseenter", mouseEnterHandler)
|
|
||||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||||
|
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch"
|
import FlexSearch from "flexsearch"
|
||||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||||
import { FullSlug, resolveRelative } from "../../util/path"
|
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: number
|
id: number
|
||||||
@@ -11,23 +11,53 @@ interface Item {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let index: Document<Item> | undefined = undefined
|
|
||||||
|
|
||||||
// Can be expanded with things like "term" in the future
|
// Can be expanded with things like "term" in the future
|
||||||
type SearchType = "basic" | "tags"
|
type SearchType = "basic" | "tags"
|
||||||
|
|
||||||
// Current searchType
|
|
||||||
let searchType: SearchType = "basic"
|
let searchType: SearchType = "basic"
|
||||||
|
let currentSearchTerm: string = ""
|
||||||
|
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
||||||
|
let index = new FlexSearch.Document<Item>({
|
||||||
|
charset: "latin:extra",
|
||||||
|
encode: encoder,
|
||||||
|
document: {
|
||||||
|
id: "id",
|
||||||
|
index: [
|
||||||
|
{
|
||||||
|
field: "title",
|
||||||
|
tokenize: "forward",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "content",
|
||||||
|
tokenize: "forward",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "tags",
|
||||||
|
tokenize: "forward",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const p = new DOMParser()
|
||||||
|
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||||
const contextWindowWords = 30
|
const contextWindowWords = 30
|
||||||
const numSearchResults = 5
|
const numSearchResults = 8
|
||||||
const numTagResults = 3
|
const numTagResults = 5
|
||||||
|
|
||||||
|
const tokenizeTerm = (term: string) => {
|
||||||
|
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||||
|
const tokenLen = tokens.length
|
||||||
|
if (tokenLen > 1) {
|
||||||
|
for (let i = 1; i < tokenLen; i++) {
|
||||||
|
tokens.push(tokens.slice(0, i + 1).join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
|
||||||
|
}
|
||||||
|
|
||||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||||
// try to highlight longest tokens first
|
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||||
const tokenizedTerms = searchTerm
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((t) => t !== "")
|
|
||||||
.sort((a, b) => b.length - a.length)
|
|
||||||
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
||||||
|
|
||||||
let startIndex = 0
|
let startIndex = 0
|
||||||
@@ -35,12 +65,12 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
|
|||||||
if (trim) {
|
if (trim) {
|
||||||
const includesCheck = (tok: string) =>
|
const includesCheck = (tok: string) =>
|
||||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||||
const occurencesIndices = tokenizedText.map(includesCheck)
|
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||||
|
|
||||||
let bestSum = 0
|
let bestSum = 0
|
||||||
let bestIndex = 0
|
let bestIndex = 0
|
||||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
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)
|
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||||
if (windowSum >= bestSum) {
|
if (windowSum >= bestSum) {
|
||||||
bestSum = windowSum
|
bestSum = windowSum
|
||||||
@@ -71,20 +101,78 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
|
function highlightHTML(searchTerm: string, el: HTMLElement) {
|
||||||
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
|
const p = new DOMParser()
|
||||||
document.addEventListener("nav", async (e: unknown) => {
|
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||||
const currentSlug = (e as CustomEventMap["nav"]).detail.url
|
const html = p.parseFromString(el.innerHTML, "text/html")
|
||||||
|
|
||||||
|
const createHighlightSpan = (text: string) => {
|
||||||
|
const span = document.createElement("span")
|
||||||
|
span.className = "highlight"
|
||||||
|
span.textContent = text
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightTextNodes = (node: Node, term: string) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const nodeText = node.nodeValue ?? ""
|
||||||
|
const regex = new RegExp(term.toLowerCase(), "gi")
|
||||||
|
const matches = nodeText.match(regex)
|
||||||
|
if (!matches || matches.length === 0) return
|
||||||
|
const spanContainer = document.createElement("span")
|
||||||
|
let lastIndex = 0
|
||||||
|
for (const match of matches) {
|
||||||
|
const matchIndex = nodeText.indexOf(match, lastIndex)
|
||||||
|
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
|
||||||
|
spanContainer.appendChild(createHighlightSpan(match))
|
||||||
|
lastIndex = matchIndex + match.length
|
||||||
|
}
|
||||||
|
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
|
||||||
|
node.parentNode?.replaceChild(spanContainer, node)
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if ((node as HTMLElement).classList.contains("highlight")) return
|
||||||
|
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const term of tokenizedTerms) {
|
||||||
|
highlightTextNodes(html.body, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.body
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
|
const currentSlug = e.detail.url
|
||||||
const data = await fetchData
|
const data = await fetchData
|
||||||
const container = document.getElementById("search-container")
|
const container = document.getElementById("search-container")
|
||||||
const sidebar = container?.closest(".sidebar") as HTMLElement
|
const sidebar = container?.closest(".sidebar") as HTMLElement
|
||||||
const searchIcon = document.getElementById("search-icon")
|
const searchIcon = document.getElementById("search-icon")
|
||||||
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
|
||||||
const results = document.getElementById("results-container")
|
const searchLayout = document.getElementById("search-layout")
|
||||||
const resultCards = document.getElementsByClassName("result-card")
|
|
||||||
const idDataMap = Object.keys(data) as FullSlug[]
|
const idDataMap = Object.keys(data) as FullSlug[]
|
||||||
|
|
||||||
|
const appendLayout = (el: HTMLElement) => {
|
||||||
|
if (searchLayout?.querySelector(`#${el.id}`) === null) {
|
||||||
|
searchLayout?.appendChild(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enablePreview = searchLayout?.dataset?.preview === "true"
|
||||||
|
let preview: HTMLDivElement | undefined = undefined
|
||||||
|
let previewInner: HTMLDivElement | undefined = undefined
|
||||||
|
const results = document.createElement("div")
|
||||||
|
results.id = "results-container"
|
||||||
|
results.style.flexBasis = enablePreview ? "min(30%, 450px)" : "100%"
|
||||||
|
appendLayout(results)
|
||||||
|
|
||||||
|
if (enablePreview) {
|
||||||
|
preview = document.createElement("div")
|
||||||
|
preview.id = "preview-container"
|
||||||
|
preview.style.flexBasis = "100%"
|
||||||
|
appendLayout(preview)
|
||||||
|
}
|
||||||
|
|
||||||
function hideSearch() {
|
function hideSearch() {
|
||||||
container?.classList.remove("active")
|
container?.classList.remove("active")
|
||||||
if (searchBar) {
|
if (searchBar) {
|
||||||
@@ -96,6 +184,12 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
if (results) {
|
if (results) {
|
||||||
removeAllChildren(results)
|
removeAllChildren(results)
|
||||||
}
|
}
|
||||||
|
if (preview) {
|
||||||
|
removeAllChildren(preview)
|
||||||
|
}
|
||||||
|
if (searchLayout) {
|
||||||
|
searchLayout.classList.remove("display-results")
|
||||||
|
}
|
||||||
|
|
||||||
searchType = "basic" // reset search type after closing
|
searchType = "basic" // reset search type after closing
|
||||||
}
|
}
|
||||||
@@ -109,11 +203,14 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
searchBar?.focus()
|
searchBar?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
let currentHover: HTMLInputElement | null = null
|
||||||
|
|
||||||
|
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const searchBarOpen = container?.classList.contains("active")
|
const searchBarOpen = container?.classList.contains("active")
|
||||||
searchBarOpen ? hideSearch() : showSearch("basic")
|
searchBarOpen ? hideSearch() : showSearch("basic")
|
||||||
|
return
|
||||||
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
// Hotkey to open tag search
|
// Hotkey to open tag search
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -122,156 +219,227 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
|
|
||||||
// add "#" prefix for tag search
|
// add "#" prefix for tag search
|
||||||
if (searchBar) searchBar.value = "#"
|
if (searchBar) searchBar.value = "#"
|
||||||
} else if (e.key === "Enter") {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHover) {
|
||||||
|
currentHover.classList.remove("focus")
|
||||||
|
currentHover.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If search is active, then we will render the first result and display accordingly
|
||||||
|
if (!container?.classList.contains("active")) return
|
||||||
|
else if (e.key === "Enter") {
|
||||||
// If result has focus, navigate to that one, otherwise pick first result
|
// If result has focus, navigate to that one, otherwise pick first result
|
||||||
if (results?.contains(document.activeElement)) {
|
if (results?.contains(document.activeElement)) {
|
||||||
const active = document.activeElement as HTMLInputElement
|
const active = document.activeElement as HTMLInputElement
|
||||||
|
if (active.classList.contains("no-match")) return
|
||||||
|
await displayPreview(active)
|
||||||
active.click()
|
active.click()
|
||||||
} else {
|
} else {
|
||||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||||
anchor?.click()
|
if (!anchor || anchor?.classList.contains("no-match")) return
|
||||||
|
await displayPreview(anchor)
|
||||||
|
anchor.click()
|
||||||
}
|
}
|
||||||
} else if (e.key === "ArrowDown") {
|
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||||
e.preventDefault()
|
|
||||||
// When first pressing ArrowDown, results wont contain the active element, so focus first element
|
|
||||||
if (!results?.contains(document.activeElement)) {
|
|
||||||
const firstResult = resultCards[0] as HTMLInputElement | null
|
|
||||||
firstResult?.focus()
|
|
||||||
} else {
|
|
||||||
// If an element in results-container already has focus, focus next one
|
|
||||||
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
|
|
||||||
nextResult?.focus()
|
|
||||||
}
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (results?.contains(document.activeElement)) {
|
if (results?.contains(document.activeElement)) {
|
||||||
// If an element in results-container already has focus, focus previous one
|
// If an element in results-container already has focus, focus previous one
|
||||||
const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
|
const currentResult = currentHover
|
||||||
|
? currentHover
|
||||||
|
: (document.activeElement as HTMLInputElement | null)
|
||||||
|
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
|
||||||
|
currentResult?.classList.remove("focus")
|
||||||
prevResult?.focus()
|
prevResult?.focus()
|
||||||
|
currentHover = prevResult
|
||||||
|
await displayPreview(prevResult)
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||||
|
e.preventDefault()
|
||||||
|
// The results should already been focused, so we need to find the next one.
|
||||||
|
// The activeElement is the search bar, so we need to find the first result and focus it.
|
||||||
|
if (document.activeElement === searchBar || currentHover !== null) {
|
||||||
|
const firstResult = currentHover
|
||||||
|
? currentHover
|
||||||
|
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
|
||||||
|
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
|
||||||
|
firstResult?.classList.remove("focus")
|
||||||
|
secondResult?.focus()
|
||||||
|
currentHover = secondResult
|
||||||
|
await displayPreview(secondResult)
|
||||||
|
} else {
|
||||||
|
// If an element in results-container already has focus, focus next one
|
||||||
|
const active = currentHover
|
||||||
|
? currentHover
|
||||||
|
: (document.activeElement as HTMLInputElement | null)
|
||||||
|
active?.classList.remove("focus")
|
||||||
|
const nextResult = active?.nextElementSibling as HTMLInputElement | null
|
||||||
|
nextResult?.focus()
|
||||||
|
currentHover = nextResult
|
||||||
|
await displayPreview(nextResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimContent(content: string) {
|
|
||||||
// works without escaping html like in `description.ts`
|
|
||||||
const sentences = content.replace(/\s+/g, " ").split(".")
|
|
||||||
let finalDesc = ""
|
|
||||||
let sentenceIdx = 0
|
|
||||||
|
|
||||||
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
|
|
||||||
const len = contextWindowWords * 5
|
|
||||||
while (finalDesc.length < len) {
|
|
||||||
const sentence = sentences[sentenceIdx]
|
|
||||||
if (!sentence) break
|
|
||||||
finalDesc += sentence + "."
|
|
||||||
sentenceIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// If more content would be available, indicate it by finishing with "..."
|
|
||||||
if (finalDesc.length < content.length) {
|
|
||||||
finalDesc += ".."
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatForDisplay = (term: string, id: number) => {
|
const formatForDisplay = (term: string, id: number) => {
|
||||||
const slug = idDataMap[id]
|
const slug = idDataMap[id]
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
slug,
|
slug,
|
||||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||||
// if searchType is tag, display context from start of file and trim, otherwise use regular highlight
|
content: highlight(term, data[slug].content ?? "", true),
|
||||||
content:
|
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||||
searchType === "tags"
|
|
||||||
? trimContent(data[slug].content)
|
|
||||||
: highlight(term, data[slug].content ?? "", true),
|
|
||||||
tags: highlightTags(term, data[slug].tags),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightTags(term: string, tags: string[]) {
|
function highlightTags(term: string, tags: string[]) {
|
||||||
if (tags && searchType === "tags") {
|
if (!tags || searchType !== "tags") {
|
||||||
// Find matching tags
|
|
||||||
const termLower = term.toLowerCase()
|
|
||||||
let matching = tags.filter((str) => str.includes(termLower))
|
|
||||||
|
|
||||||
// Substract matching from original tags, then push difference
|
|
||||||
if (matching.length > 0) {
|
|
||||||
let difference = tags.filter((x) => !matching.includes(x))
|
|
||||||
|
|
||||||
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
|
|
||||||
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
|
|
||||||
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
|
|
||||||
matching.push(...difference)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow max of `numTagResults` in preview
|
|
||||||
if (tags.length > numTagResults) {
|
|
||||||
matching.splice(numTagResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
return matching
|
|
||||||
} else {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
.map((tag) => {
|
||||||
|
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||||
|
return `<li><p class="match-tag">#${tag}</p></li>`
|
||||||
|
} else {
|
||||||
|
return `<li><p>#${tag}</p></li>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.slice(0, numTagResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(slug: FullSlug): URL {
|
||||||
|
return new URL(resolveRelative(currentSlug, slug), location.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||||
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
|
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
||||||
const button = document.createElement("button")
|
const itemTile = document.createElement("a")
|
||||||
button.classList.add("result-card")
|
itemTile.classList.add("result-card")
|
||||||
button.id = slug
|
itemTile.id = slug
|
||||||
button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
|
itemTile.href = resolveUrl(slug).toString()
|
||||||
button.addEventListener("click", () => {
|
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>`
|
||||||
const targ = resolveRelative(currentSlug, slug)
|
|
||||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
async function onMouseEnter(ev: MouseEvent) {
|
||||||
hideSearch()
|
if (!ev.target) return
|
||||||
|
currentHover?.classList.remove("focus")
|
||||||
|
currentHover?.blur()
|
||||||
|
const target = ev.target as HTMLInputElement
|
||||||
|
currentHover = target
|
||||||
|
currentHover.classList.add("focus")
|
||||||
|
await displayPreview(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMouseLeave(ev: MouseEvent) {
|
||||||
|
if (!ev.target) return
|
||||||
|
const target = ev.target as HTMLElement
|
||||||
|
target.classList.remove("focus")
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
["mouseenter", onMouseEnter],
|
||||||
|
["mouseleave", onMouseLeave],
|
||||||
|
[
|
||||||
|
"click",
|
||||||
|
(event: MouseEvent) => {
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
|
hideSearch()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
events.forEach(([event, handler]) => {
|
||||||
|
itemTile.addEventListener(event, handler)
|
||||||
|
window.addCleanup(() => itemTile.removeEventListener(event, handler))
|
||||||
})
|
})
|
||||||
return button
|
|
||||||
|
return itemTile
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayResults(finalResults: Item[]) {
|
async function displayResults(finalResults: Item[]) {
|
||||||
if (!results) return
|
if (!results) return
|
||||||
|
|
||||||
removeAllChildren(results)
|
removeAllChildren(results)
|
||||||
if (finalResults.length === 0) {
|
if (finalResults.length === 0) {
|
||||||
results.innerHTML = `<button class="result-card">
|
results.innerHTML = `<a class="result-card no-match">
|
||||||
<h3>No results.</h3>
|
<h3>No results.</h3>
|
||||||
<p>Try another search term?</p>
|
<p>Try another search term?</p>
|
||||||
</button>`
|
</a>`
|
||||||
} else {
|
} else {
|
||||||
results.append(...finalResults.map(resultToHTML))
|
results.append(...finalResults.map(resultToHTML))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finalResults.length === 0 && preview) {
|
||||||
|
// no results, clear previous preview
|
||||||
|
removeAllChildren(preview)
|
||||||
|
} else {
|
||||||
|
// focus on first result, then also dispatch preview immediately
|
||||||
|
const firstChild = results.firstElementChild as HTMLElement
|
||||||
|
firstChild.classList.add("focus")
|
||||||
|
currentHover = firstChild as HTMLInputElement
|
||||||
|
await displayPreview(firstChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||||
|
if (fetchContentCache.has(slug)) {
|
||||||
|
return fetchContentCache.get(slug) as Element[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = resolveUrl(slug).toString()
|
||||||
|
const contents = await fetch(targetUrl)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((contents) => {
|
||||||
|
if (contents === undefined) {
|
||||||
|
throw new Error(`Could not fetch ${targetUrl}`)
|
||||||
|
}
|
||||||
|
const html = p.parseFromString(contents ?? "", "text/html")
|
||||||
|
normalizeRelativeURLs(html, targetUrl)
|
||||||
|
return [...html.getElementsByClassName("popover-hint")]
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchContentCache.set(slug, contents)
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayPreview(el: HTMLElement | null) {
|
||||||
|
if (!searchLayout || !enablePreview || !el || !preview) return
|
||||||
|
const slug = el.id as FullSlug
|
||||||
|
const innerDiv = await fetchContent(slug).then((contents) =>
|
||||||
|
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
|
||||||
|
)
|
||||||
|
previewInner = document.createElement("div")
|
||||||
|
previewInner.classList.add("preview-inner")
|
||||||
|
previewInner.append(...innerDiv)
|
||||||
|
preview.replaceChildren(previewInner)
|
||||||
|
|
||||||
|
// scroll to longest
|
||||||
|
const highlights = [...preview.querySelectorAll(".highlight")].sort(
|
||||||
|
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||||
|
)
|
||||||
|
highlights[0]?.scrollIntoView({ block: "start" })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onType(e: HTMLElementEventMap["input"]) {
|
async function onType(e: HTMLElementEventMap["input"]) {
|
||||||
let term = (e.target as HTMLInputElement).value
|
if (!searchLayout || !index) return
|
||||||
let searchResults: SimpleDocumentSearchResultSetUnit[]
|
currentSearchTerm = (e.target as HTMLInputElement).value
|
||||||
|
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
|
||||||
|
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
|
||||||
|
|
||||||
if (term.toLowerCase().startsWith("#")) {
|
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
|
||||||
searchType = "tags"
|
if (searchType === "tags") {
|
||||||
} else {
|
searchResults = await index.searchAsync({
|
||||||
searchType = "basic"
|
query: currentSearchTerm.substring(1),
|
||||||
}
|
limit: numSearchResults,
|
||||||
|
index: ["tags"],
|
||||||
switch (searchType) {
|
})
|
||||||
case "tags": {
|
} else if (searchType === "basic") {
|
||||||
term = term.substring(1)
|
searchResults = await index.searchAsync({
|
||||||
searchResults =
|
query: currentSearchTerm,
|
||||||
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
|
limit: numSearchResults,
|
||||||
[]
|
index: ["title", "content"],
|
||||||
break
|
})
|
||||||
}
|
|
||||||
case "basic":
|
|
||||||
default: {
|
|
||||||
searchResults =
|
|
||||||
(await index?.searchAsync({
|
|
||||||
query: term,
|
|
||||||
limit: numSearchResults,
|
|
||||||
index: ["title", "content"],
|
|
||||||
})) ?? []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getByField = (field: string): number[] => {
|
const getByField = (field: string): number[] => {
|
||||||
@@ -285,51 +453,19 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
...getByField("content"),
|
...getByField("content"),
|
||||||
...getByField("tags"),
|
...getByField("tags"),
|
||||||
])
|
])
|
||||||
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
|
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
|
||||||
displayResults(finalResults)
|
await displayResults(finalResults)
|
||||||
}
|
|
||||||
|
|
||||||
if (prevShortcutHandler) {
|
|
||||||
document.removeEventListener("keydown", prevShortcutHandler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", shortcutHandler)
|
document.addEventListener("keydown", shortcutHandler)
|
||||||
prevShortcutHandler = shortcutHandler
|
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||||
searchIcon?.removeEventListener("click", () => showSearch("basic"))
|
|
||||||
searchIcon?.addEventListener("click", () => showSearch("basic"))
|
searchIcon?.addEventListener("click", () => showSearch("basic"))
|
||||||
searchBar?.removeEventListener("input", onType)
|
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
|
||||||
searchBar?.addEventListener("input", onType)
|
searchBar?.addEventListener("input", onType)
|
||||||
|
window.addCleanup(() => searchBar?.removeEventListener("input", onType))
|
||||||
|
|
||||||
// setup index if it hasn't been already
|
|
||||||
if (!index) {
|
|
||||||
index = new Document({
|
|
||||||
charset: "latin:extra",
|
|
||||||
optimize: true,
|
|
||||||
encode: encoder,
|
|
||||||
document: {
|
|
||||||
id: "id",
|
|
||||||
index: [
|
|
||||||
{
|
|
||||||
field: "title",
|
|
||||||
tokenize: "reverse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "content",
|
|
||||||
tokenize: "reverse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "tags",
|
|
||||||
tokenize: "reverse",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
fillDocument(index, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// register handlers
|
|
||||||
registerEscapeHandler(container, hideSearch)
|
registerEscapeHandler(container, hideSearch)
|
||||||
|
await fillDocument(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,16 +473,20 @@ document.addEventListener("nav", async (e: unknown) => {
|
|||||||
* @param index index to fill
|
* @param index index to fill
|
||||||
* @param data data to fill index with
|
* @param data data to fill index with
|
||||||
*/
|
*/
|
||||||
async function fillDocument(index: Document<Item, false>, data: any) {
|
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
|
||||||
let id = 0
|
let id = 0
|
||||||
|
const promises: Array<Promise<unknown>> = []
|
||||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||||
await index.addAsync(id, {
|
promises.push(
|
||||||
id,
|
index.addAsync(id++, {
|
||||||
slug: slug as FullSlug,
|
id,
|
||||||
title: fileData.title,
|
slug: slug as FullSlug,
|
||||||
content: fileData.content,
|
title: fileData.title,
|
||||||
tags: fileData.tags,
|
content: fileData.content,
|
||||||
})
|
tags: fileData.tags,
|
||||||
id++
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await Promise.all(promises)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) {
|
|||||||
document.dispatchEvent(event)
|
document.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanupFns: Set<(...args: any[]) => void> = new Set()
|
||||||
|
window.addCleanup = (fn) => cleanupFns.add(fn)
|
||||||
|
|
||||||
let p: DOMParser
|
let p: DOMParser
|
||||||
async function navigate(url: URL, isBack: boolean = false) {
|
async function navigate(url: URL, isBack: boolean = false) {
|
||||||
p = p || new DOMParser()
|
p = p || new DOMParser()
|
||||||
@@ -57,6 +60,10 @@ async function navigate(url: URL, isBack: boolean = false) {
|
|||||||
|
|
||||||
if (!contents) return
|
if (!contents) return
|
||||||
|
|
||||||
|
// cleanup old
|
||||||
|
cleanupFns.forEach((fn) => fn())
|
||||||
|
cleanupFns.clear()
|
||||||
|
|
||||||
const html = p.parseFromString(contents, "text/html")
|
const html = p.parseFromString(contents, "text/html")
|
||||||
normalizeRelativeURLs(html, url)
|
normalizeRelativeURLs(html, url)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const observer = new IntersectionObserver((entries) => {
|
|||||||
|
|
||||||
function toggleToc(this: HTMLElement) {
|
function toggleToc(this: HTMLElement) {
|
||||||
this.classList.toggle("collapsed")
|
this.classList.toggle("collapsed")
|
||||||
const content = this.nextElementSibling as HTMLElement
|
const content = this.nextElementSibling as HTMLElement | undefined
|
||||||
|
if (!content) return
|
||||||
content.classList.toggle("collapsed")
|
content.classList.toggle("collapsed")
|
||||||
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
|
||||||
}
|
}
|
||||||
@@ -25,10 +26,11 @@ function setupToc() {
|
|||||||
const toc = document.getElementById("toc")
|
const toc = document.getElementById("toc")
|
||||||
if (toc) {
|
if (toc) {
|
||||||
const collapsed = toc.classList.contains("collapsed")
|
const collapsed = toc.classList.contains("collapsed")
|
||||||
const content = toc.nextElementSibling as HTMLElement
|
const content = toc.nextElementSibling as HTMLElement | undefined
|
||||||
|
if (!content) return
|
||||||
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
|
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
|
||||||
toc.removeEventListener("click", toggleToc)
|
|
||||||
toc.addEventListener("click", toggleToc)
|
toc.addEventListener("click", toggleToc)
|
||||||
|
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
|
|||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
|
||||||
outsideContainer?.removeEventListener("click", click)
|
|
||||||
outsideContainer?.addEventListener("click", click)
|
outsideContainer?.addEventListener("click", click)
|
||||||
document.removeEventListener("keydown", esc)
|
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
|
||||||
document.addEventListener("keydown", esc)
|
document.addEventListener("keydown", esc)
|
||||||
|
window.addCleanup(() => document.removeEventListener("keydown", esc))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeAllChildren(node: HTMLElement) {
|
export function removeAllChildren(node: HTMLElement) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
float: right;
|
float: right;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
margin: -0.2rem 0.3rem;
|
margin: 0.3rem;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
border-color: var(--dark);
|
border-color: var(--dark);
|
||||||
background-color: var(--light);
|
background-color: var(--light);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@use "../../styles/variables.scss" as *;
|
||||||
|
|
||||||
button#explorer {
|
button#explorer {
|
||||||
all: unset;
|
all: unset;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -85,7 +87,7 @@ svg {
|
|||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
font-family: var(--headerFont);
|
font-family: var(--headerFont);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: $boldWeight;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -106,11 +108,11 @@ svg {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: var(--headerFont);
|
font-family: var(--headerFont);
|
||||||
|
|
||||||
& p {
|
& span {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
font-weight: 600;
|
font-weight: $boldWeight;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -126,7 +128,7 @@ svg {
|
|||||||
backface-visibility: visible;
|
backface-visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
div:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
padding: 0 1rem 1rem 1rem;
|
padding: 0 1rem 1rem 1rem;
|
||||||
font-weight: initial;
|
font-weight: initial;
|
||||||
|
font-style: initial;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
font-size: initial;
|
font-size: initial;
|
||||||
font-family: var(--bodyFont);
|
font-family: var(--bodyFont);
|
||||||
|
|||||||
@@ -54,15 +54,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > #search-space {
|
& > #search-space {
|
||||||
width: 50%;
|
width: 65%;
|
||||||
margin-top: 15vh;
|
margin-top: 12vh;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
@media all and (max-width: $fullPageWidth) {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -86,90 +82,133 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > #results-container {
|
& > #search-layout {
|
||||||
& .result-card {
|
display: none;
|
||||||
padding: 1em;
|
flex-direction: row;
|
||||||
cursor: pointer;
|
border: 1px solid var(--lightgray);
|
||||||
transition: background 0.2s ease;
|
|
||||||
border: 1px solid var(--lightgray);
|
|
||||||
border-bottom: none;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
// normalize button props
|
&.display-results {
|
||||||
font-family: inherit;
|
display: flex;
|
||||||
font-size: 100%;
|
}
|
||||||
line-height: 1.15;
|
|
||||||
margin: 0;
|
|
||||||
text-transform: none;
|
|
||||||
text-align: left;
|
|
||||||
background: var(--light);
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
& .highlight {
|
@media all and (min-width: $tabletBreakpoint) {
|
||||||
color: var(--secondary);
|
&[data-preview] {
|
||||||
font-weight: 700;
|
& .result-card > p.preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover,
|
& > div {
|
||||||
&:focus {
|
// vh - #search-space.margin-top
|
||||||
background: var(--lightgray);
|
height: calc(75vh - 12vh);
|
||||||
}
|
background: none;
|
||||||
|
|
||||||
&:first-of-type {
|
&:first-child {
|
||||||
border-top-left-radius: 5px;
|
border-top-left-radius: 5px;
|
||||||
border-top-right-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-of-type {
|
|
||||||
border-bottom-left-radius: 5px;
|
border-bottom-left-radius: 5px;
|
||||||
|
border-right: 1px solid var(--lightgray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-top-right-radius: 5px;
|
||||||
border-bottom-right-radius: 5px;
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: $tabletBreakpoint) {
|
||||||
|
display: block;
|
||||||
|
& > *:not(#results-container) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #results-container {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .highlight {
|
||||||
|
background: color-mix(in srgb, var(--tertiary) 60%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
scroll-margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #preview-container {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--dark);
|
||||||
|
line-height: 1.5em;
|
||||||
|
font-weight: $normalWeight;
|
||||||
|
background: var(--light);
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
& .preview-inner {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min($pageWidth, 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > #results-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
& .result-card {
|
||||||
|
padding: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
border-bottom: 1px solid var(--lightgray);
|
border-bottom: 1px solid var(--lightgray);
|
||||||
}
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
& > h3 {
|
// normalize card props
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
text-transform: none;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--light);
|
||||||
|
outline: none;
|
||||||
|
font-weight: inherit;
|
||||||
|
|
||||||
& > ul > li {
|
&:focus,
|
||||||
margin: 0;
|
&.focus {
|
||||||
display: inline-block;
|
background: var(--lightgray);
|
||||||
white-space: nowrap;
|
}
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul {
|
& > h3 {
|
||||||
list-style: none;
|
margin: 0;
|
||||||
display: flex;
|
}
|
||||||
padding-left: 0;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin: 0;
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
// Offset border radius
|
|
||||||
margin-left: -2px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-clip: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul > li > p {
|
& > ul.tags {
|
||||||
border-radius: 8px;
|
margin-top: 0.45rem;
|
||||||
background-color: var(--highlight);
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
}
|
||||||
background-clip: border-box;
|
|
||||||
padding: 0.03rem 0.4rem;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--secondary);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul > li > .match-tag {
|
& > ul > li > p {
|
||||||
color: var(--tertiary);
|
border-radius: 8px;
|
||||||
font-weight: bold;
|
background-color: var(--highlight);
|
||||||
opacity: 1;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
margin: 0 0.1rem;
|
||||||
|
line-height: 1.4rem;
|
||||||
|
font-weight: $boldWeight;
|
||||||
|
color: var(--secondary);
|
||||||
|
|
||||||
& > p {
|
&.match-tag {
|
||||||
margin-bottom: 0;
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type QuartzComponentProps = {
|
|||||||
fileData: QuartzPluginData
|
fileData: QuartzPluginData
|
||||||
cfg: GlobalConfiguration
|
cfg: GlobalConfiguration
|
||||||
children: (QuartzComponent | JSX.Element)[]
|
children: (QuartzComponent | JSX.Element)[]
|
||||||
tree: Node<QuartzPluginData>
|
tree: Node
|
||||||
allFiles: QuartzPluginData[]
|
allFiles: QuartzPluginData[]
|
||||||
displayClass?: "mobile-only" | "desktop-only"
|
displayClass?: "mobile-only" | "desktop-only"
|
||||||
} & JSX.IntrinsicAttributes & {
|
} & JSX.IntrinsicAttributes & {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FilePath, FullSlug } from "../../util/path"
|
|||||||
import { sharedPageComponents } from "../../../quartz.layout"
|
import { sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { NotFound } from "../../components"
|
import { NotFound } from "../../components"
|
||||||
import { defaultProcessedContent } from "../vfile"
|
import { defaultProcessedContent } from "../vfile"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
@@ -25,7 +26,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [Head, Body, pageBody, Footer]
|
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 cfg = ctx.cfg.configuration
|
||||||
const slug = "404" as FullSlug
|
const slug = "404" as FullSlug
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
await emit({
|
await write({
|
||||||
|
ctx,
|
||||||
content: renderPage(slug, componentData, opts, externalResources),
|
content: renderPage(slug, componentData, opts, externalResources),
|
||||||
slug,
|
slug,
|
||||||
ext: ".html",
|
ext: ".html",
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
import { QuartzEmitterPlugin } from "../types"
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||||
name: "AliasRedirects",
|
name: "AliasRedirects",
|
||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
|
async emit(ctx, content, _resources): Promise<FilePath[]> {
|
||||||
|
const { argv } = ctx
|
||||||
const fps: FilePath[] = []
|
const fps: FilePath[] = []
|
||||||
|
|
||||||
for (const [_tree, file] of content) {
|
for (const [_tree, file] of content) {
|
||||||
const ogSlug = simplifySlug(file.data.slug!)
|
const ogSlug = simplifySlug(file.data.slug!)
|
||||||
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
|
||||||
|
const aliases = file.data.frontmatter?.aliases ?? []
|
||||||
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
|
|
||||||
if (typeof aliases === "string") {
|
|
||||||
aliases = [aliases]
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
|
||||||
const permalink = file.data.frontmatter?.permalink
|
const permalink = file.data.frontmatter?.permalink
|
||||||
if (typeof permalink === "string") {
|
if (typeof permalink === "string") {
|
||||||
@@ -32,7 +29,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirUrl = resolveRelative(slug, file.data.slug!)
|
const redirUrl = resolveRelative(slug, file.data.slug!)
|
||||||
const fp = await emit({
|
const fp = await write({
|
||||||
|
ctx,
|
||||||
content: `
|
content: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return []
|
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
|
// glob all non MD/MDX/HTML files in content folder and copy it over
|
||||||
const assetsPath = argv.output
|
const assetsPath = argv.output
|
||||||
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||||
if (!cfg.configuration.baseUrl) {
|
if (!cfg.configuration.baseUrl) {
|
||||||
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { QuartzEmitterPlugin } from "../types"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import spaRouterScript from "../../components/scripts/spa.inline"
|
import spaRouterScript from "../../components/scripts/spa.inline"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import plausibleScript from "../../components/scripts/plausible.inline"
|
|
||||||
// @ts-ignore
|
|
||||||
import popoverScript from "../../components/scripts/popover.inline"
|
import popoverScript from "../../components/scripts/popover.inline"
|
||||||
import styles from "../../styles/custom.scss"
|
import styles from "../../styles/custom.scss"
|
||||||
import popoverStyle from "../../components/styles/popover.scss"
|
import popoverStyle from "../../components/styles/popover.scss"
|
||||||
@@ -14,6 +12,8 @@ import { StaticResources } from "../../util/resources"
|
|||||||
import { QuartzComponent } from "../../components/types"
|
import { QuartzComponent } from "../../components/types"
|
||||||
import { googleFontHref, joinStyles } from "../../util/theme"
|
import { googleFontHref, joinStyles } from "../../util/theme"
|
||||||
import { Features, transform } from "lightningcss"
|
import { Features, transform } from "lightningcss"
|
||||||
|
import { transform as transpile } from "esbuild"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
type ComponentResources = {
|
type ComponentResources = {
|
||||||
css: string[]
|
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
|
// 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(
|
function addGlobalPageResources(
|
||||||
@@ -85,24 +92,37 @@ function addGlobalPageResources(
|
|||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag() { dataLayer.push(arguments); }
|
function gtag() { dataLayer.push(arguments); }
|
||||||
gtag(\`js\`, new Date());
|
gtag("js", new Date());
|
||||||
gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
|
gtag("config", "${tagId}", { send_page_view: false });
|
||||||
|
|
||||||
document.addEventListener(\`nav\`, () => {
|
document.addEventListener("nav", () => {
|
||||||
gtag(\`event\`, \`page_view\`, {
|
gtag("event", "page_view", {
|
||||||
page_title: document.title,
|
page_title: document.title,
|
||||||
page_location: location.href,
|
page_location: location.href,
|
||||||
});
|
});
|
||||||
});`)
|
});`)
|
||||||
} else if (cfg.analytics?.provider === "plausible") {
|
} 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") {
|
} else if (cfg.analytics?.provider === "umami") {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
const umamiScript = document.createElement("script")
|
const umamiScript = document.createElement("script")
|
||||||
umamiScript.src = "https://analytics.umami.is/script.js"
|
umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js"
|
||||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
|
||||||
umamiScript.async = true
|
umamiScript.async = true
|
||||||
|
|
||||||
document.head.appendChild(umamiScript)
|
document.head.appendChild(umamiScript)
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
@@ -111,9 +131,11 @@ function addGlobalPageResources(
|
|||||||
componentResources.afterDOMLoaded.push(spaRouterScript)
|
componentResources.afterDOMLoaded.push(spaRouterScript)
|
||||||
} else {
|
} else {
|
||||||
componentResources.afterDOMLoaded.push(`
|
componentResources.afterDOMLoaded.push(`
|
||||||
window.spaNavigate = (url, _) => window.location.assign(url)
|
window.spaNavigate = (url, _) => window.location.assign(url)
|
||||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
window.addCleanup = () => {}
|
||||||
document.dispatchEvent(event)`)
|
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
|
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
|
||||||
@@ -127,9 +149,9 @@ function addGlobalPageResources(
|
|||||||
loadTime: "afterDOMReady",
|
loadTime: "afterDOMReady",
|
||||||
contentType: "inline",
|
contentType: "inline",
|
||||||
script: `
|
script: `
|
||||||
const socket = new WebSocket('${wsUrl}')
|
const socket = new WebSocket('${wsUrl}')
|
||||||
socket.addEventListener('message', () => document.location.reload())
|
socket.addEventListener('message', () => document.location.reload())
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +171,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
|
async emit(ctx, _content, resources): Promise<FilePath[]> {
|
||||||
// component specific scripts and styles
|
// component specific scripts and styles
|
||||||
const componentResources = getComponentResources(ctx)
|
const componentResources = getComponentResources(ctx)
|
||||||
// important that this goes *after* component scripts
|
// important that this goes *after* component scripts
|
||||||
@@ -165,10 +187,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
|||||||
addGlobalPageResources(ctx, resources, componentResources)
|
addGlobalPageResources(ctx, resources, componentResources)
|
||||||
|
|
||||||
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
|
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
|
||||||
const prescript = joinScripts(componentResources.beforeDOMLoaded)
|
const [prescript, postscript] = await Promise.all([
|
||||||
const postscript = joinScripts(componentResources.afterDOMLoaded)
|
joinScripts(componentResources.beforeDOMLoaded),
|
||||||
|
joinScripts(componentResources.afterDOMLoaded),
|
||||||
|
])
|
||||||
|
|
||||||
const fps = await Promise.all([
|
const fps = await Promise.all([
|
||||||
emit({
|
write({
|
||||||
|
ctx,
|
||||||
slug: "index" as FullSlug,
|
slug: "index" as FullSlug,
|
||||||
ext: ".css",
|
ext: ".css",
|
||||||
content: transform({
|
content: transform({
|
||||||
@@ -185,12 +211,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
|
|||||||
include: Features.MediaQueries,
|
include: Features.MediaQueries,
|
||||||
}).code.toString(),
|
}).code.toString(),
|
||||||
}),
|
}),
|
||||||
emit({
|
write({
|
||||||
|
ctx,
|
||||||
slug: "prescript" as FullSlug,
|
slug: "prescript" as FullSlug,
|
||||||
ext: ".js",
|
ext: ".js",
|
||||||
content: prescript,
|
content: prescript,
|
||||||
}),
|
}),
|
||||||
emit({
|
write({
|
||||||
|
ctx,
|
||||||
slug: "postscript" as FullSlug,
|
slug: "postscript" as FullSlug,
|
||||||
ext: ".js",
|
ext: ".js",
|
||||||
content: postscript,
|
content: postscript,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Root } from "hast"
|
|||||||
import { GlobalConfiguration } from "../../cfg"
|
import { GlobalConfiguration } from "../../cfg"
|
||||||
import { getDate } from "../../components/Date"
|
import { getDate } from "../../components/Date"
|
||||||
import { escapeHTML } from "../../util/escape"
|
import { 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 { QuartzEmitterPlugin } from "../types"
|
||||||
import { toHtml } from "hast-util-to-html"
|
import { toHtml } from "hast-util-to-html"
|
||||||
import path from "path"
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||||
export type ContentDetails = {
|
export type ContentDetails = {
|
||||||
@@ -37,7 +37,7 @@ const defaultOptions: Options = {
|
|||||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||||
const base = cfg.baseUrl ?? ""
|
const base = cfg.baseUrl ?? ""
|
||||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||||
<loc>https://${base}/${encodeURI(slug)}</loc>
|
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||||
<lastmod>${content.date?.toISOString()}</lastmod>
|
<lastmod>${content.date?.toISOString()}</lastmod>
|
||||||
</url>`
|
</url>`
|
||||||
const urls = Array.from(idx)
|
const urls = Array.from(idx)
|
||||||
@@ -48,12 +48,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
|||||||
|
|
||||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||||
const base = cfg.baseUrl ?? ""
|
const base = cfg.baseUrl ?? ""
|
||||||
const root = `https://${base}`
|
|
||||||
|
|
||||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||||
<title>${escapeHTML(content.title)}</title>
|
<title>${escapeHTML(content.title)}</title>
|
||||||
<link>${root}/${encodeURI(slug)}</link>
|
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||||
<guid>${root}/${encodeURI(slug)}</guid>
|
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||||
<description>${content.richContent ?? content.description}</description>
|
<description>${content.richContent ?? content.description}</description>
|
||||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||||
</item>`
|
</item>`
|
||||||
@@ -78,7 +77,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
|
|||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||||
<link>${root}</link>
|
<link>https://${base}</link>
|
||||||
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
|
||||||
cfg.pageTitle,
|
cfg.pageTitle,
|
||||||
)}</description>
|
)}</description>
|
||||||
@@ -92,7 +91,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
opts = { ...defaultOptions, ...opts }
|
opts = { ...defaultOptions, ...opts }
|
||||||
return {
|
return {
|
||||||
name: "ContentIndex",
|
name: "ContentIndex",
|
||||||
async emit(ctx, content, _resources, emit) {
|
async emit(ctx, content, _resources) {
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
const emitted: FilePath[] = []
|
const emitted: FilePath[] = []
|
||||||
const linkIndex: ContentIndex = new Map()
|
const linkIndex: ContentIndex = new Map()
|
||||||
@@ -116,7 +115,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
|
|
||||||
if (opts?.enableSiteMap) {
|
if (opts?.enableSiteMap) {
|
||||||
emitted.push(
|
emitted.push(
|
||||||
await emit({
|
await write({
|
||||||
|
ctx,
|
||||||
content: generateSiteMap(cfg, linkIndex),
|
content: generateSiteMap(cfg, linkIndex),
|
||||||
slug: "sitemap" as FullSlug,
|
slug: "sitemap" as FullSlug,
|
||||||
ext: ".xml",
|
ext: ".xml",
|
||||||
@@ -126,7 +126,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
|
|
||||||
if (opts?.enableRSS) {
|
if (opts?.enableRSS) {
|
||||||
emitted.push(
|
emitted.push(
|
||||||
await emit({
|
await write({
|
||||||
|
ctx,
|
||||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||||
slug: "index" as FullSlug,
|
slug: "index" as FullSlug,
|
||||||
ext: ".xml",
|
ext: ".xml",
|
||||||
@@ -134,7 +135,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(
|
const simplifiedIndex = Object.fromEntries(
|
||||||
Array.from(linkIndex).map(([slug, content]) => {
|
Array.from(linkIndex).map(([slug, content]) => {
|
||||||
// remove description and from content index as nothing downstream
|
// remove description and from content index as nothing downstream
|
||||||
@@ -147,7 +148,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
emitted.push(
|
emitted.push(
|
||||||
await emit({
|
await write({
|
||||||
|
ctx,
|
||||||
content: JSON.stringify(simplifiedIndex),
|
content: JSON.stringify(simplifiedIndex),
|
||||||
slug: fp,
|
slug: fp,
|
||||||
ext: ".json",
|
ext: ".json",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FilePath, pathToRoot } from "../../util/path"
|
|||||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { Content } from "../../components"
|
import { Content } from "../../components"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
@@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
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 cfg = ctx.cfg.configuration
|
||||||
const fps: FilePath[] = []
|
const fps: FilePath[] = []
|
||||||
const allFiles = content.map((c) => c[1].data)
|
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 content = renderPage(slug, componentData, opts, externalResources)
|
||||||
const fp = await emit({
|
const fp = await write({
|
||||||
|
ctx,
|
||||||
content,
|
content,
|
||||||
slug,
|
slug,
|
||||||
ext: ".html",
|
ext: ".html",
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
} from "../../util/path"
|
} from "../../util/path"
|
||||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { FolderContent } from "../../components"
|
import { FolderContent } from "../../components"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
...sharedPageComponents,
|
...sharedPageComponents,
|
||||||
...defaultListPageLayout,
|
...defaultListPageLayout,
|
||||||
@@ -35,7 +36,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
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 fps: FilePath[] = []
|
||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
@@ -82,7 +83,8 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = renderPage(slug, componentData, opts, externalResources)
|
const content = renderPage(slug, componentData, opts, externalResources)
|
||||||
const fp = await emit({
|
const fp = await write({
|
||||||
|
ctx,
|
||||||
content,
|
content,
|
||||||
slug,
|
slug,
|
||||||
ext: ".html",
|
ext: ".html",
|
||||||
|
|||||||
19
quartz/plugins/emitters/helpers.ts
Normal file
19
quartz/plugins/emitters/helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
import { BuildCtx } from "../../util/ctx"
|
||||||
|
import { FilePath, FullSlug, joinSegments } from "../../util/path"
|
||||||
|
|
||||||
|
type WriteOptions = {
|
||||||
|
ctx: BuildCtx
|
||||||
|
slug: FullSlug
|
||||||
|
ext: `.${string}` | ""
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
|
||||||
|
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
|
||||||
|
const dir = path.dirname(pathToPage)
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true })
|
||||||
|
await fs.promises.writeFile(pathToPage, content)
|
||||||
|
return pathToPage
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
|
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
|
||||||
const staticPath = joinSegments(QUARTZ, "static")
|
const staticPath = joinSegments(QUARTZ, "static")
|
||||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||||
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
|
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
} from "../../util/path"
|
} from "../../util/path"
|
||||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||||
import { TagContent } from "../../components"
|
import { TagContent } from "../../components"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
|
||||||
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||||
const opts: FullPageLayout = {
|
const opts: FullPageLayout = {
|
||||||
...sharedPageComponents,
|
...sharedPageComponents,
|
||||||
...defaultListPageLayout,
|
...defaultListPageLayout,
|
||||||
@@ -32,7 +33,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
|
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 fps: FilePath[] = []
|
||||||
const allFiles = content.map((c) => c[1].data)
|
const allFiles = content.map((c) => c[1].data)
|
||||||
const cfg = ctx.cfg.configuration
|
const cfg = ctx.cfg.configuration
|
||||||
@@ -40,12 +41,13 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
const tags: Set<string> = new Set(
|
const tags: Set<string> = new Set(
|
||||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||||
)
|
)
|
||||||
|
|
||||||
// add base tag
|
// add base tag
|
||||||
tags.add("index")
|
tags.add("index")
|
||||||
|
|
||||||
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||||
[...tags].map((tag) => {
|
[...tags].map((tag) => {
|
||||||
const title = tag === "" ? "Tag Index" : `Tag: #${tag}`
|
const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
|
||||||
return [
|
return [
|
||||||
tag,
|
tag,
|
||||||
defaultProcessedContent({
|
defaultProcessedContent({
|
||||||
@@ -80,7 +82,8 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = renderPage(slug, componentData, opts, externalResources)
|
const content = renderPage(slug, componentData, opts, externalResources)
|
||||||
const fp = await emit({
|
const fp = await write({
|
||||||
|
ctx,
|
||||||
content,
|
content,
|
||||||
slug: file.data.slug!,
|
slug: file.data.slug!,
|
||||||
ext: ".html",
|
ext: ".html",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { QuartzFilterPlugin } from "../types"
|
|||||||
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
||||||
name: "ExplicitPublish",
|
name: "ExplicitPublish",
|
||||||
shouldPublish(_ctx, [_tree, vfile]) {
|
shouldPublish(_ctx, [_tree, vfile]) {
|
||||||
const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
|
return vfile.data?.frontmatter?.publish ?? false
|
||||||
return publishFlag
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,5 +30,6 @@ declare module "vfile" {
|
|||||||
interface DataMap {
|
interface DataMap {
|
||||||
slug: FullSlug
|
slug: FullSlug
|
||||||
filePath: FilePath
|
filePath: FilePath
|
||||||
|
relativePath: FilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QuartzTransformerPlugin } from "../types"
|
|||||||
import yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
import toml from "toml"
|
import toml from "toml"
|
||||||
import { slugTag } from "../../util/path"
|
import { slugTag } from "../../util/path"
|
||||||
|
import { QuartzPluginData } from "../vfile"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
delims: string | string[]
|
delims: string | string[]
|
||||||
@@ -15,6 +16,29 @@ const defaultOptions: Options = {
|
|||||||
language: "yaml",
|
language: "yaml",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coalesceAliases(data: { [key: string]: any }, aliases: string[]) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (data[alias] !== undefined && data[alias] !== null) return data[alias]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceToArray(input: string | string[]): string[] | undefined {
|
||||||
|
if (input === undefined || input === null) return undefined
|
||||||
|
|
||||||
|
// coerce to array
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
input = input
|
||||||
|
.toString()
|
||||||
|
.split(",")
|
||||||
|
.map((tag: string) => tag.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove all non-strings
|
||||||
|
return input
|
||||||
|
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
|
||||||
|
.map((tag: string | number) => tag.toString())
|
||||||
|
}
|
||||||
|
|
||||||
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
return {
|
return {
|
||||||
@@ -24,7 +48,7 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
|||||||
[remarkFrontmatter, ["yaml", "toml"]],
|
[remarkFrontmatter, ["yaml", "toml"]],
|
||||||
() => {
|
() => {
|
||||||
return (_, file) => {
|
return (_, file) => {
|
||||||
const { data } = matter(file.value, {
|
const { data } = matter(Buffer.from(file.value), {
|
||||||
...opts,
|
...opts,
|
||||||
engines: {
|
engines: {
|
||||||
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
|
||||||
@@ -32,32 +56,22 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// tag is an alias for tags
|
|
||||||
if (data.tag) {
|
|
||||||
data.tags = data.tag
|
|
||||||
}
|
|
||||||
|
|
||||||
// coerce title to string
|
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
data.title = data.title.toString()
|
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)) {
|
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
|
||||||
data.tags = data.tags
|
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
|
||||||
.toString()
|
|
||||||
.split(",")
|
|
||||||
.map((tag: string) => tag.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
// slug them all!!
|
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
|
||||||
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))] ?? []
|
if (aliases) data.aliases = aliases
|
||||||
|
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
|
||||||
|
if (cssclasses) data.cssclasses = cssclasses
|
||||||
|
|
||||||
// fill in frontmatter
|
// fill in frontmatter
|
||||||
file.data.frontmatter = {
|
file.data.frontmatter = data as QuartzPluginData["frontmatter"]
|
||||||
title: file.stem ?? "Untitled",
|
|
||||||
tags: [],
|
|
||||||
...data,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -67,9 +81,16 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
|
|||||||
|
|
||||||
declare module "vfile" {
|
declare module "vfile" {
|
||||||
interface DataMap {
|
interface DataMap {
|
||||||
frontmatter: { [key: string]: any } & {
|
frontmatter: { [key: string]: unknown } & {
|
||||||
title: string
|
title: string
|
||||||
tags: string[]
|
} & Partial<{
|
||||||
}
|
tags: string[]
|
||||||
|
aliases: string[]
|
||||||
|
description: string
|
||||||
|
publish: boolean
|
||||||
|
draft: boolean
|
||||||
|
enableToc: string
|
||||||
|
cssclasses: string[]
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,42 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
|
|||||||
rehypeAutolinkHeadings,
|
rehypeAutolinkHeadings,
|
||||||
{
|
{
|
||||||
behavior: "append",
|
behavior: "append",
|
||||||
|
properties: {
|
||||||
|
ariaHidden: true,
|
||||||
|
tabIndex: -1,
|
||||||
|
"data-no-popover": true,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "element",
|
||||||
value: " §",
|
tagName: "svg",
|
||||||
|
properties: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "path",
|
||||||
|
properties: {
|
||||||
|
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
tagName: "path",
|
||||||
|
properties: {
|
||||||
|
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -50,17 +50,29 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
|
|||||||
created ||= st.birthtimeMs
|
created ||= st.birthtimeMs
|
||||||
modified ||= st.mtimeMs
|
modified ||= st.mtimeMs
|
||||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||||
created ||= file.data.frontmatter.date
|
created ||= file.data.frontmatter.date as MaybeDate
|
||||||
modified ||= file.data.frontmatter.lastmod
|
modified ||= file.data.frontmatter.lastmod as MaybeDate
|
||||||
modified ||= file.data.frontmatter.updated
|
modified ||= file.data.frontmatter.updated as MaybeDate
|
||||||
modified ||= file.data.frontmatter["last-modified"]
|
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
|
||||||
published ||= file.data.frontmatter.publishDate
|
published ||= file.data.frontmatter.publishDate as MaybeDate
|
||||||
} else if (source === "git") {
|
} else if (source === "git") {
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
repo = new Repository(file.cwd)
|
// Get a reference to the main git repo.
|
||||||
|
// It's either the same as the workdir,
|
||||||
|
// or 1+ level higher in case of a submodule/subtree setup
|
||||||
|
repo = Repository.discover(file.cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
try {
|
||||||
|
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
||||||
|
} catch {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(
|
||||||
|
`\nWarning: ${file.data
|
||||||
|
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import remarkMath from "remark-math"
|
import remarkMath from "remark-math"
|
||||||
import rehypeKatex from "rehype-katex"
|
import rehypeKatex from "rehype-katex"
|
||||||
import rehypeMathjax from "rehype-mathjax/svg.js"
|
import rehypeMathjax from "rehype-mathjax/svg"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
@@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
|
|||||||
return {
|
return {
|
||||||
css: [
|
css: [
|
||||||
// base css
|
// base css
|
||||||
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
|
"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css",
|
||||||
],
|
],
|
||||||
js: [
|
js: [
|
||||||
{
|
{
|
||||||
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
|
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
|
||||||
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
|
src: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/copy-tex.min.js",
|
||||||
loadTime: "afterDOMReady",
|
loadTime: "afterDOMReady",
|
||||||
contentType: "external",
|
contentType: "external",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { visit } from "unist-util-visit"
|
import { visit } from "unist-util-visit"
|
||||||
import isAbsoluteUrl from "is-absolute-url"
|
import isAbsoluteUrl from "is-absolute-url"
|
||||||
|
import { Root } from "hast"
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
/** How to resolve Markdown paths */
|
/** How to resolve Markdown paths */
|
||||||
@@ -19,12 +20,16 @@ interface Options {
|
|||||||
/** Strips folders from a link so that it looks nice */
|
/** Strips folders from a link so that it looks nice */
|
||||||
prettyLinks: boolean
|
prettyLinks: boolean
|
||||||
openLinksInNewTab: boolean
|
openLinksInNewTab: boolean
|
||||||
|
lazyLoad: boolean
|
||||||
|
externalLinkIcon: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
const defaultOptions: Options = {
|
||||||
markdownLinkResolution: "absolute",
|
markdownLinkResolution: "absolute",
|
||||||
prettyLinks: true,
|
prettyLinks: true,
|
||||||
openLinksInNewTab: false,
|
openLinksInNewTab: false,
|
||||||
|
lazyLoad: false,
|
||||||
|
externalLinkIcon: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
|
||||||
@@ -34,7 +39,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
htmlPlugins(ctx) {
|
htmlPlugins(ctx) {
|
||||||
return [
|
return [
|
||||||
() => {
|
() => {
|
||||||
return (tree, file) => {
|
return (tree: Root, file) => {
|
||||||
const curSlug = simplifySlug(file.data.slug!)
|
const curSlug = simplifySlug(file.data.slug!)
|
||||||
const outgoing: Set<SimpleSlug> = new Set()
|
const outgoing: Set<SimpleSlug> = new Set()
|
||||||
|
|
||||||
@@ -51,8 +56,30 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
typeof node.properties.href === "string"
|
typeof node.properties.href === "string"
|
||||||
) {
|
) {
|
||||||
let dest = node.properties.href as RelativeURL
|
let dest = node.properties.href as RelativeURL
|
||||||
node.properties.className ??= []
|
const classes = (node.properties.className ?? []) as string[]
|
||||||
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
|
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
|
// Check if the link has alias text
|
||||||
if (
|
if (
|
||||||
@@ -61,8 +88,9 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
node.children[0].value !== dest
|
node.children[0].value !== dest
|
||||||
) {
|
) {
|
||||||
// Add the 'alias' class if the text content is not the same as the href
|
// 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) {
|
if (opts.openLinksInNewTab) {
|
||||||
node.properties.target = "_blank"
|
node.properties.target = "_blank"
|
||||||
@@ -111,6 +139,10 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
|
|||||||
node.properties &&
|
node.properties &&
|
||||||
typeof node.properties.src === "string"
|
typeof node.properties.src === "string"
|
||||||
) {
|
) {
|
||||||
|
if (opts.lazyLoad) {
|
||||||
|
node.properties.loading = "lazy"
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAbsoluteUrl(node.properties.src)) {
|
if (!isAbsoluteUrl(node.properties.src)) {
|
||||||
let dest = node.properties.src as RelativeURL
|
let dest = node.properties.src as RelativeURL
|
||||||
dest = node.properties.src = transformLink(
|
dest = node.properties.src = transformLink(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { PluggableList } from "unified"
|
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
|
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
|
||||||
import { Element, Literal, Root as HtmlRoot } from "hast"
|
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 { slug as slugAnchor } from "github-slugger"
|
||||||
import rehypeRaw from "rehype-raw"
|
import rehypeRaw from "rehype-raw"
|
||||||
import { visit } from "unist-util-visit"
|
import { SKIP, visit } from "unist-util-visit"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { JSResource } from "../../util/resources"
|
import { JSResource } from "../../util/resources"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -15,6 +14,7 @@ import { toHast } from "mdast-util-to-hast"
|
|||||||
import { toHtml } from "hast-util-to-html"
|
import { toHtml } from "hast-util-to-html"
|
||||||
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
|
||||||
import { capitalize } from "../../util/lang"
|
import { capitalize } from "../../util/lang"
|
||||||
|
import { PluggableList } from "unified"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
comments: boolean
|
comments: boolean
|
||||||
@@ -23,8 +23,11 @@ export interface Options {
|
|||||||
callouts: boolean
|
callouts: boolean
|
||||||
mermaid: boolean
|
mermaid: boolean
|
||||||
parseTags: boolean
|
parseTags: boolean
|
||||||
|
parseArrows: boolean
|
||||||
parseBlockReferences: boolean
|
parseBlockReferences: boolean
|
||||||
enableInHtmlEmbed: boolean
|
enableInHtmlEmbed: boolean
|
||||||
|
enableYouTubeEmbed: boolean
|
||||||
|
enableVideoEmbed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: Options = {
|
const defaultOptions: Options = {
|
||||||
@@ -34,43 +37,14 @@ const defaultOptions: Options = {
|
|||||||
callouts: true,
|
callouts: true,
|
||||||
mermaid: true,
|
mermaid: true,
|
||||||
parseTags: true,
|
parseTags: true,
|
||||||
|
parseArrows: true,
|
||||||
parseBlockReferences: true,
|
parseBlockReferences: true,
|
||||||
enableInHtmlEmbed: false,
|
enableInHtmlEmbed: false,
|
||||||
|
enableYouTubeEmbed: true,
|
||||||
|
enableVideoEmbed: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons = {
|
const calloutMapping = {
|
||||||
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
|
|
||||||
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
|
|
||||||
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
|
|
||||||
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
|
|
||||||
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
|
|
||||||
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
|
||||||
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
|
||||||
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
|
||||||
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
|
||||||
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
|
|
||||||
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
|
|
||||||
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
|
||||||
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const callouts = {
|
|
||||||
note: icons.pencilIcon,
|
|
||||||
abstract: icons.clipboardListIcon,
|
|
||||||
info: icons.infoIcon,
|
|
||||||
todo: icons.checkCircleIcon,
|
|
||||||
tip: icons.flameIcon,
|
|
||||||
success: icons.checkIcon,
|
|
||||||
question: icons.helpCircleIcon,
|
|
||||||
warning: icons.alertTriangleIcon,
|
|
||||||
failure: icons.xIcon,
|
|
||||||
danger: icons.zapIcon,
|
|
||||||
bug: icons.bugIcon,
|
|
||||||
example: icons.listIcon,
|
|
||||||
quote: icons.quoteIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
const calloutMapping: Record<string, keyof typeof callouts> = {
|
|
||||||
note: "note",
|
note: "note",
|
||||||
abstract: "abstract",
|
abstract: "abstract",
|
||||||
summary: "abstract",
|
summary: "abstract",
|
||||||
@@ -98,33 +72,43 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
|
|||||||
example: "example",
|
example: "example",
|
||||||
quote: "quote",
|
quote: "quote",
|
||||||
cite: "quote",
|
cite: "quote",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
|
||||||
|
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
||||||
|
// if callout is not recognized, make it a custom one
|
||||||
|
return calloutMapping[normalizedCallout] ?? calloutName
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
|
export const externalLinkRegex = /^https?:\/\//i
|
||||||
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
|
|
||||||
return calloutMapping[callout] ?? "note"
|
|
||||||
}
|
|
||||||
|
|
||||||
// !? -> optional embedding
|
export const arrowRegex = new RegExp(/-{1,2}>/, "g")
|
||||||
// \[\[ -> open brace
|
|
||||||
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
|
// !? -> optional embedding
|
||||||
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
|
// \[\[ -> open brace
|
||||||
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
|
// ([^\[\]\|\#]+) -> 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)
|
||||||
export const wikilinkRegex = new RegExp(
|
export const wikilinkRegex = new RegExp(
|
||||||
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
|
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
|
||||||
"g",
|
"g",
|
||||||
)
|
)
|
||||||
const highlightRegex = 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
|
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
|
||||||
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
|
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
|
||||||
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
|
||||||
// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line
|
// (?:^| ) -> 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 #
|
// #(...) -> 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}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, 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, matches an arbitrary number of tag strings separated by "/"
|
||||||
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
|
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
|
||||||
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
|
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)$/)
|
||||||
|
const wikilinkImageEmbedRegex = new RegExp(
|
||||||
|
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
|
||||||
|
)
|
||||||
|
|
||||||
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
|
||||||
userOpts,
|
userOpts,
|
||||||
@@ -136,40 +120,25 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
return toHtml(hast, { allowDangerousHtml: true })
|
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 {
|
return {
|
||||||
name: "ObsidianFlavoredMarkdown",
|
name: "ObsidianFlavoredMarkdown",
|
||||||
textTransform(_ctx, src) {
|
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
|
// pre-transform blockquotes
|
||||||
if (opts.callouts) {
|
if (opts.callouts) {
|
||||||
src = src.toString()
|
if (src instanceof Buffer) {
|
||||||
src = src.replaceAll(calloutLineRegex, (value) => {
|
src = src.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
src = src.replace(calloutLineRegex, (value) => {
|
||||||
// force newline after title of callout
|
// force newline after title of callout
|
||||||
return value + "\n> "
|
return value + "\n> "
|
||||||
})
|
})
|
||||||
@@ -177,15 +146,24 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
|
|
||||||
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
// pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex)
|
||||||
if (opts.wikilinks) {
|
if (opts.wikilinks) {
|
||||||
src = src.toString()
|
if (src instanceof Buffer) {
|
||||||
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
|
src = src.toString()
|
||||||
const [rawFp, rawHeader, rawAlias] = capture
|
}
|
||||||
|
|
||||||
|
src = src.replace(wikilinkRegex, (value, ...capture) => {
|
||||||
|
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
|
||||||
|
|
||||||
const fp = rawFp ?? ""
|
const fp = rawFp ?? ""
|
||||||
const anchor = rawHeader?.trim().replace(/^#+/, "")
|
const anchor = rawHeader?.trim().replace(/^#+/, "")
|
||||||
const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
|
const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
|
||||||
const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
|
const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
|
||||||
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
|
||||||
const embedDisplay = value.startsWith("!") ? "!" : ""
|
const embedDisplay = value.startsWith("!") ? "!" : ""
|
||||||
|
|
||||||
|
if (rawFp?.match(externalLinkRegex)) {
|
||||||
|
return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
|
||||||
|
}
|
||||||
|
|
||||||
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -194,103 +172,186 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
},
|
},
|
||||||
markdownPlugins() {
|
markdownPlugins() {
|
||||||
const plugins: PluggableList = []
|
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
|
// regex replacements
|
||||||
if (value.startsWith("!")) {
|
plugins.push(() => {
|
||||||
const ext: string = path.extname(fp).toLowerCase()
|
return (tree: Root, file) => {
|
||||||
const url = slugifyFilePath(fp as FilePath)
|
const replacements: [RegExp, string | ReplaceFunction][] = []
|
||||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
|
const base = pathToRoot(file.data.slug!)
|
||||||
const dims = alias ?? ""
|
|
||||||
let [width, height] = dims.split("x", 2)
|
if (opts.wikilinks) {
|
||||||
width ||= "auto"
|
replacements.push([
|
||||||
height ||= "auto"
|
wikilinkRegex,
|
||||||
return {
|
(value: string, ...capture: string[]) => {
|
||||||
type: "image",
|
let [rawFp, rawHeader, rawAlias] = capture
|
||||||
url,
|
const fp = rawFp?.trim() ?? ""
|
||||||
data: {
|
const anchor = rawHeader?.trim() ?? ""
|
||||||
hProperties: {
|
const alias = rawAlias?.slice(1).trim()
|
||||||
width,
|
|
||||||
height,
|
// embed cases
|
||||||
|
if (value.startsWith("!")) {
|
||||||
|
const ext: string = path.extname(fp).toLowerCase()
|
||||||
|
const url = slugifyFilePath(fp as FilePath)
|
||||||
|
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
|
||||||
|
const match = wikilinkImageEmbedRegex.exec(alias ?? "")
|
||||||
|
const alt = match?.groups?.alt ?? ""
|
||||||
|
const width = match?.groups?.width ?? "auto"
|
||||||
|
const height = match?.groups?.height ?? "auto"
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
url,
|
||||||
|
data: {
|
||||||
|
hProperties: {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
alt,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
||||||
} else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
|
return {
|
||||||
return {
|
type: "html",
|
||||||
type: "html",
|
value: `<video src="${url}" controls></video>`,
|
||||||
value: `<video src="${url}" controls></video>`,
|
}
|
||||||
}
|
} else if (
|
||||||
} else if (
|
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
||||||
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
|
) {
|
||||||
) {
|
return {
|
||||||
return {
|
type: "html",
|
||||||
type: "html",
|
value: `<audio src="${url}" controls></audio>`,
|
||||||
value: `<audio src="${url}" controls></audio>`,
|
}
|
||||||
}
|
} else if ([".pdf"].includes(ext)) {
|
||||||
} else if ([".pdf"].includes(ext)) {
|
return {
|
||||||
return {
|
type: "html",
|
||||||
type: "html",
|
value: `<iframe src="${url}"></iframe>`,
|
||||||
value: `<iframe src="${url}"></iframe>`,
|
}
|
||||||
}
|
} else {
|
||||||
} else if (ext === "") {
|
const block = anchor
|
||||||
const block = anchor
|
return {
|
||||||
return {
|
type: "html",
|
||||||
type: "html",
|
data: { hProperties: { transclude: true } },
|
||||||
data: { hProperties: { transclude: true } },
|
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
|
||||||
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${
|
url + anchor
|
||||||
url + anchor
|
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
|
||||||
}" 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
|
if (opts.highlight) {
|
||||||
const url = fp + anchor
|
replacements.push([
|
||||||
return {
|
highlightRegex,
|
||||||
type: "link",
|
(_value: string, ...capture: string[]) => {
|
||||||
url,
|
const [inner] = capture
|
||||||
children: [
|
return {
|
||||||
{
|
type: "html",
|
||||||
type: "text",
|
value: `<span class="text-highlight">${inner}</span>`,
|
||||||
value: alias ?? fp,
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.parseArrows) {
|
||||||
|
replacements.push([
|
||||||
|
arrowRegex,
|
||||||
|
(_value: string, ..._capture: string[]) => {
|
||||||
|
return {
|
||||||
|
type: "html",
|
||||||
|
value: `<span>→</span>`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.parseTags) {
|
||||||
|
replacements.push([
|
||||||
|
tagRegex,
|
||||||
|
(_value: string, tag: string) => {
|
||||||
|
// Check if the tag only includes numbers
|
||||||
|
if (/^\d+$/.test(tag)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = slugTag(tag)
|
||||||
|
if (file.data.frontmatter) {
|
||||||
|
const noteTags = file.data.frontmatter.tags ?? []
|
||||||
|
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "link",
|
||||||
|
url: base + `/tags/${tag}`,
|
||||||
|
data: {
|
||||||
|
hProperties: {
|
||||||
|
className: ["tag-link"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: `#${tag}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.enableInHtmlEmbed) {
|
||||||
|
visit(tree, "html", (node: Html) => {
|
||||||
|
for (const [regex, replace] of replacements) {
|
||||||
|
if (typeof replace === "string") {
|
||||||
|
node.value = node.value.replace(regex, replace)
|
||||||
|
} else {
|
||||||
|
node.value = node.value.replace(regex, (substring: string, ...args) => {
|
||||||
|
const replaceValue = replace(substring, ...args)
|
||||||
|
if (typeof replaceValue === "string") {
|
||||||
|
return replaceValue
|
||||||
|
} else if (Array.isArray(replaceValue)) {
|
||||||
|
return replaceValue.map(mdastToHtml).join("")
|
||||||
|
} else if (typeof replaceValue === "object" && replaceValue !== null) {
|
||||||
|
return mdastToHtml(replaceValue)
|
||||||
|
} else {
|
||||||
|
return substring
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
mdastFindReplace(tree, replacements)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (opts.highlight) {
|
if (opts.enableVideoEmbed) {
|
||||||
plugins.push(() => {
|
plugins.push(() => {
|
||||||
return (tree: Root, _file) => {
|
return (tree: Root, _file) => {
|
||||||
findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
|
visit(tree, "image", (node, index, parent) => {
|
||||||
const [inner] = capture
|
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
|
||||||
return {
|
const newNode: Html = {
|
||||||
type: "html",
|
type: "html",
|
||||||
value: `<span class="text-highlight">${inner}</span>`,
|
value: `<video controls src="${node.url}"></video>`,
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.comments) {
|
parent.children.splice(index, 1, newNode)
|
||||||
plugins.push(() => {
|
return SKIP
|
||||||
return (tree: Root, _file) => {
|
|
||||||
findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
value: "",
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -312,36 +373,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = firstChild.children[0].value
|
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 [firstLine, ...remainingLines] = text.split("\n")
|
||||||
const remainingText = remainingLines.join("\n")
|
const remainingText = remainingLines.join("\n")
|
||||||
|
|
||||||
const match = firstLine.match(calloutRegex)
|
const match = firstLine.match(calloutRegex)
|
||||||
if (match && match.input) {
|
if (match && match.input) {
|
||||||
const [calloutDirective, typeString, collapseChar] = match
|
const [calloutDirective, typeString, collapseChar] = match
|
||||||
const calloutType = canonicalizeCallout(
|
const calloutType = canonicalizeCallout(typeString.toLowerCase())
|
||||||
typeString.toLowerCase() as keyof typeof calloutMapping,
|
|
||||||
)
|
|
||||||
const collapse = collapseChar === "+" || collapseChar === "-"
|
const collapse = collapseChar === "+" || collapseChar === "-"
|
||||||
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
|
||||||
const titleContent =
|
const titleContent = match.input.slice(calloutDirective.length).trim()
|
||||||
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
|
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
|
||||||
const titleNode: Paragraph = {
|
const titleNode: Paragraph = {
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
|
children: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
|
||||||
|
},
|
||||||
|
...restOfTitle,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
const title = mdastToHtml(titleNode)
|
const title = mdastToHtml(titleNode)
|
||||||
|
|
||||||
const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
|
const toggleIcon = `<div class="fold-callout-icon"></div>`
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>`
|
|
||||||
|
|
||||||
const titleHtml: HTML = {
|
const titleHtml: Html = {
|
||||||
type: "html",
|
type: "html",
|
||||||
value: `<div
|
value: `<div
|
||||||
class="callout-title"
|
class="callout-title"
|
||||||
>
|
>
|
||||||
<div class="callout-icon">${callouts[calloutType]}</div>
|
<div class="callout-icon"></div>
|
||||||
<div class="callout-title-inner">${title}</div>
|
<div class="callout-title-inner">${title}</div>
|
||||||
${collapse ? toggleIcon : ""}
|
${collapse ? toggleIcon : ""}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -367,7 +430,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
node.data = {
|
node.data = {
|
||||||
hProperties: {
|
hProperties: {
|
||||||
...(node.data?.hProperties ?? {}),
|
...(node.data?.hProperties ?? {}),
|
||||||
className: `callout ${collapse ? "is-collapsible" : ""} ${
|
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
|
||||||
defaultState === "collapsed" ? "is-collapsed" : ""
|
defaultState === "collapsed" ? "is-collapsed" : ""
|
||||||
}`,
|
}`,
|
||||||
"data-callout": calloutType,
|
"data-callout": calloutType,
|
||||||
@@ -396,49 +459,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
|
return plugins
|
||||||
},
|
},
|
||||||
htmlPlugins() {
|
htmlPlugins() {
|
||||||
const plugins = [rehypeRaw]
|
const plugins: PluggableList = [rehypeRaw]
|
||||||
|
|
||||||
if (opts.parseBlockReferences) {
|
if (opts.parseBlockReferences) {
|
||||||
plugins.push(() => {
|
plugins.push(() => {
|
||||||
const inlineTagTypes = new Set(["p", "li"])
|
const inlineTagTypes = new Set(["p", "li"])
|
||||||
const blockTagTypes = new Set(["blockquote"])
|
const blockTagTypes = new Set(["blockquote"])
|
||||||
return (tree, file) => {
|
return (tree: HtmlRoot, file) => {
|
||||||
file.data.blocks = {}
|
file.data.blocks = {}
|
||||||
|
|
||||||
visit(tree, "element", (node, index, parent) => {
|
visit(tree, "element", (node, index, parent) => {
|
||||||
@@ -487,6 +517,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
|
return plugins
|
||||||
},
|
},
|
||||||
externalResources() {
|
externalResources() {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
|
|||||||
[
|
[
|
||||||
rehypePrettyCode,
|
rehypePrettyCode,
|
||||||
{
|
{
|
||||||
theme: "css-variables",
|
keepBackground: false,
|
||||||
|
theme: {
|
||||||
|
dark: "github-dark",
|
||||||
|
light: "github-light",
|
||||||
|
},
|
||||||
} satisfies Partial<CodeOptions>,
|
} satisfies Partial<CodeOptions>,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PluggableList } from "unified"
|
|||||||
import { StaticResources } from "../util/resources"
|
import { StaticResources } from "../util/resources"
|
||||||
import { ProcessedContent } from "./vfile"
|
import { ProcessedContent } from "./vfile"
|
||||||
import { QuartzComponent } from "../components/types"
|
import { QuartzComponent } from "../components/types"
|
||||||
import { FilePath, FullSlug } from "../util/path"
|
import { FilePath } from "../util/path"
|
||||||
import { BuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
|
|
||||||
export interface PluginTypes {
|
export interface PluginTypes {
|
||||||
@@ -36,19 +36,6 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
|||||||
) => QuartzEmitterPluginInstance
|
) => QuartzEmitterPluginInstance
|
||||||
export type QuartzEmitterPluginInstance = {
|
export type QuartzEmitterPluginInstance = {
|
||||||
name: string
|
name: string
|
||||||
emit(
|
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
|
||||||
ctx: BuildCtx,
|
|
||||||
content: ProcessedContent[],
|
|
||||||
resources: StaticResources,
|
|
||||||
emitCallback: EmitCallback,
|
|
||||||
): Promise<FilePath[]>
|
|
||||||
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmitOptions {
|
|
||||||
slug: FullSlug
|
|
||||||
ext: `.${string}` | ""
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EmitCallback = (data: EmitOptions) => Promise<FilePath>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Node, Parent } from "hast"
|
|||||||
import { Data, VFile } from "vfile"
|
import { Data, VFile } from "vfile"
|
||||||
|
|
||||||
export type QuartzPluginData = Data
|
export type QuartzPluginData = Data
|
||||||
export type ProcessedContent = [Node<QuartzPluginData>, VFile]
|
export type ProcessedContent = [Node, VFile]
|
||||||
|
|
||||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
||||||
const root: Parent = { type: "root", children: [] }
|
const root: Parent = { type: "root", children: [] }
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import path from "path"
|
|
||||||
import fs from "fs"
|
|
||||||
import { PerfTimer } from "../util/perf"
|
import { PerfTimer } from "../util/perf"
|
||||||
import { getStaticResourcesFromPlugins } from "../plugins"
|
import { getStaticResourcesFromPlugins } from "../plugins"
|
||||||
import { EmitCallback } from "../plugins/types"
|
|
||||||
import { ProcessedContent } from "../plugins/vfile"
|
import { ProcessedContent } from "../plugins/vfile"
|
||||||
import { FilePath, joinSegments } from "../util/path"
|
|
||||||
import { QuartzLogger } from "../util/log"
|
import { QuartzLogger } from "../util/log"
|
||||||
import { trace } from "../util/trace"
|
import { trace } from "../util/trace"
|
||||||
import { BuildCtx } from "../util/ctx"
|
import { BuildCtx } from "../util/ctx"
|
||||||
@@ -15,19 +11,12 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
|
|||||||
const log = new QuartzLogger(ctx.argv.verbose)
|
const log = new QuartzLogger(ctx.argv.verbose)
|
||||||
|
|
||||||
log.start(`Emitting output files`)
|
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
|
let emittedFiles = 0
|
||||||
const staticResources = getStaticResourcesFromPlugins(ctx)
|
const staticResources = getStaticResourcesFromPlugins(ctx)
|
||||||
for (const emitter of cfg.plugins.emitters) {
|
for (const emitter of cfg.plugins.emitters) {
|
||||||
try {
|
try {
|
||||||
const emitted = await emitter.emit(ctx, content, staticResources, emit)
|
const emitted = await emitter.emit(ctx, content, staticResources)
|
||||||
emittedFiles += emitted.length
|
emittedFiles += emitted.length
|
||||||
|
|
||||||
if (ctx.argv.verbose) {
|
if (ctx.argv.verbose) {
|
||||||
|
|||||||
@@ -14,27 +14,25 @@ import { QuartzLogger } from "../util/log"
|
|||||||
import { trace } from "../util/trace"
|
import { trace } from "../util/trace"
|
||||||
import { BuildCtx } from "../util/ctx"
|
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 {
|
export function createProcessor(ctx: BuildCtx): QuartzProcessor {
|
||||||
const transformers = ctx.cfg.plugins.transformers
|
const transformers = ctx.cfg.plugins.transformers
|
||||||
|
|
||||||
// base Markdown -> MD AST
|
return (
|
||||||
let processor = unified().use(remarkParse)
|
unified()
|
||||||
|
// base Markdown -> MD AST
|
||||||
// MD AST -> MD AST transforms
|
.use(remarkParse)
|
||||||
for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
|
// MD AST -> MD AST transforms
|
||||||
processor = processor.use(plugin.markdownPlugins!(ctx))
|
.use(
|
||||||
}
|
transformers
|
||||||
|
.filter((p) => p.markdownPlugins)
|
||||||
// MD AST -> HTML AST
|
.flatMap((plugin) => plugin.markdownPlugins!(ctx)),
|
||||||
processor = processor.use(remarkRehype, { allowDangerousHtml: true })
|
)
|
||||||
|
// MD AST -> HTML AST
|
||||||
// HTML AST -> HTML AST transforms
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
|
// HTML AST -> HTML AST transforms
|
||||||
processor = processor.use(plugin.htmlPlugins!(ctx))
|
.use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx)))
|
||||||
}
|
)
|
||||||
|
|
||||||
return processor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function* chunks<T>(arr: T[], n: number) {
|
function* chunks<T>(arr: T[], n: number) {
|
||||||
@@ -89,12 +87,13 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
|
|||||||
|
|
||||||
// Text -> Text transforms
|
// Text -> Text transforms
|
||||||
for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) {
|
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
|
// base data properties that plugins may use
|
||||||
file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
|
file.data.filePath = file.path as FilePath
|
||||||
file.data.filePath = fp
|
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 ast = processor.parse(file)
|
||||||
const newAst = await processor.run(ast, file)
|
const newAst = await processor.run(ast, file)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-text-size-adjust: none;
|
|
||||||
text-size-adjust: none;
|
text-size-adjust: none;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -27,7 +26,7 @@ section {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: color-mix(in srgb, var(--tertiary) 75%, transparent);
|
background: color-mix(in srgb, var(--tertiary) 60%, transparent);
|
||||||
color: var(--darkgray);
|
color: var(--darkgray);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ ul,
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 600;
|
font-weight: $boldWeight;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
@@ -64,11 +63,27 @@ a {
|
|||||||
color: var(--tertiary) !important;
|
color: var(--tertiary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.internal:not(:has(> img)) {
|
&.internal {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: var(--highlight);
|
background-color: var(--highlight);
|
||||||
padding: 0 0.1rem;
|
padding: 0 0.1rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
line-height: 1.4rem;
|
||||||
|
|
||||||
|
&:has(> img) {
|
||||||
|
background-color: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.external .external-icon {
|
||||||
|
height: 1ex;
|
||||||
|
margin: 0 0.15em;
|
||||||
|
|
||||||
|
> path {
|
||||||
|
fill: var(--dark);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +109,6 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& article {
|
& article {
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& > h1 {
|
& > h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
@@ -158,9 +171,11 @@ a {
|
|||||||
|
|
||||||
& .sidebar.right {
|
& .sidebar.right {
|
||||||
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
|
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
|
||||||
|
flex-wrap: wrap;
|
||||||
& > * {
|
& > * {
|
||||||
@media all and (max-width: $fullPageWidth) {
|
@media all and (max-width: $fullPageWidth) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +278,6 @@ h6 {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
transform: translateY(-0.1rem);
|
transform: translateY(-0.1rem);
|
||||||
display: inline-block;
|
|
||||||
font-family: var(--codeFont);
|
font-family: var(--codeFont);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -300,11 +314,13 @@ h6 {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
div[data-rehype-pretty-code-fragment] {
|
figure[data-rehype-pretty-code-figure] {
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
line-height: 1.6rem;
|
line-height: 1.6rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
& > div[data-rehype-pretty-code-title] {
|
& > [data-rehype-pretty-code-title] {
|
||||||
font-family: var(--codeFont);
|
font-family: var(--codeFont);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
@@ -316,16 +332,17 @@ div[data-rehype-pretty-code-fragment] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > pre {
|
& > pre {
|
||||||
padding: 0.5rem 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
font-family: var(--codeFont);
|
font-family: var(--codeFont);
|
||||||
padding: 0.5rem;
|
padding: 0 0.5rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--lightgray);
|
border: 1px solid var(--lightgray);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:has(> code.mermaid) {
|
&:has(> code.mermaid) {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -338,6 +355,8 @@ pre {
|
|||||||
counter-reset: line;
|
counter-reset: line;
|
||||||
counter-increment: line 0;
|
counter-increment: line 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
overflow-x: scroll;
|
||||||
|
|
||||||
& [data-highlighted-chars] {
|
& [data-highlighted-chars] {
|
||||||
background-color: var(--highlight);
|
background-color: var(--highlight);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@use "./variables.scss" as *;
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
.callout {
|
.callout {
|
||||||
@@ -13,16 +14,33 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="note"] {
|
--callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');
|
||||||
|
--callout-icon-abstract: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>');
|
||||||
|
--callout-icon-info: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>');
|
||||||
|
--callout-icon-todo: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>');
|
||||||
|
--callout-icon-tip: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg> ');
|
||||||
|
--callout-icon-success: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> ');
|
||||||
|
--callout-icon-question: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> ');
|
||||||
|
--callout-icon-warning: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>');
|
||||||
|
--callout-icon-failure: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> ');
|
||||||
|
--callout-icon-danger: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg> ');
|
||||||
|
--callout-icon-bug: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>');
|
||||||
|
--callout-icon-example: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg> ');
|
||||||
|
--callout-icon-quote: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>');
|
||||||
|
--callout-icon-fold: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E');
|
||||||
|
|
||||||
|
&[data-callout] {
|
||||||
--color: #448aff;
|
--color: #448aff;
|
||||||
--border: #448aff44;
|
--border: #448aff44;
|
||||||
--bg: #448aff10;
|
--bg: #448aff10;
|
||||||
|
--callout-icon: var(--callout-icon-note);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="abstract"] {
|
&[data-callout="abstract"] {
|
||||||
--color: #00b0ff;
|
--color: #00b0ff;
|
||||||
--border: #00b0ff44;
|
--border: #00b0ff44;
|
||||||
--bg: #00b0ff10;
|
--bg: #00b0ff10;
|
||||||
|
--callout-icon: var(--callout-icon-abstract);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="info"],
|
&[data-callout="info"],
|
||||||
@@ -30,30 +48,39 @@
|
|||||||
--color: #00b8d4;
|
--color: #00b8d4;
|
||||||
--border: #00b8d444;
|
--border: #00b8d444;
|
||||||
--bg: #00b8d410;
|
--bg: #00b8d410;
|
||||||
|
--callout-icon: var(--callout-icon-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-callout="todo"] {
|
||||||
|
--callout-icon: var(--callout-icon-todo);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="tip"] {
|
&[data-callout="tip"] {
|
||||||
--color: #00bfa5;
|
--color: #00bfa5;
|
||||||
--border: #00bfa544;
|
--border: #00bfa544;
|
||||||
--bg: #00bfa510;
|
--bg: #00bfa510;
|
||||||
|
--callout-icon: var(--callout-icon-tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="success"] {
|
&[data-callout="success"] {
|
||||||
--color: #09ad7a;
|
--color: #09ad7a;
|
||||||
--border: #09ad7144;
|
--border: #09ad7144;
|
||||||
--bg: #09ad7110;
|
--bg: #09ad7110;
|
||||||
|
--callout-icon: var(--callout-icon-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="question"] {
|
&[data-callout="question"] {
|
||||||
--color: #dba642;
|
--color: #dba642;
|
||||||
--border: #dba64244;
|
--border: #dba64244;
|
||||||
--bg: #dba64210;
|
--bg: #dba64210;
|
||||||
|
--callout-icon: var(--callout-icon-question);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="warning"] {
|
&[data-callout="warning"] {
|
||||||
--color: #db8942;
|
--color: #db8942;
|
||||||
--border: #db894244;
|
--border: #db894244;
|
||||||
--bg: #db894210;
|
--bg: #db894210;
|
||||||
|
--callout-icon: var(--callout-icon-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="failure"],
|
&[data-callout="failure"],
|
||||||
@@ -62,50 +89,74 @@
|
|||||||
--color: #db4242;
|
--color: #db4242;
|
||||||
--border: #db424244;
|
--border: #db424244;
|
||||||
--bg: #db424210;
|
--bg: #db424210;
|
||||||
|
--callout-icon: var(--callout-icon-failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-callout="bug"] {
|
||||||
|
--callout-icon: var(--callout-icon-bug);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-callout="danger"] {
|
||||||
|
--callout-icon: var(--callout-icon-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="example"] {
|
&[data-callout="example"] {
|
||||||
--color: #7a43b5;
|
--color: #7a43b5;
|
||||||
--border: #7a43b544;
|
--border: #7a43b544;
|
||||||
--bg: #7a43b510;
|
--bg: #7a43b510;
|
||||||
|
--callout-icon: var(--callout-icon-example);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-callout="quote"] {
|
&[data-callout="quote"] {
|
||||||
--color: var(--secondary);
|
--color: var(--secondary);
|
||||||
--border: var(--lightgray);
|
--border: var(--lightgray);
|
||||||
|
--callout-icon: var(--callout-icon-quote);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-collapsed > .callout-title > .fold {
|
&.is-collapsed > .callout-title > .fold-callout-icon {
|
||||||
transform: rotateZ(-90deg);
|
transform: rotateZ(-90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout-title {
|
.callout-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
|
|
||||||
& .fold {
|
--icon-size: 18px;
|
||||||
margin-left: 0.5rem;
|
|
||||||
transition: transform 0.3s ease;
|
& .fold-callout-icon {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
--callout-icon: var(--callout-icon-fold);
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .callout-title-inner > p {
|
& > .callout-title-inner > p {
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.callout-icon {
|
.callout-icon,
|
||||||
width: 18px;
|
& .fold-callout-icon {
|
||||||
height: 18px;
|
width: var(--icon-size);
|
||||||
flex: 0 0 18px;
|
height: var(--icon-size);
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callout-title-inner {
|
// icon support
|
||||||
font-weight: 700;
|
background-size: var(--icon-size) var(--icon-size);
|
||||||
|
background-position: center;
|
||||||
|
background-color: var(--color);
|
||||||
|
mask-image: var(--callout-icon);
|
||||||
|
mask-size: var(--icon-size) var(--icon-size);
|
||||||
|
mask-position: center;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-title-inner {
|
||||||
|
font-weight: $boldWeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-light.json
|
code[data-theme*=" "] {
|
||||||
:root {
|
color: var(--shiki-light);
|
||||||
--shiki-color-text: #24292e;
|
background-color: var(--shiki-light-bg);
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// npx convert-sh-theme https://raw.githubusercontent.com/shikijs/shiki/main/packages/shiki/themes/github-dark.json
|
code[data-theme*=" "] span {
|
||||||
[saved-theme="dark"] {
|
color: var(--shiki-light);
|
||||||
--shiki-color-text: #e1e4e8 !important;
|
}
|
||||||
--shiki-color-background: #24292e !important;
|
|
||||||
--shiki-token-constant: #79b8ff !important;
|
[saved-theme="dark"] code[data-theme*=" "] {
|
||||||
--shiki-token-string: #9ecbff !important;
|
color: var(--shiki-dark);
|
||||||
--shiki-token-comment: #6a737d !important;
|
background-color: var(--shiki-dark-bg);
|
||||||
--shiki-token-keyword: #f97583 !important;
|
}
|
||||||
--shiki-token-parameter: #e1e4e8 !important;
|
|
||||||
--shiki-token-function: #e1e4e8 !important;
|
[saved-theme="dark"] code[data-theme*=" "] span {
|
||||||
--shiki-token-string-expression: #85e89d !important;
|
color: var(--shiki-dark);
|
||||||
--shiki-token-punctuation: #e1e4e8 !important;
|
|
||||||
--shiki-token-link: #e1e4e8 !important;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
$pageWidth: 750px;
|
$pageWidth: 750px;
|
||||||
$mobileBreakpoint: 600px;
|
$mobileBreakpoint: 600px;
|
||||||
$tabletBreakpoint: 1200px;
|
$tabletBreakpoint: 1000px;
|
||||||
$sidePanelWidth: 380px;
|
$sidePanelWidth: 380px;
|
||||||
$topSpacing: 6rem;
|
$topSpacing: 6rem;
|
||||||
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
|
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
|
||||||
|
$boldWeight: 700;
|
||||||
|
$normalWeight: 400;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
|
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
|
||||||
import { Node, Root } from "hast"
|
import { Node, Root } from "hast"
|
||||||
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
|
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
|
||||||
import { trace } from "./trace"
|
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 {
|
try {
|
||||||
return toJsxRuntime(tree as Root, {
|
return toJsxRuntime(tree as Root, {
|
||||||
Fragment,
|
Fragment,
|
||||||
|
|||||||
@@ -9,3 +9,13 @@ export function pluralize(count: number, s: string): string {
|
|||||||
export function capitalize(s: string): string {
|
export function capitalize(s: string): string {
|
||||||
return s.substring(0, 1).toUpperCase() + s.substring(1)
|
return s.substring(0, 1).toUpperCase() + s.substring(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classNames(
|
||||||
|
displayClass?: "mobile-only" | "desktop-only",
|
||||||
|
...classes: string[]
|
||||||
|
): string {
|
||||||
|
if (displayClass) {
|
||||||
|
classes.push(displayClass)
|
||||||
|
}
|
||||||
|
return classes.join(" ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ describe("transforms", () => {
|
|||||||
["index.md", "index"],
|
["index.md", "index"],
|
||||||
["test.mp4", "test.mp4"],
|
["test.mp4", "test.mp4"],
|
||||||
["note with spaces.md", "note-with-spaces"],
|
["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.slugifyFilePath,
|
||||||
path.isFilePath,
|
path.isFilePath,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { slug } from "github-slugger"
|
import { slug as slugAnchor } from "github-slugger"
|
||||||
import type { Element as HastElement } from "hast"
|
import 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)
|
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||||
|
|
||||||
export const QUARTZ = "quartz"
|
export const QUARTZ = "quartz"
|
||||||
@@ -43,6 +47,16 @@ export function getFullSlug(window: Window): FullSlug {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sluggify(s: string): string {
|
||||||
|
return s
|
||||||
|
.split("/")
|
||||||
|
.map((segment) =>
|
||||||
|
segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").replace(/#/g, ""),
|
||||||
|
)
|
||||||
|
.join("/") // always use / as sep
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
||||||
fp = _stripSlashes(fp) as FilePath
|
fp = _stripSlashes(fp) as FilePath
|
||||||
let ext = _getFileExtension(fp)
|
let ext = _getFileExtension(fp)
|
||||||
@@ -51,11 +65,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
|||||||
ext = ""
|
ext = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug = withoutFileExt
|
let slug = sluggify(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
|
|
||||||
|
|
||||||
// treat _index as index
|
// treat _index as index
|
||||||
if (_endsWith(slug, "_index")) {
|
if (_endsWith(slug, "_index")) {
|
||||||
@@ -117,7 +127,8 @@ const _rebaseHastElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) {
|
export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
|
||||||
|
const el = clone(rawEl) // clone so we dont modify the original page
|
||||||
_rebaseHastElement(el, "src", curBase, newBase)
|
_rebaseHastElement(el, "src", curBase, newBase)
|
||||||
_rebaseHastElement(el, "href", curBase, newBase)
|
_rebaseHastElement(el, "href", curBase, newBase)
|
||||||
if (el.children) {
|
if (el.children) {
|
||||||
@@ -156,14 +167,10 @@ export function splitAnchor(link: string): [string, string] {
|
|||||||
return [fp, anchor]
|
return [fp, anchor]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugAnchor(anchor: string) {
|
|
||||||
return slug(anchor)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function slugTag(tag: string) {
|
export function slugTag(tag: string) {
|
||||||
return tag
|
return tag
|
||||||
.split("/")
|
.split("/")
|
||||||
.map((tagSegment) => slug(tagSegment))
|
.map((tagSegment) => sluggify(tagSegment))
|
||||||
.join("/")
|
.join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
|
|||||||
} else {
|
} else {
|
||||||
const content = resource.script
|
const content = resource.script
|
||||||
return (
|
return (
|
||||||
<script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
|
<script
|
||||||
{content}
|
key={randomUUID()}
|
||||||
</script>
|
type={scriptType}
|
||||||
|
spa-preserve={spaPreserve}
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
></script>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user