Compare commits

..

1 Commits

Author SHA1 Message Date
Jacky Zhao
64c7851939 pkg: bump version to 4.1.3 2023-12-18 09:49:55 -08:00
89 changed files with 1776 additions and 2072 deletions

View File

@@ -20,19 +20,12 @@ 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 and Source** **Screenshots**
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):**
- Quartz Version: [e.g. v4.1.2] - Device: [e.g. iPhone6]
- `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]

View File

@@ -1,11 +0,0 @@
# 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"

View File

@@ -5,6 +5,8 @@
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)

View File

@@ -156,13 +156,12 @@ 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 track any event handlers via `window.addCleanup` to prevent memory leaks. It is best practice to also unmount any existing event handlers to prevent memory leaks.
This will get called on page navigation.
#### Importing Code #### Importing Code

View File

@@ -216,19 +216,22 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
export type QuartzEmitterPluginInstance = { export type QuartzEmitterPluginInstance = {
name: string name: string
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> emit(
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 `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature: 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:
```ts ```ts
export type WriteOptions = (data: { export type EmitCallback = (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

View File

@@ -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 initialized. 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 initailized. 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,13 +28,21 @@ 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. When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`.
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

View File

@@ -25,9 +25,7 @@ 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`

View File

@@ -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, // whether to display the current page in the breadcrumbs showCurrentPage: true, // wether to display the current page in the breadcrumbs
}) })
``` ```

View File

@@ -24,32 +24,14 @@ 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/styles/callouts.scss` - Editing icons: `quartz/plugins/transformers/ofm.ts`
### 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!
> > > >

View File

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

View File

@@ -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, // whether to use local storage to save "state" (which folders are opened) of explorer useSavedState: true, // wether 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,34 +179,6 @@ 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:
@@ -244,63 +216,30 @@ 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.
### Use `sort` with pre-defined sort order > [!tip]
> When writing more complicated functions, the `layout` file can start to look very cramped.
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. > You can fix this by defining your functions in another file.
>
It's also worth mentioning, that the smaller the number set in `nameOrderMap`, the higher up the entry will be in the explorer. Incrementing every folder/file by 100, makes ordering files in their folders a lot easier. Lastly, this example still allows you to use a `mapFn` or frontmatter titles to change display names, as it uses slugs for `nameOrderMap` (which is unaffected by display name changes). > ```ts title="functions.ts"
> import { Options } from "./quartz/components/ExplorerNode"
```ts title="quartz.layout.ts" > export const mapFn: Options["mapFn"] = (node) => {
Component.Explorer({ > // implement your function here
sortFn: (a, b) => { > }
const nameOrderMap: Record<string, number> = { > export const filterFn: Options["filterFn"] = (node) => {
"poetry-folder": 100, > // implement your function here
"essay-folder": 200, > }
"research-paper-file": 201, > export const sortFn: Options["sortFn"] = (a, b) => {
"dinosaur-fossils-file": 300, > // implement your function here
"other-folder": 400, > }
} > ```
>
let orderA = 0 > You can then import them like this:
let orderB = 0 >
> ```ts title="quartz.layout.ts"
if (a.file && a.file.slug) { > import { mapFn, filterFn, sortFn } from "./functions.ts"
orderA = nameOrderMap[a.file.slug] || 0 > Component.Explorer({
} else if (a.name) { > mapFn: mapFn,
orderA = nameOrderMap[a.name] || 0 > filterFn: filterFn,
} > 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
```

View File

@@ -3,7 +3,7 @@ title: Recent Notes
tags: component tags: component
--- ---
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`. 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`.
## Customization ## Customization

View File

