Jacky Zhao
2023-07-23 7db2eda76cf51fd631d385c12a7b411339406067
run prettier
101 files modified
2197 ■■■■■ changed files
.github/ISSUE_TEMPLATE/bug_report.md 7 ●●●●● patch | view | raw | blame | history
.github/ISSUE_TEMPLATE/feature_request.md 5 ●●●●● patch | view | raw | blame | history
.github/workflows/ci.yaml patch | view | raw | blame | history
CODE_OF_CONDUCT.md 37 ●●●● patch | view | raw | blame | history
content/advanced/creating components.md patch | view | raw | blame | history
content/advanced/making plugins.md 1 ●●●● patch | view | raw | blame | history
content/configuration.md 11 ●●●●● patch | view | raw | blame | history
content/features/Latex.md 6 ●●●●● patch | view | raw | blame | history
content/features/Mermaid diagrams.md 2 ●●● patch | view | raw | blame | history
content/features/SPA Routing.md patch | view | raw | blame | history
content/features/backlinks.md 1 ●●●● patch | view | raw | blame | history
content/features/callouts.md 5 ●●●●● patch | view | raw | blame | history
content/features/full-text search.md 2 ●●●●● patch | view | raw | blame | history
content/features/graph view.md 11 ●●●● patch | view | raw | blame | history
content/features/index.md patch | view | raw | blame | history
content/features/popover previews.md 1 ●●●● patch | view | raw | blame | history
content/features/syntax highlighting.md 11 ●●●● patch | view | raw | blame | history
content/features/table of contents.md patch | view | raw | blame | history
content/features/upcoming features.md patch | view | raw | blame | history
content/index.md 4 ●●● patch | view | raw | blame | history
content/philosophy.md patch | view | raw | blame | history
content/tags/component.md patch | view | raw | blame | history
globals.d.ts 8 ●●●●● patch | view | raw | blame | history
index.d.ts 4 ●●●● patch | view | raw | blame | history
quartz.config.ts 75 ●●●●● patch | view | raw | blame | history
quartz/bootstrap-cli.mjs 247 ●●●●● patch | view | raw | blame | history
quartz/bootstrap-worker.mjs 4 ●●●● patch | view | raw | blame | history
quartz/build.ts 78 ●●●●● patch | view | raw | blame | history
quartz/cfg.ts 30 ●●●● patch | view | raw | blame | history
quartz/components/Backlinks.tsx 20 ●●●● patch | view | raw | blame | history
quartz/components/Body.tsx 9 ●●●●● patch | view | raw | blame | history
quartz/components/Darkmode.tsx 14 ●●●●● patch | view | raw | blame | history
quartz/components/Date.tsx 4 ●●●● patch | view | raw | blame | history
quartz/components/Footer.tsx 16 ●●●● patch | view | raw | blame | history
quartz/components/Graph.tsx 47 ●●●●● patch | view | raw | blame | history
quartz/components/Head.tsx 12 ●●●● patch | view | raw | blame | history
quartz/components/Header.tsx 4 ●●● patch | view | raw | blame | history
quartz/components/PageList.tsx 33 ●●●● patch | view | raw | blame | history
quartz/components/PageTitle.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/ReadingTime.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/Search.tsx 24 ●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 45 ●●●● patch | view | raw | blame | history
quartz/components/TagList.tsx 18 ●●●● patch | view | raw | blame | history
quartz/components/index.ts 2 ●●● patch | view | raw | blame | history
quartz/components/pages/Content.tsx 4 ●●●● patch | view | raw | blame | history
quartz/components/pages/FolderContent.tsx 14 ●●●●● patch | view | raw | blame | history
quartz/components/pages/TagContent.tsx 14 ●●●●● patch | view | raw | blame | history
quartz/components/renderPage.tsx 88 ●●●● patch | view | raw | blame | history
quartz/components/scripts/callout.inline.ts 4 ●●● patch | view | raw | blame | history
quartz/components/scripts/darkmode.inline.ts 25 ●●●● patch | view | raw | blame | history
quartz/components/scripts/graph.inline.ts 70 ●●●● patch | view | raw | blame | history
quartz/components/scripts/plausible.inline.ts 2 ●●● patch | view | raw | blame | history
quartz/components/scripts/popover.inline.ts 30 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/search.inline.ts 36 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/spa.inline.ts 45 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/toc.inline.ts 4 ●●●● patch | view | raw | blame | history
quartz/components/scripts/util.ts 2 ●●● patch | view | raw | blame | history
quartz/components/styles/graph.scss 2 ●●● patch | view | raw | blame | history
quartz/components/styles/legacyToc.scss patch | view | raw | blame | history
quartz/components/styles/listPage.scss patch | view | raw | blame | history
quartz/components/styles/popover.scss 7 ●●●● patch | view | raw | blame | history
quartz/components/styles/search.scss 8 ●●●●● patch | view | raw | blame | history
quartz/components/styles/toc.scss 7 ●●●●● patch | view | raw | blame | history
quartz/components/types.ts 12 ●●●●● patch | view | raw | blame | history
quartz/log.ts 2 ●●● patch | view | raw | blame | history
quartz/path.test.ts 90 ●●●●● patch | view | raw | blame | history
quartz/path.ts 66 ●●●● patch | view | raw | blame | history
quartz/perf.ts 8 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/aliases.ts 14 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentIndex.ts 28 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentPage.tsx 19 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/folderPage.tsx 33 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/index.ts 10 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/tagPage.tsx 29 ●●●● patch | view | raw | blame | history
quartz/plugins/filters/draft.ts 2 ●●● patch | view | raw | blame | history
quartz/plugins/filters/explicit.ts 2 ●●● patch | view | raw | blame | history
quartz/plugins/filters/index.ts 4 ●●●● patch | view | raw | blame | history
quartz/plugins/index.ts 47 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/description.ts 22 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/frontmatter.ts 27 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/gfm.ts 29 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/index.ts 18 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/lastmod.ts 14 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/latex.ts 24 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/links.ts 52 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 102 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/syntax.ts 13 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/toc.ts 32 ●●●●● patch | view | raw | blame | history
quartz/plugins/types.ts 26 ●●●● patch | view | raw | blame | history
quartz/plugins/vfile.ts 6 ●●●● patch | view | raw | blame | history
quartz/processors/emit.ts 53 ●●●●● patch | view | raw | blame | history
quartz/processors/filter.ts 8 ●●●● patch | view | raw | blame | history
quartz/processors/parse.ts 85 ●●●●● patch | view | raw | blame | history
quartz/resources.tsx 33 ●●●●● patch | view | raw | blame | history
quartz/styles/base.scss 45 ●●●● patch | view | raw | blame | history
quartz/styles/callouts.scss 12 ●●●● patch | view | raw | blame | history
quartz/styles/variables.scss 2 ●●● patch | view | raw | blame | history
quartz/theme.ts 25 ●●●● patch | view | raw | blame | history
quartz/trace.ts 8 ●●●● patch | view | raw | blame | history
quartz/worker.ts 7 ●●●● patch | view | raw | blame | history
tsconfig.json 16 ●●●● patch | view | raw | blame | history
.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,10 +1,9 @@
---
name: Bug report
about: Something about Quartz isn't working the way you expect
title: ''
title: ""
labels: bug
assignees: ''
assignees: ""
---
**Describe the bug**
@@ -12,6 +11,7 @@
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@@ -24,6 +24,7 @@
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
 - Device: [e.g. iPhone6]
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,10 +1,9 @@
---
name: Feature request
about: Suggest an idea or improvement for Quartz
title: ''
title: ""
labels: enhancement
assignees: ''
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
.github/workflows/ci.yaml
CODE_OF_CONDUCT.md
@@ -20,28 +20,28 @@
The following behaviors are expected and requested of all community members:
 * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community.
 * Exercise consideration and respect in your speech and actions.
 * Attempt collaboration before conflict.
 * Refrain from demeaning, discriminatory, or harassing behavior and speech.
 * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.
 * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations.
- Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community.
- Exercise consideration and respect in your speech and actions.
- Attempt collaboration before conflict.
- Refrain from demeaning, discriminatory, or harassing behavior and speech.
- Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.
- Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations.
## 4. Unacceptable Behavior
The following behaviors are considered harassment and are unacceptable within our community:
 * Violence, threats of violence or violent language directed against another person.
 * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language.
 * Posting or displaying sexually explicit or violent material.
 * Posting or threatening to post other people's personally identifying information ("doxing").
 * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability.
 * Inappropriate photography or recording.
 * Inappropriate physical contact. You should have someone's consent before touching them.
 * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances.
 * Deliberate intimidation, stalking or following (online or in person).
 * Advocating for, or encouraging, any of the above behavior.
 * Sustained disruption of community events, including talks and presentations.
- Violence, threats of violence or violent language directed against another person.
- Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language.
- Posting or displaying sexually explicit or violent material.
- Posting or threatening to post other people's personally identifying information ("doxing").
- Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability.
- Inappropriate photography or recording.
- Inappropriate physical contact. You should have someone's consent before touching them.
- Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances.
- Deliberate intimidation, stalking or following (online or in person).
- Advocating for, or encouraging, any of the above behavior.
- Sustained disruption of community events, including talks and presentations.
## 5. Weapons Policy
@@ -59,15 +59,12 @@
If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. j.zhao2k19@gmail.com.
Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress.
## 8. Addressing Grievances
If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify @jackyzha0 with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 
## 9. Scope
We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues--online and in-person--as well as in all one-on-one communications pertaining to community business.
content/advanced/creating components.md
content/advanced/making plugins.md
@@ -5,6 +5,7 @@
This part of the documentation will assume you have some basic coding knowledge and will include code snippets that describe the interface of what Quartz plugins should look like.
## Transformers
```ts
export type QuartzTransformerPluginInstance = {
  name: string
content/configuration.md
@@ -16,6 +16,7 @@
```
## General Configuration
This part of the configuration concerns anything that can affect the whole site. The following is a list breaking down all the things you can configure:
- `pageTitle`: used as an anchor to return to the home page. This is also used when generating the [[RSS Feed]] for your site.
@@ -25,8 +26,8 @@
    - `null`: don't use analytics;
    - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or
    - `{ provider: 'google', tagId: <your-google-tag> }`: use Google Analytics
- `caononicalUrl`: sometimes called `baseURL` in other site generators. This is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `https://quartz.jzhao.xyz/` for this site). Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter *where* you end up actually deploying it.
- `ignorePatterns`: a list of [glob](https://en.wikipedia.org/wiki/Glob_(programming)) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder.
- `caononicalUrl`: sometimes called `baseURL` in other site generators. This is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `https://quartz.jzhao.xyz/` for this site). Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it.
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder.
- `theme`: configure how the site looks.
    - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
        - `header`: Font to use for headers
@@ -43,6 +44,7 @@
        - `highlight`: internal link background, highlighted text, [[syntax highlighting|highlighted lines of code]]
