Compare commits

..

40 Commits

Author SHA1 Message Date
Jacky Zhao
bfd72347cf fix(popover): clear popovers more aggressively, use href as id
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-04-11 08:18:28 -07:00
Jacky Zhao
091cc1b05e fix(search): properly show mobile layout
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-04-10 17:43:35 -07:00
Jacky Zhao
e9b60c7285 fix(popover): popover id calculation + scroll consistency 2025-04-10 16:46:30 -07:00
Jacky Zhao
b1a920e5c0 fix: add proper popover hint to tag content page 2025-04-10 16:28:36 -07:00
dependabot[bot]
61770d3e50 chore(deps): bump the production-dependencies group with 6 updates (#1913)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
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
Emile Bangma
9db66d500e fix(popover): round coords remove blurred popovers (#1911)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-04-07 00:23:49 +02:00
Jacky Zhao
ee8c1dc968 chore(css): style tweaks for overflow
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-04-05 11:38:50 -07:00
1 gal Rosemary
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
Emile Bangma
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
K Gopal Krishna
685c06ce2e fix(RecentNotes): Prevent folder pages from always appearing first (closes #1901) (#1904)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
* 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
Jacky Zhao
3ae89a1d16 fix(search): make closest sidebar z-index adjustment optional (closes #1905) 2025-04-04 10:17:57 -07:00
Jacky Zhao
4d6e7ccba9 chore(docs): fix explorer docs on filtering by title 2025-04-04 09:50:01 -07:00
Emile Bangma
f334e78ed6 fix(style): MathJax in callouts spacing (#1892)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-03-31 23:14:30 +02:00
dependabot[bot]
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
dependabot[bot]
99f353968e chore(deps-dev): bump @types/node in the production-dependencies group (#1869)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
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
Stephen Tse
ec4700d522 Hey folder icon don't shrink please (#1872) 2025-03-30 19:08:20 -07:00
Jacky Zhao
d6f69e830c fix: remove redundant log and display in parent of overflow
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-03-30 18:15:25 -07:00
Jacky Zhao
9ee6fe15fd fix: reproducible overflowlist (closes #1885) 2025-03-30 18:04:48 -07:00
Stephen Tse
a21f588c48 fix(toc): element can't fully collapse 2025-03-30 17:39:08 -07:00
Stephen Tse
2119025513 fix(toc): Fixed headers in second ToC element not highlight-able 2025-03-30 17:35:20 -07:00
Jacky Zhao
f70e562432 fix: overflow list bottom gradient on toc (closes #1888) 2025-03-30 17:30:01 -07:00
Emile Bangma
9ff6c7a3f5 fix(style): MathJax non-inline formulae center (#1886) 2025-03-30 21:19:53 +02:00
Jacky Zhao
7ca9dd9a70 fix: dont use cdn for twemoji, bake emojis as b64
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-03-28 16:29:33 -07:00
Stephen Tse
b397dae951 Updating breadcrumbs docs on ConditionalRender (#1871)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-03-26 08:32:13 -07:00
Jacky Zhao
23b691f38c fix: coerce fullslug
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
2025-03-23 20:43:01 -07:00
Jacky Zhao
c18e6cd5bb fix(alias): resolve relative if alias is relative 2025-03-23 20:38:06 -07:00
Jacky Zhao
fe2e16d937 fix: disallow user-select in popover 2025-03-23 18:08:07 -07:00
Jacky Zhao
722b4321db docs: clarify transclusions 2025-03-23 18:03:52 -07:00
dependabot[bot]
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
Jacky Zhao
141f053b0d chore: format path.test.ts 2025-03-23 17:43:47 -07:00
Jacky Zhao
3027eced6c chore(test): add tests for resolveRelative 2025-03-23 17:42:23 -07:00
Jacky Zhao
aaa5c8e8e4 feat: conditional render component 2025-03-23 17:34:14 -07:00
Jacky Zhao
4e74d11b1a fix: cleanup a href link construction, global shared trie, breadcrumbs use trie 2025-03-23 17:24:43 -07:00
Emile Bangma
457b77dd48 fix(frontmatter): prevent slug duplication through frontmatter (#1860)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
* 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
Karim
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
Taha
9316ddf2f5 fix(analytics): fix the load of the analytics scripts (#1865)
Some checks are pending
Build and Test / build-and-test (macos-latest) (push) Waiting to run
Build and Test / build-and-test (windows-latest) (push) Waiting to run
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
* fix(analytics): fix the load of the analytics scripts

* chore(lint): fix lint issues
2025-03-21 09:58:10 -07:00
Shane McDonald
fbca56f278 fix(lastmod) Change defaultDateType to "modified" (#1862)
Some checks failed
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
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
Jacky Zhao
eccad3da5d fix(lastmod): fallback to ctx.arg.directory instead of empty string
Some checks are pending
Build and Test / build-and-test (macos-latest) (push) Waiting to run
Build and Test / build-and-test (windows-latest) (push) Waiting to run
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
2025-03-18 21:48:24 -07:00
dralagen
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
Felix Nie
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
44 changed files with 4121 additions and 575 deletions

View File

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

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

@@ -131,7 +131,8 @@ Using this example, the display names of all `FileNodes` (folders + files) will
```ts title="quartz.layout.ts" ```ts title="quartz.layout.ts"
Component.Explorer({ Component.Explorer({
mapFn: (node) => { mapFn: (node) => {
return (node.displayName = node.displayName.toUpperCase()) node.displayName = node.displayName.toUpperCase()
return node
}, },
}) })
``` ```
@@ -145,8 +146,12 @@ Note that this example filters on the title but you can also do it via slug or a
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.data.title.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())
}, },
}) })
``` ```
@@ -159,7 +164,7 @@ You can access the tags of a file by `node.data.tags`.
Component.Explorer({ Component.Explorer({
filterFn: (node) => { filterFn: (node) => {
// exclude files with the tag "explorerexclude" // exclude files with the tag "explorerexclude"
return node.data.tags.includes("explorerexclude") !== true return node.data.tags?.includes("explorerexclude") !== true
}, },
}) })
``` ```

View File

@@ -31,7 +31,7 @@ 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 on configuration edits and incremental rebuilds for content edits - 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

View File

@@ -60,3 +60,34 @@ The `DesktopOnly` component is the counterpart to `MobileOnly`. It makes its chi
```typescript ```typescript
Component.DesktopOnly(Component.TableOfContents()) 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

@@ -62,7 +62,7 @@ The following properties can be used to customize your link previews:
| `socialDescription` | `description` | Description to be used for preview. | | `socialDescription` | `description` | Description to be used for preview. |
| `socialImage` | `image`, `cover` | Link to preview image. | | `socialImage` | `image`, `cover` | Link to preview image. |
The `socialImage` property should contain a link to an image relative to `quartz/static`. If you have a folder for all your images in `quartz/static/my-images`, an example for `socialImage` could be `"my-images/cover.png"`. 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 > [!info] Info
> >

594
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,23 +56,23 @@
"hast-util-to-string": "^3.0.1", "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.29.2", "lightningcss": "^1.29.3",
"mdast-util-find-and-replace": "^3.0.2", "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",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"pixi.js": "^8.8.1", "pixi.js": "^8.9.1",
"preact": "^10.26.4", "preact": "^10.26.5",
"preact-render-to-string": "^6.5.13", "preact-render-to-string": "^6.5.13",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"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.2.2", "rehype-citation": "^2.3.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0", "rehype-mathjax": "^7.1.0",
"rehype-pretty-code": "^0.14.0", "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",
@@ -81,13 +81,13 @@
"remark-gfm": "^4.0.1", "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.1", "remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"rfdc": "^1.4.1", "rfdc": "^1.4.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"satori": "^0.12.1", "satori": "^0.12.2",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"sharp": "^0.33.5", "sharp": "^0.34.1",
"shiki": "^1.26.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",
@@ -103,14 +103,14 @@
"@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": "^22.13.10", "@types/node": "^22.14.0",
"@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.18.0", "@types/ws": "^8.18.1",
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"esbuild": "^0.25.1", "esbuild": "^0.25.2",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.2" "typescript": "^5.8.3"
} }
} }

View File

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

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

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 < slugParts.length - 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

@@ -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,7 +1,7 @@
import { i18n } from "../i18n" import { i18n } from "../i18n"
import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path" import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
import { CSSResourceToStyleElement, 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 { unescapeHTML } from "../util/escape"
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage" import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
@@ -45,6 +45,9 @@ 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" /> <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossOrigin="anonymous" />

View File

@@ -1,5 +1,4 @@
import { JSX } from "preact" import { JSX } from "preact"
import { randomIdNonSecure } from "../util/random"
const OverflowList = ({ const OverflowList = ({
children, children,
@@ -13,8 +12,9 @@ const OverflowList = ({
) )
} }
let numExplorers = 0
export default () => { export default () => {
const id = randomIdNonSecure() const id = `list-${numExplorers++}`
return { return {
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => ( OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (

View File

@@ -7,6 +7,26 @@ import { GlobalConfiguration } from "../cfg"
export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn { export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
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) => { return (f1, f2) => {
// Sort folders first // Sort folders first
const f1IsFolder = isFolderPath(f1.slug ?? "") const f1IsFolder = isFolderPath(f1.slug ?? "")
@@ -38,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)

View File

@@ -17,6 +17,7 @@ PageTitle.css = `
.page-title { .page-title {
font-size: 1.75rem; font-size: 1.75rem;
margin: 0; margin: 0;
font-family: var(--titleFont);
} }
` `

View File

@@ -53,17 +53,15 @@ export default ((opts?: 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 class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}> <OverflowList class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
<OverflowList> {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}> {tocEntry.text}
{tocEntry.text} </a>
</a> </li>
</li> ))}
))} </OverflowList>
</OverflowList>
</div>
</div> </div>
) )
} }

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

View File

@@ -21,6 +21,7 @@ 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 Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
export { export {
ArticleTitle, ArticleTitle,
@@ -46,4 +47,5 @@ export {
Breadcrumbs, Breadcrumbs,
Comments, Comments,
Flex, Flex,
ConditionalRender,
} }

View File

@@ -8,7 +8,8 @@ import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact" import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources" import { concatenateResources } from "../../util/resources"
import { FileTrieNode } from "../../util/fileTrie" import { trieFromAllFiles } from "../../util/ctx"
interface FolderContentOptions { interface FolderContentOptions {
/** /**
* Whether to display number of folders * Whether to display number of folders
@@ -25,31 +26,11 @@ const defaultOptions: FolderContentOptions = {
export default ((opts?: Partial<FolderContentOptions>) => { export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts } const options: FolderContentOptions = { ...defaultOptions, ...opts }
let trie: FileTrieNode<
QuartzPluginData & {
slug: string
title: string
filePath: string
}
>
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
if (!trie) { const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
trie = new FileTrieNode([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
}
const folder = trie.findNode(fileData.slug!.split("/")) const folder = trie.findNode(fileData.slug!.split("/"))
if (!folder) { if (!folder) {
return null return null

View File

@@ -1,7 +1,7 @@
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"
@@ -74,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>
@@ -112,8 +115,8 @@ export default ((opts?: Partial<TagContentOptions>) => {
} }
return ( return (
<div class={classes}> <div class="popover-hint">
<article class="popover-hint">{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>

View File

@@ -3,6 +3,7 @@ import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical } from "./util"
const p = new DOMParser() const p = new DOMParser()
async function mouseEnterHandler( async function mouseEnterHandler(
this: HTMLAnchorElement, this: HTMLAnchorElement,
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
@@ -14,29 +15,42 @@ async function mouseEnterHandler(
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 hasAlreadyBeenFetched = () => !!document.getElementById(popoverId)
// dont refetch if there's already a popover
if (hasAlreadyBeenFetched()) {
showPopover(prevPopoverElement as HTMLElement)
return
}
const response = await fetchCanonical(targetUrl).catch((err) => { const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err) console.error(err)
@@ -52,12 +66,12 @@ async function mouseEnterHandler(
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":
@@ -82,30 +96,34 @@ 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)
// strip all IDs from elements to prevent duplicates // prepend all IDs inside popovers to prevent duplicates
html.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")) 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) document.body.appendChild(popoverElement)
link.appendChild(popoverElement) showPopover(popoverElement)
}
if (hash !== "") { function clearActivePopover() {
const heading = popoverInner.querySelector(hash) as HTMLElement | null const allPopoverElements = document.querySelectorAll(".popover")
if (heading) { allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
} }
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

@@ -147,8 +147,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
const container = searchElement.querySelector(".search-container") as HTMLElement const container = searchElement.querySelector(".search-container") as HTMLElement
if (!container) return if (!container) return
const sidebar = container.closest(".sidebar") as HTMLElement const sidebar = container.closest(".sidebar") as HTMLElement | null
if (!sidebar) return
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
if (!searchButton) return if (!searchButton) return
@@ -180,7 +179,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
function hideSearch() { function hideSearch() {
container.classList.remove("active") container.classList.remove("active")
searchBar.value = "" // clear the input when we dismiss the search searchBar.value = "" // clear the input when we dismiss the search
sidebar.style.zIndex = "" if (sidebar) sidebar.style.zIndex = ""
removeAllChildren(results) removeAllChildren(results)
if (preview) { if (preview) {
removeAllChildren(preview) removeAllChildren(preview)
@@ -192,7 +191,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
function showSearch(searchTypeNew: SearchType) { function showSearch(searchTypeNew: SearchType) {
searchType = searchTypeNew searchType = searchTypeNew
sidebar.style.zIndex = "1" if (sidebar) sidebar.style.zIndex = "1"
container.classList.add("active") container.classList.add("active")
searchBar.focus() searchBar.focus()
} }
@@ -301,9 +300,11 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
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()

View File

@@ -1,13 +1,13 @@
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"))
} }
} }
} }

View File

@@ -8,10 +8,12 @@
margin: 0; margin: 0;
} }
& > ul { & > ul.overflow {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0.5rem 0; margin: 0.5rem 0;
max-height: calc(100% - 2rem);
overscroll-behavior: contain;
& > li { & > li {
& > a { & > a {

View File

@@ -118,6 +118,7 @@ button.desktop-explorer {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
overscroll-behavior: contain;
& li > a { & li > a {
color: var(--dark); color: var(--dark);
@@ -198,6 +199,7 @@ button.desktop-explorer {
cursor: pointer; cursor: pointer;
transition: transform 0.3s ease; transition: transform 0.3s ease;
backface-visibility: visible; backface-visibility: visible;
flex-shrink: 0;
} }
li:has(> .folder-outer:not(.open)) > .folder-container > svg { li:has(> .folder-outer:not(.open)) > .folder-container > svg {

View File

@@ -16,9 +16,12 @@
.popover { .popover {
z-index: 999; z-index: 999;
position: absolute; position: fixed;
overflow: visible; overflow: visible;
padding: 1rem; padding: 1rem;
left: 0;
top: 0;
will-change: transform;
& > .popover-inner { & > .popover-inner {
position: relative; position: relative;
@@ -35,7 +38,10 @@
border-radius: 5px; border-radius: 5px;
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25); box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: auto; overflow: auto;
overscroll-behavior: contain;
white-space: normal; white-space: normal;
user-select: none;
cursor: default;
} }
& > .popover-inner[data-content-type] { & > .popover-inner[data-content-type] {
@@ -75,7 +81,7 @@
} }
} }
a:hover .popover, .active-popover,
.popover:hover { .popover:hover {
animation: dropin 0.3s ease; animation: dropin 0.3s ease;
animation-fill-mode: forwards; animation-fill-mode: forwards;

View File

@@ -133,11 +133,13 @@
} }
@media all and ($mobile) { @media all and ($mobile) {
& > #preview-container { flex-direction: column;
& > .preview-container {
display: none !important; display: none !important;
} }
&[data-preview] > #results-container { &[data-preview] > .results-container {
width: 100%; width: 100%;
height: auto; height: auto;
flex: 0 0 100%; flex: 0 0 100%;
@@ -204,6 +206,12 @@
margin: 0; margin: 0;
} }
@media all and not ($mobile) {
& > p.card-description {
display: none;
}
}
& > ul.tags { & > ul.tags {
margin-top: 0.45rem; margin-top: 0.45rem;
margin-bottom: 0; margin-bottom: 0;

View File

@@ -3,18 +3,11 @@
.toc { .toc {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: hidden;
min-height: 4rem; min-height: 1.4rem;
flex: 0 1 auto; flex: 0 0.5 auto;
&:has(button.toc-header.collapsed) { &:has(button.toc-header.collapsed) {
flex: 0 1 1.2rem; flex: 0 1 1.4rem;
}
}
@media all and not ($mobile) {
.toc-header {
display: flex;
} }
} }
@@ -45,23 +38,23 @@ button.toc-header {
} }
} }
.toc-content { ul.toc-content.overflow {
list-style: none; list-style: none;
position: relative; position: relative;
margin: 0.5rem 0;
padding: 0;
max-height: calc(100% - 2rem);
overscroll-behavior: contain;
list-style: none;
& ul { & > li > a {
list-style: none; color: var(--dark);
margin: 0.5rem 0; opacity: 0.35;
padding: 0; transition:
& > li > a { 0.5s ease opacity,
color: var(--dark); 0.3s ease color;
opacity: 0.35; &.in-view {
transition: opacity: 0.75;
0.5s ease opacity,
0.3s ease color;
&.in-view {
opacity: 0.75;
}
} }
} }

View File

@@ -1,14 +1,21 @@
import { resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { write } from "./helpers" import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { VFile } from "vfile" import { VFile } from "vfile"
import path from "path"
async function* processFile(ctx: BuildCtx, file: VFile) { async function* processFile(ctx: BuildCtx, file: VFile) {
const ogSlug = simplifySlug(file.data.slug!) const ogSlug = simplifySlug(file.data.slug!)
for (const slug of file.data.aliases ?? []) { for (const aliasTarget of file.data.aliases ?? []) {
const redirUrl = resolveRelative(slug, file.data.slug!) const aliasTargetSlug = (
isRelativeURL(aliasTarget)
? path.normalize(path.join(ogSlug, "..", aliasTarget))
: aliasTarget
) as FullSlug
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug)
yield write({ yield write({
ctx, ctx,
content: ` content: `
@@ -23,7 +30,7 @@ async function* processFile(ctx: BuildCtx, file: VFile) {
</head> </head>
</html> </html>
`, `,
slug, slug: aliasTargetSlug,
ext: ".html", ext: ".html",
}) })
} }

View File

@@ -9,7 +9,12 @@ import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss" import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types" import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles, processGoogleFonts } from "../../util/theme" import {
googleFontHref,
googleFontSubsetHref,
joinStyles,
processGoogleFonts,
} from "../../util/theme"
import { Features, transform } from "lightningcss" import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild" import { transform as transpile } from "esbuild"
import { write } from "./helpers" import { write } from "./helpers"
@@ -83,89 +88,108 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
if (cfg.analytics?.provider === "google") { if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId const tagId = cfg.analytics.tagId
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement("script") const gtagScript = document.createElement('script');
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}" gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
gtagScript.defer = true gtagScript.defer = true;
document.head.appendChild(gtagScript) gtagScript.onload = () => {
window.dataLayer = window.dataLayer || [];
window.dataLayer = window.dataLayer || []; function gtag() {
function gtag() { dataLayer.push(arguments); } dataLayer.push(arguments);
gtag("js", new Date()); }
gtag("config", "${tagId}", { send_page_view: false }); gtag('js', new Date());
gtag('config', '${tagId}', { send_page_view: false });
document.addEventListener("nav", () => { gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
gtag("event", "page_view", { document.addEventListener('nav', () => {
page_title: document.title, gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
page_location: location.href,
}); });
});`) };
document.head.appendChild(gtagScript);
`)
} else if (cfg.analytics?.provider === "plausible") { } else if (cfg.analytics?.provider === "plausible") {
const plausibleHost = cfg.analytics.host ?? "https://plausible.io" const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const plausibleScript = document.createElement("script") const plausibleScript = document.createElement('script');
plausibleScript.src = "${plausibleHost}/js/script.manual.js" plausibleScript.src = '${plausibleHost}/js/script.manual.js';
plausibleScript.setAttribute("data-domain", location.hostname) plausibleScript.setAttribute('data-domain', location.hostname);
plausibleScript.defer = true plausibleScript.defer = true;
document.head.appendChild(plausibleScript) plausibleScript.onload = () => {
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
plausible('pageview');
document.addEventListener('nav', () => {
plausible('pageview');
});
};
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) } document.head.appendChild(plausibleScript);
document.addEventListener("nav", () => {
plausible("pageview")
})
`) `)
} else if (cfg.analytics?.provider === "umami") { } else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script") const umamiScript = document.createElement("script");
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js" umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js";
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
umamiScript.setAttribute("data-auto-track", "false") umamiScript.setAttribute("data-auto-track", "false");
umamiScript.defer = true umamiScript.defer = true;
document.head.appendChild(umamiScript) umamiScript.onload = () => {
document.addEventListener("nav", () => {
umami.track(); umami.track();
}) document.addEventListener("nav", () => {
umami.track();
});
};
document.head.appendChild(umamiScript);
`) `)
} else if (cfg.analytics?.provider === "goatcounter") { } else if (cfg.analytics?.provider === "goatcounter") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const goatcounterScript = document.createElement("script") const goatcounterScript = document.createElement('script');
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}" goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
goatcounterScript.defer = true goatcounterScript.defer = true;
goatcounterScript.setAttribute("data-goatcounter", goatcounterScript.setAttribute(
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count") 'data-goatcounter',
document.head.appendChild(goatcounterScript) "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count"
);
goatcounterScript.onload = () => {
window.goatcounter = { no_onload: true };
goatcounter.count({ path: location.pathname });
document.addEventListener('nav', () => {
goatcounter.count({ path: location.pathname });
});
};
window.goatcounter = { no_onload: true } document.head.appendChild(goatcounterScript);
document.addEventListener("nav", () => {
goatcounter.count({ path: location.pathname })
})
`) `)
} else if (cfg.analytics?.provider === "posthog") { } else if (cfg.analytics?.provider === "posthog") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const posthogScript = document.createElement("script") const posthogScript = document.createElement("script");
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${cfg.analytics.apiKey}', { posthog.init('${cfg.analytics.apiKey}', {
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}', api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
capture_pageview: false, capture_pageview: false,
})\` })\`
document.head.appendChild(posthogScript) posthogScript.onload = () => {
posthog.capture('$pageview', { path: location.pathname });
document.addEventListener('nav', () => {
posthog.capture('$pageview', { path: location.pathname });
});
};
document.addEventListener("nav", () => { document.head.appendChild(posthogScript);
posthog.capture('$pageview', { path: location.pathname })
})
`) `)
} else if (cfg.analytics?.provider === "tinylytics") { } else if (cfg.analytics?.provider === "tinylytics") {
const siteId = cfg.analytics.siteId const siteId = cfg.analytics.siteId
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const tinylyticsScript = document.createElement("script") const tinylyticsScript = document.createElement('script');
tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js?spa" tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
tinylyticsScript.defer = true tinylyticsScript.defer = true;
document.head.appendChild(tinylyticsScript) tinylyticsScript.onload = () => {
window.tinylytics.triggerUpdate();
document.addEventListener("nav", () => { document.addEventListener('nav', () => {
window.tinylytics.triggerUpdate() window.tinylytics.triggerUpdate();
}) });
};
document.head.appendChild(tinylyticsScript);
`) `)
} else if (cfg.analytics?.provider === "cabin") { } else if (cfg.analytics?.provider === "cabin") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
@@ -211,9 +235,16 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
// let the user do it themselves in css // let the user do it themselves in css
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) { } else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
// when cdnCaching is true, we link to google fonts in Head.tsx // when cdnCaching is true, we link to google fonts in Head.tsx
const response = await fetch(googleFontHref(ctx.cfg.configuration.theme)) const theme = ctx.cfg.configuration.theme
const response = await fetch(googleFontHref(theme))
googleFontsStyleSheet = await response.text() googleFontsStyleSheet = await response.text()
if (theme.typography.title) {
const title = ctx.cfg.configuration.pageTitle
const response = await fetch(googleFontSubsetHref(theme, title))
googleFontsStyleSheet += `\n${await response.text()}`
}
if (!cfg.baseUrl) { if (!cfg.baseUrl) {
throw new Error( throw new Error(
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching", "baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",

View File

@@ -1,7 +1,7 @@
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape" import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension, joinSegments, QUARTZ } from "../../util/path" import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp" import sharp from "sharp"
import satori, { SatoriOptions } from "satori" import satori, { SatoriOptions } from "satori"
@@ -55,8 +55,9 @@ async function generateSocialImage(
fonts, fonts,
loadAdditionalAsset: async (languageCode: string, segment: string) => { loadAdditionalAsset: async (languageCode: string, segment: string) => {
if (languageCode === "emoji") { if (languageCode === "emoji") {
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}` return await loadEmoji(getIconCode(segment))
} }
return languageCode return languageCode
}, },
}) })
@@ -144,13 +145,19 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
additionalHead: [ additionalHead: [
(pageData) => { (pageData) => {
const isRealFile = pageData.filePath !== undefined const isRealFile = pageData.filePath !== undefined
const userDefinedOgImagePath = pageData.frontmatter?.socialImage let userDefinedOgImagePath = pageData.frontmatter?.socialImage
if (userDefinedOgImagePath) {
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
? userDefinedOgImagePath
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
}
const generatedOgImagePath = isRealFile const generatedOgImagePath = isRealFile
? `https://${baseUrl}/${pageData.slug!}-og-image.webp` ? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
: undefined : undefined
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}` const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
return ( return (
<> <>

View File

@@ -114,6 +114,10 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
if (socialImage) data.socialImage = socialImage if (socialImage) data.socialImage = socialImage
// Remove duplicate slugs
const uniqueSlugs = [...new Set(allSlugs)]
allSlugs.splice(0, allSlugs.length, ...uniqueSlugs)
// fill in frontmatter // fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"] file.data.frontmatter = data as QuartzPluginData["frontmatter"]
} }

View File

@@ -2,6 +2,7 @@ import fs from "fs"
import { Repository } from "@napi-rs/simple-git" import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types" import { QuartzTransformerPlugin } from "../types"
import chalk from "chalk" import chalk from "chalk"
import path from "path"
export interface Options { export interface Options {
priority: ("frontmatter" | "git" | "filesystem")[] priority: ("frontmatter" | "git" | "filesystem")[]
@@ -34,9 +35,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
return [ return [
() => { () => {
let repo: Repository | undefined = undefined let repo: Repository | undefined = undefined
let repositoryWorkdir: string
if (opts.priority.includes("git")) { if (opts.priority.includes("git")) {
try { try {
repo = Repository.discover(ctx.argv.directory) repo = Repository.discover(ctx.argv.directory)
repositoryWorkdir = repo.workdir() ?? ctx.argv.directory
} catch (e) { } catch (e) {
console.log( console.log(
chalk.yellow(`\nWarning: couldn't find git repository for ${ctx.argv.directory}`), chalk.yellow(`\nWarning: couldn't find git repository for ${ctx.argv.directory}`),
@@ -62,7 +65,8 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
published ||= file.data.frontmatter.published as MaybeDate published ||= file.data.frontmatter.published as MaybeDate
} else if (source === "git" && repo) { } else if (source === "git" && repo) {
try { try {
modified ||= await repo.getFileLatestModifiedDateAsync(fullFp) const relativePath = path.relative(repositoryWorkdir, fullFp)
modified ||= await repo.getFileLatestModifiedDateAsync(relativePath)
} catch { } catch {
console.log( console.log(
chalk.yellow( chalk.yellow(

View File

@@ -191,8 +191,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`)
const blockRef = Boolean(rawHeader?.match(/^#?\^/)) ? "^" : "" const displayAnchor = anchor ? `#${anchor.trim().replace(/^#+/, "")}` : ""
const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : "" const embedDisplay = value.startsWith("!") ? "!" : ""

View File

@@ -65,6 +65,21 @@ ul,
} }
} }
article {
> mjx-container.MathJax,
blockquote > div > mjx-container.MathJax {
display: flex;
> svg {
margin-left: auto;
margin-right: auto;
}
}
blockquote > div > mjx-container.MathJax > svg {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
strong { strong {
font-weight: $semiBoldWeight; font-weight: $semiBoldWeight;
} }
@@ -223,6 +238,7 @@ a {
padding: 0; padding: 0;
& > * { & > * {
flex: 1; flex: 1;
max-height: 24rem;
} }
& > .toc { & > .toc {
display: none; display: none;
@@ -546,8 +562,8 @@ video {
} }
div:has(> .overflow) { div:has(> .overflow) {
display: flex;
max-height: 100%; max-height: 100%;
overflow-y: hidden;
} }
ul.overflow, ul.overflow,
@@ -562,7 +578,7 @@ ol.overflow {
clear: both; clear: both;
& > li.overflow-end { & > li.overflow-end {
height: 1rem; height: 0.5rem;
margin: 0; margin: 0;
} }

View File

@@ -1,4 +1,6 @@
import { QuartzConfig } from "../cfg" import { QuartzConfig } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
import { FileTrieNode } from "./fileTrie"
import { FilePath, FullSlug } from "./path" import { FilePath, FullSlug } from "./path"
export interface Argv { export interface Argv {
@@ -13,13 +15,36 @@ export interface Argv {
concurrency?: number concurrency?: number
} }
export type BuildTimeTrieData = QuartzPluginData & {
slug: string
title: string
filePath: string
}
export interface BuildCtx { export interface BuildCtx {
buildId: string buildId: string
argv: Argv argv: Argv
cfg: QuartzConfig cfg: QuartzConfig
allSlugs: FullSlug[] allSlugs: FullSlug[]
allFiles: FilePath[] allFiles: FilePath[]
trie?: FileTrieNode<BuildTimeTrieData>
incremental: boolean incremental: boolean
} }
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg"> export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
const trie = new FileTrieNode<BuildTimeTrieData>([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
return trie
}
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">

View File

@@ -25,14 +25,23 @@ function toCodePoint(unicodeSurrogates: string) {
return r.join("-") return r.join("-")
} }
const twemoji = (code: string) => type EmojiMap = {
`https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/${code.toLowerCase()}.svg` codePointToName: Record<string, string>
const emojiCache: Record<string, Promise<any>> = {} nameToBase64: Record<string, string>
}
export function loadEmoji(code: string) {
const type = "twemoji" let emojimap: EmojiMap | undefined = undefined
const key = type + ":" + code export async function loadEmoji(code: string) {
if (key in emojiCache) return emojiCache[key] if (!emojimap) {
const data = await import("./emojimap.json")
return (emojiCache[key] = fetch(twemoji(code)).then((r) => r.text())) emojimap = data
}
const name = emojimap.codePointToName[`U+${code.toUpperCase()}`]
if (!name) throw new Error(`codepoint ${code} not found in map`)
const b64 = emojimap.nameToBase64[name]
if (!b64) throw new Error(`name ${name} not found in map`)
return b64
} }

3190
quartz/util/emojimap.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -330,4 +330,86 @@ describe("FileTrie", () => {
) )
}) })
}) })
describe("pathToNode", () => {
test("should return root node for empty path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain([])
assert.deepStrictEqual(path, [trie])
})
test("should return root node for index path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain(["index"])
assert.deepStrictEqual(path, [trie])
})
test("should return path to first level node", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["test"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
test("should return path to nested node", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder", "subfolder", "test"])
assert.deepStrictEqual(path, [
trie,
trie.children[0],
trie.children[0].children[0],
trie.children[0].children[0].children[0],
])
})
test("should return undefined for non-existent path", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["nonexistent"])
assert.strictEqual(path, undefined)
})
test("should return file data for intermediate folders", () => {
const data1 = {
title: "Root",
slug: "index",
filePath: "index.md",
}
const data2 = {
title: "Test",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
const data3 = {
title: "Folder Index",
slug: "folder/index",
filePath: "folder/index.md",
}
trie.add(data1)
trie.add(data2)
trie.add(data3)
const path = trie.ancestryChain(["folder", "subfolder"])
assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
assert.strictEqual(path[1].data, data3)
})
test("should return path for partial path", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
})
}) })