@@ -225,6 +225,6 @@ pages:
- public - public
``` ```
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. When `.gitlab-ci.yaml` is commited, GitLab will build and deploy the website as a GitLab Page. You can find the url under `Deploy > Pages` in the sidebar.
By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`. By default, the page is private and only visible when logged in to a GitLab account with access to the repository but can be opened in the settings under `Deploy` -> `Pages`.

View File

@@ -23,11 +23,10 @@ This will guide you through initializing your Quartz with content. Once you've d
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. Sync your changes with [[setting up your GitHub repository|GitHub]] 5. [[hosting|Host]] Quartz online
6. [[hosting|Host]] Quartz online
If you prefer instructions in a video format you can try following Nicole van der Hoeven's > [!info]
[video guide on how to set up Quartz!](https://www.youtube.com/watch?v=6s6DT1yN4dw&t=227s) > Coming from Quartz 3? See the [[migrating from Quartz 3|migration guide]] for the differences between Quartz 3 and Quartz 4 and how to migrate.
## 🔧 Features ## 🔧 Features

View File

@@ -15,34 +15,25 @@ At the top of your repository on GitHub.com's Quick Setup page, click the clipb
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. 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 ```bash
# list all the repositories that are tracked # add your repository
git remote -v git remote add origin REMOTE-URL
# if the origin doesn't match your own repository, set your repository as the origin # track the main quartz repository for updates
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 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. To verify that you set the remote URL correctly, run the following command.
```bash
git remote -v
```
Then, you can sync the content to upload it to your repository.
```bash ```bash
npx quartz sync --no-pull npx quartz sync --no-pull
``` ```
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase` > [!hint]
> You may have an outdated version of `git`. Updating `git` should fix this issue. > If `npx quartz sync` fails with `fatal: --[no-]autostash option is only valid with --rebase`, you
> may have an outdated version of `git`. Updating `git` should fix this issue.
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

View File

@@ -7,10 +7,13 @@ 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/)
- [Socratica Toolbox](https://toolbox.socratica.info/) - [Socratica Toolbox](https://toolbox.socratica.info/)
- [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/)
- [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/)
@@ -18,12 +21,5 @@ 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
View File

@@ -4,10 +4,9 @@ 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] | UIEvent): void dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): 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
View File

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

1204
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.2.1", "version": "4.1.3",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@@ -35,15 +35,15 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.1", "@floating-ui/dom": "^1.5.3",
"@napi-rs/simple-git": "0.1.14", "@napi-rs/simple-git": "0.1.9",
"async-mutex": "^0.4.1", "async-mutex": "^0.4.0",
"chalk": "^5.3.0", "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.16.0", "esbuild-sass-plugin": "^2.16.0",
"flexsearch": "0.7.43", "flexsearch": "0.7.21",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^14.0.0", "globby": "^14.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -52,11 +52,12 @@
"hast-util-to-string": "^3.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.23.0", "lightningcss": "^1.22.1",
"mdast-util-find-and-replace": "^3.0.1", "mdast-util-find-and-replace": "^3.0.1",
"mdast-util-to-hast": "^13.1.0", "mdast-util-to-hast": "^13.0.2",
"mdast-util-to-string": "^4.0.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.19.3",
"preact-render-to-string": "^6.3.1", "preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
@@ -64,8 +65,8 @@
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.0",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^5.0.0",
"rehype-pretty-code": "^0.12.6", "rehype-pretty-code": "^0.12.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
@@ -74,35 +75,36 @@
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0", "remark-rehype": "^11.0.0",
"remark-smartypants": "^2.0.0", "remark-smartypants": "^2.0.0",
"rfdc": "^1.3.1",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"shikiji": "^0.10.2", "shikiji": "^0.8.7",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^8.0.0", "to-vfile": "^8.0.0",
"toml": "^3.0.0", "toml": "^3.0.0",
"unified": "^11.0.4", "unified": "^11.0.4",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.1", "vfile": "^6.0.1",
"workerpool": "^9.1.0", "workerpool": "^8.0.0",
"ws": "^8.15.1", "ws": "^8.15.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cli-spinner": "^0.2.3", "@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/flexsearch": "^0.7.3",
"@types/hast": "^3.0.3",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.14", "@types/node": "^20.1.2",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/workerpool": "^6.4.7",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.32",
"esbuild": "^0.19.9", "esbuild": "^0.19.9",
"prettier": "^3.2.4", "prettier": "^3.1.1",
"tsx": "^4.7.0", "tsx": "^4.6.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }

View File

@@ -47,15 +47,13 @@ const config: QuartzConfig = {
Plugin.FrontMatter(), Plugin.FrontMatter(),
Plugin.TableOfContents(), Plugin.TableOfContents(),
Plugin.CreatedModifiedDate({ Plugin.CreatedModifiedDate({
// you can add 'git' here for last modified from Git priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
// 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()],

View File

@@ -37,13 +37,12 @@ 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.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], beforeBody: [Component.ArticleTitle()],
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: [],
} }

View File

@@ -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 { GlobbyFilterFunction, isGitIgnored } from "globby" import { 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, FullSlug, joinSegments, slugifyFilePath } from "./util/path" import { FilePath, 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,19 +18,6 @@ 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,
@@ -86,51 +73,19 @@ 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 buildData: BuildData = { const initialSlugs = ctx.allSlugs
ctx, let lastBuildMs = 0
mut, const toRebuild: Set<FilePath> = new Set()
contentMap, const toRemove: Set<FilePath> = new Set()
ignored: await isGitIgnored(), const trackedAssets: Set<FilePath> = new Set()
initialSlugs: ctx.allSlugs, async function rebuild(fp: string, action: "add" | "change" | "delete") {
toRebuild: new Set<FilePath>(),
toRemove: new Set<FilePath>(),
trackedAssets: new Set<FilePath>(),
lastBuildMs: 0,
}
const watcher = chokidar.watch(".", {
persistent: true,
cwd: argv.directory,
ignoreInitial: true,
})
watcher
.on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
.on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
return async () => {
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 // don't do anything for gitignored files
if (ignored(fp)) { if (ignored(fp)) {
return return
@@ -155,12 +110,12 @@ async function rebuildFromEntrypoint(
toRemove.add(filePath) toRemove.add(filePath)
} }
const buildStart = new Date().getTime() // debounce rebuilds every 250ms
buildData.lastBuildMs = buildStart
const release = await mut.acquire()
// there's another build after us, release and let them do it const buildStart = new Date().getTime()
if (buildData.lastBuildMs > buildStart) { lastBuildMs = buildStart
const release = await mut.acquire()
if (lastBuildMs > buildStart) {
release() release()
return return
} }
@@ -193,11 +148,8 @@ async function rebuildFromEntrypoint(
await rimraf(argv.output) await rimraf(argv.output)
await emitContent(ctx, filteredContent) await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`)) console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch (err) { } catch {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`)) console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
if (argv.verbose) {
console.log(chalk.red(err))
}
} }
release() release()
@@ -206,6 +158,22 @@ async function rebuildFromEntrypoint(
toRemove.clear() toRemove.clear()
} }
const watcher = chokidar.watch(".", {
persistent: true,
cwd: argv.directory,
ignoreInitial: true,
})
watcher
.on("add", (fp) => rebuild(fp, "add"))
.on("change", (fp) => rebuild(fp, "change"))
.on("unlink", (fp) => rebuild(fp, "delete"))
return async () => {
await watcher.close()
}
}
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)

View File

@@ -7,7 +7,6 @@ export type Analytics =
| null | null
| { | {
provider: "plausible" provider: "plausible"
host?: string
} }
| { | {
provider: "google" provider: "google"
@@ -16,7 +15,6 @@ export type Analytics =
| { | {
provider: "umami" provider: "umami"
websiteId: string websiteId: string
host?: string
} }
export interface GlobalConfiguration { export interface GlobalConfiguration {
@@ -36,12 +34,6 @@ 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 {

View File

@@ -113,10 +113,7 @@ export async function handleCreate(argv) {
} }
} }
const gitkeepPath = path.join(contentFolder, ".gitkeep") await fs.promises.unlink(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
@@ -168,20 +165,22 @@ 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. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`, message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
options: [ options: [
{
value: "shortest",
label: "Treat links as shortest path",
hint: "(default)",
},
{ {
value: "absolute", value: "absolute",
label: "Treat links as absolute path", label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
}, },
{ {
value: "relative", value: "relative",
label: "Treat links as relative paths", label: "Treat links as relative paths",
hint: "for just normal Markdown files",
}, },
], ],
}), }),
@@ -200,7 +199,6 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
// setup remote // setup remote
execSync( execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, `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:
@@ -257,7 +255,6 @@ export async function handleBuild(argv) {
}, },
write: false, write: false,
bundle: true, bundle: true,
minify: true,
platform: "browser", platform: "browser",
format: "esm", format: "esm",
}) })
@@ -347,7 +344,7 @@ export async function handleBuild(argv) {
directoryListing: false, directoryListing: false,
headers: [ headers: [
{ {
source: "**/*.*", source: "**/*.html",
headers: [{ key: "Content-Disposition", value: "inline" }], headers: [{ key: "Content-Disposition", value: "inline" }],
}, },
], ],
@@ -450,7 +447,7 @@ export async function handleUpdate(argv) {
try { try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
} catch { } catch {
console.log(chalk.red("An error occurred above while pulling updates.")) console.log(chalk.red("An error occured above while pulling updates."))
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
return return
} }
@@ -522,7 +519,7 @@ export async function handleSync(argv) {
try { try {
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
} catch { } catch {
console.log(chalk.red("An error occurred above while pulling updates.")) console.log(chalk.red("An error occured above while pulling updates."))
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
return return
} }

View File

@@ -1,10 +1,9 @@
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={classNames(displayClass, "article-title")}>{title}</h1> return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1>
} else { } else {
return null return null
} }

View File

@@ -1,13 +1,12 @@
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={classNames(displayClass, "backlinks")}> <div class={`backlinks ${displayClass ?? ""}`}>
<h3>Backlinks</h3> <h3>Backlinks</h3>
<ul class="overflow"> <ul class="overflow">
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (

View File

@@ -2,7 +2,6 @@ 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
@@ -19,15 +18,15 @@ interface BreadcrumbOptions {
*/ */
rootName: string rootName: string
/** /**
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults) * wether to look up frontmatter title for folders (could cause performance problems with big vaults)
*/ */
resolveFrontmatterTitle: boolean resolveFrontmatterTitle: boolean
/** /**
* Whether to display breadcrumbs on root `index.md` * Wether to display breadcrumbs on root `index.md`
*/ */
hideOnRoot: boolean hideOnRoot: boolean
/** /**
* Whether to display the current page in the breadcrumbs. * Wether to display the current page in the breadcrumbs.
*/ */
showCurrentPage: boolean showCurrentPage: boolean
} }
@@ -69,10 +68,9 @@ 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.slug?.split("/") const folderParts = file.filePath?.split("/")
// 2nd last to exclude the /index if (folderParts) {
const folderName = folderParts?.at(-2) const folderName = folderParts[folderParts?.length - 2]
if (folderName) {
folderIndex.set(folderName, file) folderIndex.set(folderName, file)
} }
} }
@@ -90,10 +88,7 @@ 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) {
const title = currentFile.frontmatter!.title curPathSegment = currentFile.frontmatter!.title
if (title !== "index") {
curPathSegment = title
}
} }
// Add current slug to full path // Add current slug to full path
@@ -105,16 +100,15 @@ 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 && slugParts.at(-1) !== "index") { if (options.showCurrentPage) {
crumbs.push({ crumbs.push({
displayName: fileData.frontmatter!.title, displayName: fileData.frontmatter!.title,
path: "", path: "",
}) })
} }
} }
return ( return (
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs"> <nav class={`breadcrumb-container ${displayClass ?? ""}`} 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>

View File

@@ -1,40 +1,20 @@
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)!, cfg.locale)) segments.push(formatDate(getDate(cfg, fileData)!))
} }
// Display reading time if enabled
if (options.showReadingTime) {
const { text: timeTaken, words: _words } = readingTime(text)
segments.push(timeTaken) segments.push(timeTaken)
} return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
} else { } else {
return null return null
} }

View File

@@ -4,11 +4,10 @@
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={classNames(displayClass, "darkmode")}> <div class={`darkmode ${displayClass ?? ""}`}>
<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
@@ -19,7 +18,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>
@@ -35,7 +34,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>

View File

@@ -3,7 +3,6 @@ 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"]
@@ -17,14 +16,14 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date
return data.dates?.[cfg.defaultDateType] return data.dates?.[cfg.defaultDateType]
} }
export function formatDate(d: Date, locale = "en-US"): string { export function formatDate(d: Date): string {
return d.toLocaleDateString(locale, { return d.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "2-digit", day: "2-digit",
}) })
} }
export function Date({ date, locale }: Props) { export function Date({ date }: Props) {
return <>{formatDate(date, locale)}</> return <>{formatDate(date)}</>
} }

View File

@@ -5,7 +5,6 @@ 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 = {
@@ -13,9 +12,6 @@ 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)) {
@@ -26,7 +22,6 @@ const defaultOptions = {
sensitivity: "base", sensitivity: "base",
}) })
} }
if (a.file && !b.file) { if (a.file && !b.file) {
return 1 return 1
} else { } else {
@@ -46,39 +41,52 @@ export default ((userOpts?: Partial<Options>) => {
let jsonTree: string let jsonTree: string
function constructFileTree(allFiles: QuartzPluginData[]) { function constructFileTree(allFiles: QuartzPluginData[]) {
if (fileTree) { if (!fileTree) {
return
}
// Construct tree from allFiles // Construct tree from allFiles
fileTree = new FileNode("") fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file)) allFiles.forEach((file) => fileTree.add(file, 1))
/**
* Keys of this object must match corresponding function name of `FileNode`,
* while values must be the argument that will be passed to the function.
*
* e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
*/
const functions = {
map: opts.mapFn,
sort: opts.sortFn,
filter: opts.filterFn,
}
// 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 (functionName === "map") { if (functions[functionName]) {
fileTree.map(opts.mapFn) // for every entry in order, call matching function in FileNode and pass matching argument
} else if (functionName === "sort") { // e.g. i = 0; functionName = "filter"
fileTree.sort(opts.sortFn) // converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
} else if (functionName === "filter") {
fileTree.filter(opts.filterFn) // @ts-ignore
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
fileTree[functionName].call(fileTree, functions[functionName])
} }
} }
} }
// Get all folders of tree. Initialize with collapsed state // 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") const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
// Stringify to pass json tree as data attribute ([data-tree])
jsonTree = JSON.stringify(folders) jsonTree = JSON.stringify(folders)
} }
}
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
constructFileTree(allFiles) constructFileTree(allFiles)
return ( return (
<div class={classNames(displayClass, "explorer")}> <div class={`explorer ${displayClass ?? ""}`}>
<button <button
type="button" type="button"
id="explorer" id="explorer"
@@ -112,7 +120,6 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
) )
} }
Explorer.css = explorerStyle Explorer.css = explorerStyle
Explorer.afterDOMLoaded = script Explorer.afterDOMLoaded = script
return Explorer return Explorer

View File

@@ -1,13 +1,6 @@
// @ts-ignore // @ts-ignore
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { import { resolveRelative } from "../util/path"
joinSegments,
resolveRelative,
clone,
simplifySlug,
SimpleSlug,
FilePath,
} from "../util/path"
type OrderEntries = "sort" | "filter" | "map" type OrderEntries = "sort" | "filter" | "map"
@@ -17,9 +10,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 = {
@@ -32,74 +25,59 @@ 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: Array<FileNode> children: FileNode[]
name: string // this is the slug segment name: string
displayName: string displayName: string
file: QuartzPluginData | null file: QuartzPluginData | null
depth: number depth: number
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { constructor(name: string, file?: QuartzPluginData, depth?: number) {
this.children = [] this.children = []
this.name = slugSegment this.name = name
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment this.displayName = name
this.file = file ? clone(file) : null this.file = file ? structuredClone(file) : null
this.depth = depth ?? 0 this.depth = depth ?? 0
} }
private insert(fileData: DataWrapper) { private insert(file: DataWrapper) {
if (fileData.path.length === 0) { if (file.path.length === 1) {
return if (file.path[0] !== "index.md") {
} this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
} else {
const nextSegment = fileData.path[0] const title = file.file.frontmatter?.title
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 return
} }
// find the right child to insert into
fileData.path = fileData.path.splice(1)
const child = this.children.find((c) => c.name === nextSegment)
if (child) {
child.insert(fileData)
return
} }
const newChild = new FileNode( const newChild = new FileNode(next, undefined, this.depth + 1)
nextSegment, newChild.insert(file)
getPathSegment(fileData.file.relativePath, this.depth),
undefined,
this.depth + 1,
)
newChild.insert(fileData)
this.children.push(newChild) this.children.push(newChild)
} }
}
// Add new file to tree // Add new file to tree
add(file: QuartzPluginData) { add(file: QuartzPluginData, splice: number = 0) {
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) this.insert({ file, path: file.filePath!.split("/").splice(splice) })
}
// Print tree structure (for debugging)
print(depth: number = 0) {
let folderChar = ""
if (!this.file) folderChar = "|"
console.log("-".repeat(depth), folderChar, this.name, this.depth)
this.children.forEach((e) => e.print(depth + 1))
} }
/** /**
@@ -117,6 +95,7 @@ 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))
} }
@@ -131,16 +110,16 @@ export class FileNode {
const traverse = (node: FileNode, currentPath: string) => { const traverse = (node: FileNode, currentPath: string) => {
if (!node.file) { if (!node.file) {
const folderPath = joinSegments(currentPath, node.name) const folderPath = currentPath + (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
} }
@@ -168,13 +147,14 @@ 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 = joinSegments(fullPath ?? "", node.name) folderPath = `${pathOld}/${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}>
@@ -183,7 +163,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
</a> </a>
</li> </li>
) : ( ) : (
<li> <div>
{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
@@ -205,16 +185,12 @@ 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 <a href={`${folderPath}`} data-for={node.name} class="folder-title">
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">
<span class="folder-title">{node.displayName}</span> <p class="folder-title">{node.displayName}</p>
</button> </button>
)} )}
</div> </div>
@@ -241,8 +217,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
))} ))}
</ul> </ul>
</div> </div>
</li> </div>
)} )}
</> </li>
) )
} }