## Plugins
You can think of Quartz plugins as a series of transformations over content.
![[quartz-transform-pipeline.png]]
@@ -62,18 +64,19 @@
By adding, removing, and reordering plugins from the `tranformers`, `filters`, and `emitters` fields, you can customize the behaviour of Quartz.
> [!note]
> Each node is modified by every transformer *in order*. Some transformers are position-sensitive so you may need to take special note of whether it needs come before or after any other particular plugins.
> Each node is modified by every transformer _in order_. Some transformers are position-sensitive so you may need to take special note of whether it needs come before or after any other particular plugins.
Additionally, plugins may also have their own configuration settings that you can pass in. For example, the [[Latex]] plugin allows you to pass in a field specifying the `renderEngine` to choose between Katex and MathJax.
```ts
transformers: [
    Plugin.FrontMatter(),                   // uses default options
    Plugin.Latex({ renderEngine: 'katex' }) // specify some options
  Plugin.Latex({ renderEngine: "katex" }), // specify some options
]
```
### Layout
Certain emitters may also output [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML) files. To make sure that 
### Components
content/features/Latex.md
@@ -3,6 +3,7 @@
## Formatting
### Block Math
Block math can be rendered by delimiting math expression with `$$`.
```
@@ -20,20 +21,25 @@
$$
### Inline Math
Similarly, inline math can be rendered by delimiting math expression with a single `$`. For example, `$e^{i\pi} = -1$` produces $e^{i\pi} = -1$
### Escaping symbols
There will be cases where you may have more than one `$` in a paragraph at once which may accidentally trigger MathJax/Katex. 
To get around this, you can escape the dollar sign by doing `\$` instead.
For example:
- Incorrect: `I have $1 and you have $2` produces I have $1 and you have $2
- Correct: `I have \$1 and you have \$2` produces I have \$1 and you have \$2
## MathJax
In `quartz.config.ts`, you can configure Quartz to use [MathJax SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html) by replacing `Plugin.Latex({ renderEngine: 'katex' })` with `Plugin.Latex({ renderEngine: 'mathjax' })`
## Customization
- Removing Latex support: remove all instances of `Plugin.Latex()` from `quartz.config.ts`.
- Plugin: `quartz/plugins/transformers/latex.ts`
content/features/Mermaid diagrams.md
@@ -1,5 +1,5 @@
> [!warning]
> Wondering why Mermaid diagrams may not be showing up even if you have them enabled? You may need to reorder your plugins so that `Plugin.ObsidianFlavoredMarkdown()` is *after* `Plugin.SyntaxHighlighting()`.
> Wondering why Mermaid diagrams may not be showing up even if you have them enabled? You may need to reorder your plugins so that `Plugin.ObsidianFlavoredMarkdown()` is _after_ `Plugin.SyntaxHighlighting()`.
```mermaid
sequenceDiagram
content/features/SPA Routing.md
content/features/backlinks.md
@@ -7,6 +7,7 @@
A backlink for a note is a link from another note to that note. Links in the backlink pane also feature rich [[popover previews]] if you have that feature enabled.
## Customization
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.config.ts`.
- Component: `quartz/components/Backlinks.tsx`
- Style: `quartz/components/styles/backlinks.scss`
content/features/callouts.md
@@ -3,14 +3,15 @@
---
> [!warning]
> Wondering why callouts may not be showing up even if you have them enabled? You may need to reorder your plugins so that `Plugin.ObsidianFlavoredMarkdown()` is *after* `Plugin.SyntaxHighlighting()`.
> Wondering why callouts may not be showing up even if you have them enabled? You may need to reorder your plugins so that `Plugin.ObsidianFlavoredMarkdown()` is _after_ `Plugin.SyntaxHighlighting()`.
> [!info]
> Default title
> [!question]+ Can callouts be nested?
>
> > [!todo]- Yes!, they can.
> >
> > > [!example]  You can even use multiple layers of nesting.
> [!EXAMPLE] Examples
content/features/full-text search.md
@@ -14,11 +14,13 @@
> Search requires the `ContentIndex` emitter plugin to be present in the [[configuration]].
### Indexing Behaviour
By default, it indexes every page on the site with **Markdown syntax removed**. This means link URLs for instance are not indexed.
It properly tokenizes Chinese, Korean, and Japenese characters and constructs separate indexes for the title and content, weighing title matches above content matches.
## Customization
- Removing search: delete all usages of `Component.Search()` from `quartz.config.ts`.
- Component: `quartz/components/Search.tsx`
- Style: `quartz/components/styles/search.scss`
content/features/graph view.md
@@ -6,8 +6,8 @@
Quartz features a graph-view that can show both a local graph view and a global graph view. 
- The local graph view shows files that either link to the current file or are linked from the current file. In other words, it shows all notes that are *at most* one hop away.
- The global graph view can be toggled by clicking the graph icon on the top-right of the local graph view. It shows *all* the notes in your graph and how they connect to each other.
- The local graph view shows files that either link to the current file or are linked from the current file. In other words, it shows all notes that are _at most_ one hop away.
- The global graph view can be toggled by clicking the graph icon on the top-right of the local graph view. It shows _all_ the notes in your graph and how they connect to each other.
By default, the node radius is proportional to the total number of incoming and outgoing internal links from that file.
@@ -17,6 +17,7 @@
> Graph View requires the `ContentIndex` emitter plugin to be present in the [[configuration]].
## Customization
Most configuration can be done by passing in options to `Component.Graph()`.
For example, here's what the default configuration looks like:
@@ -32,7 +33,7 @@
    centerForce: 0.3, // how much force to use when trying to center the nodes
    linkDistance: 30, // how long should the links be by default?
    fontSize: 0.6,    // what size should the node labels be?
    opacityScale: 1   // how quickly do we fade out the labels when zooming out?
    opacityScale: 1, // how quickly do we fade out the labels when zooming out?
  },
  globalGraph: {
    drag: true,
@@ -43,8 +44,8 @@
    centerForce: 0.3,
    linkDistance: 30,
    fontSize: 0.6,
    opacityScale: 1
  }
    opacityScale: 1,
  },
})
```
content/features/index.md
content/features/popover previews.md
@@ -9,6 +9,7 @@
When [[creating components|creating your own components]], you can include this `popover-hint` class to also include it in the popover.
## Configuration
- Remove popovers: set the `enablePopovers` field in `quartz.config.ts` to be `false`.
- Style: `quartz/components/styles/popover.scss`
- Script: `quartz/components/scripts/popover.inline.ts`
content/features/syntax highlighting.md
@@ -12,6 +12,7 @@
> Syntax highlighting does have an impact on build speed if you have a lot of code snippets in your notes.
## Formatting
Text inside `backticks` on a line will be formatted like code.
````
@@ -37,6 +38,7 @@
```
### Titles
Add a file title to your code block, with text inside double quotes (`""`):
````
@@ -56,6 +58,7 @@
```
### Line highlighting
Place a numeric range inside `{}`.
````
@@ -75,6 +78,7 @@
```
### Word highlighting
A series of characters, like a literal regex.
````
@@ -85,11 +89,12 @@
````
```js /useState/
const [age, setAge] = useState(50);
const [name, setName] = useState('Taylor');
const [age, setAge] = useState(50)
const [name, setName] = useState("Taylor")
```
### Line numbers
Syntax highlighting has line numbers configured automatically. If you want to start line numbers at a specific number, use `showLineNumbers{number}`:
````
@@ -109,6 +114,7 @@
```
### Escaping code blocks
You can format a codeblock inside of a codeblock by wrapping it with another level of backtick fences that has one more backtick than the previous fence.
`````
@@ -121,6 +127,7 @@
`````
## Customization
- Removing syntax highlighting: delete all usages of `Plugin.SyntaxHighlighting()` from `quartz.config.ts`.
- Style: By default, Quartz uses derivatives of the GitHub light and dark themes. You can customize the colours in the `quartz/styles/syntax.scss` file.
- Plugin: `quartz/plugins/transformers/syntax.ts`
content/features/table of contents.md
content/features/upcoming features.md
content/index.md
@@ -5,6 +5,7 @@
Quartz is a fast, batteries-included static-site generator that transforms Markdown content into fully functional websites. Thousands of students, developers, and teachers are [[showcase|already using Quartz]] to publish personal notes, wikis, and [digital gardens](https://jzhao.xyz/posts/networked-thought/) to the web.
## ðŸª´ Get Started
Quartz requires **at least [Node](https://nodejs.org/) v16** to function correctly. In your terminal of choice, enter the following commands line by line:
```shell
@@ -26,7 +27,8 @@
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
- Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]]
For a comprehensive list of features, visit the [features page](/features). You can read more the *why* behind these features on the [[philosophy]] page.
For a comprehensive list of features, visit the [features page](/features). You can read more the _why_ behind these features on the [[philosophy]] page.
### ðŸš§ Troubleshooting
Having trouble with Quartz? Try searching for your issue using the search feature. If you're still having trouble, feel free to [submit an issue](https://github.com/jackyzha0/quartz/issues) if you feel you found a bug or ask for help in our [Discord Community](https://discord.gg/cRFFHYye7t).
content/philosophy.md
content/tags/component.md
globals.d.ts
@@ -1,8 +1,10 @@
export declare global {
  interface Document {
    addEventListener<K extends keyof CustomEventMap>(type: K,
      listener: (this: Document, ev: CustomEventMap[K]) => void): void;
    dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void;
    addEventListener<K extends keyof CustomEventMap>(
      type: K,
      listener: (this: Document, ev: CustomEventMap[K]) => void,
    ): void
    dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
  }
  interface Window {
    spaNavigate(url: URL, isBack: boolean = false)
index.d.ts
@@ -1,11 +1,11 @@
declare module '*.scss' {
declare module "*.scss" {
  const content: string
  export = content
}
// dom custom event
interface CustomEventMap {
  "nav": CustomEvent<{ url: CanonicalSlug }>;
  nav: CustomEvent<{ url: CanonicalSlug }>
}
declare const fetchData: Promise<ContentIndex>
quartz.config.ts
@@ -7,7 +7,7 @@
  enableSPA: true,
  enablePopovers: true,
  analytics: {
    provider: 'plausible',
    provider: "plausible",
  },
  baseUrl: "quartz.jzhao.xyz",
  ignorePatterns: ["private", "templates"],
@@ -19,27 +19,27 @@
    },
    colors: {
      lightMode: {
        light: '#faf8f8',
        lightgray: '#e5e5e5',
        gray: '#b8b8b8',
        darkgray: '#4e4e4e',
        dark: '#2b2b2b',
        secondary: '#284b63',
        tertiary: '#84a59d',
        highlight: 'rgba(143, 159, 169, 0.15)',
        light: "#faf8f8",
        lightgray: "#e5e5e5",
        gray: "#b8b8b8",
        darkgray: "#4e4e4e",
        dark: "#2b2b2b",
        secondary: "#284b63",
        tertiary: "#84a59d",
        highlight: "rgba(143, 159, 169, 0.15)",
      },
      darkMode: {
        light: '#161618',
        lightgray: '#393639',
        gray: '#646464',
        darkgray: '#d4d4d4',
        dark: '#ebebec',
        secondary: '#7b97aa',
        tertiary: '#84a59d',
        highlight: 'rgba(143, 159, 169, 0.15)',
        light: "#161618",
        lightgray: "#393639",
        gray: "#646464",
        darkgray: "#d4d4d4",
        dark: "#ebebec",
        secondary: "#7b97aa",
        tertiary: "#84a59d",
        highlight: "rgba(143, 159, 169, 0.15)",
      },
    }
  }
    },
  },
}
const sharedPageComponents = {
@@ -47,18 +47,14 @@
  header: [],
  footer: Component.Footer({
    links: {
      "GitHub": "https://github.com/jackyzha0/quartz",
      "Discord Community": "https://discord.gg/cRFFHYye7t"
    }
  })
      GitHub: "https://github.com/jackyzha0/quartz",
      "Discord Community": "https://discord.gg/cRFFHYye7t",
    },
  }),
}
const contentPageLayout: PageLayout = {
  beforeBody: [
    Component.ArticleTitle(),
    Component.ReadingTime(),
    Component.TagList(),
  ],
  beforeBody: [Component.ArticleTitle(), Component.ReadingTime(), Component.TagList()],
  left: [
    Component.PageTitle(),
    Component.MobileOnly(Component.Spacer()),
@@ -66,21 +62,16 @@
    Component.Darkmode(),
    Component.DesktopOnly(Component.TableOfContents()),
  ],
  right: [
    Component.Graph(),
    Component.Backlinks(),
  ],
  right: [Component.Graph(), Component.Backlinks()],
}
const listPageLayout: PageLayout = {
  beforeBody: [
    Component.ArticleTitle()
  ],
  beforeBody: [Component.ArticleTitle()],
  left: [
    Component.PageTitle(),
    Component.MobileOnly(Component.Spacer()),
    Component.Search(),
    Component.Darkmode()
    Component.Darkmode(),
  ],
  right: [],
}
@@ -92,18 +83,16 @@
      Plugin.FrontMatter(),
      Plugin.TableOfContents(),
      Plugin.CreatedModifiedDate({
        priority: ['frontmatter', 'filesystem'] // you can add 'git' here for last modified from Git but this makes the build slower
        priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
      }),
      Plugin.SyntaxHighlighting(),
      Plugin.ObsidianFlavoredMarkdown(),
      Plugin.GitHubFlavoredMarkdown(),
      Plugin.CrawlLinks({ markdownLinkResolution: 'shortest' }),
      Plugin.Latex({ renderEngine: 'katex' }),
      Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
      Plugin.Latex({ renderEngine: "katex" }),
      Plugin.Description(),
    ],
    filters: [
      Plugin.RemoveDrafts(),
    ],
    filters: [Plugin.RemoveDrafts()],
    emitters: [
      Plugin.AliasRedirects(),
      Plugin.ContentPage({
@@ -125,7 +114,7 @@
        enableSiteMap: true,
        enableRSS: true,
      }),
    ]
    ],
  },
}
quartz/bootstrap-cli.mjs
@@ -1,19 +1,19 @@
#!/usr/bin/env node
import { promises, readFileSync } from 'fs'
import yargs from 'yargs'
import path from 'path'
import { hideBin } from 'yargs/helpers'
import esbuild from 'esbuild'
import chalk from 'chalk'
import { sassPlugin } from 'esbuild-sass-plugin'
import fs from 'fs'
import { intro, isCancel, outro, select, text } from '@clack/prompts'
import { rimraf } from 'rimraf'
import prettyBytes from 'pretty-bytes'
import { spawnSync } from 'child_process'
import { promises, readFileSync } from "fs"
import yargs from "yargs"
import path from "path"
import { hideBin } from "yargs/helpers"
import esbuild from "esbuild"
import chalk from "chalk"
import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs"
import { intro, isCancel, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf"
import prettyBytes from "pretty-bytes"
import { spawnSync } from "child_process"
const UPSTREAM_NAME = 'upstream'
const QUARTZ_SOURCE_BRANCH = 'v4-alpha'
const UPSTREAM_NAME = "upstream"
const QUARTZ_SOURCE_BRANCH = "v4-alpha"
const cwd = process.cwd()
const cacheDir = path.join(cwd, ".quartz-cache")
const cacheFile = "./.quartz-cache/transpiled-build.mjs"
@@ -24,16 +24,16 @@
const CommonArgv = {
  directory: {
    string: true,
    alias: ['d'],
    default: 'content',
    describe: 'directory to look for content files'
    alias: ["d"],
    default: "content",
    describe: "directory to look for content files",
  },
  verbose: {
    boolean: true,
    alias: ['v'],
    alias: ["v"],
    default: false,
    describe: 'print out extra logging information'
  }
    describe: "print out extra logging information",
  },
}
const SyncArgv = {
@@ -41,47 +41,46 @@
  commit: {
    boolean: true,
    default: true,
    describe: 'create a git commit for your unsaved changes'
    describe: "create a git commit for your unsaved changes",
  },
  push: {
    boolean: true,
    default: true,
    describe: 'push updates to your Quartz fork'
    describe: "push updates to your Quartz fork",
  },
  force: {
    boolean: true,
    alias: ['f'],
    alias: ["f"],
    default: true,
    describe: 'whether to apply the --force flag to git commands'
    describe: "whether to apply the --force flag to git commands",
  },
  pull: {
    boolean: true,
    default: true,
    describe: 'pull updates from your Quartz fork'
  }
    describe: "pull updates from your Quartz fork",
  },
}
const BuildArgv = {
  ...CommonArgv,
  output: {
    string: true,
    alias: ['o'],
    default: 'public',
    describe: 'output folder for files'
    alias: ["o"],
    default: "public",
    describe: "output folder for files",
  },
  serve: {
    boolean: true,
    default: false,
    describe: 'run a local server to live-preview your Quartz'
    describe: "run a local server to live-preview your Quartz",
  },
  port: {
    number: true,
    default: 8080,
    describe: 'port to serve Quartz on'
    describe: "port to serve Quartz on",
  },
}
function escapePath(fp) {
  return fp
    .replace(/\\ /g, " ") // unescape spaces
@@ -91,7 +90,6 @@
}
function exitIfCancel(val) {
  if (isCancel(val)) {
    outro(chalk.red("Exiting"))
    process.exit(0)
@@ -101,32 +99,48 @@
}
async function stashContentFolder(contentFolder) {
  await fs.promises.cp(contentFolder, contentCacheFolder, { force: true, recursive: true, verbatimSymlinks: true, preserveTimestamps: true })
  await fs.promises.cp(contentFolder, contentCacheFolder, {
    force: true,
    recursive: true,
    verbatimSymlinks: true,
    preserveTimestamps: true,
  })
  await fs.promises.rm(contentFolder, { force: true, recursive: true })
}
async function popContentFolder(contentFolder) {
  await fs.promises.cp(contentCacheFolder, contentFolder, { force: true, recursive: true, verbatimSymlinks: true, preserveTimestamps: true })
  await fs.promises.cp(contentCacheFolder, contentFolder, {
    force: true,
    recursive: true,
    verbatimSymlinks: true,
    preserveTimestamps: true,
  })
  await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}
yargs(hideBin(process.argv))
  .scriptName("quartz")
  .version(version)
  .usage('$0 <cmd> [args]')
  .command('create', 'Initialize Quartz', CommonArgv, async argv => {
  .usage("$0 <cmd> [args]")
  .command("create", "Initialize Quartz", CommonArgv, async (argv) => {
    console.log()
    intro(chalk.bgGreen.black(` Quartz v${version} `))
    const contentFolder = path.join(cwd, argv.directory)
    const setupStrategy = exitIfCancel(await select({
    const setupStrategy = exitIfCancel(
      await select({
      message: `Choose how to initialize the content in \`${contentFolder}\``,
      options: [
        { value: 'new', label: "Empty Quartz" },
        { value: 'copy', label: "Replace with an existing folder", hint: "overwrites `content`" },
        { value: 'symlink', label: "Symlink an existing folder", hint: "don't select this unless you know what you are doing!" },
        { value: 'keep', label: "Keep the existing files" },
      ]
    }))
          { value: "new", label: "Empty Quartz" },
          { value: "copy", label: "Replace with an existing folder", hint: "overwrites `content`" },
          {
            value: "symlink",
            label: "Symlink an existing folder",
            hint: "don't select this unless you know what you are doing!",
          },
          { value: "keep", label: "Keep the existing files" },
        ],
      }),
    )
    async function rmContentFolder() {
      const contentStat = await fs.promises.lstat(contentFolder)
@@ -139,10 +153,13 @@
      }
    }
    if (setupStrategy === 'copy' || setupStrategy === 'symlink') {
      const originalFolder = escapePath(exitIfCancel(await text({
    if (setupStrategy === "copy" || setupStrategy === "symlink") {
      const originalFolder = escapePath(
        exitIfCancel(
          await text({
        message: "Enter the full path to existing content folder",
        placeholder: 'On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path',
            placeholder:
              "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path",
        validate(fp) {
          const fullPath = escapePath(fp)
          if (!fs.existsSync(fullPath)) {
@@ -150,43 +167,63 @@
          } else if (!fs.lstatSync(fullPath).isDirectory()) {
            return "The given path is not a folder"
          }
        }
      })))
            },
          }),
        ),
      )
      await rmContentFolder()
      if (setupStrategy === 'copy') {
      if (setupStrategy === "copy") {
        await fs.promises.cp(originalFolder, contentFolder, { recursive: true })
      } else if (setupStrategy === 'symlink') {
        await fs.promises.symlink(originalFolder, contentFolder, 'dir')
      } else if (setupStrategy === "symlink") {
        await fs.promises.symlink(originalFolder, contentFolder, "dir")
      }
    } else if (setupStrategy === 'new') {
    } else if (setupStrategy === "new") {
      await rmContentFolder()
      await fs.promises.mkdir(contentFolder)
      await fs.promises.writeFile(path.join(contentFolder, "index.md"),
      await fs.promises.writeFile(
        path.join(contentFolder, "index.md"),
        `---
title: Welcome to Quartz
---
This is a blank Quartz installation.
See the [documentation](https://quartz.jzhao.xyz) for how to get started.
`
`,
      )
    }
    
    // get a prefered link resolution strategy
    const linkResolutionStrategy = exitIfCancel(await select({
    const linkResolutionStrategy = exitIfCancel(
      await select({
      message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`,
      options: [
        { value: 'absolute', 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', label: "Treat links as relative paths", hint: "for just normal Markdown files" },
      ]
    }))
          {
            value: "absolute",
            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",
            label: "Treat links as relative paths",
            hint: "for just normal Markdown files",
          },
        ],
      }),
    )
    // now, do config changes
    const configFilePath = path.join(cwd, "quartz.config.ts")
    let configContent = await fs.promises.readFile(configFilePath, { encoding: 'utf-8' })
    configContent = configContent.replace(/markdownLinkResolution: '(.+)'/, `markdownLinkResolution: '${linkResolutionStrategy}'`)
    let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
    configContent = configContent.replace(
      /markdownLinkResolution: '(.+)'/,
      `markdownLinkResolution: '${linkResolutionStrategy}'`,
    )
    await fs.promises.writeFile(configFilePath, configContent)
    outro(`You're all set! Not sure what to do next? Try:
@@ -195,46 +232,54 @@
   â€¢ Hosting your Quartz online (see: https://quartz.jzhao.xyz/setup/hosting)
`)
  })
  .command('update', 'Get the latest Quartz updates', CommonArgv, async argv => {
  .command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
    const contentFolder = path.join(cwd, argv.directory)
    console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
    console.log('Backing up your content')
    console.log("Backing up your content")
    await stashContentFolder(contentFolder)
    console.log("Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.")
    spawnSync('git', ['pull', UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH], { stdio: 'inherit' })
    console.log(
      "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
    )
    spawnSync("git", ["pull", UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
    await popContentFolder(contentFolder)
    console.log(chalk.green('Done!'))
    console.log(chalk.green("Done!"))
  })
  .command('sync', 'Sync your Quartz to and from GitHub.', SyncArgv, async argv => {
  .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => {
    const contentFolder = path.join(cwd, argv.directory)
    console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`))
    console.log('Backing up your content')
    console.log("Backing up your content")
    if (argv.commit) {
      const currentTimestamp = new Date().toLocaleString('en-US', { dateStyle: "medium", timeStyle: "short" })
      spawnSync('git', ['commit', '-am', `Quartz sync: ${currentTimestamp}`], { stdio: 'inherit' })
      const currentTimestamp = new Date().toLocaleString("en-US", {
        dateStyle: "medium",
        timeStyle: "short",
      })
      spawnSync("git", ["commit", "-am", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" })
    }
    await stashContentFolder(contentFolder)
    if (argv.pull) {
      console.log("Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.")
      spawnSync('git', ['pull', 'origin', QUARTZ_SOURCE_BRANCH], { stdio: 'inherit' })
      console.log(
        "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.",
      )
      spawnSync("git", ["pull", "origin", QUARTZ_SOURCE_BRANCH], { stdio: "inherit" })
    }
    await popContentFolder(contentFolder)
    if (argv.push) {
      console.log("Pushing your changes")
      const args = argv.force ?
        ['push', '-f', 'origin', QUARTZ_SOURCE_BRANCH] :
        ['push', 'origin', QUARTZ_SOURCE_BRANCH]
      spawnSync('git', args, { stdio: 'inherit' })
      const args = argv.force
        ? ["push", "-f", "origin", QUARTZ_SOURCE_BRANCH]
        : ["push", "origin", QUARTZ_SOURCE_BRANCH]
      spawnSync("git", args, { stdio: "inherit" })
    }
    console.log(chalk.green('Done!'))
    console.log(chalk.green("Done!"))
  })
  .command('build', 'Build Quartz into a bundle of static HTML files', BuildArgv, async argv => {
    const result = await esbuild.build({
  .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
    const result = await esbuild
      .build({
      entryPoints: [fp],
      outfile: path.join("quartz", cacheFile),
      bundle: true,
@@ -248,24 +293,24 @@
      sourcemap: true,
      plugins: [
        sassPlugin({
          type: 'css-text',
            type: "css-text",
        }),
        {
          name: 'inline-script-loader',
            name: "inline-script-loader",
          setup(build) {
            build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => {
              let text = await promises.readFile(args.path, 'utf8')
                let text = await promises.readFile(args.path, "utf8")
              // remove default exports that we manually inserted
              text = text.replace('export default', '')
              text = text.replace('export', '')
                text = text.replace("export default", "")
                text = text.replace("export", "")
              const sourcefile = path.relative(path.resolve('.'), args.path)
                const sourcefile = path.relative(path.resolve("."), args.path)
              const resolveDir = path.dirname(sourcefile)
              const transpiled = await esbuild.build({
                stdin: {
                  contents: text,
                  loader: 'ts',
                    loader: "ts",
                  resolveDir,
                  sourcefile,
                },
@@ -277,23 +322,30 @@
              const rawMod = transpiled.outputFiles[0].text
              return {
                contents: rawMod,
                loader: 'text',
                  loader: "text",
              }
            })
          }
        }
      ]
    }).catch(err => {
            },
          },
        ],
      })
      .catch((err) => {
      console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`)
      console.log(`Reason: ${chalk.grey(err)}`)
      console.log("hint: make sure all the required dependencies are installed (run `npm install`)")
        console.log(
          "hint: make sure all the required dependencies are installed (run `npm install`)",
        )
      process.exit(1)
    })
    if (argv.verbose) {
      const outputFileName = 'quartz/.quartz-cache/transpiled-build.mjs'
      const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs"
      const meta = result.metafile.outputs[outputFileName]
      console.log(`Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(meta.bytes)})`)
      console.log(
        `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes(
          meta.bytes,
        )})`,
      )
    }
    const { default: buildQuartz } = await import(cacheFile)
@@ -302,5 +354,4 @@
  .showHelpOnFail(false)
  .help()
  .strict()
  .demandCommand()
  .argv
  .demandCommand().argv
quartz/bootstrap-worker.mjs
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import workerpool from 'workerpool'
import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseFiles } = await import(cacheFile)
workerpool.worker({
  parseFiles
  parseFiles,
})
quartz/build.ts
@@ -1,4 +1,4 @@
import 'source-map-support/register.js'
import "source-map-support/register.js"
import path from "path"
import { PerfTimer } from "./perf"
import { rimraf } from "rimraf"
@@ -12,8 +12,8 @@
import cfg from "../quartz.config"
import { FilePath } from "./path"
import chokidar from "chokidar"
import { ProcessedContent } from './plugins/vfile'
import WebSocket, { WebSocketServer } from 'ws'
import { ProcessedContent } from "./plugins/vfile"
import WebSocket, { WebSocketServer } from "ws"
interface Argv {
  directory: string
@@ -29,30 +29,38 @@
  const output = argv.output
  const pluginCount = Object.values(cfg.plugins).flat().length
  const pluginNames = (key: 'transformers' | 'filters' | 'emitters') => cfg.plugins[key].map(plugin => plugin.name)
  const pluginNames = (key: "transformers" | "filters" | "emitters") =>
    cfg.plugins[key].map((plugin) => plugin.name)
  if (argv.verbose) {
    console.log(`Loaded ${pluginCount} plugins`)
    console.log(`  Transformers: ${pluginNames('transformers').join(", ")}`)
    console.log(`  Filters: ${pluginNames('filters').join(", ")}`)
    console.log(`  Emitters: ${pluginNames('emitters').join(", ")}`)
    console.log(`  Transformers: ${pluginNames("transformers").join(", ")}`)
    console.log(`  Filters: ${pluginNames("filters").join(", ")}`)
    console.log(`  Emitters: ${pluginNames("emitters").join(", ")}`)
  }
  // clean
  perf.addEvent('clean')
  perf.addEvent("clean")
  await rimraf(output)
  console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince('clean')}`)
  console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
  // glob
  perf.addEvent('glob')
  const fps = await globby('**/*.md', {
  perf.addEvent("glob")
  const fps = await globby("**/*.md", {
    cwd: argv.directory,
    ignore: cfg.configuration.ignorePatterns,
    gitignore: true,
  })
  console.log(`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince('glob')}`)
  console.log(
    `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
  )
  const filePaths = fps.map(fp => `${argv.directory}${path.sep}${fp}` as FilePath)
  const parsedFiles = await parseMarkdown(cfg.plugins.transformers, argv.directory, filePaths, argv.verbose)
  const filePaths = fps.map((fp) => `${argv.directory}${path.sep}${fp}` as FilePath)
  const parsedFiles = await parseMarkdown(
    cfg.plugins.transformers,
    argv.directory,
    filePaths,
    argv.verbose,
  )
  const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose)
  await emitContent(argv.directory, output, cfg, filteredContent, argv.serve, argv.verbose)
  console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
@@ -60,7 +68,7 @@
  if (argv.serve) {
    const wss = new WebSocketServer({ port: 3001 })
    const connections: WebSocket[] = []
    wss.on('connection', ws => connections.push(ws))
    wss.on("connection", (ws) => connections.push(ws))
    const ignored = await isGitIgnored()
    const contentMap = new Map<FilePath, ProcessedContent>()
@@ -69,15 +77,20 @@
      contentMap.set(vfile.data.filePath!, content)
    }
    async function rebuild(fp: string, action: 'add' | 'change' | 'unlink') {
      perf.addEvent('rebuild')
    async function rebuild(fp: string, action: "add" | "change" | "unlink") {
      perf.addEvent("rebuild")
      if (!ignored(fp)) {
        console.log(chalk.yellow(`Detected change in ${fp}, rebuilding...`))
        const fullPath = `${argv.directory}${path.sep}${fp}` as FilePath
        if (action === 'add' || action === 'change') {
          const [parsedContent] = await parseMarkdown(cfg.plugins.transformers, argv.directory, [fullPath], argv.verbose)
        if (action === "add" || action === "change") {
          const [parsedContent] = await parseMarkdown(
            cfg.plugins.transformers,
            argv.directory,
            [fullPath],
            argv.verbose,
          )
          contentMap.set(fullPath, parsedContent)
        } else if (action === 'unlink') {
        } else if (action === "unlink") {
          contentMap.delete(fullPath)
        }
@@ -85,21 +98,21 @@
        const parsedFiles = [...contentMap.values()]
        const filteredContent = filterContent(cfg.plugins.filters, parsedFiles, argv.verbose)
        await emitContent(argv.directory, output, cfg, filteredContent, argv.serve, argv.verbose)
        console.log(chalk.green(`Done rebuilding in ${perf.timeSince('rebuild')}`))
        connections.forEach(conn => conn.send('rebuild'))
        console.log(chalk.green(`Done rebuilding in ${perf.timeSince("rebuild")}`))
        connections.forEach((conn) => conn.send("rebuild"))
      }
    }
    const watcher = chokidar.watch('.', {
    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, 'unlink'))
      .on("add", (fp) => rebuild(fp, "add"))
      .on("change", (fp) => rebuild(fp, "change"))
      .on("unlink", (fp) => rebuild(fp, "unlink"))
    const server = http.createServer(async (req, res) => {
      await serveHandler(req, res, {
@@ -107,15 +120,16 @@
        directoryListing: false,
      })
      const status = res.statusCode
      const statusString = (status >= 200 && status < 300) ?
        chalk.green(`[${status}]`) :
        (status >= 300 && status < 400) ?
          chalk.yellow(`[${status}]`) :
          chalk.red(`[${status}]`)
      const statusString =
        status >= 200 && status < 300
          ? chalk.green(`[${status}]`)
          : status >= 300 && status < 400
          ? chalk.yellow(`[${status}]`)
          : chalk.red(`[${status}]`)
      console.log(statusString + chalk.grey(` ${req.url}`))
    })
    server.listen(argv.port)
    console.log(chalk.cyan(`Started a Quartz server listening at http://localhost:${argv.port}`))
    console.log('hint: exit with ctrl+c')
    console.log("hint: exit with ctrl+c")
  }
}
quartz/cfg.ts
@@ -5,43 +5,43 @@
export type Analytics =
  | null
  | {
    provider: 'plausible'
      provider: "plausible"
  }
  | {
    provider: 'google',
      provider: "google"
    tagId: string
  }
export interface GlobalConfiguration {
  pageTitle: string,
  pageTitle: string
  /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
  enableSPA: boolean,
  enableSPA: boolean
  /** Whether to display Wikipedia-style popovers when hovering over links */
  enablePopovers: boolean,
  enablePopovers: boolean
  /** Analytics mode */
  analytics: Analytics
  /** Glob patterns to not search */
  ignorePatterns: string[],
  ignorePatterns: string[]
  /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.
  *   Quartz will avoid using this as much as possible and use relative URLs most of the time  
  */
  baseUrl?: string,
  baseUrl?: string
  theme: Theme
}
export interface QuartzConfig {
  configuration: GlobalConfiguration,
  plugins: PluginTypes,
  configuration: GlobalConfiguration
  plugins: PluginTypes
}
export interface FullPageLayout {
  head: QuartzComponent
  header: QuartzComponent[],
  beforeBody: QuartzComponent[],
  pageBody: QuartzComponent,
  left: QuartzComponent[],
  right: QuartzComponent[],
  footer: QuartzComponent,
  header: QuartzComponent[]
  beforeBody: QuartzComponent[]
  pageBody: QuartzComponent
  left: QuartzComponent[]
  right: QuartzComponent[]
  footer: QuartzComponent
}
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
quartz/components/Backlinks.tsx
@@ -4,15 +4,25 @@
function Backlinks({ fileData, allFiles }: QuartzComponentProps) {
  const slug = canonicalizeServer(fileData.slug!)
  const backlinkFiles = allFiles.filter(file => file.links?.includes(slug))
  return <div class="backlinks">
  const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
  return (
    <div class="backlinks">
    <h3>Backlinks</h3>
    <ul class="overflow">
      {backlinkFiles.length > 0 ?
        backlinkFiles.map(f => <li><a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal">{f.frontmatter?.title}</a></li>)
        : <li>No backlinks found</li>}
        {backlinkFiles.length > 0 ? (
          backlinkFiles.map((f) => (
            <li>
              <a href={resolveRelative(slug, canonicalizeServer(f.slug!))} class="internal">
                {f.frontmatter?.title}
              </a>
            </li>
          ))
        ) : (
          <li>No backlinks found</li>
        )}
    </ul>
  </div>
  )
}
Backlinks.css = style
quartz/components/Body.tsx
@@ -1,16 +1,13 @@
// @ts-ignore
import clipboardScript from './scripts/clipboard.inline'
import clipboardStyle from './styles/clipboard.scss'
import clipboardScript from "./scripts/clipboard.inline"
import clipboardStyle from "./styles/clipboard.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Body({ children }: QuartzComponentProps) {
  return <div id="quartz-body">
    {children}
  </div>
  return <div id="quartz-body">{children}</div>
}
Body.afterDOMLoaded = clipboardScript
Body.css = clipboardStyle
export default (() => Body) satisfies QuartzComponentConstructor
quartz/components/Darkmode.tsx
@@ -2,11 +2,12 @@
// modules are automatically deferred and we don't want that to happen for critical beforeDOMLoads
// see: https://v8.dev/features/modules#defer
import darkmodeScript from "./scripts/darkmode.inline"
import styles from './styles/darkmode.scss'
import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor } from "./types"
function Darkmode() {
  return <div class="darkmode">
  return (
    <div class="darkmode">
    <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
    <label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
      <svg
@@ -21,9 +22,7 @@
        xmlSpace="preserve"
      >
        <title>Light mode</title>
        <path
          d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5    S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5    C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6    C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9    c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44    l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5    c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06    L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z     M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2    C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29    c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7    C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5    c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"
        ></path>
          <path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5    S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5    C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6    C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9    c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44    l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5    c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06    L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z     M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2    C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29    c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7    C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5    c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
      </svg>
    </label>
    <label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}>
@@ -39,12 +38,11 @@
        xmlSpace="preserve"
      >
        <title>Dark mode</title>
        <path
          d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571  C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23  c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369  c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65  c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"
        ></path>
          <path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571  C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23  c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369  c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65  c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
      </svg>
    </label>
  </div>
  )
}
Darkmode.beforeDOMLoaded = darkmodeScript
quartz/components/Date.tsx
@@ -3,10 +3,10 @@
}
export function Date({ date }: Props) {
  const formattedDate = date.toLocaleDateString('en-US', {
  const formattedDate = date.toLocaleDateString("en-US", {
    year: "numeric",
    month: "short",
    day: '2-digit'
    day: "2-digit",
  })
  return <>{formattedDate}</>
}
quartz/components/Footer.tsx
@@ -10,13 +10,21 @@
  function Footer() {
    const year = new Date().getFullYear()
    const links = opts?.links ?? []
    return <footer>
    return (
      <footer>
      <hr />
      <p>Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, Â© {year}</p>
      <ul>{Object.entries(links).map(([text, link]) => <li>
        <p>
          Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, Â© {year}
        </p>
        <ul>
          {Object.entries(links).map(([text, link]) => (
            <li>
        <a href={link}>{text}</a>
      </li>)}</ul>
            </li>
          ))}
        </ul>
    </footer>
    )
  }
  Footer.css = style
quartz/components/Graph.tsx
@@ -4,19 +4,19 @@
import style from "./styles/graph.scss"
export interface D3Config {
  drag: boolean,
  zoom: boolean,
  depth: number,
  scale: number,
  repelForce: number,
  centerForce: number,
  linkDistance: number,
  fontSize: number,
  drag: boolean
  zoom: boolean
  depth: number
  scale: number
  repelForce: number
  centerForce: number
  linkDistance: number
  fontSize: number
  opacityScale: number
}
interface GraphOptions {
  localGraph: Partial<D3Config> | undefined,
  localGraph: Partial<D3Config> | undefined
  globalGraph: Partial<D3Config> | undefined
}
@@ -30,7 +30,7 @@
    centerForce: 0.3,
    linkDistance: 30,
    fontSize: 0.6,
    opacityScale: 1
    opacityScale: 1,
  },
  globalGraph: {
    drag: true,
@@ -41,21 +41,32 @@
    centerForce: 0.3,
    linkDistance: 30,
    fontSize: 0.6,
    opacityScale: 1
  }
    opacityScale: 1,
  },
}
export default ((opts?: GraphOptions) => {
  function Graph() {
    const localGraph = { ...opts?.localGraph, ...defaultOptions.localGraph }
    const globalGraph = { ...opts?.globalGraph, ...defaultOptions.globalGraph }
    return <div class="graph">
    return (
      <div class="graph">
      <h3>Graph View</h3>
      <div class="graph-outer">
        <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
        <svg version="1.1" id="global-graph-icon" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
          viewBox="0 0 55 55" fill="currentColor" xmlSpace="preserve">
          <path d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
          <svg
            version="1.1"
            id="global-graph-icon"
            xmlns="http://www.w3.org/2000/svg"
            xmlnsXlink="http://www.w3.org/1999/xlink"
            x="0px"
            y="0px"
            viewBox="0 0 55 55"
            fill="currentColor"
            xmlSpace="preserve"
          >
            <path
              d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
    s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
    c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
    C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
@@ -65,13 +76,15 @@
    C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
    S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
    s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
    s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"/>
    s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
            />
        </svg>
      </div>
      <div id="global-graph-outer">
        <div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
      </div>
    </div>
    )
  }
  Graph.css = style
quartz/components/Head.tsx
@@ -12,7 +12,8 @@
    const iconPath = baseDir + "/static/icon.png"
    const ogImagePath = baseDir + "/static/og-image.png"
    return <head>
    return (
      <head>
      <title>{title}</title>
      <meta charSet="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -26,9 +27,14 @@
      <meta name="generator" content="Quartz" />
      <link rel="preconnect" href="https://fonts.googleapis.com"/>
      <link rel="preconnect" href="https://fonts.gstatic.com"/>
      {css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />)}
      {js.filter(resource => resource.loadTime === "beforeDOMReady").map(res => JSResourceToScriptElement(res, true))}
        {css.map((href) => (
          <link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />
        ))}
        {js
          .filter((resource) => resource.loadTime === "beforeDOMReady")
          .map((res) => JSResourceToScriptElement(res, true))}
    </head>
    )
  }
  return Head
quartz/components/Header.tsx
@@ -1,9 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Header({ children }: QuartzComponentProps) {
  return (children.length > 0) ? <header>
    {children}
  </header> : null
  return children.length > 0 ? <header>{children}</header> : null
}
Header.css = `
quartz/components/PageList.tsx
@@ -22,27 +22,46 @@
export function PageList({ fileData, allFiles }: QuartzComponentProps) {
  const slug = canonicalizeServer(fileData.slug!)
  return <ul class="section-ul">
    {allFiles.sort(byDateAndAlphabetical).map(page => {
  return (
    <ul class="section-ul">
      {allFiles.sort(byDateAndAlphabetical).map((page) => {
      const title = page.frontmatter?.title
      const pageSlug = canonicalizeServer(page.slug!)
      const tags = page.frontmatter?.tags ?? []
      return <li class="section-li">
        return (
          <li class="section-li">
        <div class="section">
          {page.dates && <p class="meta">
              {page.dates && (
                <p class="meta">
            <Date date={page.dates.modified} />
          </p>}
                </p>
              )}
          <div class="desc">
            <h3><a href={resolveRelative(slug, pageSlug)} class="internal">{title}</a></h3>
                <h3>
                  <a href={resolveRelative(slug, pageSlug)} class="internal">
                    {title}
                  </a>
                </h3>
          </div>
          <ul class="tags">
            {tags.map(tag => <li><a class="internal" href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)}>#{tag}</a></li>)}
                {tags.map((tag) => (
                  <li>
                    <a
                      class="internal"
                      href={resolveRelative(slug, `tags/${tag}` as CanonicalSlug)}
                    >
                      #{tag}
                    </a>
                  </li>
                ))}
          </ul>
        </div>
      </li>
        )
    })}
  </ul>
  )
}
PageList.css = `
quartz/components/PageTitle.tsx
@@ -5,7 +5,11 @@
  const title = cfg?.pageTitle ?? "Untitled Quartz"
  const slug = canonicalizeServer(fileData.slug!)
  const baseDir = pathToRoot(slug)
  return <h1 class="page-title"><a href={baseDir}>{title}</a></h1>
  return (
    <h1 class="page-title">
      <a href={baseDir}>{title}</a>
    </h1>
  )
}
PageTitle.css = `
quartz/components/ReadingTime.tsx
@@ -5,7 +5,11 @@
  const text = fileData.text
  if (text) {
    const { text: timeTaken, words } = readingTime(text)
    return <p class="reading-time">{words} words, {timeTaken}</p>
    return (
      <p class="reading-time">
        {words} words, {timeTaken}
      </p>
    )
  } else {
    return null
  }
quartz/components/Search.tsx
@@ -5,11 +5,18 @@
export default (() => {
  function Search() {
    return <div class="search">
    return (
      <div class="search">
      <div id="search-icon">
        <p>Search</p>
        <div></div>
        <svg tabIndex={0} aria-labelledby="title desc" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
          <svg
            tabIndex={0}
            aria-labelledby="title desc"
            role="img"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 19.9 19.7"
          >
          <title id="title">Search</title>
          <desc id="desc">Search</desc>
          <g class="search-path" fill="none">
@@ -20,12 +27,19 @@
      </div>
      <div id="search-container">
        <div id="search-space">
          <input autocomplete="off" id="search-bar" name="search" type="text" aria-label="Search for something" placeholder="Search for something" />
          <div id="results-container">
            <input
              autocomplete="off"
              id="search-bar"
              name="search"
              type="text"
              aria-label="Search for something"
              placeholder="Search for something"
            />
            <div id="results-container"></div>
          </div>
        </div>
      </div>
    </div>
    )
  }
  Search.afterDOMLoaded = script
quartz/components/TableOfContents.tsx
@@ -6,11 +6,11 @@
import script from "./scripts/toc.inline"
interface Options {
  layout: 'modern' | 'legacy'
  layout: "modern" | "legacy"
}
const defaultOptions: Options = {
  layout: 'modern'
  layout: "modern",
}
function TableOfContents({ fileData }: QuartzComponentProps) {
@@ -18,21 +18,38 @@
    return null
  }
  return <div class="desktop-only">
  return (
    <div class="desktop-only">
    <button type="button" id="toc">
      <h3>Table of Contents</h3>
      <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">
        <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>
    </button>
    <div id="toc-content">
      <ul class="overflow">
        {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
          <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
        </li>)}
          {fileData.toc.map((tocEntry) => (
            <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
              <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
                {tocEntry.text}
              </a>
            </li>
          ))}
      </ul>
    </div>
  </div>
  )
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
@@ -42,16 +59,22 @@
    return null
  }
  return <details id="toc" open>
  return (
    <details id="toc" open>
    <summary>
      <h3>Table of Contents</h3>
    </summary>
    <ul>
      {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
        <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
      </li>)}
        {fileData.toc.map((tocEntry) => (
          <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
            <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
              {tocEntry.text}
            </a>
          </li>
        ))}
    </ul>
  </details>
  )
}
LegacyTableOfContents.css = legacyStyle
quartz/components/TagList.tsx
@@ -1,19 +1,27 @@
import { canonicalizeServer, pathToRoot } from "../path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { slug as slugAnchor } from 'github-slugger'
import { slug as slugAnchor } from "github-slugger"
function TagList({ fileData }: QuartzComponentProps) {
  const tags = fileData.frontmatter?.tags
  const slug = canonicalizeServer(fileData.slug!)
  const baseDir = pathToRoot(slug)
  if (tags && tags.length > 0) {
    return <ul class="tags">{tags.map(tag => {
    return (
      <ul class="tags">
        {tags.map((tag) => {
      const display = `#${tag}`
      const linkDest = baseDir + `/tags/${slugAnchor(tag)}`
      return <li>
        <a href={linkDest} class="internal">{display}</a>
          return (
            <li>
              <a href={linkDest} class="internal">
                {display}
              </a>
      </li>
    })}</ul>
          )
        })}
      </ul>
    )
  } else {
    return null
  }
quartz/components/index.ts
@@ -33,5 +33,5 @@
  Search,
  Footer,
  DesktopOnly,
  MobileOnly
  MobileOnly,
quartz/components/pages/Content.tsx
@@ -1,10 +1,10 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
function Content({ tree }: QuartzComponentProps) {
  // @ts-ignore (preact makes it angry)
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
  return <article class="popover-hint">{content}</article>
}
quartz/components/pages/FolderContent.tsx
@@ -1,16 +1,16 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import path from "path"
import style from '../styles/listPage.scss'
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { canonicalizeServer } from "../../path"
function FolderContent(props: QuartzComponentProps) {
  const { tree, fileData, allFiles } = props
  const folderSlug = canonicalizeServer(fileData.slug!)
  const allPagesInFolder = allFiles.filter(file => {
  const allPagesInFolder = allFiles.filter((file) => {
    const fileSlug = file.slug ?? ""
    const prefixed = fileSlug.startsWith(folderSlug)
    const folderParts = folderSlug.split(path.posix.sep)
@@ -21,18 +21,20 @@
  const listProps = {
    ...props,
    allFiles: allPagesInFolder
    allFiles: allPagesInFolder,
  }
  
  // @ts-ignore
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
  return <div class="popover-hint">
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
  return (
    <div class="popover-hint">
    <article>{content}</article>
    <p>{allPagesInFolder.length} items under this folder.</p>
    <div>
      <PageList {...listProps} /> 
    </div>
  </div>
  )
}
FolderContent.css = style + PageList.css
quartz/components/pages/TagContent.tsx
@@ -1,7 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import style from '../styles/listPage.scss'
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { ServerSlug, canonicalizeServer } from "../../path"
@@ -11,21 +11,23 @@
  if (slug?.startsWith("tags/")) {
    const tag = canonicalizeServer(slug.slice("tags/".length) as ServerSlug)
    const allPagesWithTag = allFiles.filter(file => (file.frontmatter?.tags ?? []).includes(tag))
    const allPagesWithTag = allFiles.filter((file) => (file.frontmatter?.tags ?? []).includes(tag))
    const listProps = {
      ...props,
      allFiles: allPagesWithTag
      allFiles: allPagesWithTag,
    }
    // @ts-ignore
    const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
    return <div class="popover-hint">
    const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: "html" })
    return (
      <div class="popover-hint">
      <article>{content}</article>
      <p>{allPagesWithTag.length} items with this tag.</p>
      <div>
        <PageList {...listProps} />
      </div>
    </div>
    )
  } else {
    throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
  }
quartz/components/renderPage.tsx
@@ -1,21 +1,24 @@
import { render } from "preact-render-to-string";
import { QuartzComponent, QuartzComponentProps } from "./types";
import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../resources";
import { CanonicalSlug, pathToRoot } from "../path";
import { JSResourceToScriptElement, StaticResources } from "../resources"
import { CanonicalSlug, pathToRoot } from "../path"
interface RenderComponents {
  head: QuartzComponent
  header: QuartzComponent[],
  beforeBody: QuartzComponent[],
  pageBody: QuartzComponent,
  left: QuartzComponent[],
  right: QuartzComponent[],
  footer: QuartzComponent,
  header: QuartzComponent[]
  beforeBody: QuartzComponent[]
  pageBody: QuartzComponent
  left: QuartzComponent[]
  right: QuartzComponent[]
  footer: QuartzComponent
}
export function pageResources(slug: CanonicalSlug, staticResources: StaticResources): StaticResources {
export function pageResources(
  slug: CanonicalSlug,
  staticResources: StaticResources,
): StaticResources {
  const baseDir = pathToRoot(slug)
  const contentIndexPath = baseDir + "/static/contentIndex.json"
@@ -25,29 +28,59 @@
    css: [baseDir + "/index.css", ...staticResources.css],
    js: [
      { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", contentType: "external" },
      { loadTime: "beforeDOMReady", contentType: "inline", spaPreserve: true, script: contentIndexScript },
      {
        loadTime: "beforeDOMReady",
        contentType: "inline",
        spaPreserve: true,
        script: contentIndexScript,
      },
      ...staticResources.js,
      { src: baseDir + "/postscript.js", loadTime: "afterDOMReady", moduleType: 'module', contentType: "external" }
    ]
      {
        src: baseDir + "/postscript.js",
        loadTime: "afterDOMReady",
        moduleType: "module",
        contentType: "external",
      },
    ],
  }
}
export function renderPage(slug: CanonicalSlug, componentData: QuartzComponentProps, components: RenderComponents, pageResources: StaticResources): string {
  const { head: Head, header, beforeBody, pageBody: Content, left, right, footer: Footer } = components
export function renderPage(
  slug: CanonicalSlug,
  componentData: QuartzComponentProps,
  components: RenderComponents,
  pageResources: StaticResources,
): string {
  const {
    head: Head,
    header,
    beforeBody,
    pageBody: Content,
    left,
    right,
    footer: Footer,
  } = components
  const Header = HeaderConstructor()
  const Body = BodyConstructor()
  const LeftComponent =
  const LeftComponent = (
    <div class="left sidebar">
      {left.map(BodyComponent => <BodyComponent {...componentData} />)}
      {left.map((BodyComponent) => (
        <BodyComponent {...componentData} />
      ))}
    </div>
  )
  const RightComponent =
  const RightComponent = (
    <div class="right sidebar">
      {right.map(BodyComponent => <BodyComponent {...componentData} />)}
      {right.map((BodyComponent) => (
        <BodyComponent {...componentData} />
      ))}
    </div>
  )
  const doc = <html>
  const doc = (
    <html>
    <Head {...componentData} />
    <body data-slug={slug}>
      <div id="quartz-root" class="page">
@@ -56,10 +89,14 @@
          <div class="center">
            <div class="page-header">
              <Header {...componentData} >
                {header.map(HeaderComponent => <HeaderComponent {...componentData} />)}
                  {header.map((HeaderComponent) => (
                    <HeaderComponent {...componentData} />
                  ))}
              </Header>
              <div class="popover-hint">
                {beforeBody.map(BodyComponent => <BodyComponent {...componentData} />)}
                  {beforeBody.map((BodyComponent) => (
                    <BodyComponent {...componentData} />
                  ))}
              </div>
            </div>
            <Content {...componentData} />
@@ -69,8 +106,11 @@
        <Footer {...componentData} />
      </div>
    </body>
    {pageResources.js.filter(resource => resource.loadTime === "afterDOMReady").map(res => JSResourceToScriptElement(res))}
      {pageResources.js
        .filter((resource) => resource.loadTime === "afterDOMReady")
        .map((res) => JSResourceToScriptElement(res))}
  </html>
  )
  return "<!DOCTYPE html>\n" + render(doc)
}
quartz/components/scripts/callout.inline.ts
@@ -7,7 +7,9 @@
}
function setupCallout() {
  const collapsible = document.getElementsByClassName(`callout is-collapsible`) as HTMLCollectionOf<HTMLElement>
  const collapsible = document.getElementsByClassName(
    `callout is-collapsible`,
  ) as HTMLCollectionOf<HTMLElement>
  for (const div of collapsible) {
    const title = div.firstElementChild
quartz/components/scripts/darkmode.inline.ts
@@ -1,24 +1,23 @@
const userPref = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
const currentTheme = localStorage.getItem('theme') ?? userPref
document.documentElement.setAttribute('saved-theme', currentTheme)
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
const currentTheme = localStorage.getItem("theme") ?? userPref
document.documentElement.setAttribute("saved-theme", currentTheme)
document.addEventListener("nav", () => {
  const switchTheme = (e: any) => {
    if (e.target.checked) {
      document.documentElement.setAttribute('saved-theme', 'dark')
      localStorage.setItem('theme', 'dark')
    }
    else {
      document.documentElement.setAttribute('saved-theme', 'light')
      localStorage.setItem('theme', 'light')
      document.documentElement.setAttribute("saved-theme", "dark")
      localStorage.setItem("theme", "dark")
    } else {
      document.documentElement.setAttribute("saved-theme", "light")
      localStorage.setItem("theme", "light")
    }
  }
  // Darkmode toggle
  const toggleSwitch = document.querySelector('#darkmode-toggle') as HTMLInputElement
  toggleSwitch.removeEventListener('change', switchTheme)
  toggleSwitch.addEventListener('change', switchTheme)
  if (currentTheme === 'dark') {
  const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
  toggleSwitch.removeEventListener("change", switchTheme)
  toggleSwitch.addEventListener("change", switchTheme)
  if (currentTheme === "dark") {
    toggleSwitch.checked = true
  }
})
quartz/components/scripts/graph.inline.ts
@@ -1,16 +1,16 @@
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from 'd3'
import * as d3 from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { CanonicalSlug, getCanonicalSlug, getClientSlug, resolveRelative } from "../../path"
type NodeData = {
  id: CanonicalSlug,
  text: string,
  id: CanonicalSlug
  text: string
  tags: string[]
} & d3.SimulationNodeDatum
type LinkData = {
  source: CanonicalSlug,
  source: CanonicalSlug
  target: CanonicalSlug
}
@@ -40,7 +40,7 @@
    centerForce,
    linkDistance,
    fontSize,
    opacityScale
    opacityScale,
  } = JSON.parse(graph.dataset["cfg"]!)
  const data = await fetchData
@@ -66,18 +66,22 @@
        wl.push("__SENTINEL")
      } else {
        neighbourhood.add(cur)
        const outgoing = links.filter(l => l.source === cur)
        const incoming = links.filter(l => l.target === cur)
        const outgoing = links.filter((l) => l.source === cur)
        const incoming = links.filter((l) => l.target === cur)
        wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
      }
    }
  } else {
    Object.keys(data).forEach(id => neighbourhood.add(id as CanonicalSlug))
    Object.keys(data).forEach((id) => neighbourhood.add(id as CanonicalSlug))
  }
  const graphData: { nodes: NodeData[], links: LinkData[] } = {
    nodes: [...neighbourhood].map(url => ({ id: url, text: data[url]?.title ?? url, tags: data[url]?.tags ?? [] })),
    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
  const graphData: { nodes: NodeData[]; links: LinkData[] } = {
    nodes: [...neighbourhood].map((url) => ({
      id: url,
      text: data[url]?.title ?? url,
      tags: data[url]?.tags ?? [],
    })),
    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
  }
  const simulation: d3.Simulation<NodeData, LinkData> = d3
@@ -96,11 +100,11 @@
  const width = graph.offsetWidth
  const svg = d3
    .select<HTMLElement, NodeData>('#' + container)
    .select<HTMLElement, NodeData>("#" + container)
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr('viewBox', [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
    .attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
  // draw links between nodes
  const link = svg
@@ -172,7 +176,9 @@
    })
    .on("mouseover", function(_, d) {
      const neighbours: CanonicalSlug[] = data[slug].links ?? []
      const neighbourNodes = d3.selectAll<HTMLElement, NodeData>(".node").filter((d) => neighbours.includes(d.id))
      const neighbourNodes = d3
        .selectAll<HTMLElement, NodeData>(".node")
        .filter((d) => neighbours.includes(d.id))
      console.log(neighbourNodes)
      const currentId = d.id
      const linkNodes = d3
@@ -183,12 +189,7 @@
      neighbourNodes.transition().duration(200).attr("fill", color)
      // highlight links
      linkNodes
        .transition()
        .duration(200)
        .attr("stroke", "var(--gray)")
        .attr("stroke-width", 1)
      linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
      const bigFont = fontSize * 1.5
@@ -199,9 +200,9 @@
        .select("text")
        .transition()
        .duration(200)
        .attr('opacityOld', d3.select(parent).select('text').style("opacity"))
        .style('opacity', 1)
        .style('font-size', bigFont + 'em')
        .attr("opacityOld", d3.select(parent).select("text").style("opacity"))
        .style("opacity", 1)
        .style("font-size", bigFont + "em")
    })
    .on("mouseleave", function(_, d) {
      const currentId = d.id
@@ -216,8 +217,8 @@
        .select("text")
        .transition()
        .duration(200)
        .style('opacity', d3.select(parent).select('text').attr("opacityOld"))
        .style('font-size', fontSize + 'em')
        .style("opacity", d3.select(parent).select("text").attr("opacityOld"))
        .style("font-size", fontSize + "em")
    })
    // @ts-ignore
    .call(drag(simulation))
@@ -228,10 +229,12 @@
    .attr("dx", 0)
    .attr("dy", (d) => -nodeRadius(d) + "px")
    .attr("text-anchor", "middle")
    .text((d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "))
    .style('opacity', (opacityScale - 1) / 3.75)
    .text(
      (d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "),
    )
    .style("opacity", (opacityScale - 1) / 3.75)
    .style("pointer-events", "none")
    .style('font-size', fontSize + 'em')
    .style("font-size", fontSize + "em")
    .raise()
    // @ts-ignore
    .call(drag(simulation))
@@ -249,7 +252,7 @@
        .on("zoom", ({ transform }) => {
          link.attr("transform", transform)
          node.attr("transform", transform)
          const scale = transform.k * opacityScale;
          const scale = transform.k * opacityScale
          const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
          labels.attr("transform", transform).style("opacity", scaledOpacity)
        }),
@@ -263,12 +266,8 @@
      .attr("y1", (d: any) => d.source.y)
      .attr("x2", (d: any) => d.target.x)
      .attr("y2", (d: any) => d.target.y)
    node
      .attr("cx", (d: any) => d.x)
      .attr("cy", (d: any) => d.y)
    labels
      .attr("x", (d: any) => d.x)
      .attr("y", (d: any) => d.y)
    node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
    labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
  })
}
@@ -305,4 +304,3 @@
  containerIcon?.removeEventListener("click", renderGlobalGraph)
  containerIcon?.addEventListener("click", renderGlobalGraph)
})
quartz/components/scripts/plausible.inline.ts
@@ -1,3 +1,3 @@
import Plausible from 'plausible-tracker'
import Plausible from "plausible-tracker"
const { trackPageview } = Plausible()
document.addEventListener("nav", () => trackPageview())
quartz/components/scripts/popover.inline.ts
@@ -2,33 +2,25 @@
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
export function normalizeRelativeURLs(
  el: Element | Document,
  base: string | URL
) {
export function normalizeRelativeURLs(el: Element | Document, base: string | URL) {
  const update = (el: Element, attr: string, base: string | URL) => {
    el.setAttribute(attr, new URL(el.getAttribute(attr)!, base).pathname)
  }
  el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) =>
    update(item, 'href', base)
  )
  el.querySelectorAll('[href^="./"], [href^="../"]').forEach((item) => update(item, "href", base))
  el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) =>
    update(item, 'src', base)
  )
  el.querySelectorAll('[src^="./"], [src^="../"]').forEach((item) => update(item, "src", base))
}
const p = new DOMParser()
async function mouseEnterHandler(this: HTMLLinkElement, { clientX, clientY }: { clientX: number, clientY: number }) {
async function mouseEnterHandler(
  this: HTMLLinkElement,
  { clientX, clientY }: { clientX: number; clientY: number },
) {
  const link = this
  async function setPosition(popoverElement: HTMLElement) {
    const { x, y } = await computePosition(link, popoverElement, {
      middleware: [
        inline({ x: clientX, y: clientY }),
        shift(),
        flip()
      ]
      middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
    })
    Object.assign(popoverElement.style, {
      left: `${x}px`,
@@ -37,7 +29,7 @@
  }
  // dont refetch if there's already a popover
  if ([...link.children].some(child => child.classList.contains("popover"))) {
  if ([...link.children].some((child) => child.classList.contains("popover"))) {
    return setPosition(link.lastChild as HTMLElement)
  }
@@ -68,7 +60,7 @@
  const popoverInner = document.createElement("div")
  popoverInner.classList.add("popover-inner")
  popoverElement.appendChild(popoverInner)
  elts.forEach(elt => popoverInner.appendChild(elt))
  elts.forEach((elt) => popoverInner.appendChild(elt))
  setPosition(popoverElement)
  link.appendChild(popoverElement)
@@ -77,7 +69,7 @@
    const heading = popoverInner.querySelector(hash) as HTMLElement | null
    if (heading) {
      // leave ~12px of buffer when scrolling to a heading
      popoverInner.scroll({ top: heading.offsetTop - 12, behavior: 'instant' })
      popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
    }
  }
}
quartz/components/scripts/search.inline.ts
@@ -4,9 +4,9 @@
import { CanonicalSlug, getClientSlug, resolveRelative } from "../../path"
interface Item {
  slug: CanonicalSlug,
  title: string,
  content: string,
  slug: CanonicalSlug
  title: string
  content: string
}
let index: Document<Item> | undefined = undefined
@@ -15,15 +15,17 @@
const numSearchResults = 5
function highlight(searchTerm: string, text: string, trim?: boolean) {
  // try to highlight longest tokens first
  const tokenizedTerms = searchTerm.split(/\s+/).filter(t => t !== "").sort((a, b) => b.length - a.length)
  let tokenizedText = text
  const tokenizedTerms = searchTerm
    .split(/\s+/)
    .filter(t => t !== "")
    .filter((t) => t !== "")
    .sort((a, b) => b.length - a.length)
  let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
  let startIndex = 0
  let endIndex = tokenizedText.length - 1
  if (trim) {
    const includesCheck = (tok: string) => tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
    const includesCheck = (tok: string) =>
      tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
    const occurencesIndices = tokenizedText.map(includesCheck)
    let bestSum = 0
@@ -42,7 +44,8 @@
    tokenizedText = tokenizedText.slice(startIndex, endIndex)
  }
  const slice = tokenizedText.map(tok => {
  const slice = tokenizedText
    .map((tok) => {
    // see if this tok is prefixed by any search terms 
    for (const searchTok of tokenizedTerms) {
      if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
@@ -54,7 +57,9 @@
  })
    .join(" ")
  return `${startIndex === 0 ? "" : "..."}${slice}${endIndex === tokenizedText.length - 1 ? "" : "..."}`
  return `${startIndex === 0 ? "" : "..."}${slice}${
    endIndex === tokenizedText.length - 1 ? "" : "..."
  }`
}
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
@@ -113,7 +118,7 @@
    button.classList.add("result-card")
    button.id = slug
    button.innerHTML = `<h3>${title}</h3><p>${content}</p>`
    button.addEventListener('click', () => {
    button.addEventListener("click", () => {
      const targ = resolveRelative(currentSlug, slug)
      window.spaNavigate(new URL(targ, getClientSlug(window)))
    })
@@ -132,7 +137,6 @@
    } else {
      results.append(...finalResults.map(resultToHTML))
    }
  }
  function onType(e: HTMLElementEventMap["input"]) {
@@ -140,12 +144,12 @@
    const searchResults = index?.search(term, numSearchResults) ?? []
    const getByField = (field: string): CanonicalSlug[] => {
      const results = searchResults.filter((x) => x.field === field)
      return results.length === 0 ? [] : [...results[0].result] as CanonicalSlug[]
      return results.length === 0 ? [] : ([...results[0].result] as CanonicalSlug[])
    }
    // order titles ahead of content
    const allIds: Set<CanonicalSlug> = new Set([...getByField("title"), ...getByField("content")])
    const finalResults = [...allIds].map(id => formatForDisplay(term, id))
    const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
    displayResults(finalResults)
  }
@@ -160,7 +164,7 @@
  if (!index) {
    index = new Document({
      cache: true,
      charset: 'latin:extra',
      charset: "latin:extra",
      optimize: true,
      encode: encoder,
      document: {
@@ -174,7 +178,7 @@
            field: "content",
            tokenize: "reverse",
          },
        ]
        ],
      },
    })
@@ -182,7 +186,7 @@
      await index.addAsync(slug, {
        slug: slug as CanonicalSlug,
        title: fileData.title,
        content: fileData.content
        content: fileData.content,
      })
    }
  }
quartz/components/scripts/spa.inline.ts
@@ -5,8 +5,9 @@
// https://github.com/natemoo-re/micromorph
const NODE_TYPE_ELEMENT = 1
let announcer = document.createElement('route-announcer')
const isElement = (target: EventTarget | null): target is Element => (target as Node)?.nodeType === NODE_TYPE_ELEMENT
let announcer = document.createElement("route-announcer")
const isElement = (target: EventTarget | null): target is Element =>
  (target as Node)?.nodeType === NODE_TYPE_ELEMENT
const isLocalUrl = (href: string) => {
  try {
    const url = new URL(href)
@@ -20,14 +21,14 @@
  return false
}
const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined => {
const getOpts = ({ target }: Event): { url: URL; scroll?: boolean } | undefined => {
  if (!isElement(target)) return
  const a = target.closest("a")
  if (!a) return
  if ('routerIgnore' in a.dataset) return
  if ("routerIgnore" in a.dataset) return
  const { href } = a
  if (!isLocalUrl(href)) return
  return { url: new URL(href), scroll: 'routerNoscroll' in a.dataset ? false : undefined }
  return { url: new URL(href), scroll: "routerNoscroll" in a.dataset ? false : undefined }
}
function notifyNav(url: CanonicalSlug) {
@@ -44,7 +45,7 @@
      window.location.assign(url)
    })
  if (!contents) return;
  if (!contents) return
  if (!isBack) {
    history.pushState({}, "", url)
    window.scrollTo({ top: 0 })
@@ -54,22 +55,22 @@
  if (title) {
    document.title = title
  } else {
    const h1 = document.querySelector('h1')
    const h1 = document.querySelector("h1")
    title = h1?.innerText ?? h1?.textContent ?? url.pathname
  }
  if (announcer.textContent !== title) {
    announcer.textContent = title
  }
  announcer.dataset.persist = ''
  announcer.dataset.persist = ""
  html.body.appendChild(announcer)
  micromorph(document.body, html.body)
  // now, patch head 
  const elementsToRemove = document.head.querySelectorAll(':not([spa-preserve])')
  elementsToRemove.forEach(el => el.remove())
  const elementsToAdd = html.head.querySelectorAll(':not([spa-preserve])')
  elementsToAdd.forEach(el => document.head.appendChild(el))
  const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
  elementsToRemove.forEach((el) => el.remove())
  const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
  elementsToAdd.forEach((el) => document.head.appendChild(el))
  notifyNav(getCanonicalSlug(window))
  delete announcer.dataset.persist
@@ -101,7 +102,7 @@
    })
  }
  return new class Router {
  return new (class Router {
    go(pathname: RelativeURL) {
      const url = new URL(pathname, window.location.toString())
      return navigate(url, false)
@@ -114,19 +115,22 @@
    forward() {
      return window.history.forward()
    }
  }
  })()
}
createRouter()
notifyNav(getCanonicalSlug(window))
if (!customElements.get('route-announcer')) {
if (!customElements.get("route-announcer")) {
  const attrs = {
    'aria-live': 'assertive',
    'aria-atomic': 'true',
    'style': 'position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px'
    "aria-live": "assertive",
    "aria-atomic": "true",
    style:
      "position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px",
  }
  customElements.define('route-announcer', class RouteAnnouncer extends HTMLElement {
  customElements.define(
    "route-announcer",
    class RouteAnnouncer extends HTMLElement {
    constructor() {
      super()
    }
@@ -135,5 +139,6 @@
        this.setAttribute(key, value)
      }
    }
  })
    },
  )
}
quartz/components/scripts/toc.inline.ts
@@ -1,5 +1,5 @@
const bufferPx = 150
const observer = new IntersectionObserver(entries => {
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const slug = entry.target.id
    const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
@@ -38,5 +38,5 @@
  // update toc entry highlighting
  observer.disconnect()
  const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
  headers.forEach(header => observer.observe(header))
  headers.forEach((header) => observer.observe(header))
})
quartz/components/scripts/util.ts
@@ -15,7 +15,7 @@
  outsideContainer?.removeEventListener("click", click)
  outsideContainer?.addEventListener("click", click)
  document.removeEventListener("keydown", esc)
  document.addEventListener('keydown', esc)
  document.addEventListener("keydown", esc)
}
export function removeAllChildren(node: HTMLElement) {
quartz/components/styles/graph.scss
@@ -3,7 +3,7 @@
.graph {
  & > h3 {
    font-size: 1rem;
    margin: 0
    margin: 0;
  }
  & > .graph-outer {
quartz/components/styles/legacyToc.scss
quartz/components/styles/listPage.scss
quartz/components/styles/popover.scss
@@ -42,14 +42,17 @@
  visibility: hidden;
  opacity: 0;
  transition: opacity 0.3s ease, visibility 0.3s ease;
  transition:
    opacity 0.3s ease,
    visibility 0.3s ease;
  @media all and (max-width: $mobileBreakpoint) {
    display: none !important;
  }
}
a:hover .popover, .popover:hover {
a:hover .popover,
.popover:hover {
  animation: dropin 0.3s ease;
  animation-fill-mode: forwards;
  animation-delay: 0.2s;
quartz/components/styles/search.scss
@@ -67,7 +67,9 @@
        width: 100%;
        border-radius: 5px;
        background: var(--light);
        box-shadow: 0 14px 50px rgba(27, 33, 48, 0.12), 0 10px 30px rgba(27, 33, 48, 0.16);
        box-shadow:
          0 14px 50px rgba(27, 33, 48, 0.12),
          0 10px 30px rgba(27, 33, 48, 0.16);
        margin-bottom: 2em;
      }
@@ -108,7 +110,8 @@
            font-weight: 700;
          }
          &:hover, &:focus {
          &:hover,
          &:focus {
            background: var(--lightgray);
          }
@@ -132,7 +135,6 @@
          }
        }
      }
    }
  }
}
quartz/components/styles/toc.scss
@@ -21,7 +21,7 @@
  }
  &.collapsed .fold {
    transform: rotateZ(-90deg)
    transform: rotateZ(-90deg);
  }
}
  
@@ -42,7 +42,9 @@
    & > li > a {
      color: var(--dark);
      opacity: 0.35;
      transition: 0.5s ease opacity, 0.3s ease color;
      transition:
        0.5s ease opacity,
        0.3s ease color;
      &.in-view {
        opacity: 0.75;
      }
@@ -55,4 +57,3 @@
    }
  }
}
quartz/components/types.ts
@@ -11,15 +11,17 @@
  children: (QuartzComponent | JSX.Element)[]
  tree: Node<QuartzPluginData>
  allFiles: QuartzPluginData[]
  displayClass?: 'mobile-only' | 'desktop-only'
  displayClass?: "mobile-only" | "desktop-only"
} & JSX.IntrinsicAttributes & {
  [key: string]: any
}
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
  css?: string,
  beforeDOMLoaded?: string,
  afterDOMLoaded?: string,
  css?: string
  beforeDOMLoaded?: string
  afterDOMLoaded?: string
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (opts: Options) => QuartzComponent
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
  opts: Options,
) => QuartzComponent
quartz/log.ts
@@ -1,4 +1,4 @@
import { Spinner } from 'cli-spinner'
import { Spinner } from "cli-spinner"
export class QuartzLogger {
  verbose: boolean
quartz/path.test.ts
@@ -1,9 +1,9 @@
import test, { describe } from 'node:test'
import * as path from './path'
import assert from 'node:assert'
import test, { describe } from "node:test"
import * as path from "./path"
import assert from "node:assert"
describe('typeguards', () => {
  test('isClientSlug', () => {
describe("typeguards", () => {
  test("isClientSlug", () => {
    assert(path.isClientSlug("http://example.com"))
    assert(path.isClientSlug("http://example.com/index"))
    assert(path.isClientSlug("http://example.com/index.html"))
@@ -23,7 +23,7 @@
    assert(!path.isClientSlug("https"))
  })
  test('isCanonicalSlug', () => {
  test("isCanonicalSlug", () => {
    assert(path.isCanonicalSlug(""))
    assert(path.isCanonicalSlug("abc"))
    assert(path.isCanonicalSlug("notindex"))
@@ -41,7 +41,7 @@
    assert(!path.isCanonicalSlug("index.html"))
  })
  test('isRelativeURL', () => {
  test("isRelativeURL", () => {
    assert(path.isRelativeURL("."))
    assert(path.isRelativeURL(".."))
    assert(path.isRelativeURL("./abc/def"))
@@ -58,7 +58,7 @@
    assert(!path.isRelativeURL("./abc/def.md"))
  })
  test('isServerSlug', () => {
  test("isServerSlug", () => {
    assert(path.isServerSlug("index"))
    assert(path.isServerSlug("abc/def"))
@@ -72,7 +72,7 @@
    assert(!path.isServerSlug("note with spaces"))
  })
  test('isFilePath', () => {
  test("isFilePath", () => {
    assert(path.isFilePath("content/index.md"))
    assert(path.isFilePath("content/test.png"))
    assert(!path.isFilePath("../test.pdf"))
@@ -81,27 +81,41 @@
  })
})
describe('transforms', () => {
  function asserts<Inp, Out>(pairs: [string, string][], transform: (inp: Inp) => Out, checkPre: (x: any) => x is Inp, checkPost: (x: any) => x is Out) {
describe("transforms", () => {
  function asserts<Inp, Out>(
    pairs: [string, string][],
    transform: (inp: Inp) => Out,
    checkPre: (x: any) => x is Inp,
    checkPost: (x: any) => x is Out,
  ) {
    for (const [inp, expected] of pairs) {
      assert(checkPre(inp), `${inp} wasn't the expected input type`)
      const actual = transform(inp)
      assert.strictEqual(actual, expected, `after transforming ${inp}, '${actual}' was not '${expected}'`)
      assert.strictEqual(
        actual,
        expected,
        `after transforming ${inp}, '${actual}' was not '${expected}'`,
      )
      assert(checkPost(actual), `${actual} wasn't the expected output type`)
    }
  }
  test('canonicalizeServer', () => {
    asserts([
  test("canonicalizeServer", () => {
    asserts(
      [
      ["index", ""],
      ["abc/index", "abc"],
      ["abc/def", "abc/def"],
    ], path.canonicalizeServer, path.isServerSlug, path.isCanonicalSlug)
      ],
      path.canonicalizeServer,
      path.isServerSlug,
      path.isCanonicalSlug,
    )
  })
  test('canonicalizeClient', () => {
    asserts([
  test("canonicalizeClient", () => {
    asserts(
      [
      ["http://localhost:3000", ""],
      ["http://localhost:3000/index", ""],
      ["http://localhost:3000/test", "test"],
@@ -116,22 +130,32 @@
      ["https://example.com/abc/def?field=1&another=2", "abc/def"],
      ["https://example.com/abc/def?field=1&another=2#cool", "abc/def"],
      ["https://example.com/abc/def.html?field=1&another=2#cool", "abc/def"],
    ], path.canonicalizeClient, path.isClientSlug, path.isCanonicalSlug)
      ],
      path.canonicalizeClient,
      path.isClientSlug,
      path.isCanonicalSlug,
    )
  })
  describe('slugifyFilePath', () => {
    asserts([
  describe("slugifyFilePath", () => {
    asserts(
      [
      ["content/index.md", "content/index"],
      ["content/_index.md", "content/index"],
      ["/content/index.md", "content/index"],
      ["content/cool.png", "content/cool"],
      ["index.md", "index"],
      ["note with spaces.md", "note-with-spaces"],
    ], path.slugifyFilePath, path.isFilePath, path.isServerSlug)
      ],
      path.slugifyFilePath,
      path.isFilePath,
      path.isServerSlug,
    )
  })
  describe('transformInternalLink', () => {
    asserts([
  describe("transformInternalLink", () => {
    asserts(
      [
      ["", "."],
      [".", "."],
      ["./", "."],
@@ -146,15 +170,23 @@
      ["/tags/", "./tags"],
      ["content/with spaces", "./content/with-spaces"],
      ["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"],
    ], path.transformInternalLink, (_x: string): _x is string => true, path.isRelativeURL)
      ],
      path.transformInternalLink,
      (_x: string): _x is string => true,
      path.isRelativeURL,
    )
  })
  describe('pathToRoot', () => {
    asserts([
  describe("pathToRoot", () => {
    asserts(
      [
      ["", "."],
      ["abc", ".."],
      ["abc/def", "../.."],
    ], path.pathToRoot, path.isCanonicalSlug, path.isRelativeURL)
      ],
      path.pathToRoot,
      path.isCanonicalSlug,
      path.isRelativeURL,
    )
  })
})
quartz/path.ts
@@ -1,5 +1,5 @@
import { slug as slugAnchor } from 'github-slugger'
import { trace } from './trace'
import { slug as slugAnchor } from "github-slugger"
import { trace } from "./trace"
// Quartz Paths
// Things in boxes are not actual types but rather sources which these types can be acquired from
@@ -46,7 +46,7 @@
const STRICT_TYPE_CHECKS = false
const HARD_EXIT_ON_FAIL = false
function conditionCheck<T>(name: string, label: 'pre' | 'post', s: T, chk: (x: any) => x is T) {
function conditionCheck<T>(name: string, label: "pre" | "post", s: T, chk: (x: any) => x is T) {
  if (STRICT_TYPE_CHECKS && !chk(s)) {
    trace(`${name} failed ${label}-condition check: ${s} does not pass ${chk.name}`, new Error())
    if (HARD_EXIT_ON_FAIL) {
@@ -102,58 +102,58 @@
export function getClientSlug(window: Window): ClientSlug {
  const res = window.location.href as ClientSlug
  conditionCheck(getClientSlug.name, 'post', res, isClientSlug)
  conditionCheck(getClientSlug.name, "post", res, isClientSlug)
  return res
}
export function getCanonicalSlug(window: Window): CanonicalSlug {
  const res = window.document.body.dataset.slug! as CanonicalSlug
  conditionCheck(getCanonicalSlug.name, 'post', res, isCanonicalSlug)
  conditionCheck(getCanonicalSlug.name, "post", res, isCanonicalSlug)
  return res
}
export function canonicalizeClient(slug: ClientSlug): CanonicalSlug {
  conditionCheck(canonicalizeClient.name, 'pre', slug, isClientSlug)
  conditionCheck(canonicalizeClient.name, "pre", slug, isClientSlug)
  const { pathname } = new URL(slug)
  let fp = pathname.slice(1)
  fp = fp.replace(new RegExp(_getFileExtension(fp) + '$'), '')
  fp = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "")
  const res = _canonicalize(fp) as CanonicalSlug
  conditionCheck(canonicalizeClient.name, 'post', res, isCanonicalSlug)
  conditionCheck(canonicalizeClient.name, "post", res, isCanonicalSlug)
  return res
}
export function canonicalizeServer(slug: ServerSlug): CanonicalSlug {
  conditionCheck(canonicalizeServer.name, 'pre', slug, isServerSlug)
  conditionCheck(canonicalizeServer.name, "pre", slug, isServerSlug)
  let fp = slug as string
  const res = _canonicalize(fp) as CanonicalSlug
  conditionCheck(canonicalizeServer.name, 'post', res, isCanonicalSlug)
  conditionCheck(canonicalizeServer.name, "post", res, isCanonicalSlug)
  return res
}
export function slugifyFilePath(fp: FilePath): ServerSlug {
  conditionCheck(slugifyFilePath.name, 'pre', fp, isFilePath)
  conditionCheck(slugifyFilePath.name, "pre", fp, isFilePath)
  fp = _stripSlashes(fp) as FilePath
  const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + '$'), '')
  const withoutFileExt = fp.replace(new RegExp(_getFileExtension(fp) + "$"), "")
  let slug = withoutFileExt
    .split('/')
    .map((segment) => segment.replace(/\s/g, '-')) // slugify all segments
    .join('/') // always use / as sep
    .replace(/\/$/, '') // remove trailing slash
    .split("/")
    .map((segment) => segment.replace(/\s/g, "-")) // slugify all segments
    .join("/") // always use / as sep
    .replace(/\/$/, "") // remove trailing slash
  // treat _index as index
  if (_endsWith(slug, "_index")) {
    slug = slug.replace(/_index$/, "index")
  }
  conditionCheck(slugifyFilePath.name, 'post', slug, isServerSlug)
  conditionCheck(slugifyFilePath.name, "post", slug, isServerSlug)
  return slug as ServerSlug
}
export function transformInternalLink(link: string): RelativeURL {
  let [fplike, anchor] = splitAnchor(decodeURI(link))
  let segments = fplike.split("/").filter(x => x.length > 0)
  let segments = fplike.split("/").filter((x) => x.length > 0)
  let prefix = segments.filter(_isRelativeSegment).join("/")
  let fp = segments.filter(seg => !_isRelativeSegment(seg)).join("/")
  let fp = segments.filter((seg) => !_isRelativeSegment(seg)).join("/")
  // implicit markdown
  if (!_hasFileExtension(fp)) {
@@ -164,41 +164,41 @@
  fp = _trimSuffix(fp, "index")
  let joined = joinSegments(_stripSlashes(prefix), _stripSlashes(fp))
  const res = _addRelativeToStart(joined) + anchor as RelativeURL
  conditionCheck(transformInternalLink.name, 'post', res, isRelativeURL)
  const res = (_addRelativeToStart(joined) + anchor) as RelativeURL
  conditionCheck(transformInternalLink.name, "post", res, isRelativeURL)
  return res
}
// resolve /a/b/c to ../../
export function pathToRoot(slug: CanonicalSlug): RelativeURL {
  conditionCheck(pathToRoot.name, 'pre', slug, isCanonicalSlug)
  conditionCheck(pathToRoot.name, "pre", slug, isCanonicalSlug)
  let rootPath = slug
    .split('/')
    .filter(x => x !== '')
    .map(_ => '..')
    .join('/')
    .split("/")
    .filter((x) => x !== "")
    .map((_) => "..")
    .join("/")
  const res = _addRelativeToStart(rootPath) as RelativeURL
  conditionCheck(pathToRoot.name, 'post', res, isRelativeURL)
  conditionCheck(pathToRoot.name, "post", res, isRelativeURL)
  return res
}
export function resolveRelative(current: CanonicalSlug, target: CanonicalSlug): RelativeURL {
  conditionCheck(resolveRelative.name, 'pre', current, isCanonicalSlug)
  conditionCheck(resolveRelative.name, 'pre', target, isCanonicalSlug)
  conditionCheck(resolveRelative.name, "pre", current, isCanonicalSlug)
  conditionCheck(resolveRelative.name, "pre", target, isCanonicalSlug)
  const res = joinSegments(pathToRoot(current), target) as RelativeURL
  conditionCheck(resolveRelative.name, 'post', res, isRelativeURL)
  conditionCheck(resolveRelative.name, "post", res, isRelativeURL)
  return res
}
export function splitAnchor(link: string): [string, string] {
  let [fp, anchor] = link.split("#", 2)
  anchor = anchor === undefined ? "" : '#' + slugAnchor(anchor)
  anchor = anchor === undefined ? "" : "#" + slugAnchor(anchor)
  return [fp, anchor]
}
export function joinSegments(...args: string[]): string {
  return args.filter(segment => segment !== "").join('/')
  return args.filter((segment) => segment !== "").join("/")
}
export const QUARTZ = "quartz"
@@ -214,7 +214,7 @@
function _trimSuffix(s: string, suffix: string): string {
  if (_endsWith(s, suffix)) {
    s = s.slice(0, -(suffix.length))
    s = s.slice(0, -suffix.length)
  }
  return s
}
quartz/perf.ts
@@ -1,12 +1,12 @@
import chalk from 'chalk'
import pretty from 'pretty-time'
import chalk from "chalk"
import pretty from "pretty-time"
export class PerfTimer {
  evts: { [key: string]: [number, number] }
  constructor() {
    this.evts = {}
    this.addEvent('start')
    this.addEvent("start")
  }
  addEvent(evtName: string) {
@@ -14,6 +14,6 @@
  }
  timeSince(evtName?: string): string {
    return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? 'start'])))
    return chalk.yellow(pretty(process.hrtime(this.evts[evtName ?? "start"])))
  }
}
quartz/plugins/emitters/aliases.ts
@@ -1,6 +1,12 @@
import { CanonicalSlug, FilePath, ServerSlug, canonicalizeServer, resolveRelative } from "../../path"
import {
  CanonicalSlug,
  FilePath,
  ServerSlug,
  canonicalizeServer,
  resolveRelative,
} from "../../path"
import { QuartzEmitterPlugin } from "../types"
import path from 'path'
import path from "path"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
  name: "AliasRedirects",
@@ -24,7 +30,7 @@
      for (const alias of aliases) {
        const slug = path.posix.join(dir, alias) as ServerSlug
        const fp = slug + ".html" as FilePath
        const fp = (slug + ".html") as FilePath
        const redirUrl = resolveRelative(canonicalizeServer(slug), ogSlug)
        await emit({
          content: `
@@ -47,5 +53,5 @@
      }
    }
    return fps
  }
  },
})
quartz/plugins/emitters/contentIndex.ts
@@ -5,12 +5,12 @@
export type ContentIndex = Map<CanonicalSlug, ContentDetails>
export type ContentDetails = {
  title: string,
  links: CanonicalSlug[],
  tags: string[],
  content: string,
  date?: Date,
  description?: string,
  title: string
  links: CanonicalSlug[]
  tags: string[]
  content: string
  date?: Date
  description?: string
}
interface Options {
@@ -31,7 +31,9 @@
    <loc>https://${base}/${slug}</loc>
    <lastmod>${content.date?.toISOString()}</lastmod>
  </url>`
  const urls = Array.from(idx).map(([slug, content]) => createURLEntry(slug, content)).join("")
  const urls = Array.from(idx)
    .map(([slug, content]) => createURLEntry(slug, content))
    .join("")
  return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
}
@@ -47,7 +49,9 @@
    <pubDate>${content.date?.toUTCString()}</pubDate>
  </items>`
  const items = Array.from(idx).map(([slug, content]) => createURLEntry(slug, content)).join("")
  const items = Array.from(idx)
    .map(([slug, content]) => createURLEntry(slug, content))
    .join("")
  return `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0">
    <channel>
      <title>${cfg.pageTitle}</title>
@@ -77,7 +81,7 @@
          tags: file.data.frontmatter?.tags ?? [],
          content: file.data.text ?? "",
          date: date,
          description: file.data.description ?? ""
            description: file.data.description ?? "",
        })
        }
      }
@@ -86,7 +90,7 @@
        await emit({
          content: generateSiteMap(cfg, linkIndex),
          slug: "sitemap" as ServerSlug,
          ext: ".xml"
          ext: ".xml",
        })
        emitted.push("sitemap.xml" as FilePath)
      }
@@ -95,7 +99,7 @@
        await emit({
          content: generateRSSFeed(cfg, linkIndex),
          slug: "index" as ServerSlug,
          ext: ".xml"
          ext: ".xml",
        })
        emitted.push("index.xml" as FilePath)
      }
@@ -109,7 +113,7 @@
          delete content.description
          delete content.date
          return [slug, content]
        })
        }),
      )
      await emit({
quartz/plugins/emitters/contentPage.tsx
@@ -8,7 +8,9 @@
export const ContentPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
  if (!opts) {
    throw new Error("ContentPage must be initialized with options specifiying the components to use")
    throw new Error(
      "ContentPage must be initialized with options specifiying the components to use",
    )
  }
  const { head: Head, header, beforeBody, pageBody: Content, left, right, footer: Footer } = opts
@@ -22,7 +24,7 @@
    },
    async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
      const fps: FilePath[] = []
      const allFiles = content.map(c => c[1].data)
      const allFiles = content.map((c) => c[1].data)
      for (const [tree, file] of content) {
        const slug = canonicalizeServer(file.data.slug!)
        const externalResources = pageResources(slug, resources)
@@ -32,17 +34,12 @@
          cfg,
          children: [],
          tree,
          allFiles
          allFiles,
        }
        const content = renderPage(
          slug,
          componentData,
          opts,
          externalResources
        )
        const content = renderPage(slug, componentData, opts, externalResources)
        const fp = file.data.slug + ".html" as FilePath
        const fp = (file.data.slug + ".html") as FilePath
        await emit({
          content,
          slug: file.data.slug!,
@@ -52,6 +49,6 @@
        fps.push(fp)
      }
      return fps
    }
    },
  }
}
quartz/plugins/emitters/folderPage.tsx
@@ -24,20 +24,28 @@
    },
    async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
      const fps: FilePath[] = []
      const allFiles = content.map(c => c[1].data)
      const allFiles = content.map((c) => c[1].data)
      const folders: Set<CanonicalSlug> = new Set(allFiles.flatMap(data => {
      const folders: Set<CanonicalSlug> = new Set(
        allFiles.flatMap((data) => {
        const slug = data.slug
        const folderName = path.dirname(slug ?? "") as CanonicalSlug
        if (slug && folderName !== "." && folderName !== "tags") {
          return [folderName]
        }
        return []
      }))
        }),
      )
      const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries([...folders].map(folder => ([
        folder, defaultProcessedContent({ slug: joinSegments(folder, "index") as ServerSlug, frontmatter: { title: `Folder: ${folder}`, tags: [] } })
      ])))
      const folderDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
        [...folders].map((folder) => [
          folder,
          defaultProcessedContent({
            slug: joinSegments(folder, "index") as ServerSlug,
            frontmatter: { title: `Folder: ${folder}`, tags: [] },
          }),
        ]),
      )
      for (const [tree, file] of content) {
        const slug = canonicalizeServer(file.data.slug!)
@@ -56,17 +64,12 @@
          cfg,
          children: [],
          tree,
          allFiles
          allFiles,
        }
        const content = renderPage(
          slug,
          componentData,
          opts,
          externalResources
        )
        const content = renderPage(slug, componentData, opts, externalResources)
        const fp = file.data.slug! + ".html" as FilePath
        const fp = (file.data.slug! + ".html") as FilePath
        await emit({
          content,
          slug: file.data.slug!,
@@ -76,6 +79,6 @@
        fps.push(fp)
      }
      return fps
    }
    },
  }
}
quartz/plugins/emitters/index.ts
@@ -1,5 +1,5 @@
export { ContentPage } from './contentPage'
export { TagPage } from './tagPage'
export { FolderPage } from './folderPage'
export { ContentIndex } from './contentIndex'
export { AliasRedirects } from './aliases'
export { ContentPage } from "./contentPage"
export { TagPage } from "./tagPage"
export { FolderPage } from "./folderPage"
export { ContentIndex } from "./contentIndex"
export { AliasRedirects } from "./aliases"
quartz/plugins/emitters/tagPage.tsx
@@ -23,12 +23,18 @@
    },
    async emit(_contentDir, cfg, content, resources, emit): Promise<FilePath[]> {
      const fps: FilePath[] = []
      const allFiles = content.map(c => c[1].data)
      const allFiles = content.map((c) => c[1].data)
      const tags: Set<string> = new Set(allFiles.flatMap(data => data.frontmatter?.tags ?? []))
      const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries([...tags].map(tag => ([
        tag, defaultProcessedContent({ slug: `tags/${tag}/index` as ServerSlug, frontmatter: { title: `Tag: ${tag}`, tags: [] } })
      ])))
      const tags: Set<string> = new Set(allFiles.flatMap((data) => data.frontmatter?.tags ?? []))
      const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
        [...tags].map((tag) => [
          tag,
          defaultProcessedContent({
            slug: `tags/${tag}/index` as ServerSlug,
            frontmatter: { title: `Tag: ${tag}`, tags: [] },
          }),
        ]),
      )
      for (const [tree, file] of content) {
        const slug = file.data.slug!
@@ -50,17 +56,12 @@
          cfg,
          children: [],
          tree,
          allFiles
          allFiles,
        }
        const content = renderPage(
          slug,
          componentData,
          opts,
          externalResources
        )
        const content = renderPage(slug, componentData, opts, externalResources)
        const fp = file.data.slug + ".html" as FilePath
        const fp = (file.data.slug + ".html") as FilePath
        await emit({
          content,
          slug: file.data.slug!,
@@ -70,6 +71,6 @@
        fps.push(fp)
      }
      return fps
    }
    },
  }
}
quartz/plugins/filters/draft.ts
@@ -5,5 +5,5 @@
  shouldPublish([_tree, vfile]) {
    const draftFlag: boolean = vfile.data?.frontmatter?.draft ?? false
    return !draftFlag
  }
  },
})
quartz/plugins/filters/explicit.ts
@@ -5,5 +5,5 @@
  shouldPublish([_tree, vfile]) {
    const publishFlag: boolean = vfile.data?.frontmatter?.publish ?? false
    return publishFlag
  }
  },
})
quartz/plugins/filters/index.ts
@@ -1,2 +1,2 @@
export { RemoveDrafts } from './draft'
export { ExplicitPublish } from './explicit'
export { RemoveDrafts } from "./draft"
export { ExplicitPublish } from "./explicit"
quartz/plugins/index.ts
@@ -1,14 +1,14 @@
import { GlobalConfiguration } from '../cfg'
import { QuartzComponent } from '../components/types'
import { StaticResources } from '../resources'
import { joinStyles } from '../theme'
import { EmitCallback, PluginTypes } from './types'
import styles from '../styles/base.scss'
import { FilePath, ServerSlug } from '../path'
import { GlobalConfiguration } from "../cfg"
import { QuartzComponent } from "../components/types"
import { StaticResources } from "../resources"
import { joinStyles } from "../theme"
import { EmitCallback, PluginTypes } from "./types"
import styles from "../styles/base.scss"
import { FilePath, ServerSlug } from "../path"
export type ComponentResources = {
  css: string[],
  beforeDOMLoaded: string[],
  css: string[]
  beforeDOMLoaded: string[]
  afterDOMLoaded: string[]
}
@@ -24,7 +24,7 @@
  const componentResources = {
    css: new Set<string>(),
    beforeDOMLoaded: new Set<string>(),
    afterDOMLoaded: new Set<string>()
    afterDOMLoaded: new Set<string>(),
  }
  for (const component of allComponents) {
@@ -43,35 +43,38 @@
  return {
    css: [...componentResources.css],
    beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
    afterDOMLoaded: [...componentResources.afterDOMLoaded]
    afterDOMLoaded: [...componentResources.afterDOMLoaded],
  }
}
function joinScripts(scripts: string[]): string {
  // wrap with iife to prevent scope collision
  return scripts.map(script => `(function () {${script}})();`).join("\n")
  return scripts.map((script) => `(function () {${script}})();`).join("\n")
}
export async function emitComponentResources(cfg: GlobalConfiguration, res: ComponentResources, emit: EmitCallback): Promise<FilePath[]> {
export async function emitComponentResources(
  cfg: GlobalConfiguration,
  res: ComponentResources,
  emit: EmitCallback,
): Promise<FilePath[]> {
  const fps = await Promise.all([
    emit({
      slug: "index" as ServerSlug,
      ext: ".css",
      content: joinStyles(cfg.theme, styles, ...res.css)
      content: joinStyles(cfg.theme, styles, ...res.css),
    }),
    emit({
      slug: "prescript" as ServerSlug,
      ext: ".js",
      content: joinScripts(res.beforeDOMLoaded)
      content: joinScripts(res.beforeDOMLoaded),
    }),
    emit({
      slug: "postscript" as ServerSlug,
      ext: ".js",
      content: joinScripts(res.afterDOMLoaded)
    })
      content: joinScripts(res.afterDOMLoaded),
    }),
  ])
  return fps
}
export function getStaticResourcesFromPlugins(plugins: PluginTypes) {
@@ -93,11 +96,11 @@
  return staticResources
}
export * from './transformers'
export * from './filters'
export * from './emitters'
export * from "./transformers"
export * from "./filters"
export * from "./emitters"
declare module 'vfile' {
declare module "vfile" {
  // inserted in processors.ts
  interface DataMap {
    slug: ServerSlug
quartz/plugins/transformers/description.ts
@@ -1,4 +1,4 @@
import { Root as HTMLRoot } from 'hast'
import { Root as HTMLRoot } from "hast"
import { toString } from "hast-util-to-string"
import { QuartzTransformerPlugin } from "../types"
@@ -7,11 +7,16 @@
}
const defaultOptions: Options = {
  descriptionLength: 150
  descriptionLength: 150,
}
const escapeHTML = (unsafe: string) => {
  return unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
  return unsafe
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;")
}
export const Description: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@@ -26,30 +31,29 @@
            const text = escapeHTML(toString(tree))
            const desc = frontMatterDescription ?? text
            const sentences = desc.replace(/\s+/g, ' ').split('.')
            const sentences = desc.replace(/\s+/g, " ").split(".")
            let finalDesc = ""
            let sentenceIdx = 0
            const len = opts.descriptionLength
            while (finalDesc.length < len) {
              const sentence = sentences[sentenceIdx]
              if (!sentence) break
              finalDesc += sentence + '.'
              finalDesc += sentence + "."
              sentenceIdx++
            }
            file.data.description = finalDesc
            file.data.text = text
          }
        }
        },
      ]
    }
    },
  }
}
declare module 'vfile' {
declare module "vfile" {
  interface DataMap {
    description: string
    text: string
  }
}
quartz/plugins/transformers/frontmatter.ts
@@ -1,17 +1,17 @@
import matter from "gray-matter"
import remarkFrontmatter from 'remark-frontmatter'
import remarkFrontmatter from "remark-frontmatter"
import { QuartzTransformerPlugin } from "../types"
import yaml from 'js-yaml'
import { slug as slugAnchor } from 'github-slugger'
import yaml from "js-yaml"
import { slug as slugAnchor } from "github-slugger"
export interface Options {
  language: 'yaml' | 'toml',
  language: "yaml" | "toml"
  delims: string | string[]
}
const defaultOptions: Options = {
  language: 'yaml',
  delims: '---'
  language: "yaml",
  delims: "---",
}
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@@ -26,8 +26,8 @@
            const { data } = matter(file.value, {
              ...opts,
              engines: {
                yaml: s => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object
              }
                yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
              },
            })
            // tag is an alias for tags
@@ -36,7 +36,10 @@
            }
            if (data.tags && !Array.isArray(data.tags)) {
              data.tags = data.tags.toString().split(",").map((tag: string) => tag.trim())
              data.tags = data.tags
                .toString()
                .split(",")
                .map((tag: string) => tag.trim())
            }
            // slug them all!!
@@ -46,16 +49,16 @@
            file.data.frontmatter = {
              title: file.stem ?? "Untitled",
              tags: [],
              ...data
              ...data,
            }
          }
        }
        },
      ]
    },
  }
}
declare module 'vfile' {
declare module "vfile" {
  interface DataMap {
    frontmatter: { [key: string]: any } & {
      title: string
quartz/plugins/transformers/gfm.ts
@@ -1,5 +1,5 @@
import remarkGfm from "remark-gfm"
import smartypants from 'remark-smartypants'
import smartypants from "remark-smartypants"
import { QuartzTransformerPlugin } from "../types"
import rehypeSlug from "rehype-slug"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
@@ -11,10 +11,12 @@
const defaultOptions: Options = {
  enableSmartyPants: true,
  linkHeadings: true
  linkHeadings: true,
}
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "GitHubFlavoredMarkdown",
@@ -23,15 +25,22 @@
    },
    htmlPlugins() {
      if (opts.linkHeadings) {
        return [rehypeSlug, [rehypeAutolinkHeadings, {
          behavior: 'append', content: {
            type: 'text',
            value: ' Â§',
          }
        }]]
        return [
          rehypeSlug,
          [
            rehypeAutolinkHeadings,
            {
              behavior: "append",
              content: {
                type: "text",
                value: " Â§",
              },
            },
          ],
        ]
      } else {
        return []
      }
    }
    },
  }
}
quartz/plugins/transformers/index.ts
@@ -1,9 +1,9 @@
export { FrontMatter } from './frontmatter'
export { GitHubFlavoredMarkdown } from './gfm'
export { CreatedModifiedDate } from './lastmod'
export { Latex } from './latex'
export { Description } from './description'
export { CrawlLinks } from './links'
export { ObsidianFlavoredMarkdown } from './ofm'
export { SyntaxHighlighting } from './syntax'
export { TableOfContents } from './toc'
export { FrontMatter } from "./frontmatter"
export { GitHubFlavoredMarkdown } from "./gfm"
export { CreatedModifiedDate } from "./lastmod"
export { Latex } from "./latex"
export { Description } from "./description"
export { CrawlLinks } from "./links"
export { ObsidianFlavoredMarkdown } from "./ofm"
export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc"
quartz/plugins/transformers/lastmod.ts
@@ -1,18 +1,20 @@
import fs from "fs"
import path from 'path'
import path from "path"
import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types"
export interface Options {
  priority: ('frontmatter' | 'git' | 'filesystem')[],
  priority: ("frontmatter" | "git" | "filesystem")[]
}
const defaultOptions: Options = {
  priority: ['frontmatter', 'git', 'filesystem']
  priority: ["frontmatter", "git", "filesystem"],
}
type MaybeDate = undefined | string | number
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "CreatedModifiedDate",
@@ -51,13 +53,13 @@
              published: published ? new Date(published) : new Date(),
            }
          }
        }
        },
      ]
    },
  }
}
declare module 'vfile' {
declare module "vfile" {
  interface DataMap {
    dates: {
      created: Date
quartz/plugins/transformers/latex.ts
@@ -1,28 +1,24 @@
import remarkMath from "remark-math"
import rehypeKatex from 'rehype-katex'
import rehypeMathjax from 'rehype-mathjax/svg.js'
import rehypeKatex from "rehype-katex"
import rehypeMathjax from "rehype-mathjax/svg.js"
import { QuartzTransformerPlugin } from "../types"
interface Options {
  renderEngine: 'katex' | 'mathjax'
  renderEngine: "katex" | "mathjax"
}
export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
  const engine = opts?.renderEngine ?? 'katex'
  const engine = opts?.renderEngine ?? "katex"
  return {
    name: "Latex",
    markdownPlugins() {
      return [remarkMath]
    },
    htmlPlugins() {
      return [
        engine === 'katex'
          ? [rehypeKatex, { output: 'html' }]
          : [rehypeMathjax]
      ]
      return [engine === "katex" ? [rehypeKatex, { output: "html" }] : [rehypeMathjax]]
    },
    externalResources() {
      return engine === 'katex'
      return engine === "katex"
        ? {
          css: [
            // base css
@@ -33,11 +29,11 @@
              // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
              src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js",
              loadTime: "afterDOMReady",
              contentType: 'external'
            }
          ]
                contentType: "external",
              },
            ],
        }
        : {}
    }
    },
  }
}
quartz/plugins/transformers/links.ts
@@ -1,18 +1,27 @@
import { QuartzTransformerPlugin } from "../types"
import { CanonicalSlug, RelativeURL, canonicalizeServer, joinSegments, pathToRoot, resolveRelative, splitAnchor, transformInternalLink } from "../../path"
import {
  CanonicalSlug,
  RelativeURL,
  canonicalizeServer,
  joinSegments,
  pathToRoot,
  resolveRelative,
  splitAnchor,
  transformInternalLink,
} from "../../path"
import path from "path"
import { visit } from 'unist-util-visit'
import { visit } from "unist-util-visit"
import isAbsoluteUrl from "is-absolute-url"
interface Options {
  /** How to resolve Markdown paths */
  markdownLinkResolution: 'absolute' | 'relative' | 'shortest'
  markdownLinkResolution: "absolute" | "relative" | "shortest"
  /** Strips folders from a link so that it looks nice */
  prettyLinks: boolean
}
const defaultOptions: Options = {
  markdownLinkResolution: 'absolute',
  markdownLinkResolution: "absolute",
  prettyLinks: true,
}
@@ -21,20 +30,21 @@
  return {
    name: "LinkProcessing",
    htmlPlugins() {
      return [() => {
      return [
        () => {
        return (tree, file) => {
          const curSlug = canonicalizeServer(file.data.slug!)
          const transformLink = (target: string): RelativeURL => {
            const targetSlug = transformInternalLink(target).slice("./".length)
            let [targetCanonical, targetAnchor] = splitAnchor(targetSlug)
            if (opts.markdownLinkResolution === 'relative') {
              if (opts.markdownLinkResolution === "relative") {
              return targetSlug as RelativeURL
            } else if (opts.markdownLinkResolution === 'shortest') {
              } else if (opts.markdownLinkResolution === "shortest") {
              // https://forum.obsidian.md/t/settings-new-link-format-what-is-shortest-path-when-possible/6748/5
              const allSlugs = file.data.allSlugs!
              // if the file name is unique, then it's just the filename
              const matchingFileNames = allSlugs.filter(slug => {
                const matchingFileNames = allSlugs.filter((slug) => {
                const parts = slug.split(path.posix.sep)
                const fileName = parts.at(-1)
                return targetCanonical === fileName
@@ -42,7 +52,7 @@
              if (matchingFileNames.length === 1) {
                const targetSlug = canonicalizeServer(matchingFileNames[0])
                return resolveRelative(curSlug, targetSlug) + targetAnchor as RelativeURL
                  return (resolveRelative(curSlug, targetSlug) + targetAnchor) as RelativeURL
              }
              // if it's not unique, then it's the absolute path from the vault root
@@ -54,12 +64,12 @@
          }
          const outgoing: Set<CanonicalSlug> = new Set()
          visit(tree, 'element', (node, _index, _parent) => {
            visit(tree, "element", (node, _index, _parent) => {
            // rewrite all links
            if (
              node.tagName === 'a' &&
                node.tagName === "a" &&
              node.properties &&
              typeof node.properties.href === 'string'
                typeof node.properties.href === "string"
            ) {
              let dest = node.properties.href as RelativeURL
              node.properties.className = isAbsoluteUrl(dest) ? "external" : "internal"
@@ -73,7 +83,11 @@
              }
              // rewrite link internals if prettylinks is on
              if (opts.prettyLinks && node.children.length === 1 && node.children[0].type === 'text') {
                if (
                  opts.prettyLinks &&
                  node.children.length === 1 &&
                  node.children[0].type === "text"
                ) {
                node.children[0].value = path.basename(node.children[0].value)
              }
            }
@@ -82,23 +96,25 @@
            if (
              ["img", "video", "audio", "iframe"].includes(node.tagName) &&
              node.properties &&
              typeof node.properties.src === 'string'
                typeof node.properties.src === "string"
            ) {
              if (!isAbsoluteUrl(node.properties.src)) {
                const ext = path.extname(node.properties.src)
                node.properties.src = transformLink(path.join("assets", node.properties.src)) + ext
                  node.properties.src =
                    transformLink(path.join("assets", node.properties.src)) + ext
              }
            }
          })
          file.data.links = [...outgoing]
        }
      }]
    }
        },
      ]
    },
  }
}
declare module 'vfile' {
declare module "vfile" {
  interface DataMap {
    links: CanonicalSlug[]
  }
quartz/plugins/transformers/ofm.ts
@@ -1,8 +1,8 @@
import { PluggableList } from "unified"
import { QuartzTransformerPlugin } from "../types"
import { Root, HTML, BlockContent, DefinitionContent, Code } from 'mdast'
import { Root, HTML, BlockContent, DefinitionContent, Code } from "mdast"
import { findAndReplace } 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 { visit } from "unist-util-visit"
import path from "path"
@@ -71,7 +71,7 @@
    bug: "bug",
    example: "example",
    quote: "quote",
    cite: "quote"
    cite: "quote",
  }
  return calloutMapping[callout]
@@ -94,7 +94,7 @@
}
const capitalize = (s: string): string => {
  return s.substring(0, 1).toUpperCase() + s.substring(1);
  return s.substring(0, 1).toUpperCase() + s.substring(1)
}
// Match wikilinks 
@@ -114,7 +114,9 @@
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "ObsidianFlavoredMarkdown",
@@ -154,28 +156,31 @@
                  width ||= "auto"
                  height ||= "auto"
                  return {
                    type: 'image',
                    type: "image",
                    url,
                    data: {
                      hProperties: {
                        width, height
                      }
                    }
                        width,
                        height,
                      },
                    },
                  }
                } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) {
                  return {
                    type: 'html',
                    value: `<video src="${url}" controls></video>`
                    type: "html",
                    value: `<video src="${url}" controls></video>`,
                  }
                } else if ([".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)) {
                } else if (
                  [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
                ) {
                  return {
                    type: 'html',
                    value: `<audio src="${url}" controls></audio>`
                    type: "html",
                    value: `<audio src="${url}" controls></audio>`,
                  }
                } else if ([".pdf"].includes(ext)) {
                  return {
                    type: 'html',
                    value: `<iframe src="${url}"></iframe>`
                    type: "html",
                    value: `<iframe src="${url}"></iframe>`,
                  }
                } else {
                  // TODO: this is the node embed case
@@ -187,17 +192,18 @@
              // const url = transformInternalLink(fp + anchor)
              const url = fp + anchor
              return {
                type: 'link',
                type: "link",
                url,
                children: [{
                  type: 'text',
                  value: alias ?? fp
                }]
                children: [
                  {
                    type: "text",
                    value: alias ?? fp,
                  },
                ],
              }
            })
          }
        }
        )
        })
      }
      if (opts.highlight) {
@@ -206,8 +212,8 @@
            findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => {
              const [inner] = capture
              return {
                type: 'html',
                value: `<span class="text-highlight">${inner}</span>`
                type: "html",
                value: `<span class="text-highlight">${inner}</span>`,
              }
            })
          }