View File

@@ -97,6 +97,24 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1)) return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
} }
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return [this]
}
const child = this.children.find((c) => c.slugSegment === path[0])
if (!child) {
return undefined
}
const childPath = child.ancestryChain(path.slice(1))
if (!childPath) {
return undefined
}
return [this, ...childPath]
}
/** /**
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
*/ */

View File

@@ -1,7 +1,7 @@
import test, { describe } from "node:test" import test, { describe } from "node:test"
import * as path from "./path" import * as path from "./path"
import assert from "node:assert" import assert from "node:assert"
import { FullSlug, TransformOptions } from "./path" import { FullSlug, TransformOptions, SimpleSlug } from "./path"
describe("typeguards", () => { describe("typeguards", () => {
test("isSimpleSlug", () => { test("isSimpleSlug", () => {
@@ -38,6 +38,17 @@ describe("typeguards", () => {
assert(!path.isRelativeURL("./abc/def.md")) assert(!path.isRelativeURL("./abc/def.md"))
}) })
test("isAbsoluteURL", () => {
assert(path.isAbsoluteURL("https://example.com"))
assert(path.isAbsoluteURL("http://example.com"))
assert(path.isAbsoluteURL("ftp://example.com/a/b/c"))
assert(path.isAbsoluteURL("http://host/%25"))
assert(path.isAbsoluteURL("file://host/twoslashes?more//slashes"))
assert(!path.isAbsoluteURL("example.com/abc/def"))
assert(!path.isAbsoluteURL("abc"))
})
test("isFullSlug", () => { test("isFullSlug", () => {
assert(path.isFullSlug("index")) assert(path.isFullSlug("index"))
assert(path.isFullSlug("abc/def")) assert(path.isFullSlug("abc/def"))
@@ -303,3 +314,50 @@ describe("link strategies", () => {
}) })
}) })
}) })
describe("resolveRelative", () => {
test("from index", () => {
assert.strictEqual(path.resolveRelative("index" as FullSlug, "index" as FullSlug), "./")
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc" as FullSlug), "./abc")
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def" as FullSlug),
"./abc/def",
)
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def/ghi" as FullSlug),
"./abc/def/ghi",
)
})
test("from nested page", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "abc" as FullSlug), "../abc")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/def" as FullSlug),
"../abc/def",
)
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "ghi/jkl" as FullSlug),
"../ghi/jkl",
)
})
test("with index paths", () => {
assert.strictEqual(path.resolveRelative("abc/index" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(
path.resolveRelative("abc/def/index" as FullSlug, "index" as FullSlug),
"../../",
)
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc/index" as FullSlug), "./abc/")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/index" as FullSlug),
"../abc/",
)
})
test("with simple slugs", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "" as SimpleSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi" as SimpleSlug), "../ghi")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi/" as SimpleSlug), "../ghi/")
})
})