View File

@@ -2,7 +2,6 @@ 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
@@ -57,7 +56,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={classNames(displayClass, "graph")}> <div class={`graph ${displayClass ?? ""}`}>
<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>

View File

@@ -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)!} locale={cfg.locale} /> <Date date={getDate(cfg, page)!} />
</p> </p>
)} )}
<div class="desc"> <div class="desc">

View File

@@ -1,12 +1,11 @@
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={classNames(displayClass, "page-title")}> <h1 class={`page-title ${displayClass ?? ""}`}>
<a href={baseDir}>{title}</a> <a href={baseDir}>{title}</a>
</h1> </h1>
) )

View File

@@ -5,7 +5,6 @@ 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
@@ -29,7 +28,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={classNames(displayClass, "recent-notes")}> <div class={`recent-notes ${displayClass ?? ""}`}>
<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) => {
@@ -48,7 +47,7 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
{page.dates && ( {page.dates && (
<p class="meta"> <p class="meta">
<Date date={getDate(cfg, page)!} locale={cfg.locale} /> <Date date={getDate(cfg, page)!} />
</p> </p>
)} )}
<ul class="tags"> <ul class="tags">

View File

@@ -2,22 +2,11 @@ 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 interface SearchOptions { export default (() => {
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={classNames(displayClass, "search")}> <div class={`search ${displayClass ?? ""}`}>
<div id="search-icon"> <div id="search-icon">
<p>Search</p> <p>Search</p>
<div></div> <div></div>
@@ -46,7 +35,7 @@ export default ((userOpts?: Partial<SearchOptions>) => {
aria-label="Search for something" aria-label="Search for something"
placeholder="Search for something" placeholder="Search for something"
/> />
<div id="search-layout" data-preview={opts.enablePreview}></div> <div id="results-container"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,7 @@
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={classNames(displayClass, "spacer")}></div> return <div class={`spacer ${displayClass ?? ""}`}></div>
} }
export default (() => Spacer) satisfies QuartzComponentConstructor export default (() => Spacer) satisfies QuartzComponentConstructor

View File

@@ -1,7 +1,6 @@
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"
@@ -20,7 +19,7 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
} }
return ( return (
<div class={classNames(displayClass, "toc")}> <div class={`toc ${displayClass ?? ""}`}>
<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

View File

@@ -1,13 +1,12 @@
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={classNames(displayClass, "tags")}> <ul class={`tags ${displayClass ?? ""}`}>
{tags.map((tag) => { {tags.map((tag) => {
const display = `#${tag}` const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugTag(tag)}` const linkDest = baseDir + `/tags/${slugTag(tag)}`

View File

@@ -3,9 +3,7 @@ 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)
const classes: string[] = fileData.frontmatter?.cssclasses ?? [] return <article class="popover-hint">{content}</article>
const classString = ["popover-hint", ...classes].join(" ")
return <article class={classString}>{content}</article>
} }
export default (() => Content) satisfies QuartzComponentConstructor export default (() => Content) satisfies QuartzComponentConstructor

View File

@@ -8,20 +8,6 @@ import { Root } from "hast"
import { pluralize } from "../../util/lang" import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
interface FolderContentOptions {
/**
* Whether to display number of folders
*/
showFolderCount: boolean
}
const defaultOptions: FolderContentOptions = {
showFolderCount: true,
}
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
function FolderContent(props: QuartzComponentProps) { function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props const { tree, fileData, allFiles } = props
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!)) const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
@@ -33,8 +19,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const isDirectChild = fileParts.length === folderParts.length + 1 const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild return prefixed && isDirectChild
}) })
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
const listProps = { const listProps = {
...props, ...props,
allFiles: allPagesInFolder, allFiles: allPagesInFolder,
@@ -46,22 +31,17 @@ export default ((opts?: Partial<FolderContentOptions>) => {
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
return ( return (
<div class={classes}> <div class="popover-hint">
<article> <article>
<p>{content}</p> <p>{content}</p>
</article> </article>
<div class="page-listing">
{options.showFolderCount && (
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p> <p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
)}
<div> <div>
<PageList {...listProps} /> <PageList {...listProps} />
</div> </div>
</div> </div>
</div>
) )
} }
FolderContent.css = style + PageList.css FolderContent.css = style + PageList.css
return FolderContent export default (() => FolderContent) satisfies QuartzComponentConstructor
}) satisfies QuartzComponentConstructor

View File

@@ -26,8 +26,7 @@ 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 ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
if (tag === "/") { if (tag === "/") {
const tags = [ const tags = [
...new Set( ...new Set(
@@ -38,8 +37,9 @@ function TagContent(props: QuartzComponentProps) {
for (const tag of tags) { for (const tag of tags) {
tagItemMap.set(tag, allPagesWithTag(tag)) tagItemMap.set(tag, allPagesWithTag(tag))
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article> <article>
<p>{content}</p> <p>{content}</p>
</article> </article>
@@ -62,14 +62,12 @@ function TagContent(props: QuartzComponentProps) {
</a> </a>
</h2> </h2>
{content && <p>{content}</p>} {content && <p>{content}</p>}
<div class="page-listing">
<p> <p>
{pluralize(pages.length, "item")} with this tag.{" "} {pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`} {pages.length > numPages && `Showing first ${numPages}.`}
</p> </p>
<PageList limit={numPages} {...listProps} /> <PageList limit={numPages} {...listProps} />
</div> </div>
</div>
) )
})} })}
</div> </div>
@@ -83,15 +81,13 @@ function TagContent(props: QuartzComponentProps) {
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article>{content}</article> <article>{content}</article>
<div class="page-listing">
<p>{pluralize(pages.length, "item")} with this tag.</p> <p>{pluralize(pages.length, "item")} with this tag.</p>
<div> <div>
<PageList {...listProps} /> <PageList {...listProps} />
</div> </div>
</div> </div>
</div>
) )
} }
} }

View File

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

View File

@@ -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.addEventListener("click", toggleCallout) title.removeEventListener(`click`, toggleCallout)
window.addCleanup(() => title.removeEventListener("click", toggleCallout)) title.addEventListener(`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)

View File

@@ -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"
function onClick() { button.addEventListener("click", () => {
navigator.clipboard.writeText(source).then( navigator.clipboard.writeText(source).then(
() => { () => {
button.blur() button.blur()
@@ -26,9 +26,7 @@ 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)
} }
} }

View File

@@ -2,39 +2,31 @@ 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: Event) => { const switchTheme = (e: any) => {
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light" if (e.target.checked) {
document.documentElement.setAttribute("saved-theme", newTheme) document.documentElement.setAttribute("saved-theme", "dark")
localStorage.setItem("theme", newTheme) localStorage.setItem("theme", "dark")
emitThemeChangeEvent(newTheme) } else {
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", themeChange) colorSchemeMediaQuery.addEventListener("change", (e) => {
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)) const newTheme = e.matches ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
})
}) })

View File

@@ -1,106 +1,135 @@
import { FolderState } from "../ExplorerNode" import { FolderState } from "../ExplorerNode"
type MaybeHTMLElement = HTMLElement | undefined // Current state of folders
let currentExplorerState: FolderState[] let explorerState: 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 explorerUl = document.getElementById("explorer-ul") const explorer = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) { for (const entry of entries) {
if (entry.isIntersecting) { if (entry.isIntersecting) {
explorerUl.classList.add("no-background") explorer?.classList.add("no-background")
} else { } else {
explorerUl.classList.remove("no-background") explorer?.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 MaybeHTMLElement const content = this.nextElementSibling as HTMLElement
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 // corresponding <ul> element relative to clicked button/folder
? target.parentElement?.nextSibling let childFolderContainer: HTMLElement
: target.parentElement?.parentElement?.nextElementSibling
) as MaybeHTMLElement // <li> element of folder (stores folder-path dataset)
const currentFolderParent = ( let currentFolderParent: HTMLElement
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement // Get correct relative container and toggle collapsed class
if (!(childFolderContainer && currentFolderParent)) return if (isSvg) {
childFolderContainer = target.parentElement?.nextSibling as HTMLElement
currentFolderParent = target.nextElementSibling as HTMLElement
childFolderContainer.classList.toggle("open") 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
toggleCollapsedByPath(currentExplorerState, fullFolderPath) // Save folder state to localStorage
const stringifiedFileTree = JSON.stringify(currentExplorerState) const clickFolderPath = currentFolderParent.dataset.folderpath as string
// Remove leading "/"
const fullFolderPath = clickFolderPath.substring(1)
toggleCollapsedByPath(explorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(explorerState)
localStorage.setItem("fileTree", stringifiedFileTree) 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) : [] if (explorer) {
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) // Get config
const newExplorerState: FolderState[] = explorer.dataset.tree const collapseBehavior = explorer.dataset.behavior
? JSON.parse(explorer.dataset.tree)
: [] // Add click handlers for all folders (click handler on folder "label")
currentExplorerState = [] if (collapseBehavior === "collapse") {
for (const { path, collapsed } of newExplorerState) { Array.prototype.forEach.call(
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }) document.getElementsByClassName("folder-button"),
function (item) {
item.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
},
)
} }
currentExplorerState.map((folderState) => { // 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( const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`, `[data-folderpath='/${folderUl.path}']`,
) as MaybeHTMLElement ) as HTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) { // Get corresponding content <ul> tag and set state
setFolderState(folderUl, folderState.collapsed) 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
@@ -116,7 +145,11 @@ 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) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") if (collapsed) {
folderElement?.classList.remove("open")
} else {
folderElement?.classList.add("open")
}
} }
/** /**

View File

@@ -319,12 +319,12 @@ function renderGlobalGraph() {
registerEscapeHandler(container, hideGlobalGraph) registerEscapeHandler(container, hideGlobalGraph)
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: unknown) => {
const slug = e.detail.url const slug = (e as CustomEventMap["nav"]).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))
}) })

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import FlexSearch from "flexsearch" import { Document, SimpleDocumentSearchResultSetUnit } 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, normalizeRelativeURLs, resolveRelative } from "../../util/path" import { FullSlug, resolveRelative } from "../../util/path"
interface Item { interface Item {
id: number id: number
@@ -11,53 +11,23 @@ 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 = 8 const numSearchResults = 5
const numTagResults = 5 const numTagResults = 3
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) {
const tokenizedTerms = tokenizeTerm(searchTerm) // try to highlight longest tokens first
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
@@ -65,12 +35,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 occurrencesIndices = tokenizedText.map(includesCheck) const occurencesIndices = 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 = occurrencesIndices.slice(i, i + contextWindowWords) const window = occurencesIndices.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
@@ -101,78 +71,20 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}` }`
} }
function highlightHTML(searchTerm: string, el: HTMLElement) { const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
const p = new DOMParser() let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
const tokenizedTerms = tokenizeTerm(searchTerm) document.addEventListener("nav", async (e: unknown) => {
const html = p.parseFromString(el.innerHTML, "text/html") const currentSlug = (e as CustomEventMap["nav"]).detail.url
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 searchLayout = document.getElementById("search-layout") const results = document.getElementById("results-container")
const resultCards = document.getElementsByClassName("result-card")
const idDataMap = Object.keys(data) as FullSlug[] const idDataMap = Object.keys(data) as FullSlug[]
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) {
@@ -184,12 +96,6 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
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
} }
@@ -203,14 +109,11 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
searchBar?.focus() searchBar?.focus()
} }
let currentHover: HTMLInputElement | null = null function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
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()
@@ -219,227 +122,156 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
// add "#" prefix for tag search // add "#" prefix for tag search
if (searchBar) searchBar.value = "#" if (searchBar) searchBar.value = "#"
return } else if (e.key === "Enter") {
}
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
if (!anchor || anchor?.classList.contains("no-match")) return anchor?.click()
await displayPreview(anchor)
anchor.click()
} }
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { } else if (e.key === "ArrowDown") {
e.preventDefault()
// When first pressing ArrowDown, results wont contain the active element, so focus first element
if (!results?.contains(document.activeElement)) {
const firstResult = resultCards[0] as HTMLInputElement | null
firstResult?.focus()
} else {
// If an element in results-container already has focus, focus next one
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
nextResult?.focus()
}
} else if (e.key === "ArrowUp") {
e.preventDefault() 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 currentResult = currentHover const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null
? 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 ?? ""),
content: highlight(term, data[slug].content ?? "", true), // if searchType is tag, display context from start of file and trim, otherwise use regular highlight
tags: highlightTags(term.substring(1), data[slug].tags), content:
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 class="tags">${tags.join("")}</ul>` : `` const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
const itemTile = document.createElement("a") const button = document.createElement("button")
itemTile.classList.add("result-card") button.classList.add("result-card")
itemTile.id = slug button.id = slug
itemTile.href = resolveUrl(slug).toString() button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>` button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug)
async function onMouseEnter(ev: MouseEvent) { window.spaNavigate(new URL(targ, window.location.toString()))
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() hideSearch()
},
],
] as const
events.forEach(([event, handler]) => {
itemTile.addEventListener(event, handler)
window.addCleanup(() => itemTile.removeEventListener(event, handler))
}) })
return button
return itemTile
} }
async function displayResults(finalResults: Item[]) { function displayResults(finalResults: Item[]) {
if (!results) return if (!results) return
removeAllChildren(results) removeAllChildren(results)
if (finalResults.length === 0) { if (finalResults.length === 0) {
results.innerHTML = `<a class="result-card no-match"> results.innerHTML = `<button class="result-card">
<h3>No results.</h3> <h3>No results.</h3>
<p>Try another search term?</p> <p>Try another search term?</p>
</a>` </button>`
} 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"]) {
if (!searchLayout || !index) return let term = (e.target as HTMLInputElement).value
currentSearchTerm = (e.target as HTMLInputElement).value let searchResults: SimpleDocumentSearchResultSetUnit[]
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] if (term.toLowerCase().startsWith("#")) {
if (searchType === "tags") { searchType = "tags"
searchResults = await index.searchAsync({ } else {
query: currentSearchTerm.substring(1), searchType = "basic"
limit: numSearchResults, }
index: ["tags"],
}) switch (searchType) {
} else if (searchType === "basic") { case "tags": {
searchResults = await index.searchAsync({ term = term.substring(1)
query: currentSearchTerm, searchResults =
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
[]
break
}
case "basic":
default: {
searchResults =
(await index?.searchAsync({
query: term,
limit: numSearchResults, limit: numSearchResults,
index: ["title", "content"], index: ["title", "content"],
}) })) ?? []
}
} }
const getByField = (field: string): number[] => { const getByField = (field: string): number[] => {
@@ -453,19 +285,51 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
...getByField("content"), ...getByField("content"),
...getByField("tags"), ...getByField("tags"),
]) ])
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)) const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
await displayResults(finalResults) displayResults(finalResults)
}
if (prevShortcutHandler) {
document.removeEventListener("keydown", prevShortcutHandler)
} }
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) prevShortcutHandler = shortcutHandler
searchIcon?.removeEventListener("click", () => showSearch("basic"))
searchIcon?.addEventListener("click", () => showSearch("basic")) searchIcon?.addEventListener("click", () => showSearch("basic"))
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) searchBar?.removeEventListener("input", onType)
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)
}) })
/** /**
@@ -473,20 +337,16 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
* @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(data: { [key: FullSlug]: ContentDetails }) { async function fillDocument(index: Document<Item, false>, data: any) {
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)) {
promises.push( await index.addAsync(id, {
index.addAsync(id++, {
id, id,
slug: slug as FullSlug, slug: slug as FullSlug,
title: fileData.title, title: fileData.title,
content: fileData.content, content: fileData.content,
tags: fileData.tags, tags: fileData.tags,
}), })
) id++
} }
return await Promise.all(promises)
} }

View File

@@ -39,9 +39,6 @@ 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()
@@ -60,10 +57,6 @@ 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)

View File

@@ -16,8 +16,7 @@ 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 | undefined const content = this.nextElementSibling as HTMLElement
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"
} }
@@ -26,11 +25,10 @@ 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 | undefined const content = toc.nextElementSibling as HTMLElement
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))
} }
} }

View File

@@ -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)
window.addCleanup(() => outsideContainer?.removeEventListener("click", click)) document.removeEventListener("keydown", esc)
document.addEventListener("keydown", esc) document.addEventListener("keydown", esc)
window.addCleanup(() => document.removeEventListener("keydown", esc))
} }
export function removeAllChildren(node: HTMLElement) { export function removeAllChildren(node: HTMLElement) {

View File

@@ -1,5 +1,3 @@
@use "../../styles/variables.scss" as *;
button#explorer { button#explorer {
all: unset; all: unset;
background-color: transparent; background-color: transparent;
@@ -87,7 +85,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: $boldWeight; font-weight: 600;
line-height: 1.5rem; line-height: 1.5rem;
display: inline-block; display: inline-block;
} }
@@ -108,11 +106,11 @@ svg {
align-items: center; align-items: center;
font-family: var(--headerFont); font-family: var(--headerFont);
& span { & p {
font-size: 0.95rem; font-size: 0.95rem;
display: inline-block; display: inline-block;
color: var(--secondary); color: var(--secondary);
font-weight: $boldWeight; font-weight: 600;
margin: 0; margin: 0;
line-height: 1.5rem; line-height: 1.5rem;
pointer-events: none; pointer-events: none;
@@ -128,7 +126,7 @@ svg {
backface-visibility: visible; backface-visibility: visible;
} }
li:has(> .folder-outer:not(.open)) > .folder-container > svg { div:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg); transform: rotate(-90deg);
} }

View File

@@ -26,7 +26,6 @@
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);

View File

@@ -54,11 +54,15 @@
} }
& > #search-space { & > #search-space {
width: 65%; width: 50%;
margin-top: 12vh; margin-top: 15vh;
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;
@@ -82,92 +86,16 @@
} }
} }
& > #search-layout {
display: none;
flex-direction: row;
border: 1px solid var(--lightgray);
&.display-results {
display: flex;
}
@media all and (min-width: $tabletBreakpoint) {
&[data-preview] {
& .result-card > p.preview {
display: none;
}
}
}
& > div {
// vh - #search-space.margin-top
height: calc(75vh - 12vh);
background: none;
&:first-child {
border-top-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;
}
}
@media all and (max-width: $tabletBreakpoint) {
display: block;
& > *:not(#results-container) {
display: none !important;
}
& > #results-container { & > #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 { & .result-card {
padding: 1em; padding: 1em;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease; transition: background 0.2s ease;
border-bottom: 1px solid var(--lightgray); border: 1px solid var(--lightgray);
border-bottom: none;
width: 100%; width: 100%;
display: block;
box-sizing: border-box;
// normalize card props // normalize button props
font-family: inherit; font-family: inherit;
font-size: 100%; font-size: 100%;
line-height: 1.15; line-height: 1.15;
@@ -176,34 +104,68 @@
text-align: left; text-align: left;
background: var(--light); background: var(--light);
outline: none; outline: none;
font-weight: inherit;
&:focus, & .highlight {
&.focus { color: var(--secondary);
font-weight: 700;
}
&:hover,
&:focus {
background: var(--lightgray); background: var(--lightgray);
} }
&:first-of-type {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&:last-of-type {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-bottom: 1px solid var(--lightgray);
}
& > h3 { & > h3 {
margin: 0; margin: 0;
} }
& > ul.tags { & > ul > li {
margin: 0;
display: inline-block;
white-space: nowrap;
margin: 0;
overflow-wrap: normal;
}
& > ul {
list-style: none;
display: flex;
padding-left: 0;
gap: 0.4rem;
margin: 0;
margin-top: 0.45rem; margin-top: 0.45rem;
margin-bottom: 0; // Offset border radius
margin-left: -2px;
overflow: hidden;
background-clip: border-box;
} }
& > ul > li > p { & > ul > li > p {
border-radius: 8px; border-radius: 8px;
background-color: var(--highlight); background-color: var(--highlight);
padding: 0.2rem 0.4rem; overflow: hidden;
margin: 0 0.1rem; background-clip: border-box;
line-height: 1.4rem; padding: 0.03rem 0.4rem;
font-weight: $boldWeight; margin: 0;
color: var(--secondary); color: var(--secondary);
opacity: 0.85;
&.match-tag {
color: var(--tertiary);
} }
& > ul > li > .match-tag {
color: var(--tertiary);
font-weight: bold;
opacity: 1;
} }
& > p { & > p {
@@ -214,4 +176,3 @@
} }
} }
} }
}

View File

@@ -7,7 +7,6 @@ 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 = {
@@ -26,7 +25,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
getQuartzComponents() { getQuartzComponents() {
return [Head, Body, pageBody, Footer] return [Head, Body, pageBody, Footer]
}, },
async emit(ctx, _content, resources): Promise<FilePath[]> { async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug const slug = "404" as FullSlug
@@ -49,8 +48,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
} }
return [ return [
await write({ await emit({
ctx,
content: renderPage(slug, componentData, opts, externalResources), content: renderPage(slug, componentData, opts, externalResources),
slug, slug,
ext: ".html", ext: ".html",

View File

@@ -1,21 +1,24 @@
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(ctx, content, _resources): Promise<FilePath[]> { async emit({ argv }, content, _resources, emit): 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") {
@@ -29,8 +32,7 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
} }
const redirUrl = resolveRelative(slug, file.data.slug!) const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await write({ const fp = await emit({
ctx,
content: ` content: `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en-us"> <html lang="en-us">

View File

@@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
getQuartzComponents() { getQuartzComponents() {
return [] return []
}, },
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { async emit({ argv, cfg }, _content, _resources, _emit): 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])

View File

@@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
getQuartzComponents() { getQuartzComponents() {
return [] return []
}, },
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { async emit({ argv, cfg }, _content, _resources, _emit): 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 []

View File

@@ -4,6 +4,8 @@ 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"
@@ -12,8 +14,6 @@ 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,16 +56,9 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
} }
} }
async function joinScripts(scripts: string[]): Promise<string> { function joinScripts(scripts: string[]): string {
// wrap with iife to prevent scope collision // wrap with iife to prevent scope collision
const script = scripts.map((script) => `(function () {${script}})();`).join("\n") return scripts.map((script) => `(function () {${script}})();`).join("\n")
// minify with esbuild
const res = await transpile(script, {
minify: true,
})
return res.code
} }
function addGlobalPageResources( function addGlobalPageResources(
@@ -92,34 +85,21 @@ 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") {
const plausibleHost = cfg.analytics.host ?? "https://plausible.io" componentResources.afterDOMLoaded.push(plausibleScript)
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 = cfg.analytics.host ?? "https://analytics.umami.is/script.js" umamiScript.src = "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
@@ -132,10 +112,8 @@ function addGlobalPageResources(
} else { } else {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url) window.spaNavigate = (url, _) => window.location.assign(url)
window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event) document.dispatchEvent(event)`)
`)
} }
let wsUrl = `ws://localhost:${ctx.argv.wsPort}` let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
@@ -171,7 +149,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
getQuartzComponents() { getQuartzComponents() {
return [] return []
}, },
async emit(ctx, _content, resources): Promise<FilePath[]> { async emit(ctx, _content, resources, emit): 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
@@ -187,14 +165,10 @@ 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, postscript] = await Promise.all([ const prescript = joinScripts(componentResources.beforeDOMLoaded)
joinScripts(componentResources.beforeDOMLoaded), const postscript = joinScripts(componentResources.afterDOMLoaded)
joinScripts(componentResources.afterDOMLoaded),
])
const fps = await Promise.all([ const fps = await Promise.all([
write({ emit({
ctx,
slug: "index" as FullSlug, slug: "index" as FullSlug,
ext: ".css", ext: ".css",
content: transform({ content: transform({
@@ -211,14 +185,12 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
include: Features.MediaQueries, include: Features.MediaQueries,
}).code.toString(), }).code.toString(),
}), }),
write({ emit({
ctx,
slug: "prescript" as FullSlug, slug: "prescript" as FullSlug,
ext: ".js", ext: ".js",
content: prescript, content: prescript,
}), }),
write({ emit({
ctx,
slug: "postscript" as FullSlug, slug: "postscript" as FullSlug,
ext: ".js", ext: ".js",
content: postscript, content: postscript,

View File

@@ -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, joinSegments, simplifySlug } from "../../util/path" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import path from "path"
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://${joinSegments(base, encodeURI(slug))}</loc> <loc>https://${base}/${encodeURI(slug)}</loc>
<lastmod>${content.date?.toISOString()}</lastmod> <lastmod>${content.date?.toISOString()}</lastmod>
</url>` </url>`
const urls = Array.from(idx) const urls = Array.from(idx)
@@ -48,11 +48,12 @@ 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>https://${joinSegments(base, encodeURI(slug))}</link> <link>${root}/${encodeURI(slug)}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid> <guid>${root}/${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>`
@@ -77,7 +78,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>https://${base}</link> <link>${root}</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>
@@ -91,7 +92,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) { async emit(ctx, content, _resources, emit) {
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()
@@ -115,8 +116,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableSiteMap) { if (opts?.enableSiteMap) {
emitted.push( emitted.push(
await write({ await emit({
ctx,
content: generateSiteMap(cfg, linkIndex), content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug, slug: "sitemap" as FullSlug,
ext: ".xml", ext: ".xml",
@@ -126,8 +126,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableRSS) { if (opts?.enableRSS) {
emitted.push( emitted.push(
await write({ await emit({
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",
@@ -135,7 +134,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
) )
} }
const fp = joinSegments("static", "contentIndex") as FullSlug const fp = path.join("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
@@ -148,8 +147,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
) )
emitted.push( emitted.push(
await write({ await emit({
ctx,
content: JSON.stringify(simplifiedIndex), content: JSON.stringify(simplifiedIndex),
slug: fp, slug: fp,
ext: ".json", ext: ".json",

View File

@@ -8,7 +8,6 @@ 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 = {
@@ -27,7 +26,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): Promise<FilePath[]> { async emit(ctx, content, resources, emit): 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)
@@ -50,8 +49,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(slug, componentData, opts, externalResources)
const fp = await write({ const fp = await emit({
ctx,
content, content,
slug, slug,
ext: ".html", ext: ".html",

View File

@@ -17,9 +17,8 @@ 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<Partial<FullPageLayout>> = (userOpts) => { export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
@@ -36,7 +35,7 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
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): Promise<FilePath[]> { async emit(ctx, content, resources, emit): 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
@@ -83,8 +82,7 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(slug, componentData, opts, externalResources)
const fp = await write({ const fp = await emit({
ctx,
content, content,
slug, slug,
ext: ".html", ext: ".html",

View File

@@ -1,19 +0,0 @@
import path from "path"
import fs from "fs"
import { BuildCtx } from "../../util/ctx"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
const dir = path.dirname(pathToPage)
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(pathToPage, content)
return pathToPage
}

View File

@@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
getQuartzComponents() { getQuartzComponents() {
return [] return []
}, },
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> { async emit({ argv, cfg }, _content, _resources, _emit): 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"), {

View File

@@ -14,9 +14,8 @@ 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<Partial<FullPageLayout>> = (userOpts) => { export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
@@ -33,7 +32,7 @@ export const TagPage: QuartzEmitterPlugin<Partial<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): Promise<FilePath[]> { async emit(ctx, content, resources, emit): 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,8 +81,7 @@ export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts)
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(slug, componentData, opts, externalResources)
const fp = await write({ const fp = await emit({
ctx,
content, content,
slug: file.data.slug!, slug: file.data.slug!,
ext: ".html", ext: ".html",

View File

@@ -3,6 +3,7 @@ import { QuartzFilterPlugin } from "../types"
export const ExplicitPublish: QuartzFilterPlugin = () => ({ export const ExplicitPublish: QuartzFilterPlugin = () => ({
name: "ExplicitPublish", name: "ExplicitPublish",
shouldPublish(_ctx, [_tree, vfile]) { shouldPublish(_ctx, [_tree, vfile]) {
return vfile.data?.frontmatter?.publish ?? false const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
return publishFlag
}, },
}) })

View File

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

View File

@@ -9,34 +9,13 @@ import { QuartzPluginData } from "../vfile"
export interface Options { export interface Options {
delims: string | string[] delims: string | string[]
language: "yaml" | "toml" language: "yaml" | "toml"
oneLineTagDelim: string
} }
const defaultOptions: Options = { const defaultOptions: Options = {
delims: "---", delims: "---",
language: "yaml", language: "yaml",
} oneLineTagDelim: ",",
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) => {
@@ -44,6 +23,8 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
return { return {
name: "FrontMatter", name: "FrontMatter",
markdownPlugins() { markdownPlugins() {
const { oneLineTagDelim } = opts
return [ return [
[remarkFrontmatter, ["yaml", "toml"]], [remarkFrontmatter, ["yaml", "toml"]],
() => { () => {
@@ -56,19 +37,27 @@ 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) { } else if (data.title === null || data.title === undefined) {
data.title = file.stem ?? "Untitled" data.title = file.stem ?? "Untitled"
} }
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"])) if (data.tags && !Array.isArray(data.tags)) {
if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))] data.tags = data.tags
.toString()
.split(oneLineTagDelim)
.map((tag: string) => tag.trim())
}
const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"])) // slug them all!!
if (aliases) data.aliases = aliases data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
if (cssclasses) data.cssclasses = cssclasses
// fill in frontmatter // fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"] file.data.frontmatter = data as QuartzPluginData["frontmatter"]
@@ -81,16 +70,9 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
frontmatter: { [key: string]: unknown } & { frontmatter: { [key: string]: any } & {
title: string title: string
} & Partial<{
tags: string[] tags: string[]
aliases: string[] }
description: string
publish: boolean
draft: boolean
enableToc: string
cssclasses: string[]
}>
} }
} }

View File

@@ -37,36 +37,8 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
"data-no-popover": true, "data-no-popover": true,
}, },
content: { content: {
type: "element", type: "text",
tagName: "svg", value: " §",
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: [],
},
],
}, },
}, },
], ],

View File

@@ -50,29 +50,17 @@ 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 as MaybeDate created ||= file.data.frontmatter.date
modified ||= file.data.frontmatter.lastmod as MaybeDate modified ||= file.data.frontmatter.lastmod
modified ||= file.data.frontmatter.updated as MaybeDate modified ||= file.data.frontmatter.updated
modified ||= file.data.frontmatter["last-modified"] as MaybeDate modified ||= file.data.frontmatter["last-modified"]
published ||= file.data.frontmatter.publishDate as MaybeDate published ||= file.data.frontmatter.publishDate
} else if (source === "git") { } else if (source === "git") {
if (!repo) { if (!repo) {
// Get a reference to the main git repo. repo = new Repository(file.cwd)
// It's either the same as the workdir,
// or 1+ level higher in case of a submodule/subtree setup
repo = Repository.discover(file.cwd)
} }
try {
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!) modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
} catch {
console.log(
chalk.yellow(
`\nWarning: ${file.data
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
),
)
}
} }
} }

View File

@@ -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.9/dist/katex.min.css", "https://cdn.jsdelivr.net/npm/katex@0.16.0/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.9/dist/contrib/copy-tex.min.js", src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "external", contentType: "external",
}, },

View File

@@ -12,7 +12,6 @@ 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 */
@@ -20,16 +19,12 @@ 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) => {
@@ -39,7 +34,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
htmlPlugins(ctx) { htmlPlugins(ctx) {
return [ return [
() => { () => {
return (tree: Root, file) => { return (tree, 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()
@@ -56,30 +51,8 @@ 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
const classes = (node.properties.className ?? []) as string[] node.properties.className ??= []
const isExternal = isAbsoluteUrl(dest) node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
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 (
@@ -88,9 +61,8 @@ 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
classes.push("alias") node.properties.className.push("alias")
} }
node.properties.className = classes
if (opts.openLinksInNewTab) { if (opts.openLinksInNewTab) {
node.properties.target = "_blank" node.properties.target = "_blank"
@@ -139,10 +111,6 @@ 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(

View File

@@ -1,10 +1,10 @@
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast" import { Root, Html, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast" import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, 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 { SKIP, visit } from "unist-util-visit" import { 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
@@ -23,11 +23,8 @@ 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 = {
@@ -37,14 +34,43 @@ 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 calloutMapping = { const icons = {
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",
@@ -72,29 +98,24 @@ const calloutMapping = {
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
} }
export const externalLinkRegex = /^https?:\/\//i function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
export const arrowRegex = new RegExp(/-{1,2}>/, "g") return calloutMapping[callout] ?? "note"
}
// !? -> optional embedding // !? -> optional embedding
// \[\[ -> open brace // \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias) // (|[^\[\]\|\#]+)? -> | 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(/%%[\s\S]*?%%/, "g") const commentRegex = new RegExp(/%%(.+)%%/, "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")
@@ -102,13 +123,8 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// #(...) -> capturing group, tag itself must start with # // #(...) -> capturing group, tag itself must start with #
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores // (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" // (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu") const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\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,
@@ -123,22 +139,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
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) {
if (src instanceof Buffer) { if (src instanceof Buffer) {
src = src.toString() src = src.toString()
} }
src = src.replace(calloutLineRegex, (value) => { src = src.replaceAll(calloutLineRegex, (value) => {
// force newline after title of callout // force newline after title of callout
return value + "\n> " return value + "\n> "
}) })
@@ -150,20 +157,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
src = src.toString() src = src.toString()
} }
src = src.replace(wikilinkRegex, (value, ...capture) => { src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture const [rawFp, rawHeader, rawAlias] = 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}]]`
}) })
} }
@@ -192,11 +193,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (value.startsWith("!")) { if (value.startsWith("!")) {
const ext: string = path.extname(fp).toLowerCase() const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath) const url = slugifyFilePath(fp as FilePath)
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const match = wikilinkImageEmbedRegex.exec(alias ?? "") const dims = alias ?? ""
const alt = match?.groups?.alt ?? "" let [width, height] = dims.split("x", 2)
const width = match?.groups?.width ?? "auto" width ||= "auto"
const height = match?.groups?.height ?? "auto" height ||= "auto"
return { return {
type: "image", type: "image",
url, url,
@@ -204,7 +205,6 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
hProperties: { hProperties: {
width, width,
height, height,
alt,
}, },
}, },
} }
@@ -225,7 +225,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
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",
@@ -268,13 +268,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
]) ])
} }
if (opts.parseArrows) { if (opts.comments) {
replacements.push([ replacements.push([
arrowRegex, commentRegex,
(_value: string, ..._capture: string[]) => { (_value: string, ..._capture: string[]) => {
return { return {
type: "html", type: "text",
value: `<span>&rarr;</span>`, value: "",
} }
}, },
]) ])
@@ -290,9 +290,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
tag = slugTag(tag) tag = slugTag(tag)
if (file.data.frontmatter) { if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
const noteTags = file.data.frontmatter.tags ?? [] file.data.frontmatter.tags.push(tag)
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
} }
return { return {
@@ -320,7 +319,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (typeof replace === "string") { if (typeof replace === "string") {
node.value = node.value.replace(regex, replace) node.value = node.value.replace(regex, replace)
} else { } else {
node.value = node.value.replace(regex, (substring: string, ...args) => { node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args) const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") { if (typeof replaceValue === "string") {
return replaceValue return replaceValue
@@ -336,28 +335,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
}) })
} }
mdastFindReplace(tree, replacements) mdastFindReplace(tree, replacements)
} }
}) })
if (opts.enableVideoEmbed) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, "image", (node, index, parent) => {
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
const newNode: Html = {
type: "html",
value: `<video controls src="${node.url}"></video>`,
}
parent.children.splice(index, 1, newNode)
return SKIP
}
})
}
})
}
if (opts.callouts) { if (opts.callouts) {
plugins.push(() => { plugins.push(() => {
return (tree: Root, _file) => { return (tree: Root, _file) => {
@@ -373,38 +355,36 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
const text = firstChild.children[0].value const text = firstChild.children[0].value
const restOfTitle = firstChild.children.slice(1) const restChildren = 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(typeString.toLowerCase()) const calloutType = canonicalizeCallout(
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 = match.input.slice(calloutDirective.length).trim() const titleContent =
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const titleNode: Paragraph = { const titleNode: Paragraph = {
type: "paragraph", type: "paragraph",
children: [ children: [{ type: "text", value: titleContent + " " }, ...restChildren],
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
},
...restOfTitle,
],
} }
const title = mdastToHtml(titleNode) const title = mdastToHtml(titleNode)
const toggleIcon = `<div class="fold-callout-icon"></div>` 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">
<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"></div> <div class="callout-icon">${callouts[calloutType]}</div>
<div class="callout-title-inner">${title}</div> <div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""} ${collapse ? toggleIcon : ""}
</div>`, </div>`,
@@ -430,7 +410,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.data = { node.data = {
hProperties: { hProperties: {
...(node.data?.hProperties ?? {}), ...(node.data?.hProperties ?? {}),
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${ className: `callout ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : "" defaultState === "collapsed" ? "is-collapsed" : ""
}`, }`,
"data-callout": calloutType, "data-callout": calloutType,
@@ -463,12 +443,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}, },
htmlPlugins() { htmlPlugins() {
const plugins: PluggableList = [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: HtmlRoot, file) => { return (tree, file) => {
file.data.blocks = {} file.data.blocks = {}
visit(tree, "element", (node, index, parent) => { visit(tree, "element", (node, index, parent) => {
@@ -517,30 +496,6 @@ 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() {

View File

@@ -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 } from "../util/path" import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
export interface PluginTypes { export interface PluginTypes {
@@ -36,6 +36,19 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
) => QuartzEmitterPluginInstance ) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = { export type QuartzEmitterPluginInstance = {
name: string name: string
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> emit(
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>

View File

@@ -1,6 +1,10 @@
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"
@@ -11,12 +15,19 @@ 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) const emitted = await emitter.emit(ctx, content, staticResources, emit)
emittedFiles += emitted.length emittedFiles += emitted.length
if (ctx.argv.verbose) { if (ctx.argv.verbose) {

View File

@@ -91,9 +91,8 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
} }
// base data properties that plugins may use // base data properties that plugins may use
file.data.filePath = file.path as FilePath file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath file.data.filePath = fp
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)

View File

@@ -4,6 +4,7 @@
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;
@@ -26,7 +27,7 @@ section {
} }
::selection { ::selection {
background: color-mix(in srgb, var(--tertiary) 60%, transparent); background: color-mix(in srgb, var(--tertiary) 75%, transparent);
color: var(--darkgray); color: var(--darkgray);
} }
@@ -54,7 +55,7 @@ ul,
} }
a { a {
font-weight: $boldWeight; font-weight: 600;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
color: var(--secondary); color: var(--secondary);
@@ -68,7 +69,6 @@ a {
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) { &:has(> img) {
background-color: none; background-color: none;
@@ -76,15 +76,6 @@ a {
padding: 0; padding: 0;
} }
} }
&.external .external-icon {
height: 1ex;
margin: 0 0.15em;
> path {
fill: var(--dark);
}
}
} }
.desktop-only { .desktop-only {
@@ -171,11 +162,9 @@ 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;
} }
} }
} }
@@ -278,6 +267,7 @@ 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;
} }
@@ -338,11 +328,10 @@ figure[data-rehype-pretty-code-figure] {
pre { pre {
font-family: var(--codeFont); font-family: var(--codeFont);
padding: 0 0.5rem; padding: 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;
@@ -356,7 +345,6 @@ pre {
counter-increment: line 0; counter-increment: line 0;
display: grid; display: grid;
padding: 0.5rem 0; padding: 0.5rem 0;
overflow-x: scroll;
& [data-highlighted-chars] { & [data-highlighted-chars] {
background-color: var(--highlight); background-color: var(--highlight);

View File

@@ -1,4 +1,3 @@
@use "./variables.scss" as *;
@use "sass:color"; @use "sass:color";
.callout { .callout {
@@ -14,33 +13,16 @@
margin-top: 0; margin-top: 0;
} }
--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>'); &[data-callout="note"] {
--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"],
@@ -48,39 +30,30 @@
--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"],
@@ -89,74 +62,50 @@
--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-callout-icon { &.is-collapsed > .callout-title > .fold {
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);
--icon-size: 18px; & .fold {
margin-left: 0.5rem;
& .fold-callout-icon { transition: transform 0.3s ease;
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 {
& .fold-callout-icon { width: 18px;
width: var(--icon-size); height: 18px;
height: var(--icon-size); flex: 0 0 18px;
padding-top: 4px;
// icon support
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 { .callout-title-inner {
font-weight: $boldWeight; font-weight: 700;
}
} }

View File

@@ -1,8 +1,6 @@
$pageWidth: 750px; $pageWidth: 750px;
$mobileBreakpoint: 600px; $mobileBreakpoint: 600px;
$tabletBreakpoint: 1000px; $tabletBreakpoint: 1200px;
$sidePanelWidth: 380px; $sidePanelWidth: 380px;
$topSpacing: 6rem; $topSpacing: 6rem;
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth; $fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
$boldWeight: 700;
$normalWeight: 400;

View File

@@ -9,13 +9,3 @@ 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(" ")
}

View File

@@ -105,9 +105,6 @@ 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,

View File

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

View File

@@ -26,12 +26,9 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
} else { } else {
const content = resource.script const content = resource.script
return ( return (
<script <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
key={randomUUID()} {content}
type={scriptType} </script>
spa-preserve={spaPreserve}
dangerouslySetInnerHTML={{ __html: content }}
></script>
) )
} }
} }

View File

@@ -13,8 +13,8 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact"
}, },
"include": ["**/*.ts", "**/*.tsx", "./package.json"], "include": ["**/*.ts", "**/*.tsx", "./package.json"],
"exclude": ["build/**/*.d.ts"], "exclude": ["build/**/*.d.ts"]
} }