@@ -219,8 +225,8 @@
          return (tree: Root, _file) => {
            findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => {
              return {
                type: 'text',
                value: ''
                type: "text",
                value: "",
              }
            })
          }
@@ -252,7 +258,8 @@
                const calloutType = typeString.toLowerCase() as keyof typeof callouts
                const collapse = collapseChar === "+" || collapseChar === "-"
                const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
                const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
                const title =
                  match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
                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>
@@ -266,17 +273,20 @@
                  <div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div>
                  <div class="callout-title-inner">${title}</div>
                  ${collapse ? toggleIcon : ""}
                </div>`
                </div>`,
                }
                const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode]
                if (remainingText.length > 0) {
                  blockquoteContent.push({
                    type: 'paragraph',
                    children: [{
                      type: 'text',
                    type: "paragraph",
                    children: [
                      {
                        type: "text",
                      value: remainingText,
                    }, ...restChildren]
                      },
                      ...restChildren,
                    ],
                  })
                }
@@ -287,10 +297,12 @@
                node.data = {
                  hProperties: {
                    ...(node.data?.hProperties ?? {}),
                    className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`,
                    className: `callout ${collapse ? "is-collapsible" : ""} ${
                      defaultState === "collapsed" ? "is-collapsed" : ""
                    }`,
                    "data-callout": calloutType,
                    "data-callout-fold": collapse,
                  }
                  },
                }
              }
            })
@@ -301,12 +313,12 @@
      if (opts.mermaid) {
        plugins.push(() => {
          return (tree: Root, _file) => {
            visit(tree, 'code', (node: Code) => {
              if (node.lang === 'mermaid') {
            visit(tree, "code", (node: Code) => {
              if (node.lang === "mermaid") {
                node.data = {
                  hProperties: {
                    className: 'mermaid'
                  }
                    className: "mermaid",
                  },
                }
              }
            })
@@ -325,8 +337,8 @@
      if (opts.callouts) {
        js.push({
          script: calloutScript,
          loadTime: 'afterDOMReady',
          contentType: 'inline'
          loadTime: "afterDOMReady",
          contentType: "inline",
        })
      }
@@ -336,13 +348,13 @@
          import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
          mermaid.initialize({ startOnLoad: true });
          `,
          loadTime: 'afterDOMReady',
          moduleType: 'module',
          contentType: 'inline'
          loadTime: "afterDOMReady",
          moduleType: "module",
          contentType: "inline",
        })
      }
      return { js }
    }
    },
  }
}
quartz/plugins/transformers/syntax.ts
@@ -4,8 +4,13 @@
export const SyntaxHighlighting: QuartzTransformerPlugin = () => ({
  name: "SyntaxHighlighting",
  htmlPlugins() {
    return [[rehypePrettyCode, {
      theme: 'css-variables',
    } satisfies Partial<CodeOptions>]]
  }
    return [
      [
        rehypePrettyCode,
        {
          theme: "css-variables",
        } satisfies Partial<CodeOptions>,
      ],
    ]
  },
})
quartz/plugins/transformers/toc.ts
@@ -2,11 +2,11 @@
import { Root } from "mdast"
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import { slug as slugAnchor } from 'github-slugger'
import { slug as slugAnchor } from "github-slugger"
export interface Options {
  maxDepth: 1 | 2 | 3 | 4 | 5 | 6,
  minEntries: 1,
  maxDepth: 1 | 2 | 3 | 4 | 5 | 6
  minEntries: 1
  showByDefault: boolean
}
@@ -17,47 +17,53 @@
}
interface TocEntry {
  depth: number,
  text: string,
  depth: number
  text: string
  slug: string // this is just the anchor (#some-slug), not the canonical slug
}
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "TableOfContents",
    markdownPlugins() {
      return [() => {
      return [
        () => {
        return async (tree: Root, file) => {
          const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
          if (display) {
            const toc: TocEntry[] = []
            let highestDepth: number = opts.maxDepth
            visit(tree, 'heading', (node) => {
              visit(tree, "heading", (node) => {
              if (node.depth <= opts.maxDepth) {
                const text = toString(node)
                highestDepth = Math.min(highestDepth, node.depth)
                toc.push({
                  depth: node.depth,
                  text,
                  slug: slugAnchor(text)
                    slug: slugAnchor(text),
                })
              }
            })
            if (toc.length > opts.minEntries) {
              file.data.toc = toc.map(entry => ({ ...entry, depth: entry.depth - highestDepth }))
                file.data.toc = toc.map((entry) => ({
                  ...entry,
                  depth: entry.depth - highestDepth,
                }))
            }
          }
        }
      }]
        },
      ]
    },
  }
}
declare module 'vfile' {
declare module "vfile" {
  interface DataMap {
    toc: TocEntry[]
  }
}
quartz/plugins/types.ts
@@ -6,13 +6,15 @@
import { FilePath, ServerSlug } from "../path"
export interface PluginTypes {
  transformers: QuartzTransformerPluginInstance[],
  filters: QuartzFilterPluginInstance[],
  emitters: QuartzEmitterPluginInstance[],
  transformers: QuartzTransformerPluginInstance[]
  filters: QuartzFilterPluginInstance[]
  emitters: QuartzEmitterPluginInstance[]
}
type OptionType = object | undefined
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzTransformerPluginInstance
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
  opts?: Options,
) => QuartzTransformerPluginInstance
export type QuartzTransformerPluginInstance = {
  name: string
  textTransform?: (src: string | Buffer) => string | Buffer
@@ -21,16 +23,26 @@
  externalResources?: () => Partial<StaticResources>
}
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
  opts?: Options,
) => QuartzFilterPluginInstance
export type QuartzFilterPluginInstance = {
  name: string
  shouldPublish(content: ProcessedContent): boolean
}
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzEmitterPluginInstance
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
  opts?: Options,
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
  name: string
  emit(contentDir: string, cfg: GlobalConfiguration, content: ProcessedContent[], resources: StaticResources, emitCallback: EmitCallback): Promise<FilePath[]>
  emit(
    contentDir: string,
    cfg: GlobalConfiguration,
    content: ProcessedContent[],
    resources: StaticResources,
    emitCallback: EmitCallback,
  ): Promise<FilePath[]>
  getQuartzComponents(): QuartzComponent[]
}
quartz/plugins/vfile.ts
@@ -1,11 +1,11 @@
import { Node, Parent } from 'hast'
import { Data, VFile } from 'vfile'
import { Node, Parent } from "hast"
import { Data, VFile } from "vfile"
export type QuartzPluginData = Data
export type ProcessedContent = [Node<QuartzPluginData>, VFile]
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
  const root: Parent = { type: 'root', children: [] }
  const root: Parent = { type: "root", children: [] }
  const vfile = new VFile("")
  vfile.data = vfileData
  return [root, vfile]