View File

@@ -1,6 +1,7 @@
import { slug as slugAnchor } from "github-slugger" import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast" import type { Element as HastElement } from "hast"
import { clone } from "./clone" import { clone } from "./clone"
// this file must be isomorphic so it can't use node libs (e.g. path) // this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz" export const QUARTZ = "quartz"
@@ -39,6 +40,15 @@ export function isRelativeURL(s: string): s is RelativeURL {
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "") return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "")
} }
export function isAbsoluteURL(s: string): boolean {
try {
new URL(s)
} catch {
return false
}
return true
}
export function getFullSlug(window: Window): FullSlug { export function getFullSlug(window: Window): FullSlug {
const res = window.document.body.dataset.slug! as FullSlug const res = window.document.body.dataset.slug! as FullSlug
return res return res

View File

@@ -25,6 +25,7 @@ export type FontSpecification =
export interface Theme { export interface Theme {
typography: { typography: {
title?: FontSpecification
header: FontSpecification header: FontSpecification
body: FontSpecification body: FontSpecification
code: FontSpecification code: FontSpecification
@@ -48,7 +49,10 @@ export function getFontSpecificationName(spec: FontSpecification): string {
return spec.name return spec.name
} }
function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpecification) { function formatFontSpecification(
type: "title" | "header" | "body" | "code",
spec: FontSpecification,
) {
if (typeof spec === "string") { if (typeof spec === "string") {
spec = { name: spec } spec = { name: spec }
} }
@@ -82,12 +86,19 @@ function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpe
} }
export function googleFontHref(theme: Theme) { export function googleFontHref(theme: Theme) {
const { code, header, body } = theme.typography const { header, body, code } = theme.typography
const headerFont = formatFontSpecification("header", header) const headerFont = formatFontSpecification("header", header)
const bodyFont = formatFontSpecification("body", body) const bodyFont = formatFontSpecification("body", body)
const codeFont = formatFontSpecification("code", code) const codeFont = formatFontSpecification("code", code)
return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap` return `https://fonts.googleapis.com/css2?family=${headerFont}&family=${bodyFont}&family=${codeFont}&display=swap`
}
export function googleFontSubsetHref(theme: Theme, text: string) {
const title = theme.typography.title || theme.typography.header
const titleFont = formatFontSpecification("title", title)
return `https://fonts.googleapis.com/css2?family=${titleFont}&text=${encodeURIComponent(text)}&display=swap`
} }
export interface GoogleFontFile { export interface GoogleFontFile {
@@ -135,6 +146,7 @@ ${stylesheet.join("\n\n")}
--highlight: ${theme.colors.lightMode.highlight}; --highlight: ${theme.colors.lightMode.highlight};
--textHighlight: ${theme.colors.lightMode.textHighlight}; --textHighlight: ${theme.colors.lightMode.textHighlight};
--titleFont: "${getFontSpecificationName(theme.typography.title || theme.typography.header)}", ${DEFAULT_SANS_SERIF};
--headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF}; --headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF};
--bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF}; --bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF};
--codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO}; --codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO};