Compare commits

...

329 Commits

Author SHA1 Message Date
52a5196f38 chore: converted Emoji version 16 (#2018) 2025-06-18 01:18:57 -04:00
cdd0be2aa8 feat(giscus): expose language option for Comments component (#2012)
* add language support for Comment

* docs: let users know they can customize their language preference
2025-06-08 11:23:01 +02:00
9de370ae14 watcher: manually ignore .git changes 2025-06-04 15:00:41 -07:00
ae0466eedb css: unapply redundant bg on section 2025-06-03 10:57:16 -07:00
499adf4a4e fix(ios): mobile explorer overscroll behavior (#2008) 2025-06-03 13:51:38 -04:00
cd13ce3c47 docs: clarify parens on ignorePatterns (closes #2009) 2025-06-02 12:39:01 -07:00
b48aa91728 fix(flex): respect DesktopOnly and MobileOnly components (#1971)
* fix(flex): respect DesktopOnly and MobileOnly components

* Use classNames util function

* fix(ofm): allow wikilink alias to be empty (#1984)

This is in line with Obsidian's behavior.

* fix(style): Katex adding scrollbars on non-overflowing content (#1989)

* feat(i18n): Bahasa Indonesia translations (#1981)

* fix(a11y): increased content-meta text contrast (#1980)

* fix(analytics): streamline posthog script loading and event capturing (#1974)

* css: adjust color blend for search bg

* feat(links): added ofm option to style unresolved or broken links differently (#1992)

* feat: add option to disable broken wikilinks

* fix(style): update hover color for broken links and introduce new class

* feat: add "disableBrokenWikilinks" option to ObsidianFlavoredMarkdown

* chore(deps): replace `chalk` and `rimraf` with builtin functions (#1879)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore(deps): bump the production-dependencies group across 1 directory with 9 updates (#1996)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Node 22 (#1997)

* docs: showcase housekeeping

* docs: fix explorernode references (closes #1985)

* fix: tz-less date parse in local tz instead of utc (closes #1615)

* docs: added note to not forget to add https:// to the plausible-host (for #1337) (#2000)

* docs: added note to not forget to add https:// to the plausible-host (for #1337)

* Update docs/configuration.md

---------

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

* Updated documentation

---------

Co-authored-by: Nizav <106657905+Ni-zav@users.noreply.github.com>
Co-authored-by: Aswanth <aswanth366@gmail.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Keisuke ANDO <g.kei0429@gmail.com>
Co-authored-by: fl0werpowers <47599466+fl0werpowers@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sebastian Moser <64004956+c2vi@users.noreply.github.com>
2025-06-02 18:36:57 +02:00
7b052e8844 docs: added note to not forget to add https:// to the plausible-host (for #1337) (#2000)
* docs: added note to not forget to add https:// to the plausible-host (for #1337)

* Update docs/configuration.md

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2025-06-01 22:56:51 -07:00
1cfc87652c fix: tz-less date parse in local tz instead of utc (closes #1615) 2025-06-01 22:42:37 -07:00
096ef220dc docs: fix explorernode references (closes #1985) 2025-05-28 16:36:25 -07:00
bc16822d14 docs: showcase housekeeping 2025-05-28 16:27:17 -07:00
7fa9253abc Node 22 (#1997) 2025-05-28 16:20:59 -07:00
996d8d51fa chore(deps): bump the production-dependencies group across 1 directory with 9 updates (#1996)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-28 09:21:56 -04:00
951d1dec24 chore(deps): replace chalk and rimraf with builtin functions (#1879)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-28 10:40:51 +02:00
51b43a2115 feat(links): added ofm option to style unresolved or broken links differently (#1992)
* feat: add option to disable broken wikilinks

* fix(style): update hover color for broken links and introduce new class

* feat: add "disableBrokenWikilinks" option to ObsidianFlavoredMarkdown
2025-05-27 21:26:17 +02:00
c9349457ed css: adjust color blend for search bg 2025-05-27 10:00:19 -07:00
6d49d97559 fix(analytics): streamline posthog script loading and event capturing (#1974) 2025-05-24 00:55:07 -04:00
c9741d00f3 fix(a11y): increased content-meta text contrast (#1980) 2025-05-23 22:09:48 -04:00
73983cfb0e feat(i18n): Bahasa Indonesia translations (#1981) 2025-05-23 22:07:29 -04:00
52344cd816 fix(style): Katex adding scrollbars on non-overflowing content (#1989) 2025-05-23 22:05:41 -04:00
fec0a62b74 fix(ofm): allow wikilink alias to be empty (#1984)
This is in line with Obsidian's behavior.
2025-05-19 07:58:05 +02:00
e98d97a271 feat(i18n): readermode translations and icon (#1961)
* fix(readermode): Translations and a new icon for ReaderMode

* Formatted

* Replaced icon
2025-05-07 21:56:18 +02:00
c33f96e572 chore(deps): bump sigstore/cosign-installer in the ci-dependencies group (#1953)
Bumps the ci-dependencies group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 3.8.1 to 3.8.2
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v3.8.1...v3.8.2)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 3.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-07 12:46:39 -07:00
0b348a0532 chore(deps): bump the production-dependencies group with 7 updates (#1964)
Bumps the production-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@floating-ui/dom](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/dom) | `1.6.13` | `1.7.0` |
| @myriaddreamin/rehype-typst | `0.5.4` | `0.6.0` |
| [pixi.js](https://github.com/pixijs/pixijs) | `8.9.1` | `8.9.2` |
| [pretty-bytes](https://github.com/sindresorhus/pretty-bytes) | `6.1.1` | `7.0.0` |
| [ws](https://github.com/websockets/ws) | `8.18.1` | `8.18.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.15.3` | `22.15.7` |
| [tsx](https://github.com/privatenumber/tsx) | `4.19.3` | `4.19.4` |


Updates `@floating-ui/dom` from 1.6.13 to 1.7.0
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/dom/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/dom@1.7.0/packages/dom)

Updates `@myriaddreamin/rehype-typst` from 0.5.4 to 0.6.0

Updates `pixi.js` from 8.9.1 to 8.9.2
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.9.1...v8.9.2)

Updates `pretty-bytes` from 6.1.1 to 7.0.0
- [Release notes](https://github.com/sindresorhus/pretty-bytes/releases)
- [Commits](https://github.com/sindresorhus/pretty-bytes/compare/v6.1.1...v7.0.0)

Updates `ws` from 8.18.1 to 8.18.2
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.1...8.18.2)

Updates `@types/node` from 22.15.3 to 22.15.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `tsx` from 4.19.3 to 4.19.4
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.19.3...v4.19.4)

---
updated-dependencies:
- dependency-name: "@floating-ui/dom"
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@myriaddreamin/rehype-typst"
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: pixi.js
  dependency-version: 8.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: pretty-bytes
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: ws
  dependency-version: 8.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-version: 22.15.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: tsx
  dependency-version: 4.19.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-07 12:46:23 -07:00
Dan
59d4b4eddd fix(analytics): Umami tracking pageviews properly
Update componentResources.ts to fix umami SPA tracking (#1967)

Update componentResources.ts to fix umami analytics when SPA is enabled in Quartz
2025-05-07 21:44:30 +02:00
adf442036b fix(graph): provide proper workaround for pixijs webgpu issue #1899 (#1949) 2025-04-30 09:49:41 -07:00
dc2c4dca08 docs: add fix for 'remote end hung up unexpectedly' error during initial sync (#1939) 2025-04-29 09:55:15 -07:00
6ba9c7c02a doc(favicon): add documentation of favicon plugin (#1948)
* doc(favicon): add documentation of favicon plugin

* doc(favicon): add missing link to configuration page

* fix(favicon): build on public folder don't created
2025-04-28 22:00:28 -07:00
8d5b13ee03 fix(fonts): Fixed page title fonts not downloadable to local (#1898)
* Fixed url parser regex not working for Google Fonts subset API

* Prettier no
2025-04-28 14:58:06 -07:00
4d07ac93b4 chore(deps-dev): bump the production-dependencies group with 2 updates (#1952)
Bumps the production-dependencies group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [esbuild](https://github.com/evanw/esbuild).


Updates `@types/node` from 22.14.1 to 22.15.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.25.2 to 0.25.3
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.2...v0.25.3)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-version: 0.25.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 14:41:04 -07:00
32d3fc0ce8 chore(ci): fix proj name 2025-04-28 14:35:51 -07:00
00e860d8e6 ci: fix fork preview 2025-04-28 13:19:29 -07:00
2acdec323f fix(explorer): Prevent html from being scrollable when mobile explorer is open (#1895)
* Fixed html page being scrollable when mobile explorer is open

* Prettier code

* Addressed comment
2025-04-26 11:13:56 -07:00
9e58857746 feat(favicon): add plugin to expose favicon from icon.png (#1942)
* feat(favicon): add plugin to expose favicon from icon.png

* chore(favicon): clean up formatting and remove unnecessary line breaks
2025-04-26 11:06:59 -07:00
4bd714b7be fix(callout): Grid-based callout collapsible animation (#1944)
* Fixed broken nested callout maxHeight calculation

* Implemented grid-based callout collapsible
2025-04-26 11:05:51 -07:00
78e13bcb40 chore: add ci to preview all prs (#1947)
* add ci to preview all prs

* only on this repo

* fmt
2025-04-26 11:04:23 -07:00
7d49dff074 fix: prefer webgl for devices with webgpu and no float32-blendable feature flag #1899 (#1933)
Co-authored-by: Tony <32993852+0xREDACTED@users.noreply.github.com>
2025-04-23 09:32:52 -07:00
cdebd05dc9 fix(wikilinks): dont default empty alias 2025-04-23 09:30:25 -07:00
2a9290b3df fix(transclude): blockref detection 2025-04-22 11:18:50 -07:00
771c05ff18 fix: dynamically detect current branch for quartz sync push (#1930) 2025-04-22 10:22:43 -07:00
6dd772bf00 fix(popover): properly clear popover on racing fetch 2025-04-21 23:55:38 -07:00
c238dd16d9 chore(deps): bump the production-dependencies group with 2 updates (#1919)
Bumps the production-dependencies group with 2 updates: [@clack/prompts](https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@clack/prompts` from 0.10.0 to 0.10.1
- [Release notes](https://github.com/bombshell-dev/clack/releases)
- [Changelog](https://github.com/bombshell-dev/clack/blob/@clack/prompts@0.10.1/packages/prompts/CHANGELOG.md)
- [Commits](https://github.com/bombshell-dev/clack/commits/@clack/prompts@0.10.1/packages/prompts)

Updates `@types/node` from 22.14.0 to 22.14.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@clack/prompts"
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-version: 22.14.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:48:08 -07:00
b34d521293 feat: reader mode 2025-04-17 19:45:17 -07:00
bfd72347cf fix(popover): clear popovers more aggressively, use href as id 2025-04-11 08:18:28 -07:00
091cc1b05e fix(search): properly show mobile layout 2025-04-10 17:43:35 -07:00
e9b60c7285 fix(popover): popover id calculation + scroll consistency 2025-04-10 16:46:30 -07:00
b1a920e5c0 fix: add proper popover hint to tag content page 2025-04-10 16:28:36 -07:00
61770d3e50 chore(deps): bump the production-dependencies group with 6 updates (#1913)
Bumps the production-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [preact](https://github.com/preactjs/preact) | `10.26.4` | `10.26.5` |
| [remark-rehype](https://github.com/remarkjs/remark-rehype) | `11.1.1` | `11.1.2` |
| [sharp](https://github.com/lovell/sharp) | `0.33.5` | `0.34.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.14` | `22.14.0` |
| [@types/ws](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ws) | `8.18.0` | `8.18.1` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.2` | `5.8.3` |


Updates `preact` from 10.26.4 to 10.26.5
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.4...10.26.5)

Updates `remark-rehype` from 11.1.1 to 11.1.2
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.1.1...11.1.2)

Updates `sharp` from 0.33.5 to 0.34.1
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.33.5...v0.34.1)

Updates `@types/node` from 22.13.14 to 22.14.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/ws` from 8.18.0 to 8.18.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ws)

Updates `typescript` from 5.8.2 to 5.8.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.26.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: remark-rehype
  dependency-version: 11.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: sharp
  dependency-version: 0.34.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-version: 22.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@types/ws"
  dependency-version: 8.18.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: typescript
  dependency-version: 5.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-10 16:17:15 -07:00
9db66d500e fix(popover): round coords remove blurred popovers (#1911) 2025-04-07 00:23:49 +02:00
ee8c1dc968 chore(css): style tweaks for overflow 2025-04-05 11:38:50 -07:00
bb24cd13c7 fix(css): styles issues with popover, overflow, and scroll overflow (#1907)
* fix(style): fix toc overflow & scrolling overflow

* fix(style): fix explorer scrolling overflow

* fix(style): fix backlinks overflow & scrolling overflow

* fix(style): resolve popover overflow issue causing incomplete display

* chore: rename function to enhance readability

* fix(popover): make the backlink's id unique & use translate() instead of translateY()
2025-04-05 10:39:28 -07:00
d61fb266c7 fix(popover): automatically position anchored links properly (#1897)
* fix(popover): automatically position heading links at heading

* Impement linking of blockreferences

* Popover fixes

* id mapping

* Remove excess regexes

* Updated blockref

* Remove linker element

* Restore the docs to their former glory

* Move the hash out of the loop

* Redundant

* Redundant

* Restore docs

* Remove log

* Let it const
2025-04-05 10:31:17 -07:00
685c06ce2e fix(RecentNotes): Prevent folder pages from always appearing first (closes #1901) (#1904)
* Fix(RecentNotes): Prevent folder pages from always appearing first

Pass prioritizeFolders=false to byDateAndAlphabetical in RecentNotes to sort strictly by date/alphabetical order, fixing issue #1901.

* refactor: split sorting functions for clarity

- Split byDateAndAlphabetical into two separate functions\n- byDateAndAlphabetical: sorts strictly by date and alphabetically\n- byDateAndAlphabeticalFolderFirst: sorts with folders first\n- Updated RecentNotes to use date-only sorting

* Fix(PageList): keep byDateAndAlphabeticalFolderFirst as the default sorting order for PageList
2025-04-04 10:36:29 -07:00
3ae89a1d16 fix(search): make closest sidebar z-index adjustment optional (closes #1905) 2025-04-04 10:17:57 -07:00
4d6e7ccba9 chore(docs): fix explorer docs on filtering by title 2025-04-04 09:50:01 -07:00
f334e78ed6 fix(style): MathJax in callouts spacing (#1892) 2025-03-31 23:14:30 +02:00
c5304b35c0 chore(deps): bump the production-dependencies group with 5 updates (#1894)
Bumps the production-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [pixi.js](https://github.com/pixijs/pixijs) | `8.9.0` | `8.9.1` |
| [rehype-citation](https://github.com/timlrx/rehype-citation) | `2.2.2` | `2.3.1` |
| [satori](https://github.com/vercel/satori) | `0.12.1` | `0.12.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.13` | `22.13.14` |
| [esbuild](https://github.com/evanw/esbuild) | `0.25.1` | `0.25.2` |


Updates `pixi.js` from 8.9.0 to 8.9.1
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.9.0...v8.9.1)

Updates `rehype-citation` from 2.2.2 to 2.3.1
- [Release notes](https://github.com/timlrx/rehype-citation/releases)
- [Commits](https://github.com/timlrx/rehype-citation/compare/v2.2.2...v2.3.1)

Updates `satori` from 0.12.1 to 0.12.2
- [Release notes](https://github.com/vercel/satori/releases)
- [Commits](https://github.com/vercel/satori/compare/0.12.1...0.12.2)

Updates `@types/node` from 22.13.13 to 22.13.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.25.1 to 0.25.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.1...v0.25.2)

---
updated-dependencies:
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: rehype-citation
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: satori
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 14:08:30 -07:00
99f353968e chore(deps-dev): bump @types/node in the production-dependencies group (#1869)
Bumps the production-dependencies group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 22.13.11 to 22.13.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-30 19:12:18 -07:00
ec4700d522 Hey folder icon don't shrink please (#1872) 2025-03-30 19:08:20 -07:00
d6f69e830c fix: remove redundant log and display in parent of overflow 2025-03-30 18:15:25 -07:00
9ee6fe15fd fix: reproducible overflowlist (closes #1885) 2025-03-30 18:04:48 -07:00
a21f588c48 fix(toc): element can't fully collapse 2025-03-30 17:39:08 -07:00
2119025513 fix(toc): Fixed headers in second ToC element not highlight-able 2025-03-30 17:35:20 -07:00
f70e562432 fix: overflow list bottom gradient on toc (closes #1888) 2025-03-30 17:30:01 -07:00
9ff6c7a3f5 fix(style): MathJax non-inline formulae center (#1886) 2025-03-30 21:19:53 +02:00
7ca9dd9a70 fix: dont use cdn for twemoji, bake emojis as b64 2025-03-28 16:29:33 -07:00
b397dae951 Updating breadcrumbs docs on ConditionalRender (#1871) 2025-03-26 08:32:13 -07:00
23b691f38c fix: coerce fullslug 2025-03-23 20:43:01 -07:00
c18e6cd5bb fix(alias): resolve relative if alias is relative 2025-03-23 20:38:06 -07:00
fe2e16d937 fix: disallow user-select in popover 2025-03-23 18:08:07 -07:00
722b4321db docs: clarify transclusions 2025-03-23 18:03:52 -07:00
9d8d238912 chore(deps): bump the production-dependencies group across 1 directory with 4 updates (#1867)
Bumps the production-dependencies group with 4 updates in the / directory: [lightningcss](https://github.com/parcel-bundler/lightningcss), [pixi.js](https://github.com/pixijs/pixijs), [rehype-pretty-code](https://github.com/rehype-pretty/rehype-pretty-code/tree/HEAD/packages/core) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `lightningcss` from 1.29.2 to 1.29.3
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/compare/v1.29.2...v1.29.3)

Updates `pixi.js` from 8.8.1 to 8.9.0
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.8.1...v8.9.0)

Updates `rehype-pretty-code` from 0.14.0 to 0.14.1
- [Release notes](https://github.com/rehype-pretty/rehype-pretty-code/releases)
- [Changelog](https://github.com/rehype-pretty/rehype-pretty-code/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/rehype-pretty/rehype-pretty-code/commits/rehype-pretty-code@0.14.1/packages/core)

Updates `@types/node` from 22.13.10 to 22.13.11
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: lightningcss
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: rehype-pretty-code
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-23 17:57:45 -07:00
141f053b0d chore: format path.test.ts 2025-03-23 17:43:47 -07:00
3027eced6c chore(test): add tests for resolveRelative 2025-03-23 17:42:23 -07:00
aaa5c8e8e4 feat: conditional render component 2025-03-23 17:34:14 -07:00
4e74d11b1a fix: cleanup a href link construction, global shared trie, breadcrumbs use trie 2025-03-23 17:24:43 -07:00
457b77dd48 fix(frontmatter): prevent slug duplication through frontmatter (#1860)
* fix(frontmatter): prevent slug duplication through frontmatter

* Simplify duplicate slug checks

* Update quartz/plugins/transformers/frontmatter.ts

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

* lint

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2025-03-22 03:59:43 +01:00
3ce6aa49bf fix(ogImage): update socialImage path to include base URL if defined (#1858)
* fix(ogImage): update socialImage path to include base URL if defined

* feat(path): add function to check if a file path is absolute

* fix(ogImage): handle absolute paths for user defined og image paths

* docs(CustomOgImages): update socialImage property to accept full URLs

* fix(ogImage): typo

* fix(ogImage): improve user-defined OG image path handling

* Update docs/plugins/CustomOgImages.md

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

* Update quartz/plugins/emitters/ogImage.tsx

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

* refactor(path): remove isAbsoluteFilePath function

* fix(ogImage): update user-defined OG image path handling to support relative URLs

* feat(ogImage): enhance user-defined OG image path handling with absolute URL support

* refactor(ogImage): remove debug log for ogImagePath

* feat(path): add isAbsoluteURL function and corresponding tests

* refactor(path): remove unused URL import for isomorphic compatibility

---------

Co-authored-by: Karim H <karimh96@hotmail.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2025-03-21 16:49:56 -07:00
9316ddf2f5 fix(analytics): fix the load of the analytics scripts (#1865)
* fix(analytics): fix the load of the analytics scripts

* chore(lint): fix lint issues
2025-03-21 09:58:10 -07:00
fbca56f278 fix(lastmod) Change defaultDateType to "modified" (#1862)
Making this change as per https://github.com/jackyzha0/quartz/issues/1857#issuecomment-2737098252

This is necessary for the `git` source to work properly in the CreatedModifiedDate plugin.
2025-03-19 12:47:16 -07:00
eccad3da5d fix(lastmod): fallback to ctx.arg.directory instead of empty string 2025-03-18 21:48:24 -07:00
bcde2abcb2 fix(transformer): find last modified date form commit on submodule (#1831)
* fix(transformer): find last modified date form commit on submodule

when the content folder has a submodule git, the relative path start in content folder and not root folder of quartz

* fix(transformer): use path.relative for improved path handling in last modified date calculation

* fix(transformer): keep find file from relative path of repo workdir

* fix(transformer): use variable for repository workdir

use default value if repo.workdir is undefined to user fullFp value
2025-03-18 21:47:35 -07:00
25979ab216 feat(fonts): allow PageTitle to have its own font subset (#1848)
* fix(explorer): vertically center the Explorer toggle under mobile view

* Added a separate title font configuration

* Added googleSubFontHref function

* Applied --titleFont to PageTitle

* Made googleFontHref return array of URLs

* Dealing with empty and undefined title

* Minor update

* Dealing with empty and undefined title

* Refined font inclusion logic

* Adopted the googleFontHref + googleFontSubsetHref method

* Adaptively include font subset for PageTitle

* Restored default config

* Minor changes on configuration docs

* Formatted source code
2025-03-18 21:43:32 -07:00
9818e1ad57 chore: remove unused import 2025-03-18 09:00:15 -07:00
771110a72a fix(git): deprioritize git, dont fail on non-git content folders 2025-03-18 08:56:06 -07:00
dc6a9f3b12 chore(deps): bump rlespinasse/github-slug-action (#1851)
Bumps the ci-dependencies group with 1 update: [rlespinasse/github-slug-action](https://github.com/rlespinasse/github-slug-action).


Updates `rlespinasse/github-slug-action` from 5.0.0 to 5.1.0
- [Release notes](https://github.com/rlespinasse/github-slug-action/releases)
- [Commits](https://github.com/rlespinasse/github-slug-action/compare/v5.0.0...v5.1.0)

---
updated-dependencies:
- dependency-name: rlespinasse/github-slug-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-17 14:49:27 -07:00
c0b73ddaa4 fix: maybeDates will change children dates (#1843) 2025-03-17 08:27:15 -07:00
e86544064c fix: parse parallelization chunk arg, inline b64 for og image 2025-03-16 15:12:40 -07:00
a737207981 perf: incremental rebuild (--fastRebuild v2 but default) (#1841)
* checkpoint

* incremental all the things

* properly splice changes array

* smol doc update

* update docs

* make fancy logger dumb in ci
2025-03-16 14:17:31 -07:00
a72b1a4224 fix(explorer): vertically center the Explorer toggle under mobile view (#1847) 2025-03-16 12:08:45 -07:00
fbb4523853 fix(folder): use memoized trie instead of handrolled path solution (closes #1767) 2025-03-14 15:08:23 -07:00
da1b6b37fe fix(explorer): fix incorrect recursive case for folder rendering 2025-03-14 10:05:26 -07:00
e26658f4ed fix(description): calculate description properly when first sentence is longer than max 2025-03-13 14:22:11 -07:00
b579950ae5 fix(og): adjust line clamp 2025-03-13 14:05:26 -07:00
0babbdf780 fix(contentPage): dont emit folders 2025-03-13 13:57:00 -07:00
1efe2e20a4 fix(graph): avoid keeping stroke in gfx 2025-03-13 12:29:30 -07:00
5928d82a56 fix(og): search for font family properly 2025-03-13 12:11:27 -07:00
696403d3fa chore: bump version to 4.4.1 2025-03-13 10:55:37 -07:00
2c30abe457 fix(analytics): always use defer over async to account for document nav event 2025-03-13 10:48:03 -07:00
80c3196fee feat(og): add reading time to default, improve logging 2025-03-13 10:41:50 -07:00
d9159e0ac9 feat: make og images an emitter to properly await image generation (#1826)
* checkpoint

* make emitters async generators

* fix

* custom font spec

* replace spinner, use disk cache for fonts

* use readline instead

* make og images look nice
2025-03-13 10:27:46 -07:00
c005fe4408 fix(explorer): properly respect folderDefaultState (closes #1827) 2025-03-12 22:31:44 -07:00
580c1bd608 fix(typography): properly pass Google font options (#1825) 2025-03-12 11:27:41 -07:00
270a5dc14a fix(explorer): show file name instead of slug if no file data (closes #1822) 2025-03-12 11:24:28 -07:00
bfa938cc62 fix(explorer): allow setting displayName (closes #1824) 2025-03-12 10:42:07 -07:00
e3c50caf13 fix(explorer): dont invert mobile css, properly toggle .collapsed 2025-03-12 10:15:54 -07:00
ca08ec1ae7 fix(explorer): mobile explorer toggle (#1823) 2025-03-12 14:15:16 +01:00
2718ab9019 feat: flex component, document higher-order layout components 2025-03-11 14:56:43 -07:00
87b803790c fix(mermaid): themechange detector + expand simplification 2025-03-11 11:45:45 -07:00
e59181c3aa docs(explorer): update to account for new client side explorer) 2025-03-11 10:20:15 -07:00
b00198b888 fix: load mermaid as normal now that inline is safely below bundle size 2025-03-10 16:42:08 -07:00
9e3e711646 fix: mermaid script load order 2025-03-10 16:20:08 -07:00
a8001e9554 feat: support non-singleton explorer 2025-03-10 15:13:22 -07:00
dd940a007c Merge pull request #1820 from jackyzha0/dependabot/npm_and_yarn/production-dependencies-8ce87f9e70
chore(deps): bump the production-dependencies group with 5 updates
2025-03-10 17:57:39 -04:00
a71e17919b chore(deps): bump the production-dependencies group with 5 updates
Bumps the production-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [hast-util-to-jsx-runtime](https://github.com/syntax-tree/hast-util-to-jsx-runtime) | `2.3.5` | `2.3.6` |
| [lightningcss](https://github.com/parcel-bundler/lightningcss) | `1.29.1` | `1.29.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.9` | `22.13.10` |
| [@types/ws](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ws) | `8.5.14` | `8.18.0` |
| [esbuild](https://github.com/evanw/esbuild) | `0.25.0` | `0.25.1` |


Updates `hast-util-to-jsx-runtime` from 2.3.5 to 2.3.6
- [Release notes](https://github.com/syntax-tree/hast-util-to-jsx-runtime/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-jsx-runtime/compare/2.3.5...2.3.6)

Updates `lightningcss` from 1.29.1 to 1.29.2
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/commits/v1.29.2)

Updates `@types/node` from 22.13.9 to 22.13.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/ws` from 8.5.14 to 8.18.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ws)

Updates `esbuild` from 0.25.0 to 0.25.1
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.0...v0.25.1)

---
updated-dependencies:
- dependency-name: hast-util-to-jsx-runtime
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: lightningcss
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/ws"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-10 20:57:05 +00:00
aca0c330e7 docs: cleanup showcase (#1818)
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2025-03-10 11:46:49 -07:00
dcaf806190 feat: support non-singleton darkmode 2025-03-10 11:44:47 -07:00
23df17233d fix(graph): make graph non-singleton, proper cleanup, fix radial 2025-03-10 11:39:08 -07:00
8d33608808 fix(popovers): clear id to avoid anchor jumps within popover 2025-03-10 00:08:06 -07:00
d618a4e3f3 fix(explorer): dont rely on data to get slug, compute it in trie 2025-03-09 23:36:10 -07:00
9c8fec06d2 feat: support non-singleton search 2025-03-09 15:33:15 -07:00
1cd8e7f0d5 feat: support non-singleton table of contents 2025-03-09 15:06:36 -07:00
5480269d38 perf(explorer): client side explorer (#1810)
* start work on client side explorer

* fix tests

* fmt

* generic test flag

* add prenav hook

* add highlight class

* make flex more consistent, remove transition

* open folders that are prefixes of current path

* make mobile look nice

* more style fixes
2025-03-09 14:58:26 -07:00
a201105442 fix(docker): instructions + bump deps + bind mount (#1809)
* fix docker

* test with docs folder
2025-03-06 10:01:25 -08:00
cc9704becc chore(deps): bump deps, silence internal punycode deprecation 2025-03-06 09:41:26 -08:00
6d195fd40a feat: font specification flexibility 2025-03-06 09:21:50 -08:00
2213424195 docs: make role of getQuartzComponents more clear and also make it optional 2025-03-05 18:34:02 -08:00
5b13ff2199 feat: support emitters defining external resources, emit link from contentindex directly 2025-03-05 18:16:24 -08:00
5a39719898 fix(graph): set container as renderGroup to avoid redrawing multiple times (#1736)
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2025-03-05 17:33:16 -08:00
3c8ccde624 chore(og-image): force twemoji for emoji util 2025-03-05 17:21:19 -08:00
c97fd7089a Added emoji support to Satori when generating OG images (#1593) 2025-03-05 17:14:06 -08:00
2acfa0fa23 fix(og-image): overflow ellipses in title and description 2025-03-05 17:13:19 -08:00
f6f417a505 fix: engine reqiurements 2025-03-05 16:49:43 -08:00
a3b6201365 chore(deps): bump the production-dependencies group with 6 updates (#1804)
* chore(deps): bump the production-dependencies group with 6 updates

Bumps the production-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [hast-util-to-jsx-runtime](https://github.com/syntax-tree/hast-util-to-jsx-runtime) | `2.3.4` | `2.3.5` |
| [pixi.js](https://github.com/pixijs/pixijs) | `8.8.0` | `8.8.1` |
| [preact](https://github.com/preactjs/preact) | `10.26.2` | `10.26.4` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.5` | `22.13.9` |
| [prettier](https://github.com/prettier/prettier) | `3.5.2` | `3.5.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.7.3` | `5.8.2` |


Updates `hast-util-to-jsx-runtime` from 2.3.4 to 2.3.5
- [Release notes](https://github.com/syntax-tree/hast-util-to-jsx-runtime/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-jsx-runtime/compare/2.3.4...2.3.5)

Updates `pixi.js` from 8.8.0 to 8.8.1
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.8.0...v8.8.1)

Updates `preact` from 10.26.2 to 10.26.4
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.2...10.26.4)

Updates `@types/node` from 22.13.5 to 22.13.9
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `prettier` from 3.5.2 to 3.5.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.5.2...3.5.3)

Updates `typescript` from 5.7.3 to 5.8.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v5.8.2)

---
updated-dependencies:
- dependency-name: hast-util-to-jsx-runtime
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* type fixes

* fix more types

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2025-03-05 16:45:02 -08:00
a1162b978a fix(analytics): spa tracking for other providers 2025-03-05 16:27:08 -08:00
c6f10b44f6 feat(rss): configurable RSS feed URL (#1806)
* feat(rss): configurable RSS feed URL

* Update docs/features/RSS Feed.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/features/RSS Feed.md

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2025-03-05 15:54:11 -08:00
b050162f82 docs(frontmatter): mention dealiasing related fields (#1715)
* Added to the documentation which values of frontmatter are referenced.

* The source code I was looking at seemed to be out of date and the wrong listings were corrected.

* The list of frontmatter was moved to Frontmatter.md and a link was added.
2025-02-27 16:37:01 -05:00
6d350500f1 fix(goatcounter): properly count SPA page hits (#1718)
On the surface it seems that only google and plausible scripts handle
the SPA correctly - but I don't know if maybe others handle
window.history API themselves somehow or something like that.

However, I am trying out goatcounter and in it's docs I see that it
does no special SPA handling, so this has to be fixed.

Just doing the dynamic script thing on every nav seems to do the trick.
The script is not "spa-preserve" so they wouldn't accumulate - and when
I tried the "spa-preserve" + call goatcounter api route it didn't quite
work, they actually did accumulate
2025-02-27 16:35:19 -05:00
9b47279fe6 feat(i18n): added nb-NO (Norwegian) (#1795) 2025-02-27 16:33:15 -05:00
4901472aa8 chore(deps): bump the production-dependencies group with 8 updates (#1788)
* chore(deps): bump the production-dependencies group with 8 updates

Bumps the production-dependencies group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [hast-util-to-html](https://github.com/syntax-tree/hast-util-to-html) | `9.0.4` | `9.0.5` |
| [hast-util-to-jsx-runtime](https://github.com/syntax-tree/hast-util-to-jsx-runtime) | `2.3.2` | `2.3.4` |
| [preact](https://github.com/preactjs/preact) | `10.26.0` | `10.26.2` |
| [rehype-mathjax](https://github.com/remarkjs/remark-math) | `7.0.0` | `7.1.0` |
| [ws](https://github.com/websockets/ws) | `8.18.0` | `8.18.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.4` | `22.13.5` |
| [prettier](https://github.com/prettier/prettier) | `3.5.1` | `3.5.2` |
| [tsx](https://github.com/privatenumber/tsx) | `4.19.2` | `4.19.3` |


Updates `hast-util-to-html` from 9.0.4 to 9.0.5
- [Release notes](https://github.com/syntax-tree/hast-util-to-html/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-html/compare/9.0.4...9.0.5)

Updates `hast-util-to-jsx-runtime` from 2.3.2 to 2.3.4
- [Release notes](https://github.com/syntax-tree/hast-util-to-jsx-runtime/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-jsx-runtime/compare/2.3.2...2.3.4)

Updates `preact` from 10.26.0 to 10.26.2
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.0...10.26.2)

Updates `rehype-mathjax` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@7.0.0...rehype-mathjax@7.1.0)

Updates `ws` from 8.18.0 to 8.18.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.0...8.18.1)

Updates `@types/node` from 22.13.4 to 22.13.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `prettier` from 3.5.1 to 3.5.2
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.5.1...3.5.2)

Updates `tsx` from 4.19.2 to 4.19.3
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.19.2...v4.19.3)

---
updated-dependencies:
- dependency-name: hast-util-to-html
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: hast-util-to-jsx-runtime
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: rehype-mathjax
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: ws
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: update type error

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2025-02-25 16:52:52 -05:00
28fe9b2095 chore(deps): bump sigstore/cosign-installer in the ci-dependencies group (#1789)
Bumps the ci-dependencies group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 3.8.0 to 3.8.1
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v3.8.0...v3.8.1)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-25 16:35:10 -05:00
9a0d919a3b docs: add Citation plugin & feature pages (#1772)
* docs: add Citation plugin & feature pages

Discussed and extracted from #1557

* Update docs/features/Citations.md

* Apply suggestions from code review

---------

Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2025-02-25 16:34:37 -05:00
598741a571 fix(fonts): update system-ui trick for improve cross-platform compatibility (#1778) 2025-02-25 16:31:41 -05:00
29c533a265 fix(aliases): wikilink resolution for aliases (#1681)
With markdownLinkResolution: "shortest", aka "+/- how Obsidian does it"
and given pages A and nested/B which has an alias Z, if you try to link
from A using [[Z]] it wouldn't work and get 404.

This is caused by alias slugs (nested/Z in this case, emitted by
AliasRedirects) not being present in the `allSlugs` list which is used
by the link transformer.

The fix is to compute the alias slugs in the frontmatter transformer
and add them to `allSlugs` there.
Also we store them in file data to avoid recomputing them when emitting
alias redirect pages.

Fixes #904

Note: given how currently the markdown/html transformers are ordered
this doesn't really work.

Given pages A and nested/B which has an alias Z, here's the order which
currently happens:

md-transformers(A) => html-transformers(A) =>
md-transformers(B) => html-transformers(B)

Since the nested/Z slug will get added when md-transformers(B) are run,
but the slugs are used by html-transformers(A) when resolving it's
links - the link [[Z]] in A will still 404

A fix for this is to split the parser into two stages - first apply the
md-transformers to all files, and only then apply html-transformers to
all files.

I did just that in a different commit, which is needed for this one to
work correctly.
2025-02-25 16:28:16 -05:00
1a02642469 feat(i18n): add Finnish translations for i18n (#1782) 2025-02-25 16:27:32 -05:00
5794405974 feat(cli): absolute path support for -d flag (#1756) 2025-02-25 16:27:10 -05:00
59d5a0ed3a fix(graph): mobile global graph overlay (#1790) 2025-02-25 13:35:24 +01:00
dd6bd498db chore(deps): bump the production-dependencies group with 5 updates (#1775)
Bumps the production-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [pixi.js](https://github.com/pixijs/pixijs) | `8.7.3` | `8.8.0` |
| [preact](https://github.com/preactjs/preact) | `10.25.4` | `10.26.0` |
| [rehype-mathjax](https://github.com/remarkjs/remark-math) | `6.0.0` | `7.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.1` | `22.13.4` |
| [prettier](https://github.com/prettier/prettier) | `3.5.0` | `3.5.1` |


Updates `pixi.js` from 8.7.3 to 8.8.0
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.7.3...v8.8.0)

Updates `preact` from 10.25.4 to 10.26.0
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.25.4...10.26.0)

Updates `rehype-mathjax` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@6.0.0...rehype-mathjax@7.0.0)

Updates `@types/node` from 22.13.1 to 22.13.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `prettier` from 3.5.0 to 3.5.1
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.5.0...3.5.1)

---
updated-dependencies:
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: rehype-mathjax
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 21:54:07 -05:00
04423d4931 chore(deps): bump the production-dependencies group with 6 updates (#1761)
Bumps the production-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@clack/prompts](https://github.com/natemoo-re/clack/tree/HEAD/packages/prompts) | `0.9.1` | `0.10.0` |
| [globby](https://github.com/sindresorhus/globby) | `14.0.2` | `14.1.0` |
| [remark-gfm](https://github.com/remarkjs/remark-gfm) | `4.0.0` | `4.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.0` | `22.13.1` |
| [esbuild](https://github.com/evanw/esbuild) | `0.24.2` | `0.25.0` |
| [prettier](https://github.com/prettier/prettier) | `3.4.2` | `3.5.0` |


Updates `@clack/prompts` from 0.9.1 to 0.10.0
- [Release notes](https://github.com/natemoo-re/clack/releases)
- [Changelog](https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md)
- [Commits](https://github.com/natemoo-re/clack/commits/@clack/prompts@0.10.0/packages/prompts)

Updates `globby` from 14.0.2 to 14.1.0
- [Release notes](https://github.com/sindresorhus/globby/releases)
- [Commits](https://github.com/sindresorhus/globby/compare/v14.0.2...v14.1.0)

Updates `remark-gfm` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/remarkjs/remark-gfm/releases)
- [Commits](https://github.com/remarkjs/remark-gfm/compare/4.0.0...4.0.1)

Updates `@types/node` from 22.13.0 to 22.13.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.24.2 to 0.25.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.24.2...v0.25.0)

Updates `prettier` from 3.4.2 to 3.5.0
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.4.2...3.5.0)

---
updated-dependencies:
- dependency-name: "@clack/prompts"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: globby
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: remark-gfm
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-11 01:28:02 -05:00
be846e6a5f chore(deps): bump sigstore/cosign-installer in the ci-dependencies group (#1762)
Bumps the ci-dependencies group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 3.7.0 to 3.8.0
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v3.7.0...v3.8.0)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-11 01:27:43 -05:00
32ec711767 chore(deps-dev): bump @types/node in the production-dependencies group (#1751)
Bumps the production-dependencies group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 22.12.0 to 22.13.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-05 05:07:14 -05:00
91189dfd2f feat(explorer): collapsible mobile explorer (#1471)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2025-02-03 09:25:42 -05:00
fbc45548f7 feat(graph): enable radial mode (#1738) 2025-02-01 16:22:29 -05:00
7be47742a6 chore(deps): bump the production-dependencies group across 1 directory with 3 updates (#1744)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-31 06:46:45 -05:00
4e4930ef9c chore(styles): omit sass deprecation warnings (#1737)
update to newer API
2025-01-23 22:19:46 -05:00
01943ff5a0 feat(i18n): Lithuanian (#1733) 2025-01-21 00:20:02 -05:00
8cf3e3001f feat(i18n): Thai translations (#1722) 2025-01-16 15:44:33 -05:00
992ac00f7c chore(deps): bump the production-dependencies group with 7 updates (#1719)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-14 00:02:49 -05:00
09f8670db7 chore(deps): bump the production-dependencies group with 4 updates (#1711)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2025-01-07 20:32:02 -05:00
7e828252bb fix(path): handle lone slash (#1713) 2025-01-07 16:00:09 -05:00
c90dbacab0 chore(build): separate markdown and html handling into two separate stages (#1675) 2025-01-07 15:33:34 -05:00
b7a945e034 fix(tags): Dont consume a space before content tags (#1706) 2025-01-05 01:11:15 -05:00
dc3323b574 chore: use Google Fonts API v2 to get fonts for ogp (#1705) 2025-01-04 09:45:53 -08:00
c98ef7e89f perf(img): lazy load chunks (#1700) 2025-01-03 11:27:43 -08:00
2e6a675edd perf: reduce bundle size (closes #1607) (#1689) 2025-01-01 12:24:32 -08:00
e3162f7a7e chore: joinSegments fix + tests 2025-01-01 12:11:16 -08:00
9466c145b1 fix(path): correct handle URI schemes in joinSegments (#1693) 2025-01-01 10:07:59 -08:00
a934397961 fix(spa): Fix relative alias redirects (#1688) 2024-12-30 18:21:07 -08:00
b2752e7262 chore(deps): bump the production-dependencies group with 2 updates (#1696)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 18:19:22 -08:00
d9e36e60d1 fix(search): Fix super-inconsistent preview widths in search previews (#1677)
Don't know if this was uncovered by my local setup or just nobody
noticed, but page previews were pretty wonky for me
2024-12-30 09:55:53 -08:00
46adb35966 fix(spa): Normalize empty hrefs (#1695)
A final breadcrumb has an empty href, linking to the current page, but
the relative url normalization method missed those, making the link
appear broken in search previews and popovers.

Fixes #1690
2024-12-30 09:55:18 -08:00
e1c9eabef4 fix(css): oops fit-content not no width specification for codeblock title 2024-12-30 09:28:02 -08:00
d88e43010a fix(tags): Fix TagContent not sorting the page list when opening a tag (#1692) 2024-12-30 09:08:35 -08:00
ef72f1bf70 Fix ObsidianFlavoredMarkdown source link (#1694) 2024-12-30 08:03:57 -08:00
7d4bed64a9 fix(css): allow proper width on code block title (closes #1683) 2024-12-28 15:17:58 -08:00
99011cb1b0 fix(spa): handle HTML redirects for aliases (#1680) 2024-12-27 07:18:22 -07:00
c91cf97f99 feat(spa): add loading bar (#1684) 2024-12-26 22:18:17 -07:00
05e6f05a50 feat(backlinks): hide by default when empty (#1674)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2024-12-26 21:05:35 -07:00
7533d504dc fix(tags): Set consistent titles for tags with description pages (#1671) 2024-12-26 17:50:06 -07:00
69a0ddf733 fix(tags): Show tag descriptions when hovering (#1670) 2024-12-26 06:50:54 -07:00
a582505daf chore(deps): bump the production-dependencies group with 7 updates (#1667)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-23 16:35:08 -05:00
965f9e123c chore(frontmatter): dealias created/modified/updated time (#1664) 2024-12-23 15:00:26 -05:00
ff9e60a7fc fix(search): restore mobile search view (#1576) 2024-12-17 13:07:00 -05:00
367bb0e6ce chore(deps): bump the production-dependencies group across 1 directory with 14 updates (#1655)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-12-17 03:53:51 -05:00
8141cb1587 fix(cssclasses): apply frontmatter classes uniformly between page types (#1645) 2024-12-11 00:43:27 +01:00
5ccc2dcbba fix(head): update open-graph width and height protocol per ogp (#1512)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-12-03 04:52:51 -05:00
11c98f5600 feat(i18n): add zh-TW (#1621) 2024-12-03 04:52:36 -05:00
5a5ef670fe fix(darkmode): check for null (#1531) 2024-12-03 01:56:59 -05:00
285c0e9768 docs(showcase): 8cat.life (#1617) 2024-12-03 01:50:50 -05:00
adb326c933 fix(style): ensure images have no background color when linked (#1623) 2024-12-03 01:43:22 -05:00
Bao
0c4281eb53 fix: use time HTML element for date strings (#1622) 2024-12-03 01:41:55 -05:00
7ac94e1d84 fix(comments): properly check if comments is in frontmatter (#1627) 2024-11-30 21:03:20 -05:00
e1d754ef79 Update showcase.md (#1611) 2024-11-22 11:44:40 -08:00
a727b57e46 docs(hosting): add apache config example (#1608) 2024-11-20 19:29:43 -05:00
1a4cfa606f chore(deps): bump the production-dependencies group with 3 updates (#1600)
Bumps the production-dependencies group with 3 updates: [@clack/prompts](https://github.com/natemoo-re/clack/tree/HEAD/packages/prompts), [satori](https://github.com/vercel/satori) and [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki).


Updates `@clack/prompts` from 0.7.0 to 0.8.1
- [Release notes](https://github.com/natemoo-re/clack/releases)
- [Changelog](https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md)
- [Commits](https://github.com/natemoo-re/clack/commits/@clack/prompts@0.8.1/packages/prompts)

Updates `satori` from 0.10.14 to 0.11.3
- [Release notes](https://github.com/vercel/satori/releases)
- [Commits](https://github.com/vercel/satori/compare/0.10.14...0.11.3)

Updates `shiki` from 1.22.2 to 1.23.1
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.23.1/packages/shiki)

---
updated-dependencies:
- dependency-name: "@clack/prompts"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: satori
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 00:36:08 -05:00
410001b942 docs: add the pond (#1598) 2024-11-18 11:39:44 -08:00
f24a147276 fix(comment): negate check (closes #1582) (#1586) 2024-11-14 06:28:06 -05:00
137d55eb1b feat(open-graph): generate OG images + further OG support (#740)
* Quartz sync: Aug 29, 2023, 10:17 PM

* feat: add basic satori og image generation

* Squashed commit of the following:

commit fa69c2a565
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 19:35:11 2023 +0200

    fix(explorer): increase consistency, explicitly use font-family (#496)

    * fix(explorer): display name for folders without `index` file

    * docs(explorer): add section for folder display names

    * docs(explorer): fix broken wikilink

    * fix(consistency): explicitly set font + label/link fix

    Use consistent styling between folders with `folderClickBehavior: "link"` and `"collapse`

    * Update quartz/components/styles/explorer.scss

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

    * Update quartz/components/styles/explorer.scss

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

    ---------

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

commit 8eb1554b13
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 18:54:33 2023 +0200

    fix(explorer): display names for folders without frontmatter (#494)

    * fix(explorer): display name for folders without `index` file

    * docs(explorer): add section for folder display names

commit dcdeae4e7b
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Thu Sep 21 18:53:19 2023 +0200

    docs(explorer): update default config + new example (#493)

commit 48452231d5
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 16:09:18 2023 -0700

    perf: memoize filetree computation (#490)

    * perf: memoize filetree computation

    * format

    * var -> let

commit 16d33fb771
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 16:08:54 2023 -0700

    feat: display name for folders, expand explorer a little bit (#489)

    * feat: display name for folders, expand explorer a little bit

    * update docs

commit b029eeadab
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Wed Sep 20 22:55:29 2023 +0200

    feat(explorer): improve accessibility and consistency (+ bug fix) (#488)

    * feat(consistency): use `all: unset` on button

    * style: improve accessibility and consistency for explorer

    * fix: localStorage bug with folder name changes

    * chore: bump quartz version

commit 6a9e6352e8
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 13:52:45 2023 -0700

    Revert "feat: Making Quartz available offline by making it a PWA (#465)"

    This reverts commit d6301fae90.

commit 70e029d151
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 13:52:29 2023 -0700

    Revert "docs: wording changes for offline support"

    This reverts commit 52a172d1a4.

commit 0bad3ce799
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 11:58:52 2023 -0700

    docs: document enableToc

commit 52a172d1a4
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Wed Sep 20 11:40:36 2023 -0700

    docs: wording changes for offline support

commit d6301fae90
Author: Adam Brangenberg <adambrangenberg@proton.me>
Date:   Wed Sep 20 20:38:13 2023 +0200

    feat: Making Quartz available offline by making it a PWA (#465)

    * Adding PWA and chaching for offline aviability

    * renamed workbox config to fit Quartz' scheme

    * Documenting new configuration

    * Added missig umami documentation

    * Fixed formatting so the build passes, thank you prettier :)

    * specified caching strategies to improve performance

    * formatting...

    * fixing "404 manifest.json not found" on subdirectories by adding a / to manifestpath

    * turning it into a plugin

    * Removed Workbox-cli and updated @types/node

    * Added Serviceworkercode to offline.ts

    * formatting

    * Removing workbox from docs

    * applied suggestions

    * Removed path.join for sw path

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

    * Removed path.join for manifest path

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

    * Removing path module import

    * Added absolute path to manifests start_url and manifest "import" using baseUrl

    * Adding protocol to baseurl

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

    * Adding protocol to start_url too then

    * formatting...

    * Adding fallback page

    * Documenting offline plugin

    * formatting...

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * merge suggestion

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

    * formatting...

    * Fixing manifest path, all these nits hiding the actual issues .-.

    * Offline fallback page through plugins, most things taken from 404 Plugin

    * adding Offline Plugin to config

    * formatting...

    * Turned offline off as default and removed offline.md

    ---------

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

commit 27a6087dd5
Author: rwutscher <richard.wutscher@gmail.com>
Date:   Tue Sep 19 21:26:30 2023 +0200

    fix: tag regex no longer includes purely numerical 'tags' (#485)

    * fix: tag regex no longer includes purely numerical 'tags'

    * fix: formatting

    * fix: use guard in findAndReplace() instead of expanding the regex

commit 1bf7e3d8b3
Author: Jacky Zhao <j.zhao2k19@gmail.com>
Date:   Tue Sep 19 10:22:39 2023 -0700

    fix(nit): make defaultOptions on explorer not a function

commit cc31a40b0c
Author: David Fischer <david@konst.fish>
Date:   Tue Sep 19 18:25:51 2023 +0200

    feat: support changes in system theme (#484)

    * feat: support changes in system theme

    * fix: run prettier

    * fix: add content/.gitkeep

commit 0d3cf29226
Author: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date:   Mon Sep 18 23:32:00 2023 +0200

    docs: fix explorer example (#483)

* feat: dynamically generate og images, write to fs as png

* fix: og preview on discord

* feat: use `sharp` to convert to webp, add content headers

* feat: add config for theme (light or dark)

* feat: improve image margins, add font breakpoint

* feat: use config header + body fonts for satori

* perf: memoize fonts

* feat: use default og image if no path exists

* feat: add config option for social images

* feat: support custom og images via frontmatter

* refactor: clean font helpers, rename fonts helper

* refactor: make image generation cleaner

* refactor: move default image to own component

* chore: add todos

* fix: only set width/height header if known

* feat: remove html from description

* feat: make image dimensions configurable

* feat: pass userOpts to image generator

* feat: option for users to provide own image struct (satori)

This allows users to pass their own jsx for generating the default og image

* refactor: rename `defaultImage.tsx` > `socialImage.tsx`

* chore: improve comments + types

* refactor: rename socialImage frontmatter property

* feat: add frontmatter aliases for cover image

* feat: add frontmatter alias for obsidian publish

* docs: add documentation for social images

* feat: add `generateSocialImages` prop to config

* chore: update lock file

* fix: fix type error

* chore: update package.json

* chore: update package-lock.json

* docs: update docs

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

* fix: clean url for use in metadata

* refactor: clean function signature

* feat: pass `fileData` to image generator

* CI: run format

* fix: file system import

* fix: merge paths using `joinSegments`

* fix: get output dir via `ctx.argv.output`

* chore: add explanation to font regex

* Squashed commit of the following:

commit 7164857f6e
Author: Aaron Pham <29749331+aarnphm@users.noreply.github.com>
Date:   Fri Mar 15 21:17:42 2024 -0400

    chore(ofm): remove unused (#999)

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

commit 47024022e8
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Fri Mar 15 18:29:14 2024 -0400

    chore(deps-dev): bump @types/node from 20.11.24 to 20.11.25 (#990)

    Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.24 to 20.11.25.
    - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
    - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

    ---
    updated-dependencies:
    - dependency-name: "@types/node"
      dependency-type: direct:development
      update-type: version-update:semver-patch
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit b98e4be665
Author: Mara-Li <lili.simonetti@outlook.fr>
Date:   Fri Mar 15 23:28:31 2024 +0100

    feat(i18n): Add French translation for reading time (#998)

    Signed-off-by: Mara-Li <lili.simonetti@outlook.fr>

commit 8be51a0504
Author: catcodeme <1020082805@qq.com>
Date:   Fri Mar 15 14:25:01 2024 +0800

    fix: wikiLink in table (#993)

    * fix: wikiLink in table

    - update regexp to make '\' to group in alias
    - handle alias using block_id

    * style: format with prettier

    * style: add comment for block_ref(without alias) in table

    ---------

    Co-authored-by: hulinjiang <hulinjiang@58.com>

commit 92cc23dc45
Author: Linus Sehn <37184648+linozen@users.noreply.github.com>
Date:   Wed Mar 13 08:59:37 2024 +0100

    feat(plugin): citations (#984)

    * feat: add rehype-citations

    * feat: add citations transformer plugin

    * feat: add rehype-rewrite

    * feat: add csl option and add no-popover to citation links

    * revert: add rehype-rewrite

    04b2692 'feat: add rehype-rewrite'

    * feat: use existing package for html manipulation

    * fix: remove `console.log()`

commit 097abc3cda
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Mon Mar 11 13:41:48 2024 -0700

    chore(deps): bump async-mutex from 0.4.1 to 0.5.0 (#991)

    Bumps [async-mutex](https://github.com/DirtyHairy/async-mutex) from 0.4.1 to 0.5.0.
    - [Changelog](https://github.com/DirtyHairy/async-mutex/blob/master/CHANGELOG.md)
    - [Commits](https://github.com/DirtyHairy/async-mutex/compare/v0.4.1...v0.5.0)

    ---
    updated-dependencies:
    - dependency-name: async-mutex
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit a00324ddfd
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Mon Mar 11 13:41:41 2024 -0700

    chore(deps-dev): bump typescript from 5.3.3 to 5.4.2 (#989)

    Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.3 to 5.4.2.
    - [Release notes](https://github.com/Microsoft/TypeScript/releases)
    - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
    - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.3...v5.4.2)

    ---
    updated-dependencies:
    - dependency-name: typescript
      dependency-type: direct:development
      update-type: version-update:semver-minor
    ...

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 9fff6d7d0d
Author: Mara-Li <lili.simonetti@outlook.fr>
Date:   Mon Mar 11 17:46:53 2024 +0100

    fix: spelling error (#987)

    I really don't know why I translated this like that into "pas trouvé", and it bugged me a lot. I finally fixed it…

    Signed-off-by: Mara-Li <lili.simonetti@outlook.fr>

commit 0f5a9d7b66
Author: Matt Vogel <mainmoniker@googlemail.com>
Date:   Sun Mar 10 12:57:10 2024 -0400

    feat: separated content meta (#929)

    to allow for CSS styling

commit b4236e5142
Author: kabirgh <15871468+kabirgh@users.noreply.github.com>
Date:   Sun Mar 10 00:42:23 2024 +0000

    feat(perf:fast-rebuilds): Stop mutating resources param in ComponentResources emitter (#977)

    * Stop mutating resources param in ComponentResources emitter

    * Add done rebuilding log for fast rebuilds

    * Move google font loading to Head component

    * Simplify code and fix comment

commit 6e0c102970
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sun Mar 10 01:14:31 2024 +0100

    fix(transclusion): prevent duplicate transclusion if multiple transclusions are present. (#982)

commit 94a54698ab
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sat Mar 9 17:59:55 2024 +0100

    fix(resources): Use full path to font when cdnCache is false (#976)

commit 2e9a0c21db
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Sat Mar 9 17:43:40 2024 +0100

    fix(description): first sentence no longer repeats until max length (#981)

commit b30a200bd4
Author: Aaron Pham <29749331+aarnphm@users.noreply.github.com>
Date:   Fri Mar 8 12:14:22 2024 -0500

    fix(i18n): make sure to use correct fileData for manual localization (#975)

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

commit 6d59aa8201
Author: Emile Bangma <ewjbangma@hotmail.com>
Date:   Fri Mar 8 10:04:44 2024 +0100

    fix(description): counts characters instead of words (#972)

    * fix(description): make sure description counts characters instead of words

    * ref: removed duplicate ternary

* CI: fix package log post merge

* CI: fix more merge artifacts

* CI: fix package-lock.json

* feat: add new default image template

* feat: use icon.png for image generation

* chore: update satori and sharp version

* feat(image-generator): add new default template

* Update quartz/components/Head.tsx

* Update quartz/components/Head.tsx

* Update quartz/components/Head.tsx

* Update docs/features/social images.md

* Update quartz/components/Head.tsx

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

* feat(og-image): add config option to use default og image for root path

* docs(og-image): add `excludeRoot` config + update preview images

* docs(open-graph): add examples section

* chore: remove unused `socialImage2.tsx` component

* feat(open-graph): add frontmatter aliases for socialImage/cover/image

* fix(open-graph): only load satori font if config option is enabled

* refactor(open-graph): dont use async promise inside `fetchTtf()`

* chore: renaming and finished copywriting

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: update typo

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: update hinting for socialImage

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Emile Bangma <ewjbangma@hotmail.com>
Co-authored-by: Emile Bangma <github@emilebangma.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-12 07:33:35 -05:00
9188939b1f feat(math): typst support(closes #1568) (#1569)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2024-11-12 06:42:44 -05:00
92676d746e feat(ofm): support parsing footnotes in table (#1581) 2024-11-12 03:37:59 -05:00
1893196939 chore(deps): bump rehype-citation from 2.2.1 to 2.2.2 in the production-dependencies group (#1583)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-12 00:38:48 -05:00
1ab9c91df1 feat(mermaid): improvement navigation (#1575)
* feat(mermaid): custom stuff

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: use mermaid and update clipboard content

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix: explicitly use center div

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-10 18:13:12 -05:00
0ad9111388 chore(deps): bump rlespinasse/github-slug-action from 4.4.1 to 5.0.0 in the ci-dependencies group (#1578)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-10 04:46:14 -05:00
cbeef5541f chore(deps-dev): bump @types/node from 22.8.7 to 22.9.0 in the production-dependencies group (#1579)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-10 04:46:01 -05:00
0f04f1262c ci(dependabot): update GA periodically (#1577)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-10 04:41:56 -05:00
31e0b7c6f8 feat(comments): conditional display via frontmatter (#1566) 2024-11-09 04:44:32 -05:00
a6b2967df8 fix: account for chokidar v4 glob deprecation in source watching 2024-11-07 16:32:26 -08:00
f84a6554f6 fix(style): remove flex end to fix tag alignment in content meta 2024-11-07 15:13:28 -08:00
1d5b6f58d8 chore(deps): bump the production-dependencies group with 4 updates (#1565)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 21:03:30 -05:00
74f8c13598 fix: reset z-index instead of inherited unset (#1528)
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-11-04 20:22:36 -05:00
56ba2f4fa7 feat(build): add support for parsing inline sass (#1558) 2024-11-01 07:16:12 -04:00
314a88d5c6 feat(transformer): allow inline CSS styling (#1551)
* feat(transformer): allow inline CSS styling

* Updated docs

* Default to inline: false

* Removed redundant inline: false
2024-10-30 23:57:39 +01:00
a53772bbd1 feat(i18n): add turkish language (#1554) 2024-10-30 18:33:22 -04:00
1915a198d9 chore(deps): bump the production-dependencies group with 5 updates (#1548)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 20:04:45 -04:00
1d2dc167f4 feat(folder): add intermediate folders pages (#1295)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-25 12:52:33 -04:00
3ef2a24f4b chore(deps): bump the production-dependencies group across 1 directory with 7 updates (#1540) 2024-10-22 17:54:41 -04:00
d98a3a088a Merge pull request #1543 from saberzero1/draft-publish-string
feat(filters): support "true" as valid for frontmatter filters
2024-10-22 22:38:16 +02:00
ad52d09567 Feat(filters): Support "true" as valid for draft and publish frontmatter values (b3a02909ba) 2024-10-22 20:33:54 +00:00
9f701e5045 fix(grid): $desktop variable (#1511) 2024-10-17 23:50:48 -04:00
67e1beea70 feat(comments): support custom giscus themes (#1526)
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-17 18:30:16 -04:00
3aa11357aa fix(toc): regression (#1517) 2024-10-16 17:44:30 -04:00
b3a02909ba fix: make filter checks more strict (closes #1519) 2024-10-15 09:34:46 -07:00
1dc208356a fix(cli): use shell on win32 for update (#1503) (#1504)
If there is no `npm.exe` on the system, but
instead an `npm.cmd`, then node won't find
the `npm` executable when calling `spawnSync`.

This occurs frequently when using node package
managers on Windows.

See the node documentation for `.bat` and `.cmd`
files here.

<https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows>.
2024-10-13 10:46:51 -07:00
3d0ba32070 docs: Update Name and URL of Simon's SB and adding DE Vault (#1507) 2024-10-13 10:42:29 -07:00
c5d97db000 fix(toc): invalid desktop-only styling (#1502)
* fix(toc): invalid desktop-only styling

should display none instead.

* Update toc.scss
2024-10-12 18:33:07 -04:00
0d1f15d37c feat(ofm): Allow for dashes in custom callout label (#1493)
* [OFM] Allow for dashes in custom callout label

For compatibility with Obsidian's behavior, a custom callout like
[!see-also] is possible. Previously, this was parsed by Quartz as a
callout “see” with metadata “-also”. Instead, this is should be a
callout “see-also” with title “See also” (capitalization + replace
dashes by spaces).

* prettier
2024-10-10 07:15:23 -04:00
11c23a137a chore(deps): bump the production-dependencies group with 9 updates (#1495)
Bumps the production-dependencies group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [chokidar](https://github.com/paulmillr/chokidar) | `3.6.0` | `4.0.1` |
| [esbuild-sass-plugin](https://github.com/glromeo/esbuild-sass-plugin) | `2.16.1` | `3.3.1` |
| [lightningcss](https://github.com/parcel-bundler/lightningcss) | `1.26.0` | `1.27.0` |
| [preact](https://github.com/preactjs/preact) | `10.24.1` | `10.24.2` |
| [rehype-citation](https://github.com/timlrx/rehype-citation) | `2.1.1` | `2.1.2` |
| [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) | `1.18.0` | `1.22.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.7.4` | `22.7.5` |
| [esbuild](https://github.com/evanw/esbuild) | `0.19.12` | `0.24.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.6.2` | `5.6.3` |


Updates `chokidar` from 3.6.0 to 4.0.1
- [Release notes](https://github.com/paulmillr/chokidar/releases)
- [Commits](https://github.com/paulmillr/chokidar/compare/3.6.0...4.0.1)

Updates `esbuild-sass-plugin` from 2.16.1 to 3.3.1
- [Release notes](https://github.com/glromeo/esbuild-sass-plugin/releases)
- [Commits](https://github.com/glromeo/esbuild-sass-plugin/compare/v2.16.1...v3.3.1)

Updates `lightningcss` from 1.26.0 to 1.27.0
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/compare/v1.26.0...v1.27.0)

Updates `preact` from 10.24.1 to 10.24.2
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.24.1...10.24.2)

Updates `rehype-citation` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/timlrx/rehype-citation/releases)
- [Commits](https://github.com/timlrx/rehype-citation/compare/v2.1.1...v2.1.2)

Updates `shiki` from 1.18.0 to 1.22.0
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.22.0/packages/shiki)

Updates `@types/node` from 22.7.4 to 22.7.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.19.12 to 0.24.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.19.12...v0.24.0)

Updates `typescript` from 5.6.2 to 5.6.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.2...v5.6.3)

---
updated-dependencies:
- dependency-name: chokidar
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: esbuild-sass-plugin
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production-dependencies
- dependency-name: lightningcss
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: rehype-citation
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
- dependency-name: esbuild
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production-dependencies
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 14:57:33 -07:00
8a95c865c8 chore(citations): passthrough options (#1429) 2024-10-09 14:14:44 -04:00
fe4b039b60 chore(ci): group dependabot upgrade (#1491)
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-09 09:52:49 -07:00
497d51973a chore(ci): remove signing and vuln (#1484)
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-07 02:52:14 -04:00
66d7dd8677 fix(ci): run build on tags (#1483)
* fix(ci): run build on tags

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: update docker warning and not push on PR

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-06 22:34:31 -04:00
313cef60ee infra: build quartz docker image to GHCR (#1192)
* Add GitHub action to build & push Docker image to GHCR

* Use double quotes to keep `prettier` happy :)

* Don't run Docker build & push on forks

* -1 char commit lmao

* Add git metadata to Docker image

* Apply Aaron's patch

* chore: run prettier

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Aaron Pham <contact@aarnphm.xyz>
2024-10-06 22:15:19 -04:00
af14ca7c4f i18n: add Czech translation (#1477)
* add Czech translation

* fix formatting
2024-10-04 09:10:40 -07:00
e06f681ce7 chore(deps): bump preact from 10.24.0 to 10.24.1 (#1464)
Bumps [preact](https://github.com/preactjs/preact) from 10.24.0 to 10.24.1.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.24.0...10.24.1)

---
updated-dependencies:
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 04:42:14 -04:00
62906eebd3 chore(deps-dev): bump @types/node from 22.5.5 to 22.7.4 (#1465)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.5.5 to 22.7.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 04:42:02 -04:00
9cd072bfc3 chore(deps): bump hast-util-to-string from 3.0.0 to 3.0.1 (#1466)
Bumps [hast-util-to-string](https://github.com/rehypejs/rehype-minify) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/rehypejs/rehype-minify/releases)
- [Changelog](https://github.com/rehypejs/rehype-minify/blob/main/changelog.md)
- [Commits](https://github.com/rehypejs/rehype-minify/commits)

---
updated-dependencies:
- dependency-name: hast-util-to-string
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 04:41:50 -04:00
319b4497bc chore(deps-dev): bump tsx from 4.19.0 to 4.19.1 (#1467)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.19.0 to 4.19.1.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.19.0...v4.19.1)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 04:41:36 -04:00
b0c079f24a fix(layout): restore footer to the proper position (#1470)
* fix(layout): restore footer to the proper position

* align ToC scrollbar properly on short headers
2024-10-01 08:49:13 -07:00
1b122a1da0 chore(deps): bump @floating-ui/dom from 1.6.10 to 1.6.11 (#1463)
Bumps [@floating-ui/dom](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/dom) from 1.6.10 to 1.6.11.
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/dom/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/dom@1.6.11/packages/dom)

---
updated-dependencies:
- dependency-name: "@floating-ui/dom"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 20:10:05 -04:00
b8c46ba81a feat(analytics): clarity (#1446)
Add the code necessary for support of Microsoft clarity
2024-09-30 10:06:37 -07:00
1416f62a47 fix(layout): backlinks grid triage (#1447)
* fix: mobile backlinks orientation

* temp: hide broken list gradient

* fix: backlinks overflow
2024-09-29 22:24:15 -04:00
8889ab63eb fix: provide default pageTitleSuffix (closes #1452) 2024-09-28 09:38:19 -07:00
5eec1e98e6 fix(layout): grid triage with backlinks (#1442) 2024-09-25 18:15:07 -04:00
a7a0dcad22 fix(layout): grid triage and regression (#1440)
* fix(table of contents): multiple scrollbars (https://github.com/jackyzha0/quartz/issues/1388)

* fix(center): Main content mininum width (https://github.com/jackyzha0/quartz/issues/1439)

* fix(code block): Horizontal overflow fix (https://github.com/jackyzha0/quartz/issues/1438, https://github.com/jackyzha0/quartz/issues/1353)

* WIP fix for ul/ol .overflow

* Fix: restore former scrollbar behavior for overflow lists (https://github.com/jackyzha0/quartz/issues/1437)

* Fix: code block overflow-x

* fix: Table of Content overflow (https://github.com/jackyzha0/quartz/issues/1437)

* Address feedback

* Move max-height toggle from js to css
2024-09-25 17:20:58 -04:00
921f45cf70 feat: add a config option for a pageTitleSuffix (#1320)
* feat: add a config option for a pageTitleSuffix

* Run Prettier on Head.tsx

* Make pageTitleSuffix optional

Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>

---------

Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2024-09-24 20:20:36 -04:00
90c187587f chore(deps): bump pixi.js from 8.3.4 to 8.4.1 (#1432)
Bumps [pixi.js](https://github.com/pixijs/pixijs) from 8.3.4 to 8.4.1.
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.3.4...v8.4.1)

---
updated-dependencies:
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 20:19:48 -04:00
08e20a7006 chore(deps): bump remark-rehype from 11.1.0 to 11.1.1 (#1435)
Bumps [remark-rehype](https://github.com/remarkjs/remark-rehype) from 11.1.0 to 11.1.1.
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.1.0...11.1.1)

---
updated-dependencies:
- dependency-name: remark-rehype
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 20:19:37 -04:00
9e32016508 chore(deps): bump rehype-pretty-code from 0.13.2 to 0.14.0 (#1433)
Bumps [rehype-pretty-code](https://github.com/rehype-pretty/rehype-pretty-code/tree/HEAD/packages/core) from 0.13.2 to 0.14.0.
- [Release notes](https://github.com/rehype-pretty/rehype-pretty-code/releases)
- [Changelog](https://github.com/rehype-pretty/rehype-pretty-code/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/rehype-pretty/rehype-pretty-code/commits/rehype-pretty-code@0.14.0/packages/core)

---
updated-dependencies:
- dependency-name: rehype-pretty-code
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 20:19:25 -04:00
8a050c0be0 chore(deps): bump hast-util-to-html from 9.0.2 to 9.0.3 (#1434)
Bumps [hast-util-to-html](https://github.com/syntax-tree/hast-util-to-html) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/syntax-tree/hast-util-to-html/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-html/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: hast-util-to-html
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 20:19:12 -04:00
0aacd8ed2e chore(deps): bump shiki from 1.12.1 to 1.18.0 (#1436)
Bumps [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) from 1.12.1 to 1.18.0.
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.18.0/packages/shiki)

---
updated-dependencies:
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 20:18:55 -04:00
7d7e334976 feat: responsive design grid (#1354)
* Responsive design grid

* Addressed PR feedback

* Bump Quartz version 4.3.1 => 4.4.0

* Moved page-header into center

* Updated docs with new layouts

* Sync updated version number with package-lock

* Table of Content scrollbar auto

* Reset node_modules

* Updated layout images

* Fixed tablet layout

* Finilazed layout images
2024-09-23 13:40:12 -07:00
9cefcd0dd1 Revert "perf: eagerly compute explorer nodes to avoid re-render in memoized value"
This reverts commit 16a9caa555.
2024-09-20 15:48:14 -07:00
4aaeb768d8 update cabin url (#1428) 2024-09-20 09:23:19 -07:00
743ef712d5 chore(deps): bump preact-render-to-string from 6.5.10 to 6.5.11 (#1417)
Bumps [preact-render-to-string](https://github.com/preactjs/preact-render-to-string) from 6.5.10 to 6.5.11.
- [Release notes](https://github.com/preactjs/preact-render-to-string/releases)
- [Changelog](https://github.com/preactjs/preact-render-to-string/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preact-render-to-string/compare/v6.5.10...v6.5.11)

---
updated-dependencies:
- dependency-name: preact-render-to-string
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 22:24:03 -04:00
9a6e4e2f80 chore(deps-dev): bump typescript from 5.5.4 to 5.6.2 (#1418)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.5.4 to 5.6.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.5.4...v5.6.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:56:14 -04:00
14cb50de9b chore(deps): bump preact from 10.23.2 to 10.24.0 (#1419)
Bumps [preact](https://github.com/preactjs/preact) from 10.23.2 to 10.24.0.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.23.2...10.24.0)

---
updated-dependencies:
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:56:00 -04:00
dad0ae4e3f chore(deps-dev): bump @types/node from 22.5.4 to 22.5.5 (#1420)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.5.4 to 22.5.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:55:42 -04:00
9c060f3cf2 chore(deps): bump vfile from 6.0.2 to 6.0.3 (#1421)
Bumps [vfile](https://github.com/vfile/vfile) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/vfile/vfile/releases)
- [Changelog](https://github.com/vfile/vfile/blob/main/changelog.md)
- [Commits](https://github.com/vfile/vfile/compare/6.0.2...6.0.3)

---
updated-dependencies:
- dependency-name: vfile
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:55:23 -04:00
16a9caa555 perf: eagerly compute explorer nodes to avoid re-render in memoized value 2024-09-15 18:05:17 -07:00
b1c60b8833 Add alexanderweichart.de to showcase.md (#1378)
Added my own website ^^

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-09-15 17:03:22 -07:00
50a78bafa5 docs: clarify prod hosting 2024-09-15 16:47:11 -07:00
6ea359e55e perf: have more than 1ms granularity for rebuild detection 2024-09-15 16:42:07 -07:00
cd3bb25626 fix: account for subtags in numerical tags (closes #1408) (#1410) 2024-09-15 11:25:31 -04:00
eb9bbd1666 fix: constrain link icon size (#1409) 2024-09-14 10:10:01 -07:00
c36551310d chore(deps): bump preact-render-to-string from 6.5.9 to 6.5.10 (#1390)
Bumps [preact-render-to-string](https://github.com/preactjs/preact-render-to-string) from 6.5.9 to 6.5.10.
- [Release notes](https://github.com/preactjs/preact-render-to-string/releases)
- [Changelog](https://github.com/preactjs/preact-render-to-string/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preact-render-to-string/compare/v6.5.9...v6.5.10)

---
updated-dependencies:
- dependency-name: preact-render-to-string
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 14:46:22 -07:00
6215dd5565 docs: fix roam research docs 2024-09-11 14:45:04 -07:00
c89c76b40a chore(deps-dev): bump tsx from 4.18.0 to 4.19.0 (#1391)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.18.0 to 4.19.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.18.0...v4.19.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 03:30:44 -04:00
a145e320d0 chore(deps): bump hast-util-to-html from 9.0.1 to 9.0.2 (#1393)
Bumps [hast-util-to-html](https://github.com/syntax-tree/hast-util-to-html) from 9.0.1 to 9.0.2.
- [Release notes](https://github.com/syntax-tree/hast-util-to-html/releases)
- [Commits](https://github.com/syntax-tree/hast-util-to-html/compare/9.0.1...9.0.2)

---
updated-dependencies:
- dependency-name: hast-util-to-html
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 03:30:28 -04:00
082cbb74c3 chore(deps-dev): bump @types/node from 22.5.0 to 22.5.4 (#1403)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.5.0 to 22.5.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 03:29:51 -04:00
93b2481261 chore(deps): bump pixi.js from 8.3.3 to 8.3.4 (#1394)
Bumps [pixi.js](https://github.com/pixijs/pixijs) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pixijs/pixijs/releases)
- [Commits](https://github.com/pixijs/pixijs/compare/v8.3.3...v8.3.4)

---
updated-dependencies:
- dependency-name: pixi.js
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 03:29:36 -04:00
d2414b3903 feat(markdown): Roam Research flavour (#985)
* feat: Roam Research flavor markdown

* docs: Roam Research transformer

* use markdownPlugins

* fix roam matching

* cleanup: Roam Plugin

---------

Co-authored-by: Matt Vogel <>
2024-09-11 03:29:07 -04:00
01fc26d2c0 fix: Reload graph after a theme change (closes #1380) (#1383)
* FIX: Reload graph after a theme change

* FIX: Reload graph after a theme change - comment updated

* FIX: Reload graph after a theme change - comment updated

* FIX: Reload graph after a theme change

* fix: Reload graph after a theme change
2024-08-31 12:25:30 -07:00
84a9be65ce Add Projects & Privacy to showcase.md (#1381) 2024-08-31 11:43:49 -07:00
6715079a89 fix: only apply z-index on left sidebar 2024-08-28 12:50:25 -07:00
d613a3d2f2 chore(deps): bump unified from 11.0.4 to 11.0.5 (#1373)
Bumps [unified](https://github.com/unifiedjs/unified) from 11.0.4 to 11.0.5.
- [Release notes](https://github.com/unifiedjs/unified/releases)
- [Changelog](https://github.com/unifiedjs/unified/blob/main/changelog.md)
- [Commits](https://github.com/unifiedjs/unified/compare/11.0.4...11.0.5)

---
updated-dependencies:
- dependency-name: unified
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 14:13:09 -07:00
9b75faafea chore(deps-dev): bump @types/yargs from 17.0.32 to 17.0.33 (#1374)
Bumps [@types/yargs](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/yargs) from 17.0.32 to 17.0.33.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/yargs)

---
updated-dependencies:
- dependency-name: "@types/yargs"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 14:13:00 -07:00
5a26b582ed chore(deps-dev): bump @types/node from 22.1.0 to 22.5.0 (#1375)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.1.0 to 22.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 14:12:51 -07:00
4e2aea8a5a chore(deps): bump @napi-rs/simple-git from 0.1.17 to 0.1.19 (#1376)
Bumps [@napi-rs/simple-git](https://github.com/Brooooooklyn/simple-git) from 0.1.17 to 0.1.19.
- [Release notes](https://github.com/Brooooooklyn/simple-git/releases)
- [Commits](https://github.com/Brooooooklyn/simple-git/compare/v0.1.17...v0.1.19)

---
updated-dependencies:
- dependency-name: "@napi-rs/simple-git"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 14:12:45 -07:00
40f039983c chore(deps-dev): bump tsx from 4.17.0 to 4.18.0 (#1377)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.17.0 to 4.18.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.17.0...v4.18.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 14:12:35 -07:00
3b988aec61 fix: Popover z-index issue on left sidebar (#1230) 2024-08-25 19:33:34 -07:00
bca74623a3 perf(graph): canvas implementation (#1328)
* perf(graph): initial canvas layout

include nodes and links drawn

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): update persistent for nodeGfx

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore(graph): add canvas element to avoid rerendering glitch

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(spa): only render graph once in global

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): change svg as button

render global graph on toggle

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): fix anchor position and zIndex behaviour

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore(graph): increase linkDistance

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* refactor

* fmt

* pkg

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-08-25 00:33:14 -07:00
4c9e860150 a11y(darkmode): use a button for the theme toggle (#1335)
* Use a `<button>` for theme toggle

* docs: Adds back Xinyang's cs garden to showcase (#1323)

adding back my garden which was deleted from the cleanup showcase

* feat(toc,explorer): add accessibility for toggle (#1327)

* Restore focus highlight on explorer toggle button.

Remove `unset: all` declaration causing `outline`
property to be unset. This allows the default
browser focus highlight to be shown.

* Fix semantics of expandable sections (explorer, toc).

This adds the appropriate aria attributes for the [disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-image-description/#javascriptandcsssourcecode) and uses `visibility: hidden` to remove the hidden elements from the focus order without disrupting the animations. Further work is needed on the tree view nodes.

* Run prettier for SCSS files.

* feat: custom global latex macros (closes #1325)

* chore: ts fixes

* docs: recommend at least node 20 in gh

* fix: unmemoize explorer on rebuild (closes #1077)

* fix: pass buildId to worker

* Fix theme button DOM hierarchy and styles

* Restore functionality of theme button

* `aria-label` on theme svgs so their accessible labels are included in button content

---------

Co-authored-by: Xinyang Yu <47915643+xy-241@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-08-24 13:33:30 -07:00
46b63b68bf fix: Link visibility in exampanded TOC while collapseByDefault is true (#1371) 2024-08-24 12:56:17 -07:00
c4cd84dcc8 fix: Add a wrapper element to dates in PageList. (#1345)
* Add a wrapper span to dates in PageList.

This means there is a placeholder when date is not specified, so the values in grid-template-columns always line up correctly.

* Use a <div> instead -- better practice to stick to block elements.
2024-08-24 10:13:55 -07:00
2be9c096a1 chore(features): remove implemented (#1350) 2024-08-24 10:13:14 -07:00
f913332862 docs: Update Gitlab CI runner tag to use Gitlab runner (#1365)
Co-authored-by: Tony Jackson <tony.aaj.jackson@protonmail.com>
2024-08-23 18:55:03 -07:00
647c125525 docs: added permalink description to supported frontmatter doc (#1369) 2024-08-23 18:35:52 -07:00
3b74453fe6 docs: RTL showcase (#1362) 2024-08-20 09:28:52 -07:00
6b499ed90c chore(deps): bump @floating-ui/dom from 1.6.8 to 1.6.10 (#1358)
Bumps [@floating-ui/dom](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/dom) from 1.6.8 to 1.6.10.
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/dom/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/dom@1.6.10/packages/dom)

---
updated-dependencies:
- dependency-name: "@floating-ui/dom"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 14:18:00 -07:00
437d65c847 chore(deps): bump lightningcss from 1.25.1 to 1.26.0 (#1359)
Bumps [lightningcss](https://github.com/parcel-bundler/lightningcss) from 1.25.1 to 1.26.0.
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/compare/v1.25.1...v1.26.0)

---
updated-dependencies:
- dependency-name: lightningcss
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 14:15:43 -07:00
7c5709b660 chore(deps): bump preact-render-to-string from 6.5.7 to 6.5.9 (#1360)
Bumps [preact-render-to-string](https://github.com/preactjs/preact-render-to-string) from 6.5.7 to 6.5.9.
- [Release notes](https://github.com/preactjs/preact-render-to-string/releases)
- [Changelog](https://github.com/preactjs/preact-render-to-string/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preact-render-to-string/compare/v6.5.7...v6.5.9)

---
updated-dependencies:
- dependency-name: preact-render-to-string
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 14:15:34 -07:00
21e1921822 chore(deps): bump @napi-rs/simple-git from 0.1.16 to 0.1.17 (#1357)
Bumps [@napi-rs/simple-git](https://github.com/Brooooooklyn/simple-git) from 0.1.16 to 0.1.17.
- [Release notes](https://github.com/Brooooooklyn/simple-git/releases)
- [Commits](https://github.com/Brooooooklyn/simple-git/compare/v0.1.16...v0.1.17)

---
updated-dependencies:
- dependency-name: "@napi-rs/simple-git"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 14:14:45 -07:00
e47c29d2fd chore(deps): bump rehype-katex from 7.0.0 to 7.0.1 (#1356)
Bumps [rehype-katex](https://github.com/remarkjs/remark-math) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-katex@7.0.0...rehype-katex@7.0.1)

---
updated-dependencies:
- dependency-name: rehype-katex
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 14:14:29 -07:00
a5f2f874f7 chore(deps-dev): bump tsx from 4.16.2 to 4.17.0 (#1340)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.16.2 to 4.17.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.16.2...v4.17.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-15 05:07:05 -04:00
eb23cbe8da chore(deps): bump rehype-citation from 2.0.0 to 2.1.1 (#1341)
Bumps [rehype-citation](https://github.com/timlrx/rehype-citation) from 2.0.0 to 2.1.1.
- [Release notes](https://github.com/timlrx/rehype-citation/releases)
- [Commits](https://github.com/timlrx/rehype-citation/compare/v2.0.0...v2.1.1)

---
updated-dependencies:
- dependency-name: rehype-citation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-15 04:25:07 -04:00
cb68069d45 chore(deps-dev): bump typescript from 5.5.3 to 5.5.4 (#1343)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.5.3 to 5.5.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.5.3...v5.5.4)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-15 04:24:48 -04:00
d27c292736 feat: Mark the external link icon as aria-hidden (#1346) 2024-08-12 20:06:19 -07:00
0ee103a514 chore(deps): bump shiki from 1.10.3 to 1.12.1 (#1344)
Bumps [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) from 1.10.3 to 1.12.1.
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Changelog](https://github.com/shikijs/shiki/blob/main/CHANGELOG.md)
- [Commits](https://github.com/shikijs/shiki/commits/v1.12.1/packages/shiki)

---
updated-dependencies:
- dependency-name: shiki
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 13:38:15 -07:00
c5f0b69a52 chore(deps): bump preact from 10.22.1 to 10.23.2 (#1342)
Bumps [preact](https://github.com/preactjs/preact) from 10.22.1 to 10.23.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.22.1...10.23.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-12 13:37:54 -07:00
323167a001 docs(hosting): missing key with: (#1334) 2024-08-10 08:32:51 -07:00
3b5ed813f5 feat(search): keyboard-accessible search button (#1331)
* Use a `<button>` for search

* Fix search button styles to match preexisting styling

* Remove additional native button properties.

* Invoke search button on click or keyboard.

* Reorganize search button DOM hierarchy

* Restore focus to the search button when exiting the search overlay

* Run prettier on Search.tsx
2024-08-09 18:46:50 -07:00
195fc5134c fix: pass buildId to worker 2024-08-08 19:33:27 -07:00
e89c395f7c fix: unmemoize explorer on rebuild (closes #1077) 2024-08-08 19:17:20 -07:00
2db735a150 docs: recommend at least node 20 in gh 2024-08-08 18:38:17 -07:00
39eebca3cf chore: ts fixes 2024-08-08 18:28:13 -07:00
9acaa1c8ac feat: custom global latex macros (closes #1325) 2024-08-08 18:19:45 -07:00
27a41abb62 feat(toc,explorer): add accessibility for toggle (#1327)
* Restore focus highlight on explorer toggle button.

Remove `unset: all` declaration causing `outline`
property to be unset. This allows the default
browser focus highlight to be shown.

* Fix semantics of expandable sections (explorer, toc).

This adds the appropriate aria attributes for the [disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-image-description/#javascriptandcsssourcecode) and uses `visibility: hidden` to remove the hidden elements from the focus order without disrupting the animations. Further work is needed on the tree view nodes.

* Run prettier for SCSS files.
2024-08-08 18:07:47 -07:00
12904ab796 docs: Adds back Xinyang's cs garden to showcase (#1323)
adding back my garden which was deleted from the cleanup showcase
2024-08-08 18:00:20 -07:00
4bbcc0c50a pkg: minor bump for breaking nodejs bump :) 2024-08-05 19:33:23 -07:00
3938904cd0 fix: embed pdf aspect ratio (closes #1310) 2024-08-05 19:31:54 -07:00
407fad384c fix: only one h1 on a page (closes #1269) 2024-08-05 19:18:48 -07:00
ca3943b500 fix: responsive youtube embed (closes #1167) 2024-08-05 19:14:14 -07:00
6c4ed249ba chore(deps): bump rimraf from 5.0.7 to 6.0.1 (#1277)
Bumps [rimraf](https://github.com/isaacs/rimraf) from 5.0.7 to 6.0.1.
- [Changelog](https://github.com/isaacs/rimraf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/rimraf/compare/v5.0.7...v6.0.1)

---
updated-dependencies:
- dependency-name: rimraf
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 18:54:10 -07:00
563ab4aaaf docs: update node version in hosting docs 2024-08-05 18:49:20 -07:00
1c2d542138 build: add .node-version 2024-08-05 18:48:29 -07:00
e864740df7 docs: Adds back Pelayo Arbues blog to showcase (#1314)
Co-authored-by: Pelayo Arbues <pgarbues@idealista.com>
2024-08-05 18:44:34 -07:00
efed544df1 docs: Add "Ellie's Notes" to the showcase (#1315)
Thank you for Quartz! I really love using it. Just adding my notes to the showcase 😊
2024-08-05 18:43:57 -07:00
3d156b8497 deps(dev): bump nodejs in ci 2024-08-05 18:43:05 -07:00
38361aaf48 deps: change min required nodejs to v20 (breaking) 2024-08-05 18:41:46 -07:00
f3e07fd51c chore(deps-dev): bump prettier from 3.3.2 to 3.3.3 (#1293)
* chore(deps-dev): bump prettier from 3.3.2 to 3.3.3

Bumps [prettier](https://github.com/prettier/prettier) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* make prettier happy

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-08-05 18:38:07 -07:00
d79911fa79 chore(deps): bump workerpool from 9.1.2 to 9.1.3 (#1318)
Bumps [workerpool](https://github.com/josdejong/workerpool) from 9.1.2 to 9.1.3.
- [Changelog](https://github.com/josdejong/workerpool/blob/master/HISTORY.md)
- [Commits](https://github.com/josdejong/workerpool/compare/v9.1.2...v9.1.3)

---
updated-dependencies:
- dependency-name: workerpool
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 18:46:00 -04:00
963c7c8654 chore(deps-dev): bump @types/node from 20.14.11 to 22.1.0 (#1319)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.11 to 22.1.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 16:49:37 -04:00
3728929ee6 chore(deps): bump preact-render-to-string from 6.5.5 to 6.5.7 (#1317)
Bumps [preact-render-to-string](https://github.com/preactjs/preact-render-to-string) from 6.5.5 to 6.5.7.
- [Release notes](https://github.com/preactjs/preact-render-to-string/releases)
- [Changelog](https://github.com/preactjs/preact-render-to-string/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preact-render-to-string/compare/v6.5.5...v6.5.7)

---
updated-dependencies:
- dependency-name: preact-render-to-string
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 16:49:19 -04:00
1224c7d32f refactor(comments): move script to files (#1308)
* refactor(comments): move script to files

for LSP, treesitter, and the whole galore.

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(type): support removeEventListener with CustomEventMap

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix: parse bool to string first

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore: address comments and test on branch

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* revert: remove comments section from main quartz pages

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
2024-08-05 15:17:11 -04:00
bf1c9d1791 chore(deps): bump globby from 14.0.1 to 14.0.2 (#1301)
Bumps [globby](https://github.com/sindresorhus/globby) from 14.0.1 to 14.0.2.
- [Release notes](https://github.com/sindresorhus/globby/releases)
- [Commits](https://github.com/sindresorhus/globby/compare/v14.0.1...v14.0.2)

---
updated-dependencies:
- dependency-name: globby
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-02 14:48:54 -04:00
6264f5685c fix: comments on spa should work (closes #1296) (#1298)
* fix comments on spa

* fix giscus
2024-07-30 01:13:13 -07:00
e1a9661be7 docs: cleanup showcase 2024-07-29 16:33:50 -07:00
bc95332fce chore(deps-dev): bump @types/ws from 8.5.11 to 8.5.12 (#1300)
Bumps [@types/ws](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ws) from 8.5.11 to 8.5.12.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ws)

---
updated-dependencies:
- dependency-name: "@types/ws"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-29 13:14:34 -07:00
740172acb6 chore(deps): bump vfile from 6.0.1 to 6.0.2 (#1299)
Bumps [vfile](https://github.com/vfile/vfile) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/vfile/vfile/releases)
- [Changelog](https://github.com/vfile/vfile/blob/main/changelog.md)
- [Commits](https://github.com/vfile/vfile/compare/6.0.1...6.0.2)

---
updated-dependencies:
- dependency-name: vfile
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-29 13:14:25 -07:00
202 changed files with 14430 additions and 5056 deletions

View File

@ -1,11 +1,20 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
groups:
production-dependencies:
applies-to: "version-updates"
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
ci-dependencies:
applies-to: "version-updates"
patterns:
- "*"

43
.github/workflows/build-preview.yaml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Build Preview Deployment
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
jobs:
build-preview:
if: ${{ github.repository == 'jackyzha0/quartz' }}
runs-on: ubuntu-latest
name: Build Preview
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- name: Check types and style
run: npm run check
- name: Build Quartz
run: npx quartz build -d docs -v
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: preview-build
path: public

View File

@ -26,7 +26,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 22
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v4 uses: actions/cache@v4
@ -45,7 +45,7 @@ jobs:
run: npm test run: npm test
- name: Ensure Quartz builds, check bundle info - name: Ensure Quartz builds, check bundle info
run: npx quartz build --bundleInfo run: npx quartz build --bundleInfo -d docs
publish-tag: publish-tag:
if: ${{ github.repository == 'jackyzha0/quartz' && github.ref == 'refs/heads/v4' }} if: ${{ github.repository == 'jackyzha0/quartz' && github.ref == 'refs/heads/v4' }}
@ -59,7 +59,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 22
- name: Get package version - name: Get package version
run: node -p -e '`PACKAGE_VERSION=${require("./package.json").version}`' >> $GITHUB_ENV run: node -p -e '`PACKAGE_VERSION=${require("./package.json").version}`' >> $GITHUB_ENV
- name: Create release tag - name: Create release tag

37
.github/workflows/deploy-preview.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Upload Preview Deployment
on:
workflow_run:
workflows: ["Build Preview Deployment"]
types:
- completed
permissions:
actions: read
deployments: write
contents: read
pull-requests: write
jobs:
deploy-preview:
if: ${{ github.repository == 'jackyzha0/quartz' && github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
name: Deploy Preview to Cloudflare Pages
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
id: preview-build-artifact
with:
name: preview-build
path: build
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Deploy to Cloudflare Pages
uses: AdrianGonz97/refined-cf-pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
githubToken: ${{ secrets.GITHUB_TOKEN }}
projectName: quartz
deploymentName: Branch Preview
directory: ${{ steps.preview-build-artifact.outputs.download-path }}

View File

@ -0,0 +1,88 @@
name: Docker build & push image
on:
push:
branches: [v4]
tags: ["v*"]
pull_request:
branches: [v4]
paths:
- .github/workflows/docker-build-push.yaml
- quartz/**
workflow_dispatch:
jobs:
build:
if: ${{ github.repository == 'jackyzha0/quartz' }} # Comment this out if you want to publish your own images on a fork!
runs-on: ubuntu-latest
steps:
- name: Set lowercase repository owner environment variable
run: |
echo "OWNER_LOWERCASE=${OWNER,,}" >> ${GITHUB_ENV}
env:
OWNER: "${{ github.repository_owner }}"
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@v5.1.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
driver-opts: |
image=moby/buildkit:master
network=host
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.8.2
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata tags and labels on PRs
if: github.event_name == 'pull_request'
id: meta-pr
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ env.OWNER_LOWERCASE }}/quartz
tags: |
type=raw,value=sha-${{ env.GITHUB_SHA_SHORT }}
labels: |
org.opencontainers.image.source="https://github.com/${{ github.repository_owner }}/quartz"
- name: Extract metadata tags and labels for main, release or tag
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@v5
with:
flavor: |
latest=auto
images: ghcr.io/${{ env.OWNER_LOWERCASE }}/quartz
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=raw,value=sha-${{ env.GITHUB_SHA_SHORT }}
labels: |
maintainer=${{ github.repository_owner }}
org.opencontainers.image.source="https://github.com/${{ github.repository_owner }}/quartz"
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
build-args: |
GIT_SHA=${{ env.GITHUB_SHA }}
DOCKER_LABEL=sha-${{ env.GITHUB_SHA_SHORT }}
tags: ${{ steps.meta.outputs.tags || steps.meta-pr.outputs.tags }}
labels: ${{ steps.meta.outputs.labels || steps.meta-pr.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

1
.node-version Normal file
View File

@ -0,0 +1 @@
v22.16.0

View File

@ -1,10 +1,10 @@
FROM node:20-slim as builder FROM node:22-slim AS builder
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package.json . COPY package.json .
COPY package-lock.json* . COPY package-lock.json* .
RUN npm ci RUN npm ci
FROM node:20-slim FROM node:22-slim
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/ /usr/src/app/ COPY --from=builder /usr/src/app/ /usr/src/app/
COPY . . COPY . .

View File

@ -161,6 +161,18 @@ document.addEventListener("nav", () => {
}) })
``` ```
You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event.
```ts
document.addEventListener("prenav", () => {
// executed after an SPA navigation is triggered but
// before the page is replaced
// one usage pattern is to store things in sessionStorage
// in the prenav and then conditionally load then in the consequent
// nav
})
```
It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
This will get called on page navigation. This will get called on page navigation.

View File

@ -25,10 +25,11 @@ The following sections will go into detail for what methods can be implemented f
- `BuildCtx` is defined in `quartz/ctx.ts`. It consists of - `BuildCtx` is defined in `quartz/ctx.ts`. It consists of
- `argv`: The command line arguments passed to the Quartz [[build]] command - `argv`: The command line arguments passed to the Quartz [[build]] command
- `cfg`: The full Quartz [[configuration]] - `cfg`: The full Quartz [[configuration]]
- `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a `ServerSlug` is) - `allSlugs`: a list of all the valid content slugs (see [[paths]] for more information on what a slug is)
- `StaticResources` is defined in `quartz/resources.tsx`. It consists of - `StaticResources` is defined in `quartz/resources.tsx`. It consists of
- `css`: a list of URLs for stylesheets that should be loaded - `css`: a list of CSS style definitions that should be loaded. A CSS style is described with the `CSSResource` type which is also defined in `quartz/resources.tsx`. It accepts either a source URL or the inline content of the stylesheet.
- `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script. - `js`: a list of scripts that should be loaded. A script is described with the `JSResource` type which is also defined in `quartz/resources.tsx`. It allows you to define a load time (either before or after the DOM has been loaded), whether it should be a module, and either the source URL or the inline content of the script.
- `additionalHead`: a list of JSX elements or functions that return JSX elements to be added to the `<head>` tag of the page. Functions receive the page's data as an argument and can conditionally render elements.
## Transformers ## Transformers
@ -37,7 +38,7 @@ Transformers **map** over content, taking a Markdown file and outputting modifie
```ts ```ts
export type QuartzTransformerPluginInstance = { export type QuartzTransformerPluginInstance = {
name: string name: string
textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer textTransform?: (ctx: BuildCtx, src: string) => string
markdownPlugins?: (ctx: BuildCtx) => PluggableList markdownPlugins?: (ctx: BuildCtx) => PluggableList
htmlPlugins?: (ctx: BuildCtx) => PluggableList htmlPlugins?: (ctx: BuildCtx) => PluggableList
externalResources?: (ctx: BuildCtx) => Partial<StaticResources> externalResources?: (ctx: BuildCtx) => Partial<StaticResources>
@ -85,8 +86,10 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
if (engine === "katex") { if (engine === "katex") {
return { return {
css: [ css: [
// base css {
"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css", // base css
content: "https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css",
},
], ],
js: [ js: [
{ {
@ -97,8 +100,6 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
}, },
], ],
} }
} else {
return {}
} }
}, },
} }
@ -220,12 +221,26 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
export type QuartzEmitterPluginInstance = { export type QuartzEmitterPluginInstance = {
name: string name: string
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]> emit(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
): Promise<FilePath[]> | AsyncGenerator<FilePath>
partialEmit?(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
changeEvents: ChangeEvent[],
): Promise<FilePath[]> | AsyncGenerator<FilePath> | null
getQuartzComponents(ctx: BuildCtx): QuartzComponent[] getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
} }
``` ```
An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created. An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. It can optionally implement a `partialEmit` function for incremental builds.
- `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
- `partialEmit` is an optional function that enables incremental builds. It receives information about which files have changed (`changeEvents`) and can selectively rebuild only the necessary files. This is useful for optimizing build times in development mode. If `partialEmit` is undefined, it will default to the `emit` function.
- `getQuartzComponents` declares which Quartz components the emitter uses to construct its pages.
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature: Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
@ -234,7 +249,7 @@ export type WriteOptions = (data: {
// the build context // the build context
ctx: BuildCtx ctx: BuildCtx
// the name of the file to emit (not including the file extension) // the name of the file to emit (not including the file extension)
slug: ServerSlug slug: FullSlug
// the file extension // the file extension
ext: `.${string}` | "" ext: `.${string}` | ""
// the file content to add // the file content to add
@ -272,7 +287,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
const allFiles = content.map((c) => c[1].data) const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = canonicalizeServer(file.data.slug!) const slug = canonicalizeServer(file.data.slug!)
const externalResources = pageResources(slug, resources) const externalResources = pageResources(slug, file.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: file.data, fileData: file.data,
externalResources, externalResources,

View File

@ -29,11 +29,14 @@ Some common frontmatter fields that are natively supported by Quartz:
- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title. - `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title.
- `description`: Description of the page used for link previews. - `description`: Description of the page used for link previews.
- `permalink`: A custom URL for the page that will remain constant even if the path to the file changes.
- `aliases`: Other names for this note. This is a list of strings. - `aliases`: Other names for this note. This is a list of strings.
- `tags`: Tags for this note. - `tags`: Tags for this note.
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz. - `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format. - `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
See [[Frontmatter]] for a complete list of frontmatter.
## Syncing your Content ## Syncing your Content
When your Quartz is at a point you're happy with, you can save your changes to GitHub. When your Quartz is at a point you're happy with, you can save your changes to GitHub.

View File

@ -21,3 +21,7 @@ This will start a local web server to run your Quartz on your computer. Open a w
> - `--serve`: run a local hot-reloading server to preview your Quartz > - `--serve`: run a local hot-reloading server to preview your Quartz
> - `--port`: what port to run the local preview server on > - `--port`: what port to run the local preview server on
> - `--concurrency`: how many threads to use to parse notes > - `--concurrency`: how many threads to use to parse notes
> [!warning] Not to be used for production
> Serve mode is intended for local previews only.
> For production workloads, see the page on [[hosting]].

View File

@ -21,17 +21,19 @@ const config: QuartzConfig = {
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: 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`: title of the site. This is also used when generating the [[RSS Feed]] for your site. - `pageTitle`: title of the site. This is also used when generating the [[RSS Feed]] for your site.
- `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page.
- `enableSPA`: whether to enable [[SPA Routing]] on your site. - `enableSPA`: whether to enable [[SPA Routing]] on your site.
- `enablePopovers`: whether to enable [[popover previews]] on your site. - `enablePopovers`: whether to enable [[popover previews]] on your site.
- `analytics`: what to use for analytics on your site. Values can be - `analytics`: what to use for analytics on your site. Values can be
- `null`: don't use analytics; - `null`: don't use analytics;
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics; - `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;
- `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: '<your-plausible-host>' }` (self-hosted): use [Plausible](https://plausible.io/); - `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: 'https://<your-plausible-host>' }` (self-hosted, make sure to include the `https://` protocol prefix): use [Plausible](https://plausible.io/);
- `{ provider: 'umami', host: '<your-umami-host>', websiteId: '<your-umami-website-id>' }`: use [Umami](https://umami.is/); - `{ provider: 'umami', host: '<your-umami-host>', websiteId: '<your-umami-website-id>' }`: use [Umami](https://umami.is/);
- `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id' }` (managed) or `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id', host: 'my-goatcounter-domain.com', scriptSrc: 'https://my-url.to/counter.js' }` (self-hosted) use [GoatCounter](https://goatcounter.com); - `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id' }` (managed) or `{ provider: 'goatcounter', websiteId: 'my-goatcounter-id', host: 'my-goatcounter-domain.com', scriptSrc: 'https://my-url.to/counter.js' }` (self-hosted) use [GoatCounter](https://goatcounter.com);
- `{ provider: 'posthog', apiKey: '<your-posthog-project-apiKey>', host: '<your-posthog-host>' }`: use [Posthog](https://posthog.com/); - `{ provider: 'posthog', apiKey: '<your-posthog-project-apiKey>', host: '<your-posthog-host>' }`: use [Posthog](https://posthog.com/);
- `{ provider: 'tinylytics', siteId: '<your-site-id>' }`: use [Tinylytics](https://tinylytics.app/); - `{ provider: 'tinylytics', siteId: '<your-site-id>' }`: use [Tinylytics](https://tinylytics.app/);
- `{ provider: 'cabin' }` or `{ provider: 'cabin', host: 'https://cabin.example.com' }` (custom domain): use [Cabin](https://withcabin.com); - `{ provider: 'cabin' }` or `{ provider: 'cabin', host: 'https://cabin.example.com' }` (custom domain): use [Cabin](https://withcabin.com);
- `{provider: 'clarity', projectId: '<your-clarity-id-code' }`: use [Microsoft clarity](https://clarity.microsoft.com/). The project id can be found on top of the overview page.
- `locale`: used for [[i18n]] and date formatting - `locale`: used for [[i18n]] and date formatting
- `baseUrl`: 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. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes. - `baseUrl`: 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. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes.
- This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`. - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`.
@ -39,11 +41,12 @@ This part of the configuration concerns anything that can affect the whole site.
- `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. See [[private pages]] for more details. - `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. See [[private pages]] for more details.
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. - `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
- `theme`: configure how the site looks. - `theme`: configure how the site looks.
- `cdnCaching`: If `true` (default), use Google CDN to cache the fonts. This will generally will be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained. - `cdnCaching`: if `true` (default), use Google CDN to cache the fonts. This will generally be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained.
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here. - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
- `header`: Font to use for headers - `title`: font for the title of the site (optional, same as `header` by default)
- `code`: Font for inline and block quotes. - `header`: font to use for headers
- `body`: Font for everything - `code`: font for inline and block quotes
- `body`: font for everything
- `colors`: controls the theming of the site. - `colors`: controls the theming of the site.
- `light`: page background - `light`: page background
- `lightgray`: borders - `lightgray`: borders
@ -101,8 +104,30 @@ transformers: [
] ]
``` ```
Some plugins are included by default in the[ `quartz.config.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz.config.ts), but there are more available. Some plugins are included by default in the [`quartz.config.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz.config.ts), but there are more available.
You can see a list of all plugins and their configuration options [[tags/plugin|here]]. You can see a list of all plugins and their configuration options [[tags/plugin|here]].
If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide. If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide.
## Fonts
Fonts can be specified as a `string` or a `FontSpecification`:
```ts
// string
typography: {
header: "Schibsted Grotesk",
...
}
// FontSpecification
typography: {
header: {
name: "Schibsted Grotesk",
weights: [400, 700],
includeItalic: true,
},
...
}
```

View File

@ -0,0 +1,31 @@
---
title: Citations
tags:
- feature/transformer
---
Quartz uses [rehype-citation](https://github.com/timlrx/rehype-citation) to support parsing of a BibTex bibliography file.
Under the default configuration, a citation key `[@templeton2024scaling]` will be exported as `(Templeton et al., 2024)`.
> [!example]- BibTex file
>
> ```bib title="bibliography.bib"
> @article{templeton2024scaling,
> title={Scaling Monosemanticity: Extracting Interpretable Features from Claude 3 Sonnet},
> author={Templeton, Adly and Conerly, Tom and Marcus, Jonathan and Lindsey, Jack and Bricken, Trenton and Chen, Brian and Pearce, Adam and Citro, Craig and Ameisen, Emmanuel and Jones, Andy and Cunningham, Hoagy and Turner, Nicholas L and McDougall, Callum and MacDiarmid, Monte and Freeman, C. Daniel and Sumers, Theodore R. and Rees, Edward and Batson, Joshua and Jermyn, Adam and Carter, Shan and Olah, Chris and Henighan, Tom},
> year={2024},
> journal={Transformer Circuits Thread},
> url={https://transformer-circuits.pub/2024/scaling-monosemanticity/index.html}
> }
> ```
> [!note] Behaviour of references
>
> By default, the references will be included at the end of the file. To control where the references to be included, uses `[^ref]`
>
> Refer to `rehype-citation` docs for more information.
## Customization
Citation parsing is a functionality of the [[plugins/Citations|Citation]] plugin. **This plugin is not enabled by default**. See the plugin page for customization options.

View File

@ -3,5 +3,5 @@ Quartz comes shipped with a Docker image that will allow you to preview your Qua
You can run the below one-liner to run Quartz in Docker. You can run the below one-liner to run Quartz in Docker.
```sh ```sh
docker run --rm -itp 8080:8080 $(docker build -q .) docker run --rm -itp 8080:8080 -p 3001:3001 -v ./content:/usr/src/app/content $(docker build -q .)
``` ```

View File

@ -1,5 +1,10 @@
Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly.
> [!info]
> After deploying, the generated RSS link will be available at `https://${baseUrl}/index.xml` by default.
>
> The `index.xml` path can be customized by passing the `rssSlug` option to the [[ContentIndex]] plugin.
## Configuration ## Configuration
This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options. This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options.

View File

@ -0,0 +1,28 @@
---
title: "Roam Research Compatibility"
tags:
- feature/transformer
---
[Roam Research](https://roamresearch.com) is a note-taking tool that organizes your knowledge graph in a unique and interconnected way.
Quartz supports transforming the special Markdown syntax from Roam Research (like `{{[[components]]}}` and other formatting) into
regular Markdown via the [[RoamFlavoredMarkdown]] plugin.
```typescript title="quartz.config.ts"
plugins: {
transformers: [
// ...
Plugin.RoamFlavoredMarkdown(),
Plugin.ObsidianFlavoredMarkdown(),
// ...
],
},
```
> [!warning]
> As seen above placement of `Plugin.RoamFlavoredMarkdown()` within `quartz.config.ts` is very important. It must come before `Plugin.ObsidianFlavoredMarkdown()`.
## Customization
This functionality is provided by the [[RoamFlavoredMarkdown]] plugin. See the plugin page for customization options.

View File

@ -9,6 +9,7 @@ A backlink for a note is a link from another note to that note. Links in the bac
## Customization ## Customization
- Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`. - Removing backlinks: delete all usages of `Component.Backlinks()` from `quartz.layout.ts`.
- Hide when empty: hide `Backlinks` if given page doesn't contain any backlinks (default to `true`). To disable this, use `Component.Backlinks({ hideWhenEmpty: false })`.
- Component: `quartz/components/Backlinks.tsx` - Component: `quartz/components/Backlinks.tsx`
- Style: `quartz/components/styles/backlinks.scss` - Style: `quartz/components/styles/backlinks.scss`
- Script: `quartz/components/scripts/search.inline.ts` - Script: `quartz/components/scripts/search.inline.ts`

View File

@ -19,7 +19,6 @@ Component.Breadcrumbs({
spacerSymbol: "", // symbol between crumbs spacerSymbol: "", // symbol between crumbs
rootName: "Home", // name of first/root element rootName: "Home", // name of first/root element
resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles resolveFrontmatterTitle: true, // whether to resolve folder names through frontmatter titles
hideOnRoot: true, // whether to hide breadcrumbs on root `index.md` page
showCurrentPage: true, // whether to display the current page in the breadcrumbs showCurrentPage: true, // whether to display the current page in the breadcrumbs
}) })
``` ```

View File

@ -45,6 +45,8 @@ afterBody: [
category: 'Announcements', category: 'Announcements',
// from data-category-id // from data-category-id
categoryId: 'DIC_kwDOFxRnmM4B-Xg6', categoryId: 'DIC_kwDOFxRnmM4B-Xg6',
// from data-lang
lang: 'en'
} }
}), }),
], ],
@ -63,6 +65,18 @@ type Options = {
category: string category: string
categoryId: string categoryId: string
// Url to folder with custom themes
// defaults to 'https://${cfg.baseUrl}/static/giscus'
themeUrl?: string
// filename for light theme .css file
// defaults to 'light'
lightTheme?: string
// filename for dark theme .css file
// defaults to 'dark'
darkTheme?: string
// how to map pages -> discussions // how to map pages -> discussions
// defaults to 'url' // defaults to 'url'
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname" mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
@ -78,6 +92,42 @@ type Options = {
// where to put the comment input box relative to the comments // where to put the comment input box relative to the comments
// defaults to 'bottom' // defaults to 'bottom'
inputPosition?: "top" | "bottom" inputPosition?: "top" | "bottom"
// set your preference language here
// defaults to 'en'
lang?: string
} }
} }
``` ```
#### Custom CSS theme
Quartz supports custom theme for Giscus. To use a custom CSS theme, place the `.css` file inside the `quartz/static` folder and set the configuration values.
For example, if you have a light theme `light-theme.css`, a dark theme `dark-theme.css`, and your Quartz site is hosted at `https://example.com/`:
```ts
afterBody: [
Component.Comments({
provider: 'giscus',
options: {
// Other options
themeUrl: "https://example.com/static/giscus", // corresponds to quartz/static/giscus/
lightTheme: "light-theme", // corresponds to light-theme.css in quartz/static/giscus/
darkTheme: "dark-theme", // corresponds to dark-theme.css quartz/static/giscus/
}
}),
],
```
#### Conditionally display comments
Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`.
```
---
title: Comments disabled here!
comments: false
---
```

View File

@ -27,12 +27,10 @@ Component.Explorer({
folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click)
folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open") folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer
// Sort order: folders first, then files. Sort folders and files alphabetically // omitted but shown later
sortFn: (a, b) => { sortFn: ...,
... // default implementation shown later filterFn: ...,
}, mapFn: ...,
filterFn: filterFn: (node) => node.name !== "tags", // filters out 'tags' folder
mapFn: undefined,
// what order to apply functions in // what order to apply functions in
order: ["filter", "map", "sort"], order: ["filter", "map", "sort"],
}) })
@ -45,26 +43,30 @@ Want to customize it even more?
- Removing explorer: remove `Component.Explorer()` from `quartz.layout.ts` - Removing explorer: remove `Component.Explorer()` from `quartz.layout.ts`
- (optional): After removing the explorer component, you can move the [[table of contents | Table of Contents]] component back to the `left` part of the layout - (optional): After removing the explorer component, you can move the [[table of contents | Table of Contents]] component back to the `left` part of the layout
- Changing `sort`, `filter` and `map` behavior: explained in [[#Advanced customization]] - Changing `sort`, `filter` and `map` behavior: explained in [[#Advanced customization]]
- Component: - Component: `quartz/components/Explorer.tsx`
- Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx`
- Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx`
- Style: `quartz/components/styles/explorer.scss` - Style: `quartz/components/styles/explorer.scss`
- Script: `quartz/components/scripts/explorer.inline.ts` - Script: `quartz/components/scripts/explorer.inline.ts`
## Advanced customization ## Advanced customization
This component allows you to fully customize all of its behavior. You can pass a custom `sort`, `filter` and `map` function. This component allows you to fully customize all of its behavior. You can pass a custom `sort`, `filter` and `map` function.
All functions you can pass work with the `FileNode` class, which has the following properties: All functions you can pass work with the `FileTrieNode` class, which has the following properties:
```ts title="quartz/components/ExplorerNode.tsx" {2-5} ```ts title="quartz/components/Explorer.tsx"
export class FileNode { class FileTrieNode {
children: FileNode[] // children of current node isFolder: boolean
name: string // last part of slug children: Array<FileTrieNode>
displayName: string // what actually should be displayed in the explorer data: ContentDetails | null
file: QuartzPluginData | null // if node is a file, this is the file's metadata. See `QuartzPluginData` for more detail }
depth: number // depth of current node ```
... // rest of implementation ```ts title="quartz/plugins/emitters/contentIndex.tsx"
export type ContentDetails = {
slug: FullSlug
title: string
links: SimpleSlug[]
tags: string[]
content: string
} }
``` ```
@ -74,15 +76,14 @@ Every function you can pass is optional. By default, only a `sort` function will
// Sort order: folders first, then files. Sort folders and files alphabetically // Sort order: folders first, then files. Sort folders and files alphabetically
Component.Explorer({ Component.Explorer({
sortFn: (a, b) => { sortFn: (a, b) => {
if ((!a.file && !b.file) || (a.file && b.file)) { if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
return a.displayName.localeCompare(b.displayName, undefined, { return a.displayName.localeCompare(b.displayName, undefined, {
numeric: true, numeric: true,
sensitivity: "base", sensitivity: "base",
}) })
} }
if (a.file && !b.file) {
if (!a.isFolder && b.isFolder) {
return 1 return 1
} else { } else {
return -1 return -1
@ -100,41 +101,23 @@ For more information on how to use `sort`, `filter` and `map`, you can check [Ar
Type definitions look like this: Type definitions look like this:
```ts ```ts
sortFn: (a: FileNode, b: FileNode) => number type SortFn = (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileNode) => boolean type FilterFn = (node: FileTrieNode) => boolean
mapFn: (node: FileNode) => void type MapFn = (node: FileTrieNode) => void
``` ```
> [!tip]
> You can check if a `FileNode` is a folder or a file like this:
>
> ```ts
> if (node.file) {
> // node is a file
> } else {
> // node is a folder
> }
> ```
## Basic examples ## Basic examples
These examples show the basic usage of `sort`, `map` and `filter`. These examples show the basic usage of `sort`, `map` and `filter`.
### Use `sort` to put files first ### Use `sort` to put files first
Using this example, the explorer will alphabetically sort everything, but put all **files** above all **folders**. Using this example, the explorer will alphabetically sort everything.
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
sortFn: (a, b) => { sortFn: (a, b) => {
if ((!a.file && !b.file) || (a.file && b.file)) { return a.displayName.localeCompare(b.displayName)
return a.displayName.localeCompare(b.displayName)
}
if (a.file && !b.file) {
return -1
} else {
return 1
}
}, },
}) })
``` ```
@ -147,42 +130,47 @@ Using this example, the display names of all `FileNodes` (folders + files) will
Component.Explorer({ Component.Explorer({
mapFn: (node) => { mapFn: (node) => {
node.displayName = node.displayName.toUpperCase() node.displayName = node.displayName.toUpperCase()
return node
}, },
}) })
``` ```
### Remove list of elements (`filter`) ### Remove list of elements (`filter`)
Using this example, you can remove elements from your explorer by providing an array of folders/files using the `omit` set. Using this example, you can remove elements from your explorer by providing an array of folders/files to exclude.
Note that this example filters on the title but you can also do it via slug or any other field available on `FileTrieNode`.
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
filterFn: (node) => { filterFn: (node) => {
// set containing names of everything you want to filter out // set containing names of everything you want to filter out
const omit = new Set(["authoring content", "tags", "hosting"]) const omit = new Set(["authoring content", "tags", "advanced"])
return !omit.has(node.name.toLowerCase())
// can also use node.slug or by anything on node.data
// note that node.data is only present for files that exist on disk
// (e.g. implicit folder nodes that have no associated index.md)
return !omit.has(node.displayName.toLowerCase())
}, },
}) })
``` ```
You can customize this by changing the entries of the `omit` set. Simply add all folder or file names you want to remove.
### Remove files by tag ### Remove files by tag
You can access the frontmatter of a file by `node.file?.frontmatter?`. This allows you to filter out files based on their frontmatter, for example by their tags. You can access the tags of a file by `node.data.tags`.
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
filterFn: (node) => { filterFn: (node) => {
// exclude files with the tag "explorerexclude" // exclude files with the tag "explorerexclude"
return node.file?.frontmatter?.tags?.includes("explorerexclude") !== true return node.data.tags?.includes("explorerexclude") !== true
}, },
}) })
``` ```
### Show every element in explorer ### Show every element in explorer
To override the default filter function that removes the `tags` folder from the explorer, you can set the filter function to `undefined`. By default, the explorer will filter out the `tags` folder.
To override the default filter function, you can set the filter function to `undefined`.
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
@ -194,10 +182,12 @@ Component.Explorer({
> [!tip] > [!tip]
> When writing more complicated functions, the `layout` file can start to look very cramped. > When writing more complicated functions, the `layout` file can start to look very cramped.
> You can fix this by defining your functions in another file. > You can fix this by defining your sort functions outside of the component
> and passing it in.
>
> ```ts title="quartz.layout.ts"
> import { Options } from "./quartz/components/Explorer"
> >
> ```ts title="functions.ts"
> import { Options } from "./quartz/components/ExplorerNode"
> export const mapFn: Options["mapFn"] = (node) => { > export const mapFn: Options["mapFn"] = (node) => {
> // implement your function here > // implement your function here
> } > }
@ -207,16 +197,12 @@ Component.Explorer({
> export const sortFn: Options["sortFn"] = (a, b) => { > export const sortFn: Options["sortFn"] = (a, b) => {
> // implement your function here > // implement your function here
> } > }
> ```
> >
> You can then import them like this:
>
> ```ts title="quartz.layout.ts"
> import { mapFn, filterFn, sortFn } from "./functions.ts"
> Component.Explorer({ > Component.Explorer({
> mapFn: mapFn, > // ... your other options
> filterFn: filterFn, > mapFn,
> sortFn: sortFn, > filterFn,
> sortFn,
> }) > })
> ``` > ```
@ -227,93 +213,11 @@ To add emoji prefixes (📁 for folders, 📄 for files), you could use a map fu
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
mapFn: (node) => { mapFn: (node) => {
// dont change name of root node if (node.isFolder) {
if (node.depth > 0) { node.displayName = "📁 " + node.displayName
// set emoji for file/folder } else {
if (node.file) { node.displayName = "📄 " + node.displayName
node.displayName = "📄 " + node.displayName
} else {
node.displayName = "📁 " + node.displayName
}
} }
}, },
}) })
``` ```
### Putting it all together
In this example, we're going to customize the explorer by using functions from examples above to [[#Add emoji prefix | add emoji prefixes]], [[#remove-list-of-elements-filter| filter out some folders]] and [[#use-sort-to-put-files-first | sort with files above folders]].
```ts title="quartz.layout.ts"
Component.Explorer({
filterFn: sampleFilterFn,
mapFn: sampleMapFn,
sortFn: sampleSortFn,
order: ["filter", "sort", "map"],
})
```
Notice how we customized the `order` array here. This is done because the default order applies the `sort` function last. While this normally works well, it would cause unintended behavior here, since we changed the first characters of all display names. In our example, `sort` would be applied based off the emoji prefix instead of the first _real_ character.
To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function.
### Use `sort` with pre-defined sort order
Here's another example where a map containing file/folder names (as slugs) is used to define the sort order of the explorer in quartz. All files/folders that aren't listed inside of `nameOrderMap` will appear at the top of that folders hierarchy level.
It's also worth mentioning, that the smaller the number set in `nameOrderMap`, the higher up the entry will be in the explorer. Incrementing every folder/file by 100, makes ordering files in their folders a lot easier. Lastly, this example still allows you to use a `mapFn` or frontmatter titles to change display names, as it uses slugs for `nameOrderMap` (which is unaffected by display name changes).
```ts title="quartz.layout.ts"
Component.Explorer({
sortFn: (a, b) => {
const nameOrderMap: Record<string, number> = {
"poetry-folder": 100,
"essay-folder": 200,
"research-paper-file": 201,
"dinosaur-fossils-file": 300,
"other-folder": 400,
}
let orderA = 0
let orderB = 0
if (a.file && a.file.slug) {
orderA = nameOrderMap[a.file.slug] || 0
} else if (a.name) {
orderA = nameOrderMap[a.name] || 0
}
if (b.file && b.file.slug) {
orderB = nameOrderMap[b.file.slug] || 0
} else if (b.name) {
orderB = nameOrderMap[b.name] || 0
}
return orderA - orderB
},
})
```
For reference, this is how the quartz explorer window would look like with that example:
```
📖 Poetry Folder
📑 Essay Folder
⚗️ Research Paper File
🦴 Dinosaur Fossils File
🔮 Other Folder
```
And this is how the file structure would look like:
```
index.md
poetry-folder
index.md
essay-folder
index.md
research-paper-file.md
dinosaur-fossils-file.md
other-folder
index.md
```

View File

@ -36,6 +36,7 @@ Component.Graph({
opacityScale: 1, // how quickly do we fade out the labels when zooming out? opacityScale: 1, // how quickly do we fade out the labels when zooming out?
removeTags: [], // what tags to remove from the graph removeTags: [], // what tags to remove from the graph
showTags: true, // whether to show tags in the graph showTags: true, // whether to show tags in the graph
enableRadial: false, // whether to constrain the graph, similar to Obsidian
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -49,6 +50,7 @@ Component.Graph({
opacityScale: 1, opacityScale: 1,
removeTags: [], // what tags to remove from the graph removeTags: [], // what tags to remove from the graph
showTags: true, // whether to show tags in the graph showTags: true, // whether to show tags in the graph
enableRadial: true, // whether to constrain the graph, similar to Obsidian
}, },
}) })
``` ```

View File

@ -13,9 +13,7 @@ There may be some notes you want to avoid publishing as a website. Quartz suppor
If you'd like to only publish a select number of notes, you can instead use [[ExplicitPublish]] which will filter out all notes except for any that have `publish: true` in the frontmatter. If you'd like to only publish a select number of notes, you can instead use [[ExplicitPublish]] which will filter out all notes except for any that have `publish: true` in the frontmatter.
> [!warning] > [!warning]
> Regardless of the filter plugin used, **all non-markdown files will be emitted and available publically in the final build.** This includes files such as images, voice recordings, PDFs, etc. One way to prevent this and still be able to embed local images is to create a folder specifically for public media and add the following two patterns to the ignorePatterns array. > Regardless of the filter plugin used, **all non-markdown files will be emitted and available publically in the final build.** This includes files such as images, voice recordings, PDFs, etc.
>
> `"!(PublicMedia)**/!(*.md)", "!(*.md)"`
## `ignorePatterns` ## `ignorePatterns`
@ -28,7 +26,7 @@ Common examples include:
- `some/folder`: exclude the entire of `some/folder` - `some/folder`: exclude the entire of `some/folder`
- `*.md`: exclude all files with a `.md` extension - `*.md`: exclude all files with a `.md` extension
- `!*.md` exclude all files that _don't_ have a `.md` extension - `!(*.md)` exclude all files that _don't_ have a `.md` extension. Note that negations _must_ parenthesize the rest of the pattern!
- `**/private`: exclude any files or folders named `private` at any level of nesting - `**/private`: exclude any files or folders named `private` at any level of nesting
> [!warning] > [!warning]

View File

@ -0,0 +1,44 @@
---
title: Reader Mode
tags:
- component
---
Reader Mode is a feature that allows users to focus on the content by hiding the sidebars and other UI elements. When enabled, it provides a clean, distraction-free reading experience.
## Configuration
Reader Mode is enabled by default. To disable it, you can remove the component from your layout configuration in `quartz.layout.ts`:
```ts
// Remove or comment out this line
Component.ReaderMode(),
```
## Usage
The Reader Mode toggle appears as a button with a book icon. When clicked:
- Sidebars are hidden
- Hovering over the content area reveals the sidebars temporarily
Unlike Dark Mode, Reader Mode state is not persisted between page reloads but is maintained during SPA navigation within the site.
## Customization
You can customize the appearance of Reader Mode through CSS variables and styles. The component uses the following classes:
- `.readermode`: The toggle button
- `.readerIcon`: The book icon
- `[reader-mode="on"]`: Applied to the root element when Reader Mode is active
Example customization in your custom CSS:
```scss
.readermode {
// Customize the button
svg {
stroke: var(--custom-color);
}
}
```

View File

@ -0,0 +1,19 @@
---
title: "Social Media Preview Cards"
---
A lot of social media platforms can display a rich preview for your website when sharing a link (most notably, a cover image, a title and a description).
Quartz can also dynamically generate and use new cover images for every page to be used in link previews on social media for you.
## Showcase
After enabling the [[CustomOgImages]] emitter plugin, the social media link preview for [[authoring content | Authoring Content]] looks like this:
| Light | Dark |
| ----------------------------------- | ---------------------------------- |
| ![[social-image-preview-light.png]] | ![[social-image-preview-dark.png]] |
## Configuration
This functionality is provided by the [[CustomOgImages]] plugin. See the plugin page for customization options.

View File

@ -6,7 +6,6 @@ draft: true
- static dead link detection - static dead link detection
- cursor chat extension - cursor chat extension
- https://giscus.app/ extension
- sidenotes? https://github.com/capnfabs/paperesque - sidenotes? https://github.com/capnfabs/paperesque
- direct match in search using double quotes - direct match in search using double quotes
- https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI - https://help.obsidian.md/Advanced+topics/Using+Obsidian+URI

View File

@ -61,6 +61,8 @@ jobs:
with: with:
fetch-depth: 0 # Fetch all history for git info fetch-depth: 0 # Fetch all history for git info
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Build Quartz - name: Build Quartz
@ -187,7 +189,7 @@ stages:
- build - build
- deploy - deploy
image: node:18 image: node:22
cache: # Cache modules in between jobs cache: # Cache modules in between jobs
key: $CI_COMMIT_REF_SLUG key: $CI_COMMIT_REF_SLUG
paths: paths:
@ -206,7 +208,7 @@ build:
paths: paths:
- public - public
tags: tags:
- docker - gitlab-org-docker
pages: pages:
stage: deploy stage: deploy
@ -245,6 +247,28 @@ server {
} }
``` ```
### Using Apache
Here's an example of how to do this with Apache:
```apache title=".htaccess"
RewriteEngine On
ErrorDocument 404 /404.html
# Rewrite rule for .html extension removal (with directory check)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_URI}.html -f
RewriteRule ^(.*)$ $1.html [L]
# Handle directory requests explicitly
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^(.*)/$ $1/index.html [L]
```
Don't forget to activate brotli / gzip compression.
### Using Caddy ### Using Caddy
Here's and example of how to do this with Caddy: Here's and example of how to do this with Caddy:

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -6,7 +6,7 @@ Quartz is a fast, batteries-included static-site generator that transforms Markd
## 🪴 Get Started ## 🪴 Get Started
Quartz requires **at least [Node](https://nodejs.org/) v18.14** and `npm` v9.3.1 to function correctly. Ensure you have this installed on your machine before continuing. Quartz requires **at least [Node](https://nodejs.org/) v22** and `npm` v10.9.2 to function correctly. Ensure you have this installed on your machine before continuing.
Then, in your terminal of choice, enter the following commands line by line: Then, in your terminal of choice, enter the following commands line by line:
@ -31,13 +31,13 @@ If you prefer instructions in a video format you can try following Nicole van de
## 🔧 Features ## 🔧 Features
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features) right out of the box - [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks|wikilinks, transclusions]], [[backlinks]], [[features/Latex|Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]], [[comments]] and [many more](./features/) right out of the box
- Hot-reload for both configuration and content - Hot-reload on configuration edits and incremental rebuilds for content edits
- Simple JSX layouts and [[creating components|page components]] - Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
- Fully-customizable parsing, filtering, and page generation through [[making plugins|plugins]] - 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 about the _why_ behind these features on the [[philosophy]] page and a technical overview on the [[architecture]] page. For a comprehensive list of features, visit the [features page](./features/). You can read more about the _why_ behind these features on the [[philosophy]] page and a technical overview on the [[architecture]] page.
### 🚧 Troubleshooting + Updating ### 🚧 Troubleshooting + Updating

102
docs/layout-components.md Normal file
View File

@ -0,0 +1,102 @@
---
title: Higher-Order Layout Components
---
Quartz provides several higher-order components that help with layout composition and responsive design. These components wrap other components to add additional functionality or modify their behavior.
## `Flex` Component
The `Flex` component creates a [flexible box layout](https://developer.mozilla.org/en-US/docs/Web/CSS/flex) that can arrange child components in various ways. It's particularly useful for creating responsive layouts and organizing components in rows or columns.
```typescript
type FlexConfig = {
components: {
Component: QuartzComponent
grow?: boolean // whether component should grow to fill space
shrink?: boolean // whether component should shrink if needed
basis?: string // initial main size of the component
order?: number // order in flex container
align?: "start" | "end" | "center" | "stretch" // cross-axis alignment
justify?: "start" | "end" | "center" | "between" | "around" // main-axis alignment
}[]
direction?: "row" | "row-reverse" | "column" | "column-reverse"
wrap?: "nowrap" | "wrap" | "wrap-reverse"
gap?: string
}
```
### Example Usage
```typescript
Component.Flex({
components: [
{
Component: Component.Search(),
grow: true, // Search will grow to fill available space
},
{ Component: Component.Darkmode() }, // Darkmode keeps its natural size
],
direction: "row",
gap: "1rem",
})
```
> [!note] Overriding behavior
> Components inside `Flex` get an additional CSS class `flex-component` that add the `display: flex` property. If you want to override this behavior, you can add a `display` property to the component's CSS class in your custom CSS file.
>
> ```scss
> .flex-component {
> display: block; // or any other display type
> }
> ```
## `MobileOnly` Component
The `MobileOnly` component is a wrapper that makes its child component only visible on mobile devices. This is useful for creating responsive layouts where certain components should only appear on smaller screens.
### Example Usage
```typescript
Component.MobileOnly(Component.Spacer())
```
## `DesktopOnly` Component
The `DesktopOnly` component is the counterpart to `MobileOnly`. It makes its child component only visible on desktop devices. This helps create responsive layouts where certain components should only appear on larger screens.
### Example Usage
```typescript
Component.DesktopOnly(Component.TableOfContents())
```
## `ConditionalRender` Component
The `ConditionalRender` component is a wrapper that conditionally renders its child component based on a provided condition function. This is useful for creating dynamic layouts where components should only appear under certain conditions.
```typescript
type ConditionalRenderConfig = {
component: QuartzComponent
condition: (props: QuartzComponentProps) => boolean
}
```
### Example Usage
```typescript
Component.ConditionalRender({
component: Component.Search(),
condition: (props) => props.displayClass !== "fullpage",
})
```
The example above would only render the Search component when the page is not in fullpage mode.
```typescript
Component.ConditionalRender({
component: Component.Breadcrumbs(),
condition: (page) => page.fileData.slug !== "index",
})
```
The example above would hide breadcrumbs on the root `index.md` page.

View File

@ -13,15 +13,19 @@ export interface FullPageLayout {
beforeBody: QuartzComponent[] // laid out vertically beforeBody: QuartzComponent[] // laid out vertically
pageBody: QuartzComponent // single component pageBody: QuartzComponent // single component
afterBody: QuartzComponent[] // laid out vertically afterBody: QuartzComponent[] // laid out vertically
left: QuartzComponent[] // vertical on desktop, horizontal on mobile left: QuartzComponent[] // vertical on desktop and tablet, horizontal on mobile
right: QuartzComponent[] // vertical on desktop, horizontal on mobile right: QuartzComponent[] // vertical on desktop, horizontal on tablet and mobile
footer: QuartzComponent // single component footer: QuartzComponent // single component
} }
``` ```
These correspond to following parts of the page: These correspond to following parts of the page:
![[quartz layout.png|800]] | Layout | Preview |
| ------------------------------- | ----------------------------------- |
| Desktop (width > 1200px) | ![[quartz-layout-desktop.png\|800]] |
| Tablet (800px < width < 1200px) | ![[quartz-layout-tablet.png\|800]] |
| Mobile (width < 800px) | ![[quartz-layout-mobile.png\|800]] |
> [!note] > [!note]
> There are two additional layout fields that are _not_ shown in the above diagram. > There are two additional layout fields that are _not_ shown in the above diagram.
@ -31,7 +35,26 @@ These correspond to following parts of the page:
Quartz **components**, like plugins, can take in additional properties as configuration options. If you're familiar with React terminology, you can think of them as Higher-order Components. Quartz **components**, like plugins, can take in additional properties as configuration options. If you're familiar with React terminology, you can think of them as Higher-order Components.
See [a list of all the components](component.md) for all available components along with their configuration options. You can also checkout the guide on [[creating components]] if you're interested in further customizing the behaviour of Quartz. See [a list of all the components](component.md) for all available components along with their configuration options. Additionally, Quartz provides several built-in higher-order components for layout composition - see [[layout-components]] for more details.
You can also checkout the guide on [[creating components]] if you're interested in further customizing the behaviour of Quartz.
### Layout breakpoints
Quartz has different layouts depending on the width the screen viewing the website.
The breakpoints for layouts can be configured in `variables.scss`.
- `mobile`: screen width below this size will use mobile layout.
- `desktop`: screen width above this size will use desktop layout.
- Screen width between `mobile` and `desktop` width will use the tablet layout.
```scss
$breakpoints: (
mobile: 800px,
desktop: 1200px,
);
```
### Style ### Style

24
docs/plugins/Citations.md Normal file
View File

@ -0,0 +1,24 @@
---
title: "Citations"
tags:
- plugin/transformer
---
This plugin adds Citation support to Quartz.
> [!note]
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `bibliographyFile`: the path to the bibliography file. Defaults to `./bibliography.bib`. This is relative to git source of your vault.
- `suppressBibliography`: whether to suppress the bibliography at the end of the document. Defaults to `false`.
- `linkCitations`: whether to link citations to the bibliography. Defaults to `false`.
- `csl`: the citation style to use. Defaults to `apa`. Reference [rehype-citation](https://rehype-citation.netlify.app/custom-csl) for more options.
- `prettyLink`: whether to use pretty links for citations. Defaults to `true`.
## API
- Category: Transformer
- Function name: `Plugin.Citations()`.
- Source: [`quartz/plugins/transformers/citations.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/citations.ts).

View File

@ -17,6 +17,7 @@ This plugin accepts the following configuration options:
- `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates. - `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates.
- `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`. - `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`.
- `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries. - `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries.
- `rssSlug`: Slug to the generated RSS feed XML file. Defaults to `"index"`.
- `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources. - `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources.
## API ## API

View File

@ -13,6 +13,8 @@ This plugin accepts the following configuration options:
- `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `["frontmatter", "git", "filesystem"]`. - `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `["frontmatter", "git", "filesystem"]`.
When loading the frontmatter, the value of [[Frontmatter#List]] is used.
> [!warning] > [!warning]
> If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`. > If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`.
> >

View File

@ -0,0 +1,360 @@
---
title: Custom OG Images
tags:
- feature/emitter
---
The Custom OG Images emitter plugin generates social media preview images for your pages. It uses [satori](https://github.com/vercel/satori) to convert HTML/CSS into images, allowing you to create beautiful and consistent social media preview cards for your content.
> [!note]
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
## Features
- Automatically generates social media preview images for each page
- Supports both light and dark mode themes
- Customizable through frontmatter properties
- Fallback to default image when needed
- Full control over image design through custom components
## Configuration
> [!info] Info
>
> The `baseUrl` property in your [[configuration]] must be set properly for social images to work correctly, as they require absolute paths.
This plugin accepts the following configuration options:
```typescript title="quartz.config.ts"
import { CustomOgImages } from "./quartz/plugins/emitters/ogImage"
const config: QuartzConfig = {
plugins: {
emitters: [
CustomOgImages({
colorScheme: "lightMode", // what colors to use for generating image, same as theme colors from config, valid values are "darkMode" and "lightMode"
width: 1200, // width to generate with (in pixels)
height: 630, // height to generate with (in pixels)
excludeRoot: false, // wether to exclude "/" index path to be excluded from auto generated images (false = use auto, true = use default og image)
imageStructure: defaultImage, // custom image component to use
}),
],
},
}
```
### Configuration Options
| Option | Type | Default | Description |
| ---------------- | --------- | ------------ | ----------------------------------------------------------------- |
| `colorScheme` | string | "lightMode" | Theme to use for generating images ("darkMode" or "lightMode") |
| `width` | number | 1200 | Width of the generated image in pixels |
| `height` | number | 630 | Height of the generated image in pixels |
| `excludeRoot` | boolean | false | Whether to exclude the root index page from auto-generated images |
| `imageStructure` | component | defaultImage | Custom component to use for image generation |
## Frontmatter Properties
The following properties can be used to customize your link previews:
| Property | Alias | Summary |
| ------------------- | ---------------- | ----------------------------------- |
| `socialDescription` | `description` | Description to be used for preview. |
| `socialImage` | `image`, `cover` | Link to preview image. |
The `socialImage` property should contain a link to an image either relative to `quartz/static`, or a full URL. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. Alternatively, you can use a fully qualified URL like `"https://example.com/cover.png"`.
> [!info] Info
>
> The priority for what image will be used for the cover image looks like the following: `frontmatter property > generated image (if enabled) > default image`.
>
> The default image (`quartz/static/og-image.png`) will only be used as a fallback if nothing else is set. If the Custom OG Images emitter plugin is enabled, it will be treated as the new default per page, but can be overwritten by setting the `socialImage` frontmatter property for that page.
## Customization
You can fully customize how the images being generated look by passing your own component to `imageStructure`. This component takes JSX + some page metadata/config options and converts it to an image using [satori](https://github.com/vercel/satori). Vercel provides an [online playground](https://og-playground.vercel.app/) that can be used to preview how your JSX looks like as a picture. This is ideal for prototyping your custom design.
### Fonts
You will also be passed an array containing a header and a body font (where the first entry is header and the second is body). The fonts matches the ones selected in `theme.typography.header` and `theme.typography.body` from `quartz.config.ts` and will be passed in the format required by [`satori`](https://github.com/vercel/satori). To use them in CSS, use the `.name` property (e.g. `fontFamily: fonts[1].name` to use the "body" font family).
An example of a component using the header font could look like this:
```tsx title="socialImage.tsx"
export const myImage: SocialImageOptions["imageStructure"] = (...) => {
return <p style={{ fontFamily: fonts[0].name }}>Cool Header!</p>
}
```
> [!example]- Local fonts
>
> For cases where you use a local fonts under `static` folder, make sure to set the correct `@font-face` in `custom.scss`
>
> ```scss title="custom.scss"
> @font-face {
> font-family: "Newsreader";
> font-style: normal;
> font-weight: normal;
> font-display: swap;
> src: url("/static/Newsreader.woff2") format("woff2");
> }
> ```
>
> Then in `quartz/util/og.tsx`, you can load the Satori fonts like so:
>
> ```tsx title="quartz/util/og.tsx"
> import { joinSegments, QUARTZ } from "../path"
> import fs from "fs"
> import path from "path"
>
> const newsreaderFontPath = joinSegments(QUARTZ, "static", "Newsreader.woff2")
> export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
> // ... rest of implementation remains same
> const fonts: SatoriOptions["fonts"] = [
> ...headerFontData.map((data, idx) => ({
> name: headerFontName,
> data,
> weight: headerWeights[idx],
> style: "normal" as const,
> })),
> ...bodyFontData.map((data, idx) => ({
> name: bodyFontName,
> data,
> weight: bodyWeights[idx],
> style: "normal" as const,
> })),
> {
> name: "Newsreader",
> data: await fs.promises.readFile(path.resolve(newsreaderFontPath)),
> weight: 400,
> style: "normal" as const,
> },
> ]
>
> return fonts
> }
> ```
>
> This font then can be used with your custom structure.
## Examples
Here are some example image components you can use as a starting point:
### Basic Example
This example will generate images that look as follows:
| Light | Dark |
| ------------------------------------------ | ----------------------------------------- |
| ![[custom-social-image-preview-light.png]] | ![[custom-social-image-preview-dark.png]] |
```tsx
import { SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg"
import { SocialImageOptions, UserOpts } from "./imageHelper"
import { QuartzPluginData } from "../plugins/vfile"
export const customImage: SocialImageOptions["imageStructure"] = (
cfg: GlobalConfiguration,
userOpts: UserOpts,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData,
) => {
// How many characters are allowed before switching to smaller font
const fontBreakPoint = 22
const useSmallerFont = title.length > fontBreakPoint
const { colorScheme } = userOpts
return (
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
height: "100%",
width: "100%",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
flexDirection: "column",
gap: "2.5rem",
paddingTop: "2rem",
paddingBottom: "2rem",
}}
>
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
marginLeft: "4rem",
textAlign: "center",
marginRight: "4rem",
fontFamily: fonts[0].name,
}}
>
{title}
</p>
<p
style={{
color: cfg.theme.colors[colorScheme].dark,
fontSize: 44,
marginLeft: "8rem",
marginRight: "8rem",
lineClamp: 3,
fontFamily: fonts[1].name,
}}
>
{description}
</p>
</div>
<div
style={{
height: "100%",
width: "2vw",
position: "absolute",
backgroundColor: cfg.theme.colors[colorScheme].tertiary,
opacity: 0.85,
}}
/>
</div>
)
}
```
### Advanced Example
The following example includes a customized social image with a custom background and formatted date:
```typescript title="custom-og.tsx"
export const og: SocialImageOptions["Component"] = (
cfg: GlobalConfiguration,
fileData: QuartzPluginData,
{ colorScheme }: Options,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
) => {
let created: string | undefined
let reading: string | undefined
if (fileData.dates) {
created = formatDate(getDate(cfg, fileData)!, cfg.locale)
}
const { minutes, text: _timeTaken, words: _words } = readingTime(fileData.text!)
reading = i18n(cfg.locale).components.contentMeta.readingTime({
minutes: Math.ceil(minutes),
})
const Li = [created, reading]
return (
<div
style={{
position: "relative",
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
height: "100%",
width: "100%",
backgroundImage: `url("https://${cfg.baseUrl}/static/og-image.jpeg")`,
backgroundSize: "100% 100%",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "radial-gradient(circle at center, transparent, rgba(0, 0, 0, 0.4) 70%)",
}}
/>
<div
style={{
display: "flex",
height: "100%",
width: "100%",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "flex-start",
gap: "1.5rem",
paddingTop: "4rem",
paddingBottom: "4rem",
marginLeft: "4rem",
}}
>
<img
src={`"https://${cfg.baseUrl}/static/icon.jpeg"`}
style={{
position: "relative",
backgroundClip: "border-box",
borderRadius: "6rem",
}}
width={80}
/>
<div
style={{
display: "flex",
flexDirection: "column",
textAlign: "left",
fontFamily: fonts[0].name,
}}
>
<h2
style={{
color: cfg.theme.colors[colorScheme].light,
fontSize: "3rem",
fontWeight: 700,
marginRight: "4rem",
fontFamily: fonts[0].name,
}}
>
{title}
</h2>
<ul
style={{
color: cfg.theme.colors[colorScheme].gray,
gap: "1rem",
fontSize: "1.5rem",
fontFamily: fonts[1].name,
}}
>
{Li.map((item, index) => {
if (item) {
return <li key={index}>{item}</li>
}
})}
</ul>
</div>
<p
style={{
color: cfg.theme.colors[colorScheme].light,
fontSize: "1.5rem",
overflow: "hidden",
marginRight: "8rem",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 7,
WebkitBoxOrient: "vertical",
lineClamp: 7,
fontFamily: fonts[1].name,
}}
>
{description}
</p>
</div>
</div>
)
}
```

19
docs/plugins/Favicon.md Normal file
View File

@ -0,0 +1,19 @@
---
title: Favicon
tags:
- plugin/emitter
---
This plugin emits a `favicon.ico` into the `public` folder. It creates the favicon from `icon.png` located in the `quartz/static` folder.
The plugin resizes `icon.png` to 48x48px to make it as small as possible.
> [!note]
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
This plugin has no configuration options.
## API
- Category: Emitter
- Function name: `Plugin.Favicon()`.
- Source: [`quartz/plugins/emitters/favicon.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/emitters/favicon.ts).

View File

@ -17,6 +17,54 @@ This plugin accepts the following configuration options:
> [!warning] > [!warning]
> This plugin must not be removed, otherwise Quartz will break. > This plugin must not be removed, otherwise Quartz will break.
## List
Quartz supports the following frontmatter:
- title
- `title`
- description
- `description`
- permalink
- `permalink`
- comments
- `comments`
- lang
- `lang`
- publish
- `publish`
- draft
- `draft`
- enableToc
- `enableToc`
- tags
- `tags`
- `tag`
- aliases
- `aliases`
- `alias`
- cssclasses
- `cssclasses`
- `cssclass`
- socialDescription
- `socialDescription`
- socialImage
- `socialImage`
- `image`
- `cover`
- created
- `created`
- `date`
- modified
- `modified`
- `lastmod`
- `updated`
- `last-modified`
- published
- `published`
- `publishDate`
- `date`
## API ## API
- Category: Transformer - Category: Transformer

View File

@ -11,7 +11,12 @@ This plugin adds LaTeX support to Quartz. See [[features/Latex|Latex]] for more
This plugin accepts the following configuration options: This plugin accepts the following configuration options:
- `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/) or `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html). Defaults to KaTeX. - `renderEngine`: the engine to use to render LaTeX equations. Can be `"katex"` for [KaTeX](https://katex.org/), `"mathjax"` for [MathJax](https://www.mathjax.org/) [SVG rendering](https://docs.mathjax.org/en/latest/output/svg.html), or `"typst"` for [Typst](https://typst.app/) (a new way to compose LaTeX equation). Defaults to KaTeX.
- `customMacros`: custom macros for all LaTeX blocks. It takes the form of a key-value pair where the key is a new command name and the value is the expansion of the macro. For example: `{"\\R": "\\mathbb{R}"}`
> [!note] Typst support
>
> Currently, typst doesn't support inline-math
## API ## API

View File

@ -23,6 +23,7 @@ This plugin accepts the following configuration options:
- `enableYouTubeEmbed`: If `true` (default), enables the embedding of YouTube videos and playlists using external image Markdown syntax. - `enableYouTubeEmbed`: If `true` (default), enables the embedding of YouTube videos and playlists using external image Markdown syntax.
- `enableVideoEmbed`: If `true` (default), enables the embedding of video files. - `enableVideoEmbed`: If `true` (default), enables the embedding of video files.
- `enableCheckbox`: If `true`, adds support for interactive checkboxes in content. Defaults to `false`. - `enableCheckbox`: If `true`, adds support for interactive checkboxes in content. Defaults to `false`.
- `disableBrokenWikilinks`: If `true`, replaces links to non-existent notes with a dimmed, disabled link. Defaults to `false`.
> [!warning] > [!warning]
> Don't remove this plugin if you're using [[Obsidian compatibility|Obsidian]] to author the content! > Don't remove this plugin if you're using [[Obsidian compatibility|Obsidian]] to author the content!
@ -31,4 +32,4 @@ This plugin accepts the following configuration options:
- Category: Transformer - Category: Transformer
- Function name: `Plugin.ObsidianFlavoredMarkdown()`. - Function name: `Plugin.ObsidianFlavoredMarkdown()`.
- Source: [`quartz/plugins/transformers/toc.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/toc.ts). - Source: [`quartz/plugins/transformers/ofm.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/ofm.ts)

View File

@ -0,0 +1,26 @@
---
title: RoamFlavoredMarkdown
tags:
- plugin/transformer
---
This plugin provides support for [Roam Research](https://roamresearch.com) compatibility. See [[Roam Research Compatibility]] for more information.
> [!note]
> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page.
This plugin accepts the following configuration options:
- `orComponent`: If `true` (default), converts Roam `{{ or:ONE|TWO|THREE }}` shortcodes into HTML Dropdown options.
- `TODOComponent`: If `true` (default), converts Roam `{{[[TODO]]}}` shortcodes into HTML check boxes.
- `DONEComponent`: If `true` (default), converts Roam `{{[[DONE]]}}` shortcodes into checked HTML check boxes.
- `videoComponent`: If `true` (default), converts Roam `{{[[video]]:URL}}` shortcodes into embeded HTML video.
- `audioComponent`: If `true` (default), converts Roam `{{[[audio]]:URL}}` shortcodes into embeded HTML audio.
- `pdfComponent`: If `true` (default), converts Roam `{{[[pdf]]:URL}}` shortcodes into embeded HTML PDF viewer.
- `blockquoteComponent`: If `true` (default), converts Roam `{{[[>]]}}` shortcodes into Quartz blockquotes.
## API
- Category: Transformer
- Function name: `Plugin.RoamFlavoredMarkdown()`.
- Source: [`quartz/plugins/transformers/roam.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/roam.ts).

View File

@ -34,6 +34,13 @@ npx quartz sync --no-pull
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase` > [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
> You may have an outdated version of `git`. Updating `git` should fix this issue. > You may have an outdated version of `git`. Updating `git` should fix this issue.
> [!warning]- `fatal: The remote end hung up unexpectedly`
> It might be due to Git's default buffer size. You can fix it by increasing the buffer with this command:
>
> ```bash
> git config http.postBuffer 524288000
> ```
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository. In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
> [!hint] Flags and options > [!hint] Flags and options

View File

@ -6,28 +6,18 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/) - [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/) - [Jacky Zhao's Garden](https://jzhao.xyz/)
- [Socratica Toolbox](https://toolbox.socratica.info/)
- [oldwinter の数字花园](https://garden.oldwinter.top/)
- [Aaron Pham's Garden](https://aarnphm.xyz/) - [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Quantum Garden](https://quantumgardener.blog/) - [The Pond](https://turntrout.com/welcome)
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Eilleen's Everything Notebook](https://quartz.eilleeenz.com/)
- [Matt Dunn's Second Brain](https://mattdunn.info/) - [Morrowind Modding Wiki](https://morrowind-modding.github.io/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) - [Stanford CME 302 Numerical Linear Algebra](https://ericdarve.github.io/NLA/)
- [Vince Imbat's Talahardin](https://vinceimbat.com/) - [Socratica Toolbox](https://toolbox.socratica.info/)
- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/)
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
- [🌊 Collapsed Wave](https://collapsedwave.com/)
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [🪴Aster's notebook](https://notes.asterhu.com)
- [🥷🏻🌳🍃 Computer Science & Thinkering Garden](https://notes.yxy.ninja)
- [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/) - [A Pattern Language - Christopher Alexander (Architecture)](https://patternlanguage.cc/)
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Data Engineering Vault: A Second Brain Knowledge Network](https://vault.ssp.sh/)
- [🪴Aster's notebook](https://notes.asterhu.com)
- [Gatekeeper Wiki](https://www.gatekeeper.wiki) - [Gatekeeper Wiki](https://www.gatekeeper.wiki)
- [Ellie's Notes](https://ellie.wtf)
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! - [Eledah's Crystalline](https://blog.eledah.ir/)
- [🌓 Projects & Privacy - FOSS, tech, law](https://be-far.com)

4
globals.d.ts vendored
View File

@ -4,6 +4,10 @@ export declare global {
type: K, type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void, listener: (this: Document, ev: CustomEventMap[K]) => void,
): void ): void
removeEventListener<K extends keyof CustomEventMap>(
type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
} }
interface Window { interface Window {

3
index.d.ts vendored
View File

@ -5,8 +5,11 @@ declare module "*.scss" {
// dom custom event // dom custom event
interface CustomEventMap { interface CustomEventMap {
prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }> themechange: CustomEvent<{ theme: "light" | "dark" }>
readermodechange: CustomEvent<{ mode: "on" | "off" }>
} }
type ContentIndex = Record<FullSlug, ContentDetails>
declare const fetchData: Promise<ContentIndex> declare const fetchData: Promise<ContentIndex>

4133
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.2.4", "version": "4.5.1",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@ -16,12 +16,12 @@
"docs": "npx quartz build --serve -d docs", "docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check", "check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write", "format": "npx prettier . --write",
"test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", "test": "tsx --test",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
}, },
"engines": { "engines": {
"npm": ">=9.3.1", "npm": ">=10.9.2",
"node": ">=18.14" "node": ">=22"
}, },
"keywords": [ "keywords": [
"site generator", "site generator",
@ -35,76 +35,81 @@
"quartz": "./quartz/bootstrap-cli.mjs" "quartz": "./quartz/bootstrap-cli.mjs"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.11.0",
"@floating-ui/dom": "^1.6.8", "@floating-ui/dom": "^1.7.0",
"@napi-rs/simple-git": "0.1.16", "@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.19",
"@tweenjs/tween.js": "^25.0.0",
"@webgpu/types": "^0.1.61",
"ansi-truncate": "^1.2.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"chalk": "^5.3.0", "chokidar": "^4.0.3",
"chokidar": "^3.6.0",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"d3": "^7.9.0", "d3": "^7.9.0",
"esbuild-sass-plugin": "^2.16.1", "esbuild-sass-plugin": "^3.3.1",
"flexsearch": "0.7.43", "flexsearch": "0.7.43",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^14.0.1", "globby": "^14.1.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hast-util-to-html": "^9.0.1", "hast-util-to-html": "^9.0.5",
"hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-jsx-runtime": "^2.3.6",
"hast-util-to-string": "^3.0.0", "hast-util-to-string": "^3.0.1",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lightningcss": "^1.25.1", "lightningcss": "^1.30.1",
"mdast-util-find-and-replace": "^3.0.1", "mdast-util-find-and-replace": "^3.0.2",
"mdast-util-to-hast": "^13.2.0", "mdast-util-to-hast": "^13.2.0",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5", "micromorph": "^0.4.5",
"preact": "^10.22.1", "minimatch": "^10.0.1",
"preact-render-to-string": "^6.5.5", "pixi.js": "^8.9.2",
"pretty-bytes": "^6.1.1", "preact": "^10.26.7",
"preact-render-to-string": "^6.5.13",
"pretty-bytes": "^7.0.0",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.0.0", "rehype-citation": "^2.3.1",
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.1",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^7.1.0",
"rehype-pretty-code": "^0.13.2", "rehype-pretty-code": "^0.14.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0", "remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1", "rfdc": "^1.4.1",
"rimraf": "^5.0.7", "satori": "^0.13.1",
"serve-handler": "^6.1.5", "serve-handler": "^6.1.6",
"shiki": "^1.10.3", "sharp": "^0.34.2",
"shiki": "^1.26.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"to-vfile": "^8.0.0", "to-vfile": "^8.0.0",
"toml": "^3.0.0", "toml": "^3.0.0",
"unified": "^11.0.4", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.1", "vfile": "^6.0.3",
"workerpool": "^9.1.2", "workerpool": "^9.2.0",
"ws": "^8.18.0", "ws": "^8.18.2",
"yargs": "^17.7.2" "yargs": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^20.14.11", "@types/node": "^22.15.23",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.11", "@types/ws": "^8.18.1",
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.33",
"esbuild": "^0.19.9", "esbuild": "^0.25.5",
"prettier": "^3.3.2", "prettier": "^3.5.3",
"tsx": "^4.16.2", "tsx": "^4.19.4",
"typescript": "^5.5.3" "typescript": "^5.8.3"
} }
} }

View File

@ -2,13 +2,14 @@ import { QuartzConfig } from "./quartz/cfg"
import * as Plugin from "./quartz/plugins" import * as Plugin from "./quartz/plugins"
/** /**
* Quartz 4.0 Configuration * Quartz 4 Configuration
* *
* See https://quartz.jzhao.xyz/configuration for more information. * See https://quartz.jzhao.xyz/configuration for more information.
*/ */
const config: QuartzConfig = { const config: QuartzConfig = {
configuration: { configuration: {
pageTitle: "🪴 Quartz 4.0", pageTitle: "Quartz 4",
pageTitleSuffix: "",
enableSPA: true, enableSPA: true,
enablePopovers: true, enablePopovers: true,
analytics: { analytics: {
@ -17,7 +18,7 @@ const config: QuartzConfig = {
locale: "en-US", locale: "en-US",
baseUrl: "quartz.jzhao.xyz", baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"], ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created", defaultDateType: "modified",
theme: { theme: {
fontOrigin: "googleFonts", fontOrigin: "googleFonts",
cdnCaching: true, cdnCaching: true,
@ -56,7 +57,7 @@ const config: QuartzConfig = {
transformers: [ transformers: [
Plugin.FrontMatter(), Plugin.FrontMatter(),
Plugin.CreatedModifiedDate({ Plugin.CreatedModifiedDate({
priority: ["frontmatter", "filesystem"], priority: ["frontmatter", "git", "filesystem"],
}), }),
Plugin.SyntaxHighlighting({ Plugin.SyntaxHighlighting({
theme: { theme: {
@ -85,7 +86,10 @@ const config: QuartzConfig = {
}), }),
Plugin.Assets(), Plugin.Assets(),
Plugin.Static(), Plugin.Static(),
Plugin.Favicon(),
Plugin.NotFoundPage(), Plugin.NotFoundPage(),
// Comment out CustomOgImages to speed up build time
Plugin.CustomOgImages(),
], ],
}, },
} }

View File

@ -17,7 +17,10 @@ export const sharedPageComponents: SharedLayout = {
// components for pages that display a single page (e.g. a single note) // components for pages that display a single page (e.g. a single note)
export const defaultContentPageLayout: PageLayout = { export const defaultContentPageLayout: PageLayout = {
beforeBody: [ beforeBody: [
Component.Breadcrumbs(), Component.ConditionalRender({
component: Component.Breadcrumbs(),
condition: (page) => page.fileData.slug !== "index",
}),
Component.ArticleTitle(), Component.ArticleTitle(),
Component.ContentMeta(), Component.ContentMeta(),
Component.TagList(), Component.TagList(),
@ -25,9 +28,17 @@ export const defaultContentPageLayout: PageLayout = {
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Flex({
Component.Darkmode(), components: [
Component.DesktopOnly(Component.Explorer()), {
Component: Component.Search(),
grow: true,
},
{ Component: Component.Darkmode() },
{ Component: Component.ReaderMode() },
],
}),
Component.Explorer(),
], ],
right: [ right: [
Component.Graph(), Component.Graph(),
@ -42,9 +53,16 @@ export const defaultListPageLayout: PageLayout = {
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Flex({
Component.Darkmode(), components: [
Component.DesktopOnly(Component.Explorer()), {
Component: Component.Search(),
grow: true,
},
{ Component: Component.Darkmode() },
],
}),
Component.Explorer(),
], ],
right: [], right: [],
} }

View File

@ -1,4 +1,4 @@
#!/usr/bin/env node #!/usr/bin/env -S node --no-deprecation
import yargs from "yargs" import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { import {

View File

@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import workerpool from "workerpool" import workerpool from "workerpool"
const cacheFile = "./.quartz-cache/transpiled-worker.mjs" const cacheFile = "./.quartz-cache/transpiled-worker.mjs"
const { parseFiles } = await import(cacheFile) const { parseMarkdown, processHtml } = await import(cacheFile)
workerpool.worker({ workerpool.worker({
parseFiles, parseMarkdown,
processHtml,
}) })

View File

@ -2,14 +2,14 @@ import sourceMapSupport from "source-map-support"
sourceMapSupport.install(options) sourceMapSupport.install(options)
import path from "path" import path from "path"
import { PerfTimer } from "./util/perf" import { PerfTimer } from "./util/perf"
import { rimraf } from "rimraf" import { rm } from "fs/promises"
import { GlobbyFilterFunction, isGitIgnored } from "globby" import { GlobbyFilterFunction, isGitIgnored } from "globby"
import chalk from "chalk" import { styleText } from "util"
import { parseMarkdown } from "./processors/parse" import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter" import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit" import { emitContent } from "./processors/emit"
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar" import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile" import { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx" import { Argv, BuildCtx } from "./util/ctx"
@ -17,32 +17,39 @@ import { glob, toPosixPath } from "./util/glob"
import { trace } from "./util/trace" import { trace } from "./util/trace"
import { options } from "./util/sourcemap" import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex" import { Mutex } from "async-mutex"
import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins" import { getStaticResourcesFromPlugins } from "./plugins"
import { randomIdNonSecure } from "./util/random"
import { ChangeEvent } from "./plugins/types"
import { minimatch } from "minimatch"
type Dependencies = Record<string, DepGraph<FilePath> | null> type ContentMap = Map<
FilePath,
| {
type: "markdown"
content: ProcessedContent
}
| {
type: "other"
}
>
type BuildData = { type BuildData = {
ctx: BuildCtx ctx: BuildCtx
ignored: GlobbyFilterFunction ignored: GlobbyFilterFunction
mut: Mutex mut: Mutex
initialSlugs: FullSlug[] contentMap: ContentMap
// TODO merge contentMap and trackedAssets changesSinceLastBuild: Record<FilePath, ChangeEvent["type"]>
contentMap: Map<FilePath, ProcessedContent>
trackedAssets: Set<FilePath>
toRebuild: Set<FilePath>
toRemove: Set<FilePath>
lastBuildMs: number lastBuildMs: number
dependencies: Dependencies
} }
type FileEvent = "add" | "change" | "delete"
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = { const ctx: BuildCtx = {
buildId: randomIdNonSecure(),
argv, argv,
cfg, cfg,
allSlugs: [], allSlugs: [],
allFiles: [],
incremental: false,
} }
const perf = new PerfTimer() const perf = new PerfTimer()
@ -60,69 +67,78 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const release = await mut.acquire() const release = await mut.acquire()
perf.addEvent("clean") perf.addEvent("clean")
await rimraf(path.join(output, "*"), { glob: true }) await rm(output, { recursive: true, force: true })
console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`) console.log(`Cleaned output directory \`${output}\` in ${perf.timeSince("clean")}`)
perf.addEvent("glob") perf.addEvent("glob")
const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns)
const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() const markdownPaths = allFiles.filter((fp) => fp.endsWith(".md")).sort()
console.log( console.log(
`Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, `Found ${markdownPaths.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`,
) )
const filePaths = fps.map((fp) => joinSegments(argv.directory, fp) as FilePath) const filePaths = markdownPaths.map((fp) => joinSegments(argv.directory, fp) as FilePath)
ctx.allFiles = allFiles
ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const parsedFiles = await parseMarkdown(ctx, filePaths) const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles) const filteredContent = filterContent(ctx, parsedFiles)
const dependencies: Record<string, DepGraph<FilePath> | null> = {}
// Only build dependency graphs if we're doing a fast rebuild
if (argv.fastRebuild) {
const staticResources = getStaticResourcesFromPlugins(ctx)
for (const emitter of cfg.plugins.emitters) {
dependencies[emitter.name] =
(await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
}
}
await emitContent(ctx, filteredContent) await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) console.log(
styleText("green", `Done processing ${markdownPaths.length} files in ${perf.timeSince()}`),
)
release() release()
if (argv.serve) { if (argv.watch) {
return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) ctx.incremental = true
return startWatching(ctx, mut, parsedFiles, clientRefresh)
} }
} }
// setup watcher for rebuilds // setup watcher for rebuilds
async function startServing( async function startWatching(
ctx: BuildCtx, ctx: BuildCtx,
mut: Mutex, mut: Mutex,
initialContent: ProcessedContent[], initialContent: ProcessedContent[],
clientRefresh: () => void, clientRefresh: () => void,
dependencies: Dependencies, // emitter name: dep graph
) { ) {
const { argv } = ctx const { argv, allFiles } = ctx
// cache file parse results const contentMap: ContentMap = new Map()
const contentMap = new Map<FilePath, ProcessedContent>() for (const filePath of allFiles) {
for (const content of initialContent) { contentMap.set(filePath, {
const [_tree, vfile] = content type: "other",
contentMap.set(vfile.data.filePath!, content) })
} }
for (const content of initialContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.relativePath!, {
type: "markdown",
content,
})
}
const gitIgnoredMatcher = await isGitIgnored()
const buildData: BuildData = { const buildData: BuildData = {
ctx, ctx,
mut, mut,
dependencies,
contentMap, contentMap,
ignored: await isGitIgnored(), ignored: (fp) => {
initialSlugs: ctx.allSlugs, const pathStr = toPosixPath(fp.toString())
toRebuild: new Set<FilePath>(), if (pathStr.startsWith(".git/")) return true
toRemove: new Set<FilePath>(), if (gitIgnoredMatcher(pathStr)) return true
trackedAssets: new Set<FilePath>(), for (const pattern of cfg.configuration.ignorePatterns) {
if (minimatch(pathStr, pattern)) {
return true
}
}
return false
},
changesSinceLastBuild: {},
lastBuildMs: 0, lastBuildMs: 0,
} }
@ -132,274 +148,146 @@ async function startServing(
ignoreInitial: true, ignoreInitial: true,
}) })
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint const changes: ChangeEvent[] = []
watcher watcher
.on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) .on("add", (fp) => {
.on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) if (buildData.ignored(fp)) return
.on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)) changes.push({ path: fp as FilePath, type: "add" })
void rebuild(changes, clientRefresh, buildData)
})
.on("change", (fp) => {
if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "change" })
void rebuild(changes, clientRefresh, buildData)
})
.on("unlink", (fp) => {
if (buildData.ignored(fp)) return
changes.push({ path: fp as FilePath, type: "delete" })
void rebuild(changes, clientRefresh, buildData)
})
return async () => { return async () => {
await watcher.close() await watcher.close()
} }
} }
async function partialRebuildFromEntrypoint( async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildData: BuildData) {
filepath: string, const { ctx, contentMap, mut, changesSinceLastBuild } = buildData
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
const { argv, cfg } = ctx const { argv, cfg } = ctx
// don't do anything for gitignored files const buildId = randomIdNonSecure()
if (ignored(filepath)) { ctx.buildId = buildId
return buildData.lastBuildMs = new Date().getTime()
} const numChangesInBuild = changes.length
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const release = await mut.acquire() const release = await mut.acquire()
if (buildData.lastBuildMs > buildStart) {
// if there's another build after us, release and let them do it
if (ctx.buildId !== buildId) {
release() release()
return return
} }
const perf = new PerfTimer() const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding...")) perf.addEvent("rebuild")
console.log(styleText("yellow", "Detected change, rebuilding..."))
// UPDATE DEP GRAPH // update changesSinceLastBuild
const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath for (const change of changes) {
changesSinceLastBuild[change.path] = change.type
}
const staticResources = getStaticResourcesFromPlugins(ctx) const staticResources = getStaticResourcesFromPlugins(ctx)
let processedFiles: ProcessedContent[] = [] const pathsToParse: FilePath[] = []
for (const [fp, type] of Object.entries(changesSinceLastBuild)) {
switch (action) { if (type === "delete" || path.extname(fp) !== ".md") continue
case "add": const fullPath = joinSegments(argv.directory, toPosixPath(fp)) as FilePath
// add to cache when new file is added pathsToParse.push(fullPath)
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// update the dep graph by asking all emitters whether they depend on this file
for (const emitter of cfg.plugins.emitters) {
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
if (emitterGraph) {
const existingGraph = dependencies[emitter.name]
if (existingGraph !== null) {
existingGraph.mergeGraph(emitterGraph)
} else {
// might be the first time we're adding a mardown file
dependencies[emitter.name] = emitterGraph
}
}
}
break
case "change":
// invalidate cache when file is changed
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// only content files can have added/removed dependencies because of transclusions
if (path.extname(fp) === ".md") {
for (const emitter of cfg.plugins.emitters) {
// get new dependencies from all emitters for this file
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
// only update the graph if the emitter plugin uses the changed file
// eg. Assets plugin ignores md files, so we skip updating the graph
if (emitterGraph?.hasNode(fp)) {
// merge the new dependencies into the dep graph
dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
}
}
}
break
case "delete":
toRemove.add(fp)
break
} }
if (argv.verbose) { const parsed = await parseMarkdown(ctx, pathsToParse)
console.log(`Updated dependency graphs in ${perf.timeSince()}`) for (const content of parsed) {
contentMap.set(content[1].data.relativePath!, {
type: "markdown",
content,
})
} }
// EMIT // update state using changesSinceLastBuild
perf.addEvent("rebuild") // we do this weird play of add => compute change events => remove
// so that partialEmitters can do appropriate cleanup based on the content of deleted files
for (const [file, change] of Object.entries(changesSinceLastBuild)) {
if (change === "delete") {
// universal delete case
contentMap.delete(file as FilePath)
}
// manually track non-markdown files as processed files only
// contains markdown files
if (change === "add" && path.extname(file) !== ".md") {
contentMap.set(file as FilePath, {
type: "other",
})
}
}
const changeEvents: ChangeEvent[] = Object.entries(changesSinceLastBuild).map(([fp, type]) => {
const path = fp as FilePath
const processedContent = contentMap.get(path)
if (processedContent?.type === "markdown") {
const [_tree, file] = processedContent.content
return {
type,
path,
file,
}
}
return {
type,
path,
}
})
// update allFiles and then allSlugs with the consistent view of content map
ctx.allFiles = Array.from(contentMap.keys())
ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath))
const processedFiles = Array.from(contentMap.values())
.filter((file) => file.type === "markdown")
.map((file) => file.content)
let emittedFiles = 0 let emittedFiles = 0
for (const emitter of cfg.plugins.emitters) { for (const emitter of cfg.plugins.emitters) {
const depGraph = dependencies[emitter.name] // Try to use partialEmit if available, otherwise assume the output is static
const emitFn = emitter.partialEmit ?? emitter.emit
// emitter hasn't defined a dependency graph. call it with all processed files const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
if (depGraph === null) { if (emitted === null) {
if (argv.verbose) {
console.log(
`Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
)
}
const files = [...contentMap.values()].filter(
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
)
const emittedFps = await emitter.emit(ctx, files, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
emittedFiles += emittedFps.length
continue continue
} }
// only call the emitter if it uses this file if (Symbol.asyncIterator in emitted) {
if (depGraph.hasNode(fp)) { // Async generator case
// re-emit using all files that are needed for the downstream of this file for await (const file of emitted) {
// eg. for ContentIndex, the dep graph could be: emittedFiles++
// a.md --> contentIndex.json if (ctx.argv.verbose) {
// b.md ------^ console.log(`[emit:${emitter.name}] ${file}`)
// }
// if a.md changes, we need to re-emit contentIndex.json, }
// and supply [a.md, b.md] to the emitter } else {
const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] // Array case
emittedFiles += emitted.length
const upstreamContent = upstreams if (ctx.argv.verbose) {
// filter out non-markdown files for (const file of emitted) {
.filter((file) => contentMap.has(file))
// if file was deleted, don't give it to the emitter
.filter((file) => !toRemove.has(file))
.map((file) => contentMap.get(file)!)
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} }
} }
emittedFiles += emittedFps.length
} }
} }
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
console.log(styleText("green", `Done rebuilding in ${perf.timeSince()}`))
// CLEANUP changes.splice(0, numChangesInBuild)
const destinationsToDelete = new Set<FilePath>()
for (const file of toRemove) {
// remove from cache
contentMap.delete(file)
Object.values(dependencies).forEach((depGraph) => {
// remove the node from dependency graphs
depGraph?.removeNode(file)
// remove any orphan nodes. eg if a.md is deleted, a.html is orphaned and should be removed
const orphanNodes = depGraph?.removeOrphanNodes()
orphanNodes?.forEach((node) => {
// only delete files that are in the output directory
if (node.startsWith(argv.output)) {
destinationsToDelete.add(node)
}
})
})
}
await rimraf([...destinationsToDelete])
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
toRemove.clear()
release()
clientRefresh() clientRefresh()
}
async function rebuildFromEntrypoint(
fp: string,
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
buildData
const { argv } = ctx
// don't do anything for gitignored files
if (ignored(fp)) {
return
}
// dont bother rebuilding for non-content files, just track and refresh
fp = toPosixPath(fp)
const filePath = joinSegments(argv.directory, fp) as FilePath
if (path.extname(fp) !== ".md") {
if (action === "add" || action === "change") {
trackedAssets.add(filePath)
} else if (action === "delete") {
trackedAssets.delete(filePath)
}
clientRefresh()
return
}
if (action === "add" || action === "change") {
toRebuild.add(filePath)
} else if (action === "delete") {
toRemove.add(filePath)
}
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const release = await mut.acquire()
// there's another build after us, release and let them do it
if (buildData.lastBuildMs > buildStart) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
for (const fp of toRemove) {
contentMap.delete(fp)
}
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(path.join(argv.output, ".*"), { glob: true })
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch (err) {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
if (argv.verbose) {
console.log(chalk.red(err))
}
}
release() release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
} }
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {

View File

@ -38,9 +38,14 @@ export type Analytics =
provider: "cabin" provider: "cabin"
host?: string host?: string
} }
| {
provider: "clarity"
projectId?: string
}
export interface GlobalConfiguration { export interface GlobalConfiguration {
pageTitle: string pageTitle: string
pageTitleSuffix?: string
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ /** 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 */ /** Whether to display Wikipedia-style popovers when hovering over links */
@ -59,7 +64,7 @@ export interface GlobalConfiguration {
/** /**
* Allow to translate the date in the language of your choice. * Allow to translate the date in the language of your choice.
* Also used for UI translation (default: en-US) * Also used for UI translation (default: en-US)
* Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag * Need to be formatted following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* The first part is the language (en) and the second part is the script/region (US) * The first part is the language (en) and the second part is the script/region (US)
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes * Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2

View File

@ -71,10 +71,10 @@ export const BuildArgv = {
default: false, default: false,
describe: "run a local server to live-preview your Quartz", describe: "run a local server to live-preview your Quartz",
}, },
fastRebuild: { watch: {
boolean: true, boolean: true,
default: false, default: false,
describe: "[experimental] rebuild only the changed files", describe: "watch for changes and rebuild automatically",
}, },
baseDir: { baseDir: {
string: true, string: true,

View File

@ -1,11 +1,11 @@
import { promises } from "fs" import { promises } from "fs"
import path from "path" import path from "path"
import esbuild from "esbuild" import esbuild from "esbuild"
import chalk from "chalk" import { styleText } from "util"
import { sassPlugin } from "esbuild-sass-plugin" import { sassPlugin } from "esbuild-sass-plugin"
import fs from "fs" import fs from "fs"
import { intro, outro, select, text } from "@clack/prompts" import { intro, outro, select, text } from "@clack/prompts"
import { rimraf } from "rimraf" import { rm } from "fs/promises"
import chokidar from "chokidar" import chokidar from "chokidar"
import prettyBytes from "pretty-bytes" import prettyBytes from "pretty-bytes"
import { execSync, spawnSync } from "child_process" import { execSync, spawnSync } from "child_process"
@ -15,6 +15,7 @@ import { WebSocketServer } from "ws"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { Mutex } from "async-mutex" import { Mutex } from "async-mutex"
import { CreateArgv } from "./args.js" import { CreateArgv } from "./args.js"
import { globby } from "globby"
import { import {
exitIfCancel, exitIfCancel,
escapePath, escapePath,
@ -32,31 +33,44 @@ import {
cwd, cwd,
} from "./constants.js" } from "./constants.js"
/**
* Resolve content directory path
* @param contentPath path to resolve
*/
function resolveContentPath(contentPath) {
if (path.isAbsolute(contentPath)) return path.relative(cwd, contentPath)
return path.join(cwd, contentPath)
}
/** /**
* Handles `npx quartz create` * Handles `npx quartz create`
* @param {*} argv arguments for `create` * @param {*} argv arguments for `create`
*/ */
export async function handleCreate(argv) { export async function handleCreate(argv) {
console.log() console.log()
intro(chalk.bgGreen.black(` Quartz v${version} `)) intro(styleText(["bgGreen", "black"], ` Quartz v${version} `))
const contentFolder = path.join(cwd, argv.directory) const contentFolder = resolveContentPath(argv.directory)
let setupStrategy = argv.strategy?.toLowerCase() let setupStrategy = argv.strategy?.toLowerCase()
let linkResolutionStrategy = argv.links?.toLowerCase() let linkResolutionStrategy = argv.links?.toLowerCase()
const sourceDirectory = argv.source const sourceDirectory = argv.source
// If all cmd arguments were provided, check if theyre valid // If all cmd arguments were provided, check if they're valid
if (setupStrategy && linkResolutionStrategy) { if (setupStrategy && linkResolutionStrategy) {
// If setup isn't, "new", source argument is required // If setup isn't, "new", source argument is required
if (setupStrategy !== "new") { if (setupStrategy !== "new") {
// Error handling // Error handling
if (!sourceDirectory) { if (!sourceDirectory) {
outro( outro(
chalk.red( styleText(
`Setup strategies (arg '${chalk.yellow( "red",
`Setup strategies (arg '${styleText(
"yellow",
`-${CreateArgv.strategy.alias[0]}`, `-${CreateArgv.strategy.alias[0]}`,
)}') other than '${chalk.yellow( )}') other than '${styleText(
"yellow",
"new", "new",
)}' require content folder argument ('${chalk.yellow( )}' require content folder argument ('${styleText(
"yellow",
`-${CreateArgv.source.alias[0]}`, `-${CreateArgv.source.alias[0]}`,
)}') to be set`, )}') to be set`,
), ),
@ -65,19 +79,23 @@ export async function handleCreate(argv) {
} else { } else {
if (!fs.existsSync(sourceDirectory)) { if (!fs.existsSync(sourceDirectory)) {
outro( outro(
chalk.red( styleText(
`Input directory to copy/symlink 'content' from not found ('${chalk.yellow( "red",
`Input directory to copy/symlink 'content' from not found ('${styleText(
"yellow",
sourceDirectory, sourceDirectory,
)}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, )}', invalid argument "${styleText("yellow", `-${CreateArgv.source.alias[0]}`)})`,
), ),
) )
process.exit(1) process.exit(1)
} else if (!fs.lstatSync(sourceDirectory).isDirectory()) { } else if (!fs.lstatSync(sourceDirectory).isDirectory()) {
outro( outro(
chalk.red( styleText(
`Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( "red",
`Source directory to copy/symlink 'content' from is not a directory (found file at '${styleText(
"yellow",
sourceDirectory, sourceDirectory,
)}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, )}', invalid argument ${styleText("yellow", `-${CreateArgv.source.alias[0]}`)}")`,
), ),
) )
process.exit(1) process.exit(1)
@ -109,7 +127,7 @@ export async function handleCreate(argv) {
if (contentStat.isSymbolicLink()) { if (contentStat.isSymbolicLink()) {
await fs.promises.unlink(contentFolder) await fs.promises.unlink(contentFolder)
} else { } else {
await rimraf(contentFolder) await rm(contentFolder, { recursive: true, force: true })
} }
} }
@ -215,7 +233,11 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
* @param {*} argv arguments for `build` * @param {*} argv arguments for `build`
*/ */
export async function handleBuild(argv) { export async function handleBuild(argv) {
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) if (argv.serve) {
argv.watch = true
}
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
const ctx = await esbuild.context({ const ctx = await esbuild.context({
entryPoints: [fp], entryPoints: [fp],
outfile: cacheFile, outfile: cacheFile,
@ -236,6 +258,11 @@ export async function handleBuild(argv) {
type: "css-text", type: "css-text",
cssImports: true, cssImports: true,
}), }),
sassPlugin({
filter: /\.inline\.scss$/,
type: "css",
cssImports: true,
}),
{ {
name: "inline-script-loader", name: "inline-script-loader",
setup(build) { setup(build) {
@ -285,13 +312,13 @@ export async function handleBuild(argv) {
} }
if (cleanupBuild) { if (cleanupBuild) {
console.log(styleText("yellow", "Detected a source code change, doing a hard rebuild..."))
await cleanupBuild() await cleanupBuild()
console.log(chalk.yellow("Detected a source code change, doing a hard rebuild..."))
} }
const result = await ctx.rebuild().catch((err) => { const result = await ctx.rebuild().catch((err) => {
console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) console.error(`${styleText("red", "Couldn't parse Quartz configuration:")} ${fp}`)
console.log(`Reason: ${chalk.grey(err)}`) console.log(`Reason: ${styleText("grey", err)}`)
process.exit(1) process.exit(1)
}) })
release() release()
@ -316,9 +343,10 @@ export async function handleBuild(argv) {
clientRefresh() clientRefresh()
} }
let clientRefresh = () => {}
if (argv.serve) { if (argv.serve) {
const connections = [] const connections = []
const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) clientRefresh = () => connections.forEach((conn) => conn.send("rebuild"))
if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) {
argv.baseDir = "/" + argv.baseDir argv.baseDir = "/" + argv.baseDir
@ -328,7 +356,8 @@ export async function handleBuild(argv) {
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) { if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) {
console.log( console.log(
chalk.red( styleText(
"red",
`[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`, `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`,
), ),
) )
@ -350,12 +379,23 @@ export async function handleBuild(argv) {
source: "**/*.*", source: "**/*.*",
headers: [{ key: "Content-Disposition", value: "inline" }], headers: [{ key: "Content-Disposition", value: "inline" }],
}, },
{
source: "**/*.webp",
headers: [{ key: "Content-Type", value: "image/webp" }],
},
// fixes bug where avif images are displayed as text instead of images (future proof)
{
source: "**/*.avif",
headers: [{ key: "Content-Type", value: "image/avif" }],
},
], ],
}) })
const status = res.statusCode const status = res.statusCode
const statusString = const statusString =
status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`) status >= 200 && status < 300
console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`)) ? styleText("green", `[${status}]`)
: styleText("red", `[${status}]`)
console.log(statusString + styleText("grey", ` ${argv.baseDir}${req.url}`))
release() release()
} }
@ -364,7 +404,10 @@ export async function handleBuild(argv) {
res.writeHead(302, { res.writeHead(302, {
Location: newFp, Location: newFp,
}) })
console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`)) console.log(
styleText("yellow", "[302]") +
styleText("grey", ` ${argv.baseDir}${req.url} -> ${newFp}`),
)
res.end() res.end()
} }
@ -409,26 +452,38 @@ export async function handleBuild(argv) {
return serve() return serve()
}) })
server.listen(argv.port) server.listen(argv.port)
const wss = new WebSocketServer({ port: argv.wsPort }) const wss = new WebSocketServer({ port: argv.wsPort })
wss.on("connection", (ws) => connections.push(ws)) wss.on("connection", (ws) => connections.push(ws))
console.log( console.log(
chalk.cyan( styleText(
"cyan",
`Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`,
), ),
) )
console.log("hint: exit with ctrl+c")
chokidar
.watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], {
ignoreInitial: true,
})
.on("all", async () => {
build(clientRefresh)
})
} else { } else {
await build(() => {}) await build(clientRefresh)
ctx.dispose() ctx.dispose()
} }
if (argv.watch) {
const paths = await globby([
"**/*.ts",
"quartz/cli/*.js",
"quartz/static/**/*",
"**/*.tsx",
"**/*.scss",
"package.json",
])
chokidar
.watch(paths, { ignoreInitial: true })
.on("add", () => build(clientRefresh))
.on("change", () => build(clientRefresh))
.on("unlink", () => build(clientRefresh))
console.log(styleText("grey", "hint: exit with ctrl+c"))
}
} }
/** /**
@ -436,8 +491,8 @@ export async function handleBuild(argv) {
* @param {*} argv arguments for `update` * @param {*} argv arguments for `update`
*/ */
export async function handleUpdate(argv) { export async function handleUpdate(argv) {
const contentFolder = path.join(cwd, argv.directory) const contentFolder = resolveContentPath(argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
console.log("Backing up your content") console.log("Backing up your content")
execSync( execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
@ -450,18 +505,36 @@ export async function handleUpdate(argv) {
try { try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
} catch { } catch {
console.log(chalk.red("An error occurred above while pulling updates.")) console.log(styleText("red", "An error occurred above while pulling updates."))
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
return return
} }
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
console.log("Ensuring dependencies are up to date") console.log("Ensuring dependencies are up to date")
const res = spawnSync("npm", ["i"], { stdio: "inherit" })
/*
On Windows, if the command `npm` is really `npm.cmd', this call fails
as it will be unable to find `npm`. This is often the case on systems
where `npm` is installed via a package manager.
This means `npx quartz update` will not actually update dependencies
on Windows, without a manual `npm i` from the caller.
However, by spawning a shell, we are able to call `npm.cmd`.
See: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
const opts = { stdio: "inherit" }
if (process.platform === "win32") {
opts.shell = true
}
const res = spawnSync("npm", ["i"], opts)
if (res.status === 0) { if (res.status === 0) {
console.log(chalk.green("Done!")) console.log(styleText("green", "Done!"))
} else { } else {
console.log(chalk.red("An error occurred above while installing dependencies.")) console.log(styleText("red", "An error occurred above while installing dependencies."))
} }
} }
@ -470,7 +543,7 @@ export async function handleUpdate(argv) {
* @param {*} argv arguments for `restore` * @param {*} argv arguments for `restore`
*/ */
export async function handleRestore(argv) { export async function handleRestore(argv) {
const contentFolder = path.join(cwd, argv.directory) const contentFolder = resolveContentPath(argv.directory)
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
} }
@ -479,15 +552,15 @@ export async function handleRestore(argv) {
* @param {*} argv arguments for `sync` * @param {*} argv arguments for `sync`
*/ */
export async function handleSync(argv) { export async function handleSync(argv) {
const contentFolder = path.join(cwd, argv.directory) const contentFolder = resolveContentPath(argv.directory)
console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)}\n`)
console.log("Backing up your content") console.log("Backing up your content")
if (argv.commit) { if (argv.commit) {
const contentStat = await fs.promises.lstat(contentFolder) const contentStat = await fs.promises.lstat(contentFolder)
if (contentStat.isSymbolicLink()) { if (contentStat.isSymbolicLink()) {
const linkTarg = await fs.promises.readlink(contentFolder) const linkTarg = await fs.promises.readlink(contentFolder)
console.log(chalk.yellow("Detected symlink, trying to dereference before committing")) console.log(styleText("yellow", "Detected symlink, trying to dereference before committing"))
// stash symlink file // stash symlink file
await stashContentFolder(contentFolder) await stashContentFolder(contentFolder)
@ -522,7 +595,7 @@ export async function handleSync(argv) {
try { try {
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
} catch { } catch {
console.log(chalk.red("An error occurred above while pulling updates.")) console.log(styleText("red", "An error occurred above while pulling updates."))
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
return return
} }
@ -531,14 +604,17 @@ export async function handleSync(argv) {
await popContentFolder(contentFolder) await popContentFolder(contentFolder)
if (argv.push) { if (argv.push) {
console.log("Pushing your changes") console.log("Pushing your changes")
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { const currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
const res = spawnSync("git", ["push", "-uf", ORIGIN_NAME, currentBranch], {
stdio: "inherit", stdio: "inherit",
}) })
if (res.status !== 0) { if (res.status !== 0) {
console.log(chalk.red(`An error occurred above while pushing to remote ${ORIGIN_NAME}.`)) console.log(
styleText("red", `An error occurred above while pushing to remote ${ORIGIN_NAME}.`),
)
return return
} }
} }
console.log(chalk.green("Done!")) console.log(styleText("green", "Done!"))
} }

View File

@ -1,5 +1,5 @@
import { isCancel, outro } from "@clack/prompts" import { isCancel, outro } from "@clack/prompts"
import chalk from "chalk" import { styleText } from "util"
import { contentCacheFolder } from "./constants.js" import { contentCacheFolder } from "./constants.js"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import fs from "fs" import fs from "fs"
@ -14,7 +14,7 @@ export function escapePath(fp) {
export function exitIfCancel(val) { export function exitIfCancel(val) {
if (isCancel(val)) { if (isCancel(val)) {
outro(chalk.red("Exiting")) outro(styleText("red", "Exiting"))
process.exit(0) process.exit(0)
} else { } else {
return val return val
@ -36,9 +36,9 @@ export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) { if (out.stderr) {
throw new Error(chalk.red(`Error while pulling updates: ${out.stderr}`)) throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`))
} else if (out.status !== 0) { } else if (out.status !== 0) {
throw new Error(chalk.red("Error while pulling updates")) throw new Error(styleText("red", "Error while pulling updates"))
} }
} }

View File

@ -3,34 +3,53 @@ import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path" import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import OverflowListFactory from "./OverflowList"
const Backlinks: QuartzComponent = ({ interface BacklinksOptions {
fileData, hideWhenEmpty: boolean
allFiles,
displayClass,
cfg,
}: QuartzComponentProps) => {
const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
<ul class="overflow">
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
{f.frontmatter?.title}
</a>
</li>
))
) : (
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
)}
</ul>
</div>
)
} }
Backlinks.css = style const defaultOptions: BacklinksOptions = {
export default (() => Backlinks) satisfies QuartzComponentConstructor hideWhenEmpty: true,
}
export default ((opts?: Partial<BacklinksOptions>) => {
const options: BacklinksOptions = { ...defaultOptions, ...opts }
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Backlinks: QuartzComponent = ({
fileData,
allFiles,
displayClass,
cfg,
}: QuartzComponentProps) => {
const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
return null
}
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
<OverflowList>
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
{f.frontmatter?.title}
</a>
</li>
))
) : (
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
)}
</OverflowList>
</div>
)
}
Backlinks.css = style
Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
return Backlinks
}) satisfies QuartzComponentConstructor

View File

@ -1,8 +1,8 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss" import breadcrumbsStyle from "./styles/breadcrumbs.scss"
import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path" import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import { trieFromAllFiles } from "../util/ctx"
type CrumbData = { type CrumbData = {
displayName: string displayName: string
@ -22,10 +22,6 @@ interface BreadcrumbOptions {
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults) * Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
*/ */
resolveFrontmatterTitle: boolean resolveFrontmatterTitle: boolean
/**
* Whether to display breadcrumbs on root `index.md`
*/
hideOnRoot: boolean
/** /**
* Whether to display the current page in the breadcrumbs. * Whether to display the current page in the breadcrumbs.
*/ */
@ -36,7 +32,6 @@ const defaultOptions: BreadcrumbOptions = {
spacerSymbol: "", spacerSymbol: "",
rootName: "Home", rootName: "Home",
resolveFrontmatterTitle: true, resolveFrontmatterTitle: true,
hideOnRoot: true,
showCurrentPage: true, showCurrentPage: true,
} }
@ -48,78 +43,37 @@ function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: Simpl
} }
export default ((opts?: Partial<BreadcrumbOptions>) => { export default ((opts?: Partial<BreadcrumbOptions>) => {
// Merge options with defaults
const options: BreadcrumbOptions = { ...defaultOptions, ...opts } const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
// computed index of folder name to its associated file data
let folderIndex: Map<string, QuartzPluginData> | undefined
const Breadcrumbs: QuartzComponent = ({ const Breadcrumbs: QuartzComponent = ({
fileData, fileData,
allFiles, allFiles,
displayClass, displayClass,
ctx,
}: QuartzComponentProps) => { }: QuartzComponentProps) => {
// Hide crumbs on root if enabled const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
if (options.hideOnRoot && fileData.slug === "index") { const slugParts = fileData.slug!.split("/")
return <></> const pathNodes = trie.ancestryChain(slugParts)
if (!pathNodes) {
return null
} }
// Format entry for root element const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug) const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
const crumbs: CrumbData[] = [firstEntry] if (idx === 0) {
crumb.displayName = options.rootName
if (!folderIndex && options.resolveFrontmatterTitle) {
folderIndex = new Map()
// construct the index for the first time
for (const file of allFiles) {
const folderParts = file.slug?.split("/")
if (folderParts?.at(-1) === "index") {
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
}
}
}
// Split slug into hierarchy/parts
const slugParts = fileData.slug?.split("/")
if (slugParts) {
// is tag breadcrumb?
const isTagPath = slugParts[0] === "tags"
// full path until current part
let currentPath = ""
for (let i = 0; i < slugParts.length - 1; i++) {
let curPathSegment = slugParts[i]
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
if (currentFile) {
const title = currentFile.frontmatter!.title
if (title !== "index") {
curPathSegment = title
}
}
// Add current slug to full path
currentPath = joinSegments(currentPath, slugParts[i])
const includeTrailingSlash = !isTagPath || i < 1
// Format and add current crumb
const crumb = formatCrumb(
curPathSegment,
fileData.slug!,
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
)
crumbs.push(crumb)
} }
// Add current file to crumb (can directly use frontmatter title) // For last node (current page), set empty path
if (options.showCurrentPage && slugParts.at(-1) !== "index") { if (idx === pathNodes.length - 1) {
crumbs.push({ crumb.path = ""
displayName: fileData.frontmatter!.title,
path: "",
})
} }
return crumb
})
if (!options.showCurrentPage) {
crumbs.pop()
} }
return ( return (

View File

@ -1,4 +1,7 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/comments.inline"
type Options = { type Options = {
provider: "giscus" provider: "giscus"
@ -7,10 +10,14 @@ type Options = {
repoId: string repoId: string
category: string category: string
categoryId: string categoryId: string
themeUrl?: string
lightTheme?: string
darkTheme?: string
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname" mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict?: boolean strict?: boolean
reactionsEnabled?: boolean reactionsEnabled?: boolean
inputPosition?: "top" | "bottom" inputPosition?: "top" | "bottom"
lang?: string
} }
} }
@ -19,48 +26,37 @@ function boolToStringBool(b: boolean): string {
} }
export default ((opts: Options) => { export default ((opts: Options) => {
const Comments: QuartzComponent = (_props: QuartzComponentProps) => <div class="giscus"></div> const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
Comments.afterDOMLoaded = ` // check if comments should be displayed according to frontmatter
const giscusScript = document.createElement("script") const disableComment: boolean =
giscusScript.src = "https://giscus.app/client.js" typeof fileData.frontmatter?.comments !== "undefined" &&
giscusScript.async = true (!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
giscusScript.crossOrigin = "anonymous" if (disableComment) {
giscusScript.setAttribute("data-loading", "lazy") return <></>
giscusScript.setAttribute("data-emit-metadata", "0") }
giscusScript.setAttribute("data-repo", "${opts.options.repo}")
giscusScript.setAttribute("data-repo-id", "${opts.options.repoId}")
giscusScript.setAttribute("data-category", "${opts.options.category}")
giscusScript.setAttribute("data-category-id", "${opts.options.categoryId}")
giscusScript.setAttribute("data-mapping", "${opts.options.mapping ?? "url"}")
giscusScript.setAttribute("data-strict", "${boolToStringBool(opts.options.strict ?? true)}")
giscusScript.setAttribute("data-reactions-enabled", "${boolToStringBool(opts.options.reactionsEnabled ?? true)}")
giscusScript.setAttribute("data-input-position", "${opts.options.inputPosition ?? "bottom"}")
const theme = document.documentElement.getAttribute("saved-theme") return (
giscusScript.setAttribute("data-theme", theme) <div
document.head.appendChild(giscusScript) class={classNames(displayClass, "giscus")}
data-repo={opts.options.repo}
const changeTheme = (e) => { data-repo-id={opts.options.repoId}
const theme = e.detail.theme data-category={opts.options.category}
const iframe = document.querySelector('iframe.giscus-frame') data-category-id={opts.options.categoryId}
if (!iframe) { data-mapping={opts.options.mapping ?? "url"}
return data-strict={boolToStringBool(opts.options.strict ?? true)}
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
data-input-position={opts.options.inputPosition ?? "bottom"}
data-light-theme={opts.options.lightTheme ?? "light"}
data-dark-theme={opts.options.darkTheme ?? "dark"}
data-theme-url={
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
} }
data-lang={opts.options.lang ?? "en"}
></div>
)
}
iframe.contentWindow.postMessage({ Comments.afterDOMLoaded = script
giscus: {
setConfig: {
theme: theme
}
}
}, 'https://giscus.app')
}
document.addEventListener("nav", () => {
document.addEventListener("themechange", changeTheme)
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
})
`
return Comments return Comments
}) satisfies QuartzComponentConstructor<Options> }) satisfies QuartzComponentConstructor<Options>

View File

@ -0,0 +1,22 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
type ConditionalRenderConfig = {
component: QuartzComponent
condition: (props: QuartzComponentProps) => boolean
}
export default ((config: ConditionalRenderConfig) => {
const ConditionalRender: QuartzComponent = (props: QuartzComponentProps) => {
if (config.condition(props)) {
return <config.component {...props} />
}
return null
}
ConditionalRender.afterDOMLoaded = config.component.afterDOMLoaded
ConditionalRender.beforeDOMLoaded = config.component.beforeDOMLoaded
ConditionalRender.css = config.component.css
return ConditionalRender
}) satisfies QuartzComponentConstructor<ConditionalRenderConfig>

View File

@ -1,4 +1,4 @@
import { formatDate, getDate } from "./Date" import { Date, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time" import readingTime from "reading-time"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
@ -30,7 +30,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const segments: (string | JSX.Element)[] = [] const segments: (string | JSX.Element)[] = []
if (fileData.dates) { if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale)) segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
} }
// Display reading time if enabled // Display reading time if enabled
@ -39,14 +39,12 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({ const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
minutes: Math.ceil(minutes), minutes: Math.ceil(minutes),
}) })
segments.push(displayedTime) segments.push(<span>{displayedTime}</span>)
} }
const segmentsElements = segments.map((segment) => <span>{segment}</span>)
return ( return (
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}> <p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
{segmentsElements} {segments}
</p> </p>
) )
} else { } else {

View File

@ -1,6 +1,4 @@
// @ts-ignore: this is safe, we don't want to actually make darkmode.inline.ts a module as // @ts-ignore
// 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 darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss" import styles from "./styles/darkmode.scss"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
@ -9,41 +7,38 @@ import { classNames } from "../util/lang"
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
return ( return (
<div class={classNames(displayClass, "darkmode")}> <button class={classNames(displayClass, "darkmode")}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} /> <svg
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}> xmlns="http://www.w3.org/2000/svg"
<svg xmlnsXlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" version="1.1"
xmlnsXlink="http://www.w3.org/1999/xlink" class="dayIcon"
version="1.1" x="0px"
id="dayIcon" y="0px"
x="0px" viewBox="0 0 35 35"
y="0px" style="enable-background:new 0 0 35 35"
viewBox="0 0 35 35" xmlSpace="preserve"
style="enable-background:new 0 0 35 35" aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
xmlSpace="preserve" >
> <title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</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>
</svg> <svg
</label> xmlns="http://www.w3.org/2000/svg"
<label id="toggle-label-dark" for="darkmode-toggle" tabIndex={-1}> xmlnsXlink="http://www.w3.org/1999/xlink"
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" class="nightIcon"
xmlnsXlink="http://www.w3.org/1999/xlink" x="0px"
version="1.1" y="0px"
id="nightIcon" viewBox="0 0 100 100"
x="0px" style="enable-background:new 0 0 100 100"
y="0px" xmlSpace="preserve"
viewBox="0 0 100 100" aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
style="enable-background:new 0 0 100 100" >
xmlSpace="preserve" <title>{i18n(cfg.locale).components.themeToggle.lightMode}</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>
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title> </svg>
<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> </button>
</svg>
</label>
</div>
) )
} }

View File

@ -27,5 +27,5 @@ export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
} }
export function Date({ date, locale }: Props) { export function Date({ date, locale }: Props) {
return <>{formatDate(date, locale)}</> return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
} }

View File

@ -1,18 +1,14 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => { export default ((component: QuartzComponent) => {
if (component) { const Component = component
const Component = component const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => { return <Component displayClass="desktop-only" {...props} />
return <Component displayClass="desktop-only" {...props} />
}
DesktopOnly.displayName = component.displayName
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
DesktopOnly.css = component?.css
return DesktopOnly
} else {
return () => <></>
} }
}) satisfies QuartzComponentConstructor
DesktopOnly.displayName = component.displayName
DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
DesktopOnly.css = component?.css
return DesktopOnly
}) satisfies QuartzComponentConstructor<QuartzComponent>

View File

@ -1,24 +1,37 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss" import style from "./styles/explorer.scss"
// @ts-ignore // @ts-ignore
import script from "./scripts/explorer.inline" import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie"
import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
// Options interface defined in `ExplorerNode` to avoid circular dependency type OrderEntries = "sort" | "filter" | "map"
const defaultOptions = {
folderClickBehavior: "collapse", export interface Options {
title?: string
folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: OrderEntries[]
}
const defaultOptions: Options = {
folderDefaultState: "collapsed", folderDefaultState: "collapsed",
folderClickBehavior: "link",
useSavedState: true, useSavedState: true,
mapFn: (node) => { mapFn: (node) => {
return node return node
}, },
sortFn: (a, b) => { sortFn: (a, b) => {
// Sort order: folders first, then files. Sort folders and files alphabetically // Sort order: folders first, then files. Sort folders and files alphabeticall
if ((!a.file && !b.file) || (a.file && b.file)) { if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
return a.displayName.localeCompare(b.displayName, undefined, { return a.displayName.localeCompare(b.displayName, undefined, {
@ -27,72 +40,67 @@ const defaultOptions = {
}) })
} }
if (a.file && !b.file) { if (!a.isFolder && b.isFolder) {
return 1 return 1
} else { } else {
return -1 return -1
} }
}, },
filterFn: (node) => node.name !== "tags", filterFn: (node) => node.slugSegment !== "tags",
order: ["filter", "map", "sort"], order: ["filter", "map", "sort"],
} satisfies Options }
export type FolderState = {
path: string
collapsed: boolean
}
export default ((userOpts?: Partial<Options>) => { export default ((userOpts?: Partial<Options>) => {
// Parse config
const opts: Options = { ...defaultOptions, ...userOpts } const opts: Options = { ...defaultOptions, ...userOpts }
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
// memoized const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
let fileTree: FileNode
let jsonTree: string
function constructFileTree(allFiles: QuartzPluginData[]) {
if (fileTree) {
return
}
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functionName === "map") {
fileTree.map(opts.mapFn)
} else if (functionName === "sort") {
fileTree.sort(opts.sortFn)
} else if (functionName === "filter") {
fileTree.filter(opts.filterFn)
}
}
}
// Get all folders of tree. Initialize with collapsed state
// Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
jsonTree = JSON.stringify(folders)
}
const Explorer: QuartzComponent = ({
cfg,
allFiles,
displayClass,
fileData,
}: QuartzComponentProps) => {
constructFileTree(allFiles)
return ( return (
<div class={classNames(displayClass, "explorer")}> <div
class={classNames(displayClass, "explorer")}
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-data-fns={JSON.stringify({
order: opts.order,
sortFn: opts.sortFn.toString(),
filterFn: opts.filterFn.toString(),
mapFn: opts.mapFn.toString(),
})}
>
<button <button
type="button" type="button"
id="explorer" class="explorer-toggle mobile-explorer hide-until-loaded"
data-behavior={opts.folderClickBehavior} data-mobile={true}
data-collapsed={opts.folderDefaultState} aria-controls="explorer-content"
data-savestate={opts.useSavedState}
data-tree={jsonTree}
> >
<h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<button
type="button"
class="title-button explorer-toggle desktop-explorer"
data-mobile={false}
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"
@ -108,17 +116,47 @@ export default ((userOpts?: Partial<Options>) => {
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>
</button> </button>
<div id="explorer-content"> <div class="explorer-content" aria-expanded={false}>
<ul class="overflow" id="explorer-ul"> <OverflowList class="explorer-ul" />
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
<li id="explorer-end" />
</ul>
</div> </div>
<template id="template-file">
<li>
<a href="#"></a>
</li>
</template>
<template id="template-folder">
<li>
<div class="folder-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="folder-icon"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div>
<button class="folder-button">
<span class="folder-title"></span>
</button>
</div>
</div>
<div class="folder-outer">
<ul class="content"></ul>
</div>
</li>
</template>
</div> </div>
) )
} }
Explorer.css = explorerStyle Explorer.css = style
Explorer.afterDOMLoaded = script Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
return Explorer return Explorer
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -1,242 +0,0 @@
// @ts-ignore
import { QuartzPluginData } from "../plugins/vfile"
import {
joinSegments,
resolveRelative,
clone,
simplifySlug,
SimpleSlug,
FilePath,
} from "../util/path"
type OrderEntries = "sort" | "filter" | "map"
export interface Options {
title?: string
folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileNode, b: FileNode) => number
filterFn: (node: FileNode) => boolean
mapFn: (node: FileNode) => void
order: OrderEntries[]
}
type DataWrapper = {
file: QuartzPluginData
path: string[]
}
export type FolderState = {
path: string
collapsed: boolean
}
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
if (!fp) {
return undefined
}
return fp.split("/").at(idx)
}
// Structure to add all files into a tree
export class FileNode {
children: Array<FileNode>
name: string // this is the slug segment
displayName: string
file: QuartzPluginData | null
depth: number
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
this.children = []
this.name = slugSegment
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
this.file = file ? clone(file) : null
this.depth = depth ?? 0
}
private insert(fileData: DataWrapper) {
if (fileData.path.length === 0) {
return
}
const nextSegment = fileData.path[0]
// base case, insert here
if (fileData.path.length === 1) {
if (nextSegment === "") {
// index case (we are the root and we just found index.md), set our data appropriately
const title = fileData.file.frontmatter?.title
if (title && title !== "index") {
this.displayName = title
}
} else {
// direct child
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
}
return
}
// find the right child to insert into
fileData.path = fileData.path.splice(1)
const child = this.children.find((c) => c.name === nextSegment)
if (child) {
child.insert(fileData)
return
}
const newChild = new FileNode(
nextSegment,
getPathSegment(fileData.file.relativePath, this.depth),
undefined,
this.depth + 1,
)
newChild.insert(fileData)
this.children.push(newChild)
}
// Add new file to tree
add(file: QuartzPluginData) {
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
}
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
* @param filterFn function to filter tree with
*/
filter(filterFn: (node: FileNode) => boolean) {
this.children = this.children.filter(filterFn)
this.children.forEach((child) => child.filter(filterFn))
}
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
* @param mapFn function to use for mapping over tree
*/
map(mapFn: (node: FileNode) => void) {
mapFn(this)
this.children.forEach((child) => child.map(mapFn))
}
/**
* Get folder representation with state of tree.
* Intended to only be called on root node before changes to the tree are made
* @param collapsed default state of folders (collapsed by default or not)
* @returns array containing folder state for tree
*/
getFolderPaths(collapsed: boolean): FolderState[] {
const folderPaths: FolderState[] = []
const traverse = (node: FileNode, currentPath: string) => {
if (!node.file) {
const folderPath = joinSegments(currentPath, node.name)
if (folderPath !== "") {
folderPaths.push({ path: folderPath, collapsed })
}
node.children.forEach((child) => traverse(child, folderPath))
}
}
traverse(this, "")
return folderPaths
}
// Sort order: folders first, then files. Sort folders and files alphabetically
/**
* Sorts tree according to sort/compare function
* @param sortFn compare function used for `.sort()`, also used recursively for children
*/
sort(sortFn: (a: FileNode, b: FileNode) => number) {
this.children = this.children.sort(sortFn)
this.children.forEach((e) => e.sort(sortFn))
}
}
type ExplorerNodeProps = {
node: FileNode
opts: Options
fileData: QuartzPluginData
fullPath?: string
}
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
// Get options
const folderBehavior = opts.folderClickBehavior
const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
return (
<>
{node.file ? (
// Single file node
<li key={node.file.slug}>
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
{node.displayName}
</a>
</li>
) : (
<li>
{node.name !== "" && (
// Node with entire folder
// Render svg button + folder name, then children
<div class="folder-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="folder-icon"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
<div key={node.name} data-folderpath={folderPath}>
{folderBehavior === "link" ? (
<a href={href} data-for={node.name} class="folder-title">
{node.displayName}
</a>
) : (
<button class="folder-button">
<span class="folder-title">{node.displayName}</span>
</button>
)}
</div>
</div>
)}
{/* Recursively render children of folder */}
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
<ul
// Inline style for left folder paddings
style={{
paddingLeft: node.name !== "" ? "1.4rem" : "0",
}}
class="content"
data-folderul={folderPath}
>
{node.children.map((childNode, i) => (
<ExplorerNode
node={childNode}
key={i}
opts={opts}
fullPath={folderPath}
fileData={fileData}
/>
))}
</ul>
</div>
</li>
)}
</>
)
}

View File

@ -0,0 +1,59 @@
import { concatenateResources } from "../util/resources"
import { classNames } from "../util/lang"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
type FlexConfig = {
components: {
Component: QuartzComponent
grow?: boolean
shrink?: boolean
basis?: string
order?: number
align?: "start" | "end" | "center" | "stretch"
justify?: "start" | "end" | "center" | "between" | "around"
}[]
direction?: "row" | "row-reverse" | "column" | "column-reverse"
wrap?: "nowrap" | "wrap" | "wrap-reverse"
gap?: string
}
export default ((config: FlexConfig) => {
const Flex: QuartzComponent = (props: QuartzComponentProps) => {
const direction = config.direction ?? "row"
const wrap = config.wrap ?? "nowrap"
const gap = config.gap ?? "1rem"
return (
<div
class={classNames(props.displayClass, "flex-component")}
style={`flex-direction: ${direction}; flex-wrap: ${wrap}; gap: ${gap};`}
>
{config.components.map((c) => {
const grow = c.grow ? 1 : 0
const shrink = (c.shrink ?? true) ? 1 : 0
const basis = c.basis ?? "auto"
const order = c.order ?? 0
const align = c.align ?? "center"
const justify = c.justify ?? "center"
return (
<div
style={`flex-grow: ${grow}; flex-shrink: ${shrink}; flex-basis: ${basis}; order: ${order}; align-self: ${align}; justify-self: ${justify};`}
>
<c.Component {...props} />
</div>
)
})}
</div>
)
}
Flex.afterDOMLoaded = concatenateResources(
...config.components.map((c) => c.Component.afterDOMLoaded),
)
Flex.beforeDOMLoaded = concatenateResources(
...config.components.map((c) => c.Component.beforeDOMLoaded),
)
Flex.css = concatenateResources(...config.components.map((c) => c.Component.css))
return Flex
}) satisfies QuartzComponentConstructor<FlexConfig>

View File

@ -18,6 +18,7 @@ export interface D3Config {
removeTags: string[] removeTags: string[]
showTags: boolean showTags: boolean
focusOnHover?: boolean focusOnHover?: boolean
enableRadial?: boolean
} }
interface GraphOptions { interface GraphOptions {
@ -39,6 +40,7 @@ const defaultOptions: GraphOptions = {
showTags: true, showTags: true,
removeTags: [], removeTags: [],
focusOnHover: false, focusOnHover: false,
enableRadial: false,
}, },
globalGraph: { globalGraph: {
drag: true, drag: true,
@ -46,17 +48,18 @@ const defaultOptions: GraphOptions = {
depth: -1, depth: -1,
scale: 0.9, scale: 0.9,
repelForce: 0.5, repelForce: 0.5,
centerForce: 0.3, centerForce: 0.2,
linkDistance: 30, linkDistance: 30,
fontSize: 0.6, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
showTags: true, showTags: true,
removeTags: [], removeTags: [],
focusOnHover: true, focusOnHover: true,
enableRadial: true,
}, },
} }
export default ((opts?: GraphOptions) => { export default ((opts?: Partial<GraphOptions>) => {
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
@ -64,35 +67,36 @@ export default ((opts?: GraphOptions) => {
<div class={classNames(displayClass, "graph")}> <div class={classNames(displayClass, "graph")}>
<h3>{i18n(cfg.locale).components.graph.title}</h3> <h3>{i18n(cfg.locale).components.graph.title}</h3>
<div class="graph-outer"> <div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> <div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<svg <button class="global-graph-icon" aria-label="Global Graph">
version="1.1" <svg
id="global-graph-icon" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px" x="0px"
y="0px" y="0px"
viewBox="0 0 55 55" viewBox="0 0 55 55"
fill="currentColor" fill="currentColor"
xmlSpace="preserve" xmlSpace="preserve"
> >
<path <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 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 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 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 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
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91 c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4 v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665 s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
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 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 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 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> </svg>
</button>
</div> </div>
<div id="global-graph-outer"> <div class="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div> <div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
</div> </div>
</div> </div>
) )

View File

@ -1,22 +1,40 @@
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { FullSlug, joinSegments, pathToRoot } from "../util/path" import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources" import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
import { googleFontHref } from "../util/theme" import { googleFontHref, googleFontSubsetHref } from "../util/theme"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { unescapeHTML } from "../util/escape"
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
export default (() => { export default (() => {
const Head: QuartzComponent = ({ cfg, fileData, externalResources }: QuartzComponentProps) => { const Head: QuartzComponent = ({
const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title cfg,
fileData,
externalResources,
ctx,
}: QuartzComponentProps) => {
const titleSuffix = cfg.pageTitleSuffix ?? ""
const title =
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
const description = const description =
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description fileData.frontmatter?.socialDescription ??
const { css, js } = externalResources fileData.frontmatter?.description ??
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
const { css, js, additionalHead } = externalResources
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug const path = url.pathname as FullSlug
const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
const iconPath = joinSegments(baseDir, "static/icon.png") const iconPath = joinSegments(baseDir, "static/icon.png")
const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png`
// Url of current page
const socialUrl =
fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
(e) => e.name === CustomOgImagesEmitterName,
)
const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
return ( return (
<head> <head>
@ -27,23 +45,58 @@ export default (() => {
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="stylesheet" href={googleFontHref(cfg.theme)} /> <link rel="stylesheet" href={googleFontHref(cfg.theme)} />
{cfg.theme.typography.title && (
<link rel="stylesheet" href={googleFontSubsetHref(cfg.theme, cfg.pageTitle)} />
)}
</> </>
)} )}
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="og:site_name" content={cfg.pageTitle}></meta>
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
{cfg.baseUrl && <meta property="og:image" content={ogImagePath} />} <meta property="og:image:alt" content={description} />
<meta property="og:width" content="1200" />
<meta property="og:height" content="675" /> {!usesCustomOgImage && (
<>
<meta property="og:image" content={ogImageDefaultPath} />
<meta property="og:image:url" content={ogImageDefaultPath} />
<meta name="twitter:image" content={ogImageDefaultPath} />
<meta
property="og:image:type"
content={`image/${getFileExtension(ogImageDefaultPath) ?? "png"}`}
/>
</>
)}
{cfg.baseUrl && (
<>
<meta property="twitter:domain" content={cfg.baseUrl}></meta>
<meta property="og:url" content={socialUrl}></meta>
<meta property="twitter:url" content={socialUrl}></meta>
</>
)}
<link rel="icon" href={iconPath} /> <link rel="icon" href={iconPath} />
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="generator" content="Quartz" /> <meta name="generator" content="Quartz" />
{css.map((href) => (
<link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve /> {css.map((resource) => CSSResourceToStyleElement(resource, true))}
))}
{js {js
.filter((resource) => resource.loadTime === "beforeDOMReady") .filter((resource) => resource.loadTime === "beforeDOMReady")
.map((res) => JSResourceToScriptElement(res, true))} .map((res) => JSResourceToScriptElement(res, true))}
{additionalHead.map((resource) => {
if (typeof resource === "function") {
return resource(fileData)
} else {
return resource
}
})}
</head> </head>
) )
} }

View File

@ -1,18 +1,14 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default ((component?: QuartzComponent) => { export default ((component: QuartzComponent) => {
if (component) { const Component = component
const Component = component const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => { return <Component displayClass="mobile-only" {...props} />
return <Component displayClass="mobile-only" {...props} />
}
MobileOnly.displayName = component.displayName
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
MobileOnly.css = component?.css
return MobileOnly
} else {
return () => <></>
} }
}) satisfies QuartzComponentConstructor
MobileOnly.displayName = component.displayName
MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
MobileOnly.css = component?.css
return MobileOnly
}) satisfies QuartzComponentConstructor<QuartzComponent>

View File

@ -0,0 +1,48 @@
import { JSX } from "preact"
const OverflowList = ({
children,
...props
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
return (
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
{children}
<li class="overflow-end" />
</ul>
)
}
let numExplorers = 0
export default () => {
const id = `list-${numExplorers++}`
return {
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
<OverflowList {...props} id={id} />
),
overflowListAfterDOMLoaded: `
document.addEventListener("nav", (e) => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const parentUl = entry.target.parentElement
if (!parentUl) return
if (entry.isIntersecting) {
parentUl.classList.remove("gradient-active")
} else {
parentUl.classList.add("gradient-active")
}
}
})
const ul = document.getElementById("${id}")
if (!ul) return
const end = ul.querySelector(".overflow-end")
if (!end) return
observer.observe(end)
window.addCleanup(() => observer.disconnect())
})
`,
}
}

View File

@ -1,4 +1,4 @@
import { FullSlug, resolveRelative } from "../util/path" import { FullSlug, isFolderPath, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date" import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentProps } from "./types"
@ -8,6 +8,33 @@ export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn { export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
return (f1, f2) => { return (f1, f2) => {
// Sort by date/alphabetical
if (f1.dates && f2.dates) {
// sort descending
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
} else if (f1.dates && !f2.dates) {
// prioritize files with dates
return -1
} else if (!f1.dates && f2.dates) {
return 1
}
// otherwise, sort lexographically by title
const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
return f1Title.localeCompare(f2Title)
}
}
export function byDateAndAlphabeticalFolderFirst(cfg: GlobalConfiguration): SortFn {
return (f1, f2) => {
// Sort folders first
const f1IsFolder = isFolderPath(f1.slug ?? "")
const f2IsFolder = isFolderPath(f2.slug ?? "")
if (f1IsFolder && !f2IsFolder) return -1
if (!f1IsFolder && f2IsFolder) return 1
// If both are folders or both are files, sort by date/alphabetical
if (f1.dates && f2.dates) { if (f1.dates && f2.dates) {
// sort descending // sort descending
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime() return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
@ -31,7 +58,7 @@ type Props = {
} & QuartzComponentProps } & QuartzComponentProps
export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => { export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => {
const sorter = sort ?? byDateAndAlphabetical(cfg) const sorter = sort ?? byDateAndAlphabeticalFolderFirst(cfg)
let list = allFiles.sort(sorter) let list = allFiles.sort(sorter)
if (limit) { if (limit) {
list = list.slice(0, limit) list = list.slice(0, limit)
@ -46,11 +73,9 @@ export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort
return ( return (
<li class="section-li"> <li class="section-li">
<div class="section"> <div class="section">
{page.dates && ( <p class="meta">
<p class="meta"> {page.dates && <Date date={getDate(cfg, page)!} locale={cfg.locale} />}
<Date date={getDate(cfg, page)!} locale={cfg.locale} /> </p>
</p>
)}
<div class="desc"> <div class="desc">
<h3> <h3>
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal"> <a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">

View File

@ -7,15 +7,17 @@ const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzCompo
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
const baseDir = pathToRoot(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
return ( return (
<h1 class={classNames(displayClass, "page-title")}> <h2 class={classNames(displayClass, "page-title")}>
<a href={baseDir}>{title}</a> <a href={baseDir}>{title}</a>
</h1> </h2>
) )
} }
PageTitle.css = ` PageTitle.css = `
.page-title { .page-title {
font-size: 1.75rem;
margin: 0; margin: 0;
font-family: var(--titleFont);
} }
` `

View File

@ -0,0 +1,38 @@
// @ts-ignore
import readerModeScript from "./scripts/readermode.inline"
import styles from "./styles/readermode.scss"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
return (
<button class={classNames(displayClass, "readermode")}>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
version="1.1"
class="readerIcon"
fill="currentColor"
stroke="currentColor"
stroke-width="0.2"
stroke-linecap="round"
stroke-linejoin="round"
width="64px"
height="64px"
viewBox="0 0 24 24"
aria-label={i18n(cfg.locale).components.readerMode.title}
>
<title>{i18n(cfg.locale).components.readerMode.title}</title>
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
</g>
</svg>
</button>
)
}
ReaderMode.beforeDOMLoaded = readerModeScript
ReaderMode.css = styles
export default (() => ReaderMode) satisfies QuartzComponentConstructor

View File

@ -19,35 +19,27 @@ export default ((userOpts?: Partial<SearchOptions>) => {
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
return ( return (
<div class={classNames(displayClass, "search")}> <div class={classNames(displayClass, "search")}>
<div id="search-icon"> <button class="search-button">
<p>{i18n(cfg.locale).components.search.title}</p> <p>{i18n(cfg.locale).components.search.title}</p>
<div></div> <svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
<svg <title>Search</title>
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"> <g class="search-path" fill="none">
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" /> <path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
<circle cx="8" cy="8" r="7" /> <circle cx="8" cy="8" r="7" />
</g> </g>
</svg> </svg>
</div> </button>
<div id="search-container"> <div class="search-container">
<div id="search-space"> <div class="search-space">
<input <input
autocomplete="off" autocomplete="off"
id="search-bar" class="search-bar"
name="search" name="search"
type="text" type="text"
aria-label={searchPlaceholder} aria-label={searchPlaceholder}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
/> />
<div id="search-layout" data-preview={opts.enablePreview}></div> <div class="search-layout" data-preview={opts.enablePreview}></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,6 +6,8 @@ import { classNames } from "../util/lang"
// @ts-ignore // @ts-ignore
import script from "./scripts/toc.inline" import script from "./scripts/toc.inline"
import { i18n } from "../i18n" import { i18n } from "../i18n"
import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
interface Options { interface Options {
layout: "modern" | "legacy" layout: "modern" | "legacy"
@ -15,36 +17,68 @@ const defaultOptions: Options = {
layout: "modern", layout: "modern",
} }
const TableOfContents: QuartzComponent = ({ export default ((opts?: Partial<Options>) => {
fileData, const layout = opts?.layout ?? defaultOptions.layout
displayClass, const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
cfg, const TableOfContents: QuartzComponent = ({
}: QuartzComponentProps) => { fileData,
if (!fileData.toc) { displayClass,
return null cfg,
}: QuartzComponentProps) => {
if (!fileData.toc) {
return null
}
return (
<div class={classNames(displayClass, "toc")}>
<button
type="button"
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
aria-controls="toc-content"
aria-expanded={!fileData.collapseToc}
>
<h3>{i18n(cfg.locale).components.tableOfContents.title}</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"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
{tocEntry.text}
</a>
</li>
))}
</OverflowList>
</div>
)
} }
return ( TableOfContents.css = modernStyle
<div class={classNames(displayClass, "toc")}> TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3> const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
<svg if (!fileData.toc) {
xmlns="http://www.w3.org/2000/svg" return null
width="24" }
height="24" return (
viewBox="0 0 24 24" <details class="toc" open={!fileData.collapseToc}>
fill="none" <summary>
stroke="currentColor" <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
stroke-width="2" </summary>
stroke-linecap="round" <ul>
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) => ( {fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}> <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@ -53,37 +87,10 @@ const TableOfContents: QuartzComponent = ({
</li> </li>
))} ))}
</ul> </ul>
</div> </details>
</div> )
)
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) {
return null
} }
return ( LegacyTableOfContents.css = legacyStyle
<details id="toc" open={!fileData.collapseToc}>
<summary>
<h3>{i18n(cfg.locale).components.tableOfContents.title}</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>
))}
</ul>
</details>
)
}
LegacyTableOfContents.css = legacyStyle
export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
return layout === "modern" ? TableOfContents : LegacyTableOfContents return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -1,15 +1,14 @@
import { pathToRoot, slugTag } from "../util/path" import { FullSlug, resolveRelative } from "../util/path"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang" import { classNames } from "../util/lang"
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
const tags = fileData.frontmatter?.tags const tags = fileData.frontmatter?.tags
const baseDir = pathToRoot(fileData.slug!)
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
return ( return (
<ul class={classNames(displayClass, "tags")}> <ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => { {tags.map((tag) => {
const linkDest = baseDir + `/tags/${slugTag(tag)}` const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
return ( return (
<li> <li>
<a href={linkDest} class="internal tag-link"> <a href={linkDest} class="internal tag-link">
@ -33,7 +32,6 @@ TagList.css = `
gap: 0.4rem; gap: 0.4rem;
margin: 1rem 0; margin: 1rem 0;
flex-wrap: wrap; flex-wrap: wrap;
justify-self: end;
} }
.section-li > .section > .tags { .section-li > .section > .tags {

View File

@ -4,6 +4,7 @@ import FolderContent from "./pages/FolderContent"
import NotFound from "./pages/404" import NotFound from "./pages/404"
import ArticleTitle from "./ArticleTitle" import ArticleTitle from "./ArticleTitle"
import Darkmode from "./Darkmode" import Darkmode from "./Darkmode"
import ReaderMode from "./ReaderMode"
import Head from "./Head" import Head from "./Head"
import PageTitle from "./PageTitle" import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta" import ContentMeta from "./ContentMeta"
@ -20,6 +21,8 @@ import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes" import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs" import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments" import Comments from "./Comments"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
export { export {
ArticleTitle, ArticleTitle,
@ -27,6 +30,7 @@ export {
TagContent, TagContent,
FolderContent, FolderContent,
Darkmode, Darkmode,
ReaderMode,
Head, Head,
PageTitle, PageTitle,
ContentMeta, ContentMeta,
@ -44,4 +48,6 @@ export {
NotFound, NotFound,
Breadcrumbs, Breadcrumbs,
Comments, Comments,
Flex,
ConditionalRender,
} }

View File

@ -1,8 +1,9 @@
import { ComponentChildren } from "preact"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => { const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
const content = htmlToJsx(fileData.filePath!, tree) const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
const classes: string[] = fileData.frontmatter?.cssclasses ?? [] const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const classString = ["popover-hint", ...classes].join(" ") const classString = ["popover-hint", ...classes].join(" ")
return <article class={classString}>{content}</article> return <article class={classString}>{content}</article>

View File

@ -1,23 +1,27 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import path from "path"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList" import { PageList, SortFn } from "../PageList"
import { stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
import { trieFromAllFiles } from "../../util/ctx"
interface FolderContentOptions { interface FolderContentOptions {
/** /**
* Whether to display number of folders * Whether to display number of folders
*/ */
showFolderCount: boolean showFolderCount: boolean
showSubfolders: boolean
sort?: SortFn sort?: SortFn
} }
const defaultOptions: FolderContentOptions = { const defaultOptions: FolderContentOptions = {
showFolderCount: true, showFolderCount: true,
showSubfolders: true,
} }
export default ((opts?: Partial<FolderContentOptions>) => { export default ((opts?: Partial<FolderContentOptions>) => {
@ -25,31 +29,82 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => { const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
const fileSlug = stripSlashes(simplifySlug(file.slug!)) const folder = trie.findNode(fileData.slug!.split("/"))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug if (!folder) {
const folderParts = folderSlug.split(path.posix.sep) return null
const fileParts = fileSlug.split(path.posix.sep) }
const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild const allPagesInFolder: QuartzPluginData[] =
}) folder.children
.map((node) => {
// regular file, proceed
if (node.data) {
return node.data
}
if (node.isFolder && options.showSubfolders) {
// folders that dont have data need synthetic files
const getMostRecentDates = (): QuartzPluginData["dates"] => {
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
for (const child of node.children) {
if (child.data?.dates) {
// compare all dates and assign to maybeDates if its more recent or its not set
if (!maybeDates) {
maybeDates = { ...child.data.dates }
} else {
if (child.data.dates.created > maybeDates.created) {
maybeDates.created = child.data.dates.created
}
if (child.data.dates.modified > maybeDates.modified) {
maybeDates.modified = child.data.dates.modified
}
if (child.data.dates.published > maybeDates.published) {
maybeDates.published = child.data.dates.published
}
}
}
}
return (
maybeDates ?? {
created: new Date(),
modified: new Date(),
published: new Date(),
}
)
}
return {
slug: node.slug,
dates: getMostRecentDates(),
frontmatter: {
title: node.displayName,
tags: [],
},
}
}
})
.filter((page) => page !== undefined) ?? []
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? [] const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ") const classes = cssClasses.join(" ")
const listProps = { const listProps = {
...props, ...props,
sort: options.sort, sort: options.sort,
allFiles: allPagesInFolder, allFiles: allPagesInFolder,
} }
const content = const content = (
(tree as Root).children.length === 0 (tree as Root).children.length === 0
? fileData.description ? fileData.description
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
) as ComponentChildren
return ( return (
<div class={classes}> <div class="popover-hint">
<article>{content}</article> <article class={classes}>{content}</article>
<div class="page-listing"> <div class="page-listing">
{options.showFolderCount && ( {options.showFolderCount && (
<p> <p>
@ -66,6 +121,6 @@ export default ((opts?: Partial<FolderContentOptions>) => {
) )
} }
FolderContent.css = style + PageList.css FolderContent.css = concatenateResources(style, PageList.css)
return FolderContent return FolderContent
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -1,11 +1,13 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList" import { PageList, SortFn } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
interface TagContentOptions { interface TagContentOptions {
sort?: SortFn sort?: SortFn
@ -33,12 +35,13 @@ export default ((opts?: Partial<TagContentOptions>) => {
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag), (file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
) )
const content = const content = (
(tree as Root).children.length === 0 (tree as Root).children.length === 0
? fileData.description ? fileData.description
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
) as ComponentChildren
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? [] const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ") const classes = cssClasses.join(" ")
if (tag === "/") { if (tag === "/") {
const tags = [ const tags = [
...new Set( ...new Set(
@ -50,8 +53,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
tagItemMap.set(tag, allPagesWithTag(tag)) tagItemMap.set(tag, allPagesWithTag(tag))
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article> <article class={classes}>
<p>{content}</p> <p>{content}</p>
</article> </article>
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p> <p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
@ -71,10 +74,13 @@ export default ((opts?: Partial<TagContentOptions>) => {
? contentPage?.description ? contentPage?.description
: htmlToJsx(contentPage.filePath!, root) : htmlToJsx(contentPage.filePath!, root)
const tagListingPage = `/tags/${tag}` as FullSlug
const href = resolveRelative(fileData.slug!, tagListingPage)
return ( return (
<div> <div>
<h2> <h2>
<a class="internal tag-link" href={`../tags/${tag}`}> <a class="internal tag-link" href={href}>
{tag} {tag}
</a> </a>
</h2> </h2>
@ -93,7 +99,7 @@ export default ((opts?: Partial<TagContentOptions>) => {
</> </>
)} )}
</p> </p>
<PageList limit={options.numPages} {...listProps} sort={opts?.sort} /> <PageList limit={options.numPages} {...listProps} sort={options?.sort} />
</div> </div>
</div> </div>
) )
@ -109,12 +115,12 @@ export default ((opts?: Partial<TagContentOptions>) => {
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article>{content}</article> <article class={classes}>{content}</article>
<div class="page-listing"> <div class="page-listing">
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p> <p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div> <div>
<PageList {...listProps} /> <PageList {...listProps} sort={options?.sort} />
</div> </div>
</div> </div>
</div> </div>
@ -122,6 +128,6 @@ export default ((opts?: Partial<TagContentOptions>) => {
} }
} }
TagContent.css = style + PageList.css TagContent.css = concatenateResources(style, PageList.css)
return TagContent return TagContent
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header" import HeaderConstructor from "./Header"
import BodyConstructor from "./Body" import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { clone } from "../util/clone"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast" import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
@ -28,8 +29,13 @@ export function pageResources(
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return { const resources: StaticResources = {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css], css: [
{
content: joinSegments(baseDir, "index.css"),
},
...staticResources.css,
],
js: [ js: [
{ {
src: joinSegments(baseDir, "prescript.js"), src: joinSegments(baseDir, "prescript.js"),
@ -43,34 +49,33 @@ export function pageResources(
script: contentIndexScript, script: contentIndexScript,
}, },
...staticResources.js, ...staticResources.js,
{
src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
},
], ],
additionalHead: staticResources.additionalHead,
} }
resources.js.push({
src: joinSegments(baseDir, "postscript.js"),
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "external",
})
return resources
} }
export function renderPage( function renderTranscludes(
root: Root,
cfg: GlobalConfiguration, cfg: GlobalConfiguration,
slug: FullSlug, slug: FullSlug,
componentData: QuartzComponentProps, componentData: QuartzComponentProps,
components: RenderComponents, ) {
pageResources: StaticResources,
): string {
// make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts
const root = clone(componentData.tree) as Root
// process transcludes in componentData // process transcludes in componentData
visit(root, "element", (node, _index, _parent) => { visit(root, "element", (node, _index, _parent) => {
if (node.tagName === "blockquote") { if (node.tagName === "blockquote") {
const classNames = (node.properties?.className ?? []) as string[] const classNames = (node.properties?.className ?? []) as string[]
if (classNames.includes("transclude")) { if (classNames.includes("transclude")) {
const inner = node.children[0] as Element const inner = node.children[0] as Element
const transcludeTarget = inner.properties["data-slug"] as FullSlug const transcludeTarget = (inner.properties["data-slug"] ?? slug) as FullSlug
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget) const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
if (!page) { if (!page) {
return return
@ -179,6 +184,19 @@ export function renderPage(
} }
} }
}) })
}
export function renderPage(
cfg: GlobalConfiguration,
slug: FullSlug,
componentData: QuartzComponentProps,
components: RenderComponents,
pageResources: StaticResources,
): string {
// make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts
const root = clone(componentData.tree) as Root
renderTranscludes(root, cfg, slug, componentData)
// set componentData.tree to the edited html that has transclusions rendered // set componentData.tree to the edited html that has transclusions rendered
componentData.tree = root componentData.tree = root
@ -242,8 +260,8 @@ export function renderPage(
</div> </div>
</div> </div>
{RightComponent} {RightComponent}
<Footer {...componentData} />
</Body> </Body>
<Footer {...componentData} />
</div> </div>
</body> </body>
{pageResources.js {pageResources.js

View File

@ -1,25 +1,10 @@
function toggleCallout(this: HTMLElement) { function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement! const outerBlock = this.parentElement!
outerBlock.classList.toggle("is-collapsed") outerBlock.classList.toggle("is-collapsed")
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
if (!content) return
const collapsed = outerBlock.classList.contains("is-collapsed") const collapsed = outerBlock.classList.contains("is-collapsed")
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
outerBlock.style.maxHeight = height + "px"
// walk and adjust height of all parents
let current = outerBlock
let parent = outerBlock.parentElement
while (parent) {
if (!parent.classList.contains("callout")) {
return
}
const collapsed = parent.classList.contains("is-collapsed")
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
parent.style.maxHeight = height + "px"
current = parent
parent = parent.parentElement
}
} }
function setupCallout() { function setupCallout() {
@ -27,18 +12,16 @@ function setupCallout() {
`callout is-collapsible`, `callout is-collapsible`,
) as HTMLCollectionOf<HTMLElement> ) as HTMLCollectionOf<HTMLElement>
for (const div of collapsible) { for (const div of collapsible) {
const title = div.firstElementChild const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
if (!title || !content) continue
if (title) { title.addEventListener("click", toggleCallout)
title.addEventListener("click", toggleCallout) window.addCleanup(() => title.removeEventListener("click", toggleCallout))
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
const collapsed = div.classList.contains("is-collapsed") const collapsed = div.classList.contains("is-collapsed")
const height = collapsed ? title.scrollHeight : div.scrollHeight content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
div.style.maxHeight = height + "px"
}
} }
} }
document.addEventListener("nav", setupCallout) document.addEventListener("nav", setupCallout)
window.addEventListener("resize", setupCallout)

View File

@ -8,7 +8,9 @@ document.addEventListener("nav", () => {
for (let i = 0; i < els.length; i++) { for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0] const codeBlock = els[i].getElementsByTagName("code")[0]
if (codeBlock) { if (codeBlock) {
const source = codeBlock.innerText.replace(/\n\n/g, "\n") const source = (
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
).replace(/\n\n/g, "\n")
const button = document.createElement("button") const button = document.createElement("button")
button.className = "clipboard-button" button.className = "clipboard-button"
button.type = "button" button.type = "button"

View File

@ -0,0 +1,92 @@
const changeTheme = (e: CustomEventMap["themechange"]) => {
const theme = e.detail.theme
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
if (!iframe) {
return
}
if (!iframe.contentWindow) {
return
}
iframe.contentWindow.postMessage(
{
giscus: {
setConfig: {
theme: getThemeUrl(getThemeName(theme)),
},
},
},
"https://giscus.app",
)
}
const getThemeName = (theme: string) => {
if (theme !== "dark" && theme !== "light") {
return theme
}
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return theme
}
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
return theme === "dark" ? darkGiscus : lightGiscus
}
const getThemeUrl = (theme: string) => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return `https://giscus.app/themes/${theme}.css`
}
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
}
type GiscusElement = Omit<HTMLElement, "dataset"> & {
dataset: DOMStringMap & {
repo: `${string}/${string}`
repoId: string
category: string
categoryId: string
themeUrl: string
lightTheme: string
darkTheme: string
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
strict: string
reactionsEnabled: string
inputPosition: "top" | "bottom"
lang: string
}
}
document.addEventListener("nav", () => {
const giscusContainer = document.querySelector(".giscus") as GiscusElement
if (!giscusContainer) {
return
}
const giscusScript = document.createElement("script")
giscusScript.src = "https://giscus.app/client.js"
giscusScript.async = true
giscusScript.crossOrigin = "anonymous"
giscusScript.setAttribute("data-loading", "lazy")
giscusScript.setAttribute("data-emit-metadata", "0")
giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo)
giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId)
giscusScript.setAttribute("data-category", giscusContainer.dataset.category)
giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId)
giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping)
giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict)
giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled)
giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition)
giscusScript.setAttribute("data-lang", giscusContainer.dataset.lang)
const theme = document.documentElement.getAttribute("saved-theme")
if (theme) {
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
}
giscusContainer.appendChild(giscusScript)
document.addEventListener("themechange", changeTheme)
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
})

View File

@ -10,8 +10,9 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => {
} }
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const switchTheme = (e: Event) => { const switchTheme = () => {
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light" const newTheme =
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
document.documentElement.setAttribute("saved-theme", newTheme) document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme) localStorage.setItem("theme", newTheme)
emitThemeChangeEvent(newTheme) emitThemeChangeEvent(newTheme)
@ -21,16 +22,12 @@ document.addEventListener("nav", () => {
const newTheme = e.matches ? "dark" : "light" const newTheme = e.matches ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme) document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme) localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
emitThemeChangeEvent(newTheme) emitThemeChangeEvent(newTheme)
} }
// Darkmode toggle for (const darkmodeButton of document.getElementsByClassName("darkmode")) {
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement darkmodeButton.addEventListener("click", switchTheme)
toggleSwitch.addEventListener("change", switchTheme) window.addCleanup(() => darkmodeButton.removeEventListener("click", switchTheme))
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
if (currentTheme === "dark") {
toggleSwitch.checked = true
} }
// Listen for changes in prefers-color-scheme // Listen for changes in prefers-color-scheme

View File

@ -1,27 +1,40 @@
import { FolderState } from "../ExplorerNode" import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
type MaybeHTMLElement = HTMLElement | undefined type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
explorerUl.classList.add("no-background")
} else {
explorerUl.classList.remove("no-background")
}
}
})
interface ParsedOptions {
folderClickBehavior: "collapse" | "link"
folderDefaultState: "collapsed" | "open"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: "sort" | "filter" | "map"[]
}
type FolderState = {
path: string
collapsed: boolean
}
let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
this.classList.toggle("collapsed") const nearestExplorer = this.closest(".explorer") as HTMLElement
const content = this.nextElementSibling as MaybeHTMLElement if (!nearestExplorer) return
if (!content) return const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
nearestExplorer.setAttribute(
"aria-expanded",
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
content.classList.toggle("collapsed") if (!explorerCollapsed) {
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" // Stop <html> from being scrollable when mobile explorer is open
document.documentElement.classList.add("mobile-no-scroll")
} else {
document.documentElement.classList.remove("mobile-no-scroll")
}
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
@ -29,104 +42,260 @@ function toggleFolder(evt: MouseEvent) {
const target = evt.target as MaybeHTMLElement const target = evt.target as MaybeHTMLElement
if (!target) return if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg" const isSvg = target.nodeName === "svg"
const childFolderContainer = (
// corresponding <ul> element relative to clicked button/folder
const folderContainer = (
isSvg isSvg
? target.parentElement?.nextSibling ? // svg -> div.folder-container
: target.parentElement?.parentElement?.nextElementSibling target.parentElement
: // button.folder-button -> div -> div.folder-container
target.parentElement?.parentElement
) as MaybeHTMLElement ) as MaybeHTMLElement
const currentFolderParent = ( if (!folderContainer) return
isSvg ? target.nextElementSibling : target.parentElement const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
) as MaybeHTMLElement if (!childFolderContainer) return
if (!(childFolderContainer && currentFolderParent)) return
childFolderContainer.classList.toggle("open") childFolderContainer.classList.toggle("open")
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed) // Collapse folder container
const fullFolderPath = currentFolderParent.dataset.folderpath as string const isCollapsed = !childFolderContainer.classList.contains("open")
toggleCollapsedByPath(currentExplorerState, fullFolderPath) setFolderState(childFolderContainer, isCollapsed)
const currentFolderState = currentExplorerState.find(
(item) => item.path === folderContainer.dataset.folderpath,
)
if (currentFolderState) {
currentFolderState.collapsed = isCollapsed
} else {
currentExplorerState.push({
path: folderContainer.dataset.folderpath as FullSlug,
collapsed: isCollapsed,
})
}
const stringifiedFileTree = JSON.stringify(currentExplorerState) const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree) localStorage.setItem("fileTree", stringifiedFileTree)
} }
function setupExplorer() { function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
const explorer = document.getElementById("explorer") const template = document.getElementById("template-file") as HTMLTemplateElement
if (!explorer) return const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const a = li.querySelector("a") as HTMLAnchorElement
a.href = resolveRelative(currentSlug, node.slug)
a.dataset.for = node.slug
a.textContent = node.displayName
if (explorer.dataset.behavior === "collapse") { if (currentSlug === node.slug) {
for (const item of document.getElementsByClassName( a.classList.add("active")
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
} }
explorer.addEventListener("click", toggleExplorer) return li
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
const newExplorerState: FolderState[] = explorer.dataset.tree
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
}
currentExplorerState.map((folderState) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
setFolderState(folderUl, folderState.collapsed)
}
})
} }
window.addEventListener("resize", setupExplorer) function createFolderNode(
document.addEventListener("nav", () => { currentSlug: FullSlug,
setupExplorer() node: FileTrieNode,
observer.disconnect() opts: ParsedOptions,
): HTMLLIElement {
const template = document.getElementById("template-folder") as HTMLTemplateElement
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const folderContainer = li.querySelector(".folder-container") as HTMLElement
const titleContainer = folderContainer.querySelector("div") as HTMLElement
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
const ul = folderOuter.querySelector("ul") as HTMLUListElement
// select pseudo element at end of list const folderPath = node.slug
const lastItem = document.getElementById("explorer-end") folderContainer.dataset.folderpath = folderPath
if (lastItem) {
observer.observe(lastItem) if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement
const a = document.createElement("a")
a.href = resolveRelative(currentSlug, folderPath)
a.dataset.for = folderPath
a.className = "folder-title"
a.textContent = node.displayName
button.replaceWith(a)
} else {
const span = titleContainer.querySelector(".folder-title") as HTMLElement
span.textContent = node.displayName
}
// if the saved state is collapsed or the default state is collapsed
const isCollapsed =
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
opts.folderDefaultState === "collapsed"
// if this folder is a prefix of the current path we
// want to open it anyways
const simpleFolderPath = simplifySlug(folderPath)
const folderIsPrefixOfCurrentSlug =
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
folderOuter.classList.add("open")
}
for (const child of node.children) {
const childNode = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
ul.appendChild(childNode)
}
return li
}
async function setupExplorer(currentSlug: FullSlug) {
const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
const opts: ParsedOptions = {
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
useSavedState: explorer.dataset.savestate === "true",
order: dataFns.order || ["filter", "map", "sort"],
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
const oldIndex = new Map<string, boolean>(
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
)
const data = await fetchData
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
const trie = FileTrieNode.fromEntries(entries)
// Apply functions in order
for (const fn of opts.order) {
switch (fn) {
case "filter":
if (opts.filterFn) trie.filter(opts.filterFn)
break
case "map":
if (opts.mapFn) trie.map(opts.mapFn)
break
case "sort":
if (opts.sortFn) trie.sort(opts.sortFn)
break
}
}
// Get folder paths for state management
const folderPaths = trie.getFolderPaths()
currentExplorerState = folderPaths.map((path) => {
const previousState = oldIndex.get(path)
return {
path,
collapsed:
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
}
})
const explorerUl = explorer.querySelector(".explorer-ul")
if (!explorerUl) continue
// Create and insert new content
const fragment = document.createDocumentFragment()
for (const child of trie.children) {
const node = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
fragment.appendChild(node)
}
explorerUl.insertBefore(fragment, explorerUl.firstChild)
// restore explorer scrollTop position if it exists
const scrollTop = sessionStorage.getItem("explorerScrollTop")
if (scrollTop) {
explorerUl.scrollTop = parseInt(scrollTop)
} else {
// try to scroll to the active element if it exists
const activeElement = explorerUl.querySelector(".active")
if (activeElement) {
activeElement.scrollIntoView({ behavior: "smooth" })
}
}
// Set up event handlers
const explorerButtons = explorer.getElementsByClassName(
"explorer-toggle",
) as HTMLCollectionOf<HTMLElement>
for (const button of explorerButtons) {
button.addEventListener("click", toggleExplorer)
window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
}
// Set up folder click handlers
if (opts.folderClickBehavior === "collapse") {
const folderButtons = explorer.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>
for (const button of folderButtons) {
button.addEventListener("click", toggleFolder)
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
}
}
const folderIcons = explorer.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>
for (const icon of folderIcons) {
icon.addEventListener("click", toggleFolder)
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
}
}
}
document.addEventListener("prenav", async () => {
// save explorer scrollTop position
const explorer = document.querySelector(".explorer-ul")
if (!explorer) return
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default
for (const explorer of document.getElementsByClassName("explorer")) {
const mobileExplorer = explorer.querySelector(".mobile-explorer")
if (!mobileExplorer) return
if (mobileExplorer.checkVisibility()) {
explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false")
// Allow <html> to be scrollable when mobile explorer is collapsed
document.documentElement.classList.remove("mobile-no-scroll")
}
mobileExplorer.classList.remove("hide-until-loaded")
}
})
window.addEventListener("resize", function () {
// Desktop explorer opens by default, and it stays open when the window is resized
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
const explorer = document.querySelector(".explorer")
if (explorer && !explorer.classList.contains("collapsed")) {
document.documentElement.classList.add("mobile-no-scroll")
return
} }
}) })
/**
* Toggles the state of a given folder
* @param folderElement <div class="folder-outer"> Element of folder (parent)
* @param collapsed if folder should be set to collapsed or not
*/
function setFolderState(folderElement: HTMLElement, collapsed: boolean) { function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
} }
/**
* Toggles visibility of a folder
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
* @param path path to folder (e.g. 'advanced/more/more2')
*/
function toggleCollapsedByPath(array: FolderState[], path: string) {
const entry = array.find((item) => item.path === path)
if (entry) {
entry.collapsed = !entry.collapsed
}
}

View File

@ -1,19 +1,57 @@
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex" import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" import {
SimulationNodeDatum,
SimulationLinkDatum,
Simulation,
forceSimulation,
forceManyBody,
forceCenter,
forceLink,
forceCollide,
forceRadial,
zoomIdentity,
select,
drag,
zoom,
} from "d3"
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { D3Config } from "../Graph"
type GraphicsInfo = {
color: string
gfx: Graphics
alpha: number
active: boolean
}
type NodeData = { type NodeData = {
id: SimpleSlug id: SimpleSlug
text: string text: string
tags: string[] tags: string[]
} & d3.SimulationNodeDatum } & SimulationNodeDatum
type LinkData = { type SimpleLinkData = {
source: SimpleSlug source: SimpleSlug
target: SimpleSlug target: SimpleSlug
} }
type LinkData = {
source: NodeData
target: NodeData
} & SimulationLinkDatum<NodeData>
type LinkRenderData = GraphicsInfo & {
simulationData: LinkData
}
type NodeRenderData = GraphicsInfo & {
simulationData: NodeData
label: Text
}
const localStorageKey = "graph-visited" const localStorageKey = "graph-visited"
function getVisited(): Set<SimpleSlug> { function getVisited(): Set<SimpleSlug> {
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
@ -25,11 +63,38 @@ function addToVisited(slug: SimpleSlug) {
localStorage.setItem(localStorageKey, JSON.stringify([...visited])) localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
} }
async function renderGraph(container: string, fullSlug: FullSlug) { type TweenNode = {
update: (time: number) => void
stop: () => void
}
// workaround for pixijs webgpu issue: https://github.com/pixijs/pixijs/issues/11389
async function determineGraphicsAPI(): Promise<"webgpu" | "webgl"> {
const adapter = await navigator.gpu?.requestAdapter().catch(() => null)
const device = adapter && (await adapter.requestDevice().catch(() => null))
if (!device) {
return "webgl"
}
const canvas = document.createElement("canvas")
const gl =
(canvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
(canvas.getContext("webgl") as WebGLRenderingContext | null)
// we have to return webgl so pixijs automatically falls back to canvas
if (!gl) {
return "webgl"
}
const webglMaxTextures = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)
const webgpuMaxTextures = device.limits.maxSampledTexturesPerShaderStage
return webglMaxTextures === webgpuMaxTextures ? "webgpu" : "webgl"
}
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
const graph = document.getElementById(container)
if (!graph) return
removeAllChildren(graph) removeAllChildren(graph)
let { let {
@ -45,7 +110,8 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags, removeTags,
showTags, showTags,
focusOnHover, focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!) enableRadial,
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map( const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [ Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
@ -53,10 +119,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
v, v,
]), ]),
) )
const links: LinkData[] = [] const links: SimpleLinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
const tweens = new Map<string, TweenNode>()
for (const [source, details] of data.entries()) { for (const [source, details] of data.entries()) {
const outgoing = details.links ?? [] const outgoing = details.links ?? []
@ -100,271 +167,508 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
} }
const nodes = [...neighbourhood].map((url) => {
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
return {
id: url,
text,
tags: data.get(url)?.tags ?? [],
}
})
const graphData: { nodes: NodeData[]; links: LinkData[] } = { const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes: [...neighbourhood].map((url) => { nodes,
const text = url.startsWith("tags/") ? "#" + url.substring(5) : data.get(url)?.title ?? url links: links
return { .filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
id: url, .map((l) => ({
text: text, source: nodes.find((n) => n.id === l.source)!,
tags: data.get(url)?.tags ?? [], target: nodes.find((n) => n.id === l.target)!,
} })),
}),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
} }
const simulation: d3.Simulation<NodeData, LinkData> = d3
.forceSimulation(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
.force(
"link",
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force("center", d3.forceCenter().strength(centerForce))
const height = Math.max(graph.offsetHeight, 250)
const width = graph.offsetWidth const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250)
const svg = d3 // we virtualize the simulation and use pixi to actually render it
.select<HTMLElement, NodeData>("#" + container) const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.append("svg") .force("charge", forceManyBody().strength(-100 * repelForce))
.attr("width", width) .force("center", forceCenter().strength(centerForce))
.attr("height", height) .force("link", forceLink(graphData.links).distance(linkDistance))
.attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]) .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
// draw links between nodes const radius = (Math.min(width, height) / 2) * 0.8
const link = svg if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
.append("g")
.selectAll("line")
.data(graphData.links)
.join("line")
.attr("class", "link")
.attr("stroke", "var(--lightgray)")
.attr("stroke-width", 1)
// svg groups // precompute style prop strings as pixi doesn't support css variables
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g") const cssVars = [
"--secondary",
"--tertiary",
"--gray",
"--light",
"--lightgray",
"--dark",
"--darkgray",
"--bodyFont",
] as const
const computedStyleMap = cssVars.reduce(
(acc, key) => {
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
return acc
},
{} as Record<(typeof cssVars)[number], string>,
)
// calculate color // calculate color
const color = (d: NodeData) => { const color = (d: NodeData) => {
const isCurrent = d.id === slug const isCurrent = d.id === slug
if (isCurrent) { if (isCurrent) {
return "var(--secondary)" return computedStyleMap["--secondary"]
} else if (visited.has(d.id) || d.id.startsWith("tags/")) { } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)" return computedStyleMap["--tertiary"]
} else { } else {
return "var(--gray)" return computedStyleMap["--gray"]
} }
} }
const drag = (simulation: d3.Simulation<NodeData, LinkData>) => {
function dragstarted(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(1).restart()
d.fx = d.x
d.fy = d.y
}
function dragged(event: any, d: NodeData) {
d.fx = event.x
d.fy = event.y
}
function dragended(event: any, d: NodeData) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
const noop = () => {}
return d3
.drag<Element, NodeData>()
.on("start", enableDrag ? dragstarted : noop)
.on("drag", enableDrag ? dragged : noop)
.on("end", enableDrag ? dragended : noop)
}
function nodeRadius(d: NodeData) { function nodeRadius(d: NodeData) {
const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length const numLinks = graphData.links.filter(
(l) => l.source.id === d.id || l.target.id === d.id,
).length
return 2 + Math.sqrt(numLinks) return 2 + Math.sqrt(numLinks)
} }
let connectedNodes: SimpleSlug[] = [] let hoveredNodeId: string | null = null
let hoveredNeighbours: Set<string> = new Set()
const linkRenderData: LinkRenderData[] = []
const nodeRenderData: NodeRenderData[] = []
function updateHoverInfo(newHoveredId: string | null) {
hoveredNodeId = newHoveredId
// draw individual nodes if (newHoveredId === null) {
const node = graphNode hoveredNeighbours = new Set()
.append("circle") for (const n of nodeRenderData) {
.attr("class", "node") n.active = false
.attr("id", (d) => d.id)
.attr("r", nodeRadius)
.attr("fill", color)
.style("cursor", "pointer")
.on("click", (_, d) => {
const targ = resolveRelative(fullSlug, d.id)
window.spaNavigate(new URL(targ, window.location.toString()))
})
.on("mouseover", function (_, d) {
const currentId = d.id
const linkNodes = d3
.selectAll(".link")
.filter((d: any) => d.source.id === currentId || d.target.id === currentId)
if (focusOnHover) {
// fade out non-neighbour nodes
connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id])
d3.selectAll<HTMLElement, NodeData>(".link")
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.nodes()
.map((it) => d3.select(it.parentNode as HTMLElement).select("text"))
.forEach((it) => {
let opacity = parseFloat(it.style("opacity"))
it.transition()
.duration(200)
.attr("opacityOld", opacity)
.style("opacity", Math.min(opacity, 0.2))
})
} }
// highlight links for (const l of linkRenderData) {
linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1) l.active = false
const bigFont = fontSize * 1.5
// show text for self
const parent = this.parentNode as HTMLElement
d3.select<HTMLElement, NodeData>(parent)
.raise()
.select("text")
.transition()
.duration(200)
.attr("opacityOld", d3.select(parent).select("text").style("opacity"))
.style("opacity", 1)
.style("font-size", bigFont + "em")
})
.on("mouseleave", function (_, d) {
if (focusOnHover) {
d3.selectAll<HTMLElement, NodeData>(".link").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".node").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.nodes()
.map((it) => d3.select(it.parentNode as HTMLElement).select("text"))
.forEach((it) => it.transition().duration(200).style("opacity", it.attr("opacityOld")))
} }
const currentId = d.id } else {
const linkNodes = d3 hoveredNeighbours = new Set()
.selectAll(".link") for (const l of linkRenderData) {
.filter((d: any) => d.source.id === currentId || d.target.id === currentId) const linkData = l.simulationData
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
hoveredNeighbours.add(linkData.source.id)
hoveredNeighbours.add(linkData.target.id)
}
linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)") l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
}
const parent = this.parentNode as HTMLElement for (const n of nodeRenderData) {
d3.select<HTMLElement, NodeData>(parent) n.active = hoveredNeighbours.has(n.simulationData.id)
.select("text") }
.transition() }
.duration(200) }
.style("opacity", d3.select(parent).select("text").attr("opacityOld"))
.style("font-size", fontSize + "em") let dragStartTime = 0
let dragging = false
function renderLinks() {
tweens.get("link")?.stop()
const tweenGroup = new TweenGroup()
for (const l of linkRenderData) {
let alpha = 1
// if we are hovering over a node, we want to highlight the immediate neighbours
// with full alpha and the rest with default alpha
if (hoveredNodeId) {
alpha = l.active ? 1 : 0.2
}
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("link", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
}) })
// @ts-ignore }
.call(drag(simulation))
// make tags hollow circles function renderLabels() {
node tweens.get("label")?.stop()
.filter((d) => d.id.startsWith("tags/")) const tweenGroup = new TweenGroup()
.attr("stroke", color)
.attr("stroke-width", 2)
.attr("fill", "var(--light)")
// draw labels const defaultScale = 1 / scale
const labels = graphNode const activeScale = defaultScale * 1.1
.append("text") for (const n of nodeRenderData) {
.attr("dx", 0) const nodeId = n.simulationData.id
.attr("dy", (d) => -nodeRadius(d) + "px")
.attr("text-anchor", "middle") if (hoveredNodeId === nodeId) {
.text((d) => d.text) tweenGroup.add(
.style("opacity", (opacityScale - 1) / 3.75) new Tweened<Text>(n.label).to(
.style("pointer-events", "none") {
.style("font-size", fontSize + "em") alpha: 1,
.raise() scale: { x: activeScale, y: activeScale },
// @ts-ignore },
.call(drag(simulation)) 100,
),
)
} else {
tweenGroup.add(
new Tweened<Text>(n.label).to(
{
alpha: n.label.alpha,
scale: { x: defaultScale, y: defaultScale },
},
100,
),
)
}
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("label", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
}
function renderNodes() {
tweens.get("hover")?.stop()
const tweenGroup = new TweenGroup()
for (const n of nodeRenderData) {
let alpha = 1
// if we are hovering over a node, we want to highlight the immediate neighbours
if (hoveredNodeId !== null && focusOnHover) {
alpha = n.active ? 1 : 0.2
}
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
}
tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("hover", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tw) => tw.stop())
},
})
}
function renderPixiFromD3() {
renderNodes()
renderLinks()
renderLabels()
}
tweens.forEach((tween) => tween.stop())
tweens.clear()
const pixiPreference = await determineGraphicsAPI()
const app = new Application()
await app.init({
width,
height,
antialias: true,
autoStart: false,
autoDensity: true,
backgroundAlpha: 0,
preference: pixiPreference,
resolution: window.devicePixelRatio,
eventMode: "static",
})
graph.appendChild(app.canvas)
const stage = app.stage
stage.interactive = false
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
stage.addChild(nodesContainer, labelsContainer, linkContainer)
for (const n of graphData.nodes) {
const nodeId = n.id
const label = new Text({
interactive: false,
eventMode: "none",
text: n.text,
alpha: 0,
anchor: { x: 0.5, y: 1.2 },
style: {
fontSize: fontSize * 15,
fill: computedStyleMap["--dark"],
fontFamily: computedStyleMap["--bodyFont"],
},
resolution: window.devicePixelRatio * 4,
})
label.scale.set(1 / scale)
let oldLabelOpacity = 0
const isTagNode = nodeId.startsWith("tags/")
const gfx = new Graphics({
interactive: true,
label: nodeId,
eventMode: "static",
hitArea: new Circle(0, 0, nodeRadius(n)),
cursor: "pointer",
})
.circle(0, 0, nodeRadius(n))
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
.on("pointerover", (e) => {
updateHoverInfo(e.target.label)
oldLabelOpacity = label.alpha
if (!dragging) {
renderPixiFromD3()
}
})
.on("pointerleave", () => {
updateHoverInfo(null)
label.alpha = oldLabelOpacity
if (!dragging) {
renderPixiFromD3()
}
})
if (isTagNode) {
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
}
nodesContainer.addChild(gfx)
labelsContainer.addChild(label)
const nodeRenderDatum: NodeRenderData = {
simulationData: n,
gfx,
label,
color: color(n),
alpha: 1,
active: false,
}
nodeRenderData.push(nodeRenderDatum)
}
for (const l of graphData.links) {
const gfx = new Graphics({ interactive: false, eventMode: "none" })
linkContainer.addChild(gfx)
const linkRenderDatum: LinkRenderData = {
simulationData: l,
gfx,
color: computedStyleMap["--lightgray"],
alpha: 1,
active: false,
}
linkRenderData.push(linkRenderDatum)
}
let currentTransform = zoomIdentity
if (enableDrag) {
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
drag<HTMLCanvasElement, NodeData | undefined>()
.container(() => app.canvas)
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
.on("start", function dragstarted(event) {
if (!event.active) simulation.alphaTarget(1).restart()
event.subject.fx = event.subject.x
event.subject.fy = event.subject.y
event.subject.__initialDragPos = {
x: event.subject.x,
y: event.subject.y,
fx: event.subject.fx,
fy: event.subject.fy,
}
dragStartTime = Date.now()
dragging = true
})
.on("drag", function dragged(event) {
const initPos = event.subject.__initialDragPos
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
})
.on("end", function dragended(event) {
if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null
event.subject.fy = null
dragging = false
// if the time between mousedown and mouseup is short, we consider it a click
if (Date.now() - dragStartTime < 500) {
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
const targ = resolveRelative(fullSlug, node.id)
window.spaNavigate(new URL(targ, window.location.toString()))
}
}),
)
} else {
for (const node of nodeRenderData) {
node.gfx.on("click", () => {
const targ = resolveRelative(fullSlug, node.simulationData.id)
window.spaNavigate(new URL(targ, window.location.toString()))
})
}
}
// set panning
if (enableZoom) { if (enableZoom) {
svg.call( select<HTMLCanvasElement, NodeData>(app.canvas).call(
d3 zoom<HTMLCanvasElement, NodeData>()
.zoom<SVGSVGElement, NodeData>()
.extent([ .extent([
[0, 0], [0, 0],
[width, height], [width, height],
]) ])
.scaleExtent([0.25, 4]) .scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => { .on("zoom", ({ transform }) => {
link.attr("transform", transform) currentTransform = transform
node.attr("transform", transform) stage.scale.set(transform.k, transform.k)
stage.position.set(transform.x, transform.y)
// zoom adjusts opacity of labels too
const scale = transform.k * opacityScale const scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0) let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity) const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
for (const label of labelsContainer.children) {
if (!activeNodes.includes(label)) {
label.alpha = scaleOpacity
}
}
}), }),
) )
} }
// progress the simulation let stopAnimation = false
simulation.on("tick", () => { function animate(time: number) {
link if (stopAnimation) return
.attr("x1", (d: any) => d.source.x) for (const n of nodeRenderData) {
.attr("y1", (d: any) => d.source.y) const { x, y } = n.simulationData
.attr("x2", (d: any) => d.target.x) if (!x || !y) continue
.attr("y2", (d: any) => d.target.y) n.gfx.position.set(x + width / 2, y + height / 2)
node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y) if (n.label) {
labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y) n.label.position.set(x + width / 2, y + height / 2)
}) }
}
for (const l of linkRenderData) {
const linkData = l.simulationData
l.gfx.clear()
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
l.gfx
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
.stroke({ alpha: l.alpha, width: 1, color: l.color })
}
tweens.forEach((t) => t.update(time))
app.renderer.render(stage)
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
return () => {
stopAnimation = true
app.destroy()
}
} }
function renderGlobalGraph() { let localGraphCleanups: (() => void)[] = []
const slug = getFullSlug(window) let globalGraphCleanups: (() => void)[] = []
const container = document.getElementById("global-graph-outer")
const sidebar = container?.closest(".sidebar") as HTMLElement function cleanupLocalGraphs() {
container?.classList.add("active") for (const cleanup of localGraphCleanups) {
if (sidebar) { cleanup()
sidebar.style.zIndex = "1"
} }
localGraphCleanups = []
}
renderGraph("global-graph-container", slug) function cleanupGlobalGraphs() {
for (const cleanup of globalGraphCleanups) {
function hideGlobalGraph() { cleanup()
container?.classList.remove("active")
const graph = document.getElementById("global-graph-container")
if (sidebar) {
sidebar.style.zIndex = "unset"
}
if (!graph) return
removeAllChildren(graph)
} }
globalGraphCleanups = []
registerEscapeHandler(container, hideGlobalGraph)
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url const slug = e.detail.url
addToVisited(simplifySlug(slug)) addToVisited(simplifySlug(slug))
await renderGraph("graph-container", slug)
const containerIcon = document.getElementById("global-graph-icon") async function renderLocalGraph() {
containerIcon?.addEventListener("click", renderGlobalGraph) cleanupLocalGraphs()
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) const localGraphContainers = document.getElementsByClassName("graph-container")
for (const container of localGraphContainers) {
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
}
}
await renderLocalGraph()
const handleThemeChange = () => {
void renderLocalGraph()
}
document.addEventListener("themechange", handleThemeChange)
window.addCleanup(() => {
document.removeEventListener("themechange", handleThemeChange)
})
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
async function renderGlobalGraph() {
const slug = getFullSlug(window)
for (const container of containers) {
container.classList.add("active")
const sidebar = container.closest(".sidebar") as HTMLElement
if (sidebar) {
sidebar.style.zIndex = "1"
}
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
registerEscapeHandler(container, hideGlobalGraph)
if (graphContainer) {
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
}
}
}
function hideGlobalGraph() {
cleanupGlobalGraphs()
for (const container of containers) {
container.classList.remove("active")
const sidebar = container.closest(".sidebar") as HTMLElement
if (sidebar) {
sidebar.style.zIndex = ""
}
}
}
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault()
const anyGlobalGraphOpen = containers.some((container) =>
container.classList.contains("active"),
)
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
}
}
const containerIcons = document.getElementsByClassName("global-graph-icon")
Array.from(containerIcons).forEach((icon) => {
icon.addEventListener("click", renderGlobalGraph)
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
})
document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => {
document.removeEventListener("keydown", shortcutHandler)
cleanupLocalGraphs()
cleanupGlobalGraphs()
})
}) })

View File

@ -0,0 +1,258 @@
import { registerEscapeHandler, removeAllChildren } from "./util"
interface Position {
x: number
y: number
}
class DiagramPanZoom {
private isDragging = false
private startPan: Position = { x: 0, y: 0 }
private currentPan: Position = { x: 0, y: 0 }
private scale = 1
private readonly MIN_SCALE = 0.5
private readonly MAX_SCALE = 3
cleanups: (() => void)[] = []
constructor(
private container: HTMLElement,
private content: HTMLElement,
) {
this.setupEventListeners()
this.setupNavigationControls()
this.resetTransform()
}
private setupEventListeners() {
// Mouse drag events
const mouseDownHandler = this.onMouseDown.bind(this)
const mouseMoveHandler = this.onMouseMove.bind(this)
const mouseUpHandler = this.onMouseUp.bind(this)
const resizeHandler = this.resetTransform.bind(this)
this.container.addEventListener("mousedown", mouseDownHandler)
document.addEventListener("mousemove", mouseMoveHandler)
document.addEventListener("mouseup", mouseUpHandler)
window.addEventListener("resize", resizeHandler)
this.cleanups.push(
() => this.container.removeEventListener("mousedown", mouseDownHandler),
() => document.removeEventListener("mousemove", mouseMoveHandler),
() => document.removeEventListener("mouseup", mouseUpHandler),
() => window.removeEventListener("resize", resizeHandler),
)
}
cleanup() {
for (const cleanup of this.cleanups) {
cleanup()
}
}
private setupNavigationControls() {
const controls = document.createElement("div")
controls.className = "mermaid-controls"
// Zoom controls
const zoomIn = this.createButton("+", () => this.zoom(0.1))
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
const resetBtn = this.createButton("Reset", () => this.resetTransform())
controls.appendChild(zoomOut)
controls.appendChild(resetBtn)
controls.appendChild(zoomIn)
this.container.appendChild(controls)
}
private createButton(text: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button")
button.textContent = text
button.className = "mermaid-control-button"
button.addEventListener("click", onClick)
window.addCleanup(() => button.removeEventListener("click", onClick))
return button
}
private onMouseDown(e: MouseEvent) {
if (e.button !== 0) return // Only handle left click
this.isDragging = true
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
this.container.style.cursor = "grabbing"
}
private onMouseMove(e: MouseEvent) {
if (!this.isDragging) return
e.preventDefault()
this.currentPan = {
x: e.clientX - this.startPan.x,
y: e.clientY - this.startPan.y,
}
this.updateTransform()
}
private onMouseUp() {
this.isDragging = false
this.container.style.cursor = "grab"
}
private zoom(delta: number) {
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
// Zoom around center
const rect = this.content.getBoundingClientRect()
const centerX = rect.width / 2
const centerY = rect.height / 2
const scaleDiff = newScale - this.scale
this.currentPan.x -= centerX * scaleDiff
this.currentPan.y -= centerY * scaleDiff
this.scale = newScale
this.updateTransform()
}
private updateTransform() {
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
}
private resetTransform() {
this.scale = 1
const svg = this.content.querySelector("svg")!
this.currentPan = {
x: svg.getBoundingClientRect().width / 2,
y: svg.getBoundingClientRect().height / 2,
}
this.updateTransform()
}
}
const cssVars = [
"--secondary",
"--tertiary",
"--gray",
"--light",
"--lightgray",
"--highlight",
"--dark",
"--darkgray",
"--codeFont",
] as const
let mermaidImport = undefined
document.addEventListener("nav", async () => {
const center = document.querySelector(".center") as HTMLElement
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
if (nodes.length === 0) return
mermaidImport ||= await import(
// @ts-ignore
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
)
const mermaid = mermaidImport.default
const textMapping: WeakMap<HTMLElement, string> = new WeakMap()
for (const node of nodes) {
textMapping.set(node, node.innerText)
}
async function renderMermaid() {
// de-init any other diagrams
for (const node of nodes) {
node.removeAttribute("data-processed")
const oldText = textMapping.get(node)
if (oldText) {
node.innerHTML = oldText
}
}
const computedStyleMap = cssVars.reduce(
(acc, key) => {
acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key)
return acc
},
{} as Record<(typeof cssVars)[number], string>,
)
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
mermaid.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: darkMode ? "dark" : "base",
themeVariables: {
fontFamily: computedStyleMap["--codeFont"],
primaryColor: computedStyleMap["--light"],
primaryTextColor: computedStyleMap["--darkgray"],
primaryBorderColor: computedStyleMap["--tertiary"],
lineColor: computedStyleMap["--darkgray"],
secondaryColor: computedStyleMap["--secondary"],
tertiaryColor: computedStyleMap["--tertiary"],
clusterBkg: computedStyleMap["--light"],
edgeLabelBackground: computedStyleMap["--highlight"],
},
})
await mermaid.run({ nodes })
}
await renderMermaid()
document.addEventListener("themechange", renderMermaid)
window.addCleanup(() => document.removeEventListener("themechange", renderMermaid))
for (let i = 0; i < nodes.length; i++) {
const codeBlock = nodes[i] as HTMLElement
const pre = codeBlock.parentElement as HTMLPreElement
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
const clipboardStyle = window.getComputedStyle(clipboardBtn)
const clipboardWidth =
clipboardBtn.offsetWidth +
parseFloat(clipboardStyle.marginLeft || "0") +
parseFloat(clipboardStyle.marginRight || "0")
// Set expand button position
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
pre.prepend(expandBtn)
// query popup container
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
if (!popupContainer) return
let panZoom: DiagramPanZoom | null = null
function showMermaid() {
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
if (!content) return
removeAllChildren(content)
// Clone the mermaid content
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
content.appendChild(mermaidContent)
// Show container
popupContainer.classList.add("active")
container.style.cursor = "grab"
// Initialize pan-zoom after showing the popup
panZoom = new DiagramPanZoom(container, content)
}
function hideMermaid() {
popupContainer.classList.remove("active")
panZoom?.cleanup()
panZoom = null
}
expandBtn.addEventListener("click", showMermaid)
registerEscapeHandler(popupContainer, hideMermaid)
window.addCleanup(() => {
panZoom?.cleanup()
expandBtn.removeEventListener("click", showMermaid)
})
}
})

View File

@ -1,62 +1,72 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { normalizeRelativeURLs } from "../../util/path" import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
const p = new DOMParser() const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null
async function mouseEnterHandler( async function mouseEnterHandler(
this: HTMLAnchorElement, this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
) { ) {
const link = this const link = (activeAnchor = this)
if (link.dataset.noPopover === "true") { if (link.dataset.noPopover === "true") {
return return
} }
async function setPosition(popoverElement: HTMLElement) { async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, { const { x, y } = await computePosition(link, popoverElement, {
strategy: "fixed",
middleware: [inline({ x: clientX, y: clientY }), shift(), flip()], middleware: [inline({ x: clientX, y: clientY }), shift(), flip()],
}) })
Object.assign(popoverElement.style, { Object.assign(popoverElement.style, {
left: `${x}px`, transform: `translate(${x.toFixed()}px, ${y.toFixed()}px)`,
top: `${y}px`,
}) })
} }
const hasAlreadyBeenFetched = () => function showPopover(popoverElement: HTMLElement) {
[...link.children].some((child) => child.classList.contains("popover")) clearActivePopover()
popoverElement.classList.add("active-popover")
setPosition(popoverElement as HTMLElement)
// dont refetch if there's already a popover if (hash !== "") {
if (hasAlreadyBeenFetched()) { const targetAnchor = `#popover-internal-${hash.slice(1)}`
return setPosition(link.lastChild as HTMLElement) const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
} }
const thisUrl = new URL(document.location.href)
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href) const targetUrl = new URL(link.href)
const hash = decodeURIComponent(targetUrl.hash) const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = "" targetUrl.hash = ""
targetUrl.search = "" targetUrl.search = ""
const popoverId = `popover-${link.pathname}`
const prevPopoverElement = document.getElementById(popoverId)
const response = await fetch(`${targetUrl}`).catch((err) => { // dont refetch if there's already a popover
console.error(err) if (!!document.getElementById(popoverId)) {
}) showPopover(prevPopoverElement as HTMLElement)
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return return
} }
const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err)
})
if (!response) return if (!response) return
const [contentType] = response.headers.get("Content-Type")!.split(";") const [contentType] = response.headers.get("Content-Type")!.split(";")
const [contentTypeCategory, typeInfo] = contentType.split("/") const [contentTypeCategory, typeInfo] = contentType.split("/")
const popoverElement = document.createElement("div") const popoverElement = document.createElement("div")
popoverElement.id = popoverId
popoverElement.classList.add("popover") popoverElement.classList.add("popover")
const popoverInner = document.createElement("div") const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner") popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
popoverInner.dataset.contentType = contentType ?? undefined popoverInner.dataset.contentType = contentType ?? undefined
popoverElement.appendChild(popoverInner)
switch (contentTypeCategory) { switch (contentTypeCategory) {
case "image": case "image":
@ -81,28 +91,43 @@ async function mouseEnterHandler(
const contents = await response.text() const contents = await response.text()
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl) normalizeRelativeURLs(html, targetUrl)
// prepend all IDs inside popovers to prevent duplicates
html.querySelectorAll("[id]").forEach((el) => {
const targetID = `popover-internal-${el.id}`
el.id = targetID
})
const elts = [...html.getElementsByClassName("popover-hint")] const elts = [...html.getElementsByClassName("popover-hint")]
if (elts.length === 0) return if (elts.length === 0) return
elts.forEach((elt) => popoverInner.appendChild(elt)) elts.forEach((elt) => popoverInner.appendChild(elt))
} }
setPosition(popoverElement) if (!!document.getElementById(popoverId)) {
link.appendChild(popoverElement) return
if (hash !== "") {
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" })
}
} }
document.body.appendChild(popoverElement)
if (activeAnchor !== this) {
return
}
showPopover(popoverElement)
}
function clearActivePopover() {
activeAnchor = null
const allPopoverElements = document.querySelectorAll(".popover")
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
} }
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[] const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
for (const link of links) { for (const link of links) {
link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseenter", mouseEnterHandler)
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler)) link.addEventListener("mouseleave", clearActivePopover)
window.addCleanup(() => {
link.removeEventListener("mouseenter", mouseEnterHandler)
link.removeEventListener("mouseleave", clearActivePopover)
})
} }
}) })

View File

@ -0,0 +1,25 @@
let isReaderMode = false
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
detail: { mode },
})
document.dispatchEvent(event)
}
document.addEventListener("nav", () => {
const switchReaderMode = () => {
isReaderMode = !isReaderMode
const newMode = isReaderMode ? "on" : "off"
document.documentElement.setAttribute("reader-mode", newMode)
emitReaderModeChangeEvent(newMode)
}
for (const readerModeButton of document.getElementsByClassName("readermode")) {
readerModeButton.addEventListener("click", switchReaderMode)
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
}
// Set initial state
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
})

View File

@ -143,81 +143,74 @@ function highlightHTML(searchTerm: string, el: HTMLElement) {
return html.body return html.body
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
const currentSlug = e.detail.url const container = searchElement.querySelector(".search-container") as HTMLElement
const data = await fetchData if (!container) return
const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const searchLayout = document.getElementById("search-layout")
const idDataMap = Object.keys(data) as FullSlug[]
const sidebar = container.closest(".sidebar") as HTMLElement | null
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
if (!searchButton) return
const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement
if (!searchBar) return
const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement
if (!searchLayout) return
const idDataMap = Object.keys(data) as FullSlug[]
const appendLayout = (el: HTMLElement) => { const appendLayout = (el: HTMLElement) => {
if (searchLayout?.querySelector(`#${el.id}`) === null) { searchLayout.appendChild(el)
searchLayout?.appendChild(el)
}
} }
const enablePreview = searchLayout?.dataset?.preview === "true" const enablePreview = searchLayout.dataset.preview === "true"
let preview: HTMLDivElement | undefined = undefined let preview: HTMLDivElement | undefined = undefined
let previewInner: HTMLDivElement | undefined = undefined let previewInner: HTMLDivElement | undefined = undefined
const results = document.createElement("div") const results = document.createElement("div")
results.id = "results-container" results.className = "results-container"
appendLayout(results) appendLayout(results)
if (enablePreview) { if (enablePreview) {
preview = document.createElement("div") preview = document.createElement("div")
preview.id = "preview-container" preview.className = "preview-container"
appendLayout(preview) appendLayout(preview)
} }
function hideSearch() { function hideSearch() {
container?.classList.remove("active") container.classList.remove("active")
if (searchBar) { searchBar.value = "" // clear the input when we dismiss the search
searchBar.value = "" // clear the input when we dismiss the search if (sidebar) sidebar.style.zIndex = ""
} removeAllChildren(results)
if (sidebar) {
sidebar.style.zIndex = "unset"
}
if (results) {
removeAllChildren(results)
}
if (preview) { if (preview) {
removeAllChildren(preview) removeAllChildren(preview)
} }
if (searchLayout) { searchLayout.classList.remove("display-results")
searchLayout.classList.remove("display-results")
}
searchType = "basic" // reset search type after closing searchType = "basic" // reset search type after closing
searchButton.focus()
} }
function showSearch(searchTypeNew: SearchType) { function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew searchType = searchTypeNew
if (sidebar) { if (sidebar) sidebar.style.zIndex = "1"
sidebar.style.zIndex = "1" container.classList.add("active")
} searchBar.focus()
container?.classList.add("active")
searchBar?.focus()
} }
let currentHover: HTMLInputElement | null = null let currentHover: HTMLInputElement | null = null
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") const searchBarOpen = container.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("basic") searchBarOpen ? hideSearch() : showSearch("basic")
return return
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search // Hotkey to open tag search
e.preventDefault() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") const searchBarOpen = container.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("tags") searchBarOpen ? hideSearch() : showSearch("tags")
// add "#" prefix for tag search // add "#" prefix for tag search
if (searchBar) searchBar.value = "#" searchBar.value = "#"
return return
} }
@ -226,23 +219,23 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
} }
// If search is active, then we will render the first result and display accordingly // If search is active, then we will render the first result and display accordingly
if (!container?.classList.contains("active")) return if (!container.classList.contains("active")) return
if (e.key === "Enter") { if (e.key === "Enter") {
// If result has focus, navigate to that one, otherwise pick first result // If result has focus, navigate to that one, otherwise pick first result
if (results?.contains(document.activeElement)) { if (results.contains(document.activeElement)) {
const active = document.activeElement as HTMLInputElement const active = document.activeElement as HTMLInputElement
if (active.classList.contains("no-match")) return if (active.classList.contains("no-match")) return
await displayPreview(active) await displayPreview(active)
active.click() active.click()
} else { } else {
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (!anchor || anchor?.classList.contains("no-match")) return if (!anchor || anchor.classList.contains("no-match")) return
await displayPreview(anchor) await displayPreview(anchor)
anchor.click() anchor.click()
} }
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
e.preventDefault() e.preventDefault()
if (results?.contains(document.activeElement)) { if (results.contains(document.activeElement)) {
// If an element in results-container already has focus, focus previous one // If an element in results-container already has focus, focus previous one
const currentResult = currentHover const currentResult = currentHover
? currentHover ? currentHover
@ -307,9 +300,11 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
itemTile.classList.add("result-card") itemTile.classList.add("result-card")
itemTile.id = slug itemTile.id = slug
itemTile.href = resolveUrl(slug).toString() itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${ itemTile.innerHTML = `
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>` <h3 class="card-title">${title}</h3>
}` ${htmlTags}
<p class="card-description">${content}</p>
`
itemTile.addEventListener("click", (event) => { itemTile.addEventListener("click", (event) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch() hideSearch()
@ -335,8 +330,6 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
} }
async function displayResults(finalResults: Item[]) { async function displayResults(finalResults: Item[]) {
if (!results) return
removeAllChildren(results) removeAllChildren(results)
if (finalResults.length === 0) { if (finalResults.length === 0) {
results.innerHTML = `<a class="result-card no-match"> results.innerHTML = `<a class="result-card no-match">
@ -392,7 +385,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
preview.replaceChildren(previewInner) preview.replaceChildren(previewInner)
// scroll to longest // scroll to longest
const highlights = [...preview.querySelectorAll(".highlight")].sort( const highlights = [...preview.getElementsByClassName("highlight")].sort(
(a, b) => b.innerHTML.length - a.innerHTML.length, (a, b) => b.innerHTML.length - a.innerHTML.length,
) )
highlights[0]?.scrollIntoView({ block: "start" }) highlights[0]?.scrollIntoView({ block: "start" })
@ -458,21 +451,23 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.addEventListener("click", () => showSearch("basic")) searchButton.addEventListener("click", () => showSearch("basic"))
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic")))
searchBar?.addEventListener("input", onType) searchBar.addEventListener("input", onType)
window.addCleanup(() => searchBar?.removeEventListener("input", onType)) window.addCleanup(() => searchBar.removeEventListener("input", onType))
registerEscapeHandler(container, hideSearch) registerEscapeHandler(container, hideSearch)
await fillDocument(data) await fillDocument(data)
}) }
/** /**
* Fills flexsearch document with data * Fills flexsearch document with data
* @param index index to fill * @param index index to fill
* @param data data to fill index with * @param data data to fill index with
*/ */
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) { let indexPopulated = false
async function fillDocument(data: ContentIndex) {
if (indexPopulated) return
let id = 0 let id = 0
const promises: Array<Promise<unknown>> = [] const promises: Array<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
@ -487,5 +482,15 @@ async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
) )
} }
return await Promise.all(promises) await Promise.all(promises)
indexPopulated = true
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
const data = await fetchData
const searchElement = document.getElementsByClassName("search")
for (const element of searchElement) {
await setupSearch(element, currentSlug, data)
}
})

View File

@ -1,5 +1,6 @@
import micromorph from "micromorph" import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/micromorph // https://github.com/natemoo-re/micromorph
@ -42,10 +43,26 @@ function notifyNav(url: FullSlug) {
const cleanupFns: Set<(...args: any[]) => void> = new Set() const cleanupFns: Set<(...args: any[]) => void> = new Set()
window.addCleanup = (fn) => cleanupFns.add(fn) window.addCleanup = (fn) => cleanupFns.add(fn)
function startLoading() {
const loadingBar = document.createElement("div")
loadingBar.className = "navigation-progress"
loadingBar.style.width = "0"
if (!document.body.contains(loadingBar)) {
document.body.appendChild(loadingBar)
}
setTimeout(() => {
loadingBar.style.width = "80%"
}, 100)
}
let isNavigating = false
let p: DOMParser let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) { async function _navigate(url: URL, isBack: boolean = false) {
isNavigating = true
startLoading()
p = p || new DOMParser() p = p || new DOMParser()
const contents = await fetch(`${url}`) const contents = await fetchCanonical(url)
.then((res) => { .then((res) => {
const contentType = res.headers.get("content-type") const contentType = res.headers.get("content-type")
if (contentType?.startsWith("text/html")) { if (contentType?.startsWith("text/html")) {
@ -60,6 +77,10 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return if (!contents) return
// notify about to nav
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
document.dispatchEvent(event)
// cleanup old // cleanup old
cleanupFns.forEach((fn) => fn()) cleanupFns.forEach((fn) => fn())
cleanupFns.clear() cleanupFns.clear()
@ -93,7 +114,7 @@ async function navigate(url: URL, isBack: boolean = false) {
} }
} }
// now, patch head // now, patch head, re-executing scripts
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove()) elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
@ -104,10 +125,24 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!isBack) { if (!isBack) {
history.pushState({}, "", url) history.pushState({}, "", url)
} }
notifyNav(getFullSlug(window)) notifyNav(getFullSlug(window))
delete announcer.dataset.persist delete announcer.dataset.persist
} }
async function navigate(url: URL, isBack: boolean = false) {
if (isNavigating) return
isNavigating = true
try {
await _navigate(url, isBack)
} catch (e) {
console.error(e)
window.location.assign(url)
} finally {
isNavigating = false
}
}
window.spaNavigate = navigate window.spaNavigate = navigate
function createRouter() { function createRouter() {
@ -125,21 +160,13 @@ function createRouter() {
return return
} }
try { navigate(url, false)
navigate(url, false)
} catch (e) {
window.location.assign(url)
}
}) })
window.addEventListener("popstate", (event) => { window.addEventListener("popstate", (event) => {
const { url } = getOpts(event) ?? {} const { url } = getOpts(event) ?? {}
if (window.location.hash && window.location.pathname === url?.pathname) return if (window.location.hash && window.location.pathname === url?.pathname) return
try { navigate(new URL(window.location.toString()), true)
navigate(new URL(window.location.toString()), true)
} catch (e) {
window.location.reload()
}
return return
}) })
} }

View File

@ -1,14 +1,13 @@
const bufferPx = 150
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const slug = entry.target.id const slug = entry.target.id
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`) const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
const windowHeight = entry.rootBounds?.height const windowHeight = entry.rootBounds?.height
if (windowHeight && tocEntryElement) { if (windowHeight && tocEntryElements.length > 0) {
if (entry.boundingClientRect.y < windowHeight) { if (entry.boundingClientRect.y < windowHeight) {
tocEntryElement.classList.add("in-view") tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
} else { } else {
tocEntryElement.classList.remove("in-view") tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
} }
} }
} }
@ -16,25 +15,25 @@ const observer = new IntersectionObserver((entries) => {
function toggleToc(this: HTMLElement) { function toggleToc(this: HTMLElement) {
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as HTMLElement | undefined const content = this.nextElementSibling as HTMLElement | undefined
if (!content) return if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
} }
function setupToc() { function setupToc() {
const toc = document.getElementById("toc") for (const toc of document.getElementsByClassName("toc")) {
if (toc) { const button = toc.querySelector(".toc-header")
const collapsed = toc.classList.contains("collapsed") const content = toc.querySelector(".toc-content")
const content = toc.nextElementSibling as HTMLElement | undefined if (!button || !content) return
if (!content) return button.addEventListener("click", toggleToc)
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px" window.addCleanup(() => button.removeEventListener("click", toggleToc))
toc.addEventListener("click", toggleToc)
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
} }
} }
window.addEventListener("resize", setupToc)
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
setupToc() setupToc()

View File

@ -3,6 +3,7 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
function click(this: HTMLElement, e: HTMLElementEventMap["click"]) { function click(this: HTMLElement, e: HTMLElementEventMap["click"]) {
if (e.target !== this) return if (e.target !== this) return
e.preventDefault() e.preventDefault()
e.stopPropagation()
cb() cb()
} }
@ -23,3 +24,23 @@ export function removeAllChildren(node: HTMLElement) {
node.removeChild(node.firstChild) node.removeChild(node.firstChild)
} }
} }
// AliasRedirect emits HTML redirects which also have the link[rel="canonical"]
// containing the URL it's redirecting to.
// Extracting it here with regex is _probably_ faster than parsing the entire HTML
// with a DOMParser effectively twice (here and later in the SPA code), even if
// way less robust - we only care about our own generated redirects after all.
const canonicalRegex = /<link rel="canonical" href="([^"]*)">/
export async function fetchCanonical(url: URL): Promise<Response> {
const res = await fetch(`${url}`)
if (!res.headers.get("content-type")?.startsWith("text/html")) {
return res
}
// reading the body can only be done once, so we need to clone the response
// to allow the caller to read it if it's was not a redirect
const text = await res.clone().text()
const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res
}

Some files were not shown because too many files have changed in this diff Show More