quartz/processors/emit.ts
@@ -2,25 +2,35 @@
import fs from "fs"
import { GlobalConfiguration, QuartzConfig } from "../cfg"
import { PerfTimer } from "../perf"
import { ComponentResources, emitComponentResources, getComponentResources, getStaticResourcesFromPlugins } from "../plugins"
import {
  ComponentResources,
  emitComponentResources,
  getComponentResources,
  getStaticResourcesFromPlugins,
} from "../plugins"
import { EmitCallback } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
import { FilePath, QUARTZ, slugifyFilePath } from "../path"
import { globbyStream } from "globby"
// @ts-ignore
import spaRouterScript from '../components/scripts/spa.inline'
import spaRouterScript from "../components/scripts/spa.inline"
// @ts-ignore
import plausibleScript from '../components/scripts/plausible.inline'
import plausibleScript from "../components/scripts/plausible.inline"
// @ts-ignore
import popoverScript from '../components/scripts/popover.inline'
import popoverStyle from '../components/styles/popover.scss'
import popoverScript from "../components/scripts/popover.inline"
import popoverStyle from "../components/styles/popover.scss"
import { StaticResources } from "../resources"
import { QuartzLogger } from "../log"
import { googleFontHref } from "../theme"
import { trace } from "../trace"
function addGlobalPageResources(cfg: GlobalConfiguration, reloadScript: boolean, staticResources: StaticResources, componentResources: ComponentResources) {
function addGlobalPageResources(
  cfg: GlobalConfiguration,
  reloadScript: boolean,
  staticResources: StaticResources,
  componentResources: ComponentResources,
) {
  staticResources.css.push(googleFontHref(cfg.theme))
  // popovers
@@ -33,8 +43,8 @@
    const tagId = cfg.analytics.tagId
    staticResources.js.push({
      src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`,
      contentType: 'external',
      loadTime: 'afterDOMReady',
      contentType: "external",
      loadTime: "afterDOMReady",
    })
    componentResources.afterDOMLoaded.push(`
    window.dataLayer = window.dataLayer || [];
@@ -47,8 +57,7 @@
        page_title: document.title,
        page_location: location.href,
      });
    });`
    )
    });`)
  } else if (cfg.analytics?.provider === "plausible") {
    componentResources.afterDOMLoaded.push(plausibleScript)
  }
@@ -60,8 +69,7 @@
    componentResources.afterDOMLoaded.push(`
      window.spaNavigate = (url, _) => window.location.assign(url)
      const event = new CustomEvent("nav", { detail: { slug: document.body.dataset.slug } })
      document.dispatchEvent(event)`
    )
      document.dispatchEvent(event)`)
  }
  if (reloadScript) {
@@ -71,12 +79,19 @@
      script: `
        const socket = new WebSocket('ws://localhost:3001')
        socket.addEventListener('message', () => document.location.reload())
      `
      `,
    })
  }
}
export async function emitContent(contentFolder: string, output: string, cfg: QuartzConfig, content: ProcessedContent[], reloadScript: boolean, verbose: boolean) {
export async function emitContent(
  contentFolder: string,
  output: string,
  cfg: QuartzConfig,
  content: ProcessedContent[],
  reloadScript: boolean,
  verbose: boolean,
) {
  const perf = new PerfTimer()
  const log = new QuartzLogger(verbose)
@@ -112,7 +127,13 @@
  // emitter plugins
  for (const emitter of cfg.plugins.emitters) {
    try {
      const emitted = await emitter.emit(contentFolder, cfg.configuration, content, staticResources, emit)
      const emitted = await emitter.emit(
        contentFolder,
        cfg.configuration,
        content,
        staticResources,
        emit,
      )
      emittedFiles += emitted.length
      if (verbose) {
@@ -141,7 +162,7 @@
    const fp = rawFp as FilePath
    const ext = path.extname(fp)
    const src = path.join(contentFolder, fp) as FilePath
    const name = slugifyFilePath(fp as FilePath) + ext as FilePath
    const name = (slugifyFilePath(fp as FilePath) + ext) as FilePath
    const dest = path.join(assetsPath, name) as FilePath
    const dir = path.dirname(dest) as FilePath
    await fs.promises.mkdir(dir, { recursive: true }) // ensure dir exists
quartz/processors/filter.ts
@@ -2,14 +2,18 @@
import { QuartzFilterPluginInstance } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
export function filterContent(plugins: QuartzFilterPluginInstance[], content: ProcessedContent[], verbose: boolean): ProcessedContent[] {
export function filterContent(
  plugins: QuartzFilterPluginInstance[],
  content: ProcessedContent[],
  verbose: boolean,
): ProcessedContent[] {
  const perf = new PerfTimer()
  const initialLength = content.length
  for (const plugin of plugins) {
    const updatedContent = content.filter(plugin.shouldPublish)
    if (verbose) {
      const diff = content.filter(x => !updatedContent.includes(x))
      const diff = content.filter((x) => !updatedContent.includes(x))
      for (const file of diff) {
        console.log(`[filter:${plugin.name}] ${file[1].data.slug}`)
      }
quartz/processors/parse.ts
@@ -1,19 +1,19 @@
import esbuild from 'esbuild'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import esbuild from "esbuild"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import { Processor, unified } from "unified"
import { Root as MDRoot } from 'remark-parse/lib'
import { Root as HTMLRoot } from 'hast'
import { ProcessedContent } from '../plugins/vfile'
import { PerfTimer } from '../perf'
import { read } from 'to-vfile'
import { FilePath, QUARTZ, ServerSlug, slugifyFilePath } from '../path'
import path from 'path'
import os from 'os'
import workerpool, { Promise as WorkerPromise } from 'workerpool'
import { QuartzTransformerPluginInstance } from '../plugins/types'
import { QuartzLogger } from '../log'
import { trace } from '../trace'
import { Root as MDRoot } from "remark-parse/lib"
import { Root as HTMLRoot } from "hast"
import { ProcessedContent } from "../plugins/vfile"
import { PerfTimer } from "../perf"
import { read } from "to-vfile"
import { FilePath, QUARTZ, ServerSlug, slugifyFilePath } from "../path"
import path from "path"
import os from "os"
import workerpool, { Promise as WorkerPromise } from "workerpool"
import { QuartzTransformerPluginInstance } from "../plugins/types"
import { QuartzLogger } from "../log"
import { trace } from "../trace"
export type QuartzProcessor = Processor<MDRoot, HTMLRoot, void>
export function createProcessor(transformers: QuartzTransformerPluginInstance[]): QuartzProcessor {
@@ -21,16 +21,15 @@
  let processor = unified().use(remarkParse)
  // MD AST -> MD AST transforms
  for (const plugin of transformers.filter(p => p.markdownPlugins)) {
  for (const plugin of transformers.filter((p) => p.markdownPlugins)) {
    processor = processor.use(plugin.markdownPlugins!())
  }
  // MD AST -> HTML AST
  processor = processor.use(remarkRehype, { allowDangerousHtml: true })
  // HTML AST -> HTML AST transforms
  for (const plugin of transformers.filter(p => p.htmlPlugins)) {
  for (const plugin of transformers.filter((p) => p.htmlPlugins)) {
    processor = processor.use(plugin.htmlPlugins!())
  }
@@ -57,23 +56,29 @@
    packages: "external",
    plugins: [
      {
        name: 'css-and-scripts-as-text',
        name: "css-and-scripts-as-text",
        setup(build) {
          build.onLoad({ filter: /\.scss$/ }, (_) => ({
            contents: '',
            loader: 'text'
            contents: "",
            loader: "text",
          }))
          build.onLoad({ filter: /\.inline\.(ts|js)$/ }, (_) => ({
            contents: '',
            loader: 'text'
            contents: "",
            loader: "text",
          }))
        }
      }
    ]
        },
      },
    ],
  })
}
export function createFileParser(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: FilePath[], allSlugs: ServerSlug[], verbose: boolean) {
export function createFileParser(
  transformers: QuartzTransformerPluginInstance[],
  baseDir: string,
  fps: FilePath[],
  allSlugs: ServerSlug[],
  verbose: boolean,
) {
  return async (processor: QuartzProcessor) => {
    const res: ProcessedContent[] = []
    for (const fp of fps) {
@@ -84,7 +89,7 @@
        file.value = file.value.toString().trim()
        // Text -> Text transforms
        for (const plugin of transformers.filter(p => p.textTransform)) {
        for (const plugin of transformers.filter((p) => p.textTransform)) {
          file.value = plugin.textTransform!(file.value)
        }
@@ -110,7 +115,12 @@
  }
}
export async function parseMarkdown(transformers: QuartzTransformerPluginInstance[], baseDir: string, fps: FilePath[], verbose: boolean): Promise<ProcessedContent[]> {
export async function parseMarkdown(
  transformers: QuartzTransformerPluginInstance[],
  baseDir: string,
  fps: FilePath[],
  verbose: boolean,
): Promise<ProcessedContent[]> {
  const perf = new PerfTimer()
  const log = new QuartzLogger(verbose)
@@ -118,7 +128,9 @@
  let concurrency = fps.length < CHUNK_SIZE ? 1 : os.availableParallelism()
  // get all slugs ahead of time as each thread needs a copy
  const allSlugs = fps.map(fp => slugifyFilePath(path.relative(baseDir, path.resolve(fp)) as FilePath))
  const allSlugs = fps.map((fp) =>
    slugifyFilePath(path.relative(baseDir, path.resolve(fp)) as FilePath),
  )
  let res: ProcessedContent[] = []
  log.start(`Parsing input files using ${concurrency} threads`)
@@ -128,18 +140,15 @@
    res = await parse(processor)
  } else {
    await transpileWorkerScript()
    const pool = workerpool.pool(
      './quartz/bootstrap-worker.mjs',
      {
        minWorkers: 'max',
    const pool = workerpool.pool("./quartz/bootstrap-worker.mjs", {
      minWorkers: "max",
        maxWorkers: concurrency,
        workerType: 'thread'
      }
    )
      workerType: "thread",
    })
    const childPromises: WorkerPromise<ProcessedContent[]>[] = []
    for (const chunk of chunks(fps, CHUNK_SIZE)) {
      childPromises.push(pool.exec('parseFiles', [baseDir, chunk, allSlugs, verbose]))
      childPromises.push(pool.exec("parseFiles", [baseDir, chunk, allSlugs, verbose]))
    }
    const results: ProcessedContent[][] = await WorkerPromise.all(childPromises)
quartz/resources.tsx
@@ -2,29 +2,38 @@
import { JSX } from "preact/jsx-runtime"
export type JSResource = {
  loadTime: 'beforeDOMReady' | 'afterDOMReady'
  moduleType?: 'module',
  loadTime: "beforeDOMReady" | "afterDOMReady"
  moduleType?: "module"
  spaPreserve?: boolean
} & ({
} & (
  | {
  src: string
  contentType: 'external'
} | {
      contentType: "external"
    }
  | {
  script: string
  contentType: 'inline'
})
      contentType: "inline"
    }
)
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
  const scriptType = resource.moduleType ?? 'application/javascript'
  const scriptType = resource.moduleType ?? "application/javascript"
  const spaPreserve = preserve ?? resource.spaPreserve
  if (resource.contentType === 'external') {
    return <script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve}/>
  if (resource.contentType === "external") {
    return (
      <script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve} />
    )
  } else {
    const content = resource.script
    return <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>{content}</script>
    return (
      <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>
        {content}
      </script>
    )
  }
}
export interface StaticResources {
  css: string[],
  css: string[]
  js: JSResource[]
}
quartz/styles/base.scss
@@ -21,7 +21,17 @@
  border-radius: 5px;
}
p, ul, text, a, tr, td, li, ol, ul, .katex, .math {
p,
ul,
text,
a,
tr,
td,
li,
ol,
ul,
.katex,
.math {
  color: var(--darkgray);
  fill: var(--darkgray);
}
@@ -79,7 +89,7 @@
      font-size: 2rem;
    }
    & li:has(> input[type='checkbox']) {
    & li:has(> input[type="checkbox"]) {
      list-style-type: none;
      padding-left: 0;
      margin-left: -1.4rem;
@@ -144,7 +154,8 @@
    }
  }
  & .center, & footer {
  & .center,
  & footer {
    width: $pageWidth;
    margin-left: auto;
    margin-right: auto;
@@ -195,9 +206,12 @@
  }
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
  &[id] > a[href^="#"] {
    margin: 0 0.5rem;
    opacity: 0;
@@ -277,11 +291,11 @@
      }
    }
    &[data-line-numbers-max-digits='2'] > [data-line]::before {
    &[data-line-numbers-max-digits="2"] > [data-line]::before {
      width: 2rem;
    }
     
    &[data-line-numbers-max-digits='3'] > [data-line]::before {
    &[data-line-numbers-max-digits="3"] > [data-line]::before {
      width: 3rem;
    }
  }
@@ -296,7 +310,9 @@
  background: var(--lightgray);
}
tbody, li, p {
tbody,
li,
p {
  line-height: 1.5rem;
}
@@ -307,7 +323,8 @@
  border-collapse: collapse;
}
td, th {
td,
th {
  padding: 0.2rem 1rem;
  border: 1px solid var(--gray);
}
@@ -331,7 +348,8 @@
  background-color: var(--lightgray);
}
audio, video {
audio,
video {
  width: 100%;
  border-radius: 5px;
}
@@ -340,7 +358,8 @@
  flex: 1 1 auto;
}
ul.overflow, ol.overflow {
ul.overflow,
ol.overflow {
  height: 400px;
  overflow-y: scroll;
@@ -354,7 +373,7 @@
  &:after {
    pointer-events: none;
    content: '';
    content: "";
    width: 100%;
    height: 50px;    
    position: absolute;
quartz/styles/callouts.scss
@@ -24,7 +24,8 @@
      --bg: #00b0ff09;
    }
    &[data-callout="info"], &[data-callout="todo"] {
  &[data-callout="info"],
  &[data-callout="todo"] {
      --color: #00b8d4;
      --border: #00b8d422;
      --bg: #00b8d409;
@@ -54,7 +55,9 @@
      --bg: #db894209;
    }
    &[data-callout="failure"], &[data-callout="danger"], &[data-callout="bug"] {
  &[data-callout="failure"],
  &[data-callout="danger"],
  &[data-callout="bug"] {
      --color: #db4242;
      --border: #db424222;
      --bg: #db424209;
@@ -72,11 +75,10 @@
    }
  
  &.is-collapsed > .callout-title > .fold {
    transform: rotateZ(-90deg)
    transform: rotateZ(-90deg);
  }
}
.callout-title {
    display: flex;
    align-items: center;
@@ -90,7 +92,6 @@
    opacity: 0.8;
    cursor: pointer;
  }
}
.callout-icon {
@@ -101,4 +102,3 @@
.callout-title-inner {
    font-weight: 700;
}
quartz/styles/variables.scss
@@ -3,4 +3,4 @@
$tabletBreakpoint: 1200px;
$sidePanelWidth: 400px;
$topSpacing: 6rem;
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth
$fullPageWidth: $pageWidth + 2 * $sidePanelWidth;
quartz/theme.ts
@@ -1,27 +1,28 @@
export interface ColorScheme {
  light: string,
  lightgray: string,
  gray: string,
  darkgray: string,
  dark: string,
  secondary: string,
  tertiary: string,
  light: string
  lightgray: string
  gray: string
  darkgray: string
  dark: string
  secondary: string
  tertiary: string
  highlight: string
}
export interface Theme {
  typography: {
    header: string,
    body: string,
    header: string
    body: string
    code: string
  },
  }
  colors: {
    lightMode: ColorScheme,
    lightMode: ColorScheme
    darkMode: ColorScheme
  }
}
const DEFAULT_SANS_SERIF = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif"
const DEFAULT_SANS_SERIF =
  '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"
export function googleFontHref(theme: Theme) {
  const { code, header, body } = theme.typography
quartz/trace.ts
@@ -4,13 +4,17 @@
export function trace(msg: string, err: Error) {
  const stack = err.stack
  console.log()
  console.log(chalk.bgRed.white.bold(" ERROR ") + chalk.red(` ${msg}`) + (err.message.length > 0 ? `: ${err.message}` : ""))
  console.log(
    chalk.bgRed.white.bold(" ERROR ") +
      chalk.red(` ${msg}`) +
      (err.message.length > 0 ? `: ${err.message}` : ""),
  )
  if (!stack) {
    return
  }
  let reachedEndOfLegibleTrace = false
  for (const line of stack.split('\n').slice(1)) {
  for (const line of stack.split("\n").slice(1)) {
    if (reachedEndOfLegibleTrace) {
      break
    }
quartz/worker.ts
@@ -6,7 +6,12 @@
const processor = createProcessor(transformers)
// only called from worker thread
export async function parseFiles(baseDir: string, fps: FilePath[], allSlugs: ServerSlug[], verbose: boolean) {
export async function parseFiles(
  baseDir: string,
  fps: FilePath[],
  allSlugs: ServerSlug[],
  verbose: boolean,
) {
  const parse = createFileParser(transformers, baseDir, fps, allSlugs, verbose)
  return parse(processor)
}
tsconfig.json
@@ -1,10 +1,6 @@
{
  "compilerOptions": {
    "lib": [
      "esnext",
      "DOM",
      "DOM.Iterable"
    ],
    "lib": ["esnext", "DOM", "DOM.Iterable"],
    "experimentalDecorators": true,
    "module": "esnext",
    "target": "esnext",
@@ -19,12 +15,6 @@
    "jsx": "react-jsx",
    "jsxImportSource": "preact"
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    "./package.json"
  ],
  "exclude": [
    "build/**/*.d.ts"
  ]
  "include": ["**/*.ts", "**/*.tsx", "./package.json"],
  "exclude": ["build/**/*.d.ts"]
}