Compare commits

..

105 Commits

Author SHA1 Message Date
Jacky Zhao
c891ad8ff5 pkg: bump to 4.2.2 2024-02-04 21:23:17 -08:00
Jacky Zhao
06ee73e006 fix(path): properly path encode & 2024-02-04 21:22:57 -08:00
Jacky Zhao
36e4cc41a9 chore(i18n): refactor and cleanup (#805)
* checkpoint

* finish

* docs
2024-02-04 20:57:10 -08:00
Mats Fangohr
dff4b06313 fix(i18n): backlinks naming in mapping (#800) 2024-02-04 09:48:31 -05:00
Aaron Pham
5b90fbd0d0 feat(ofm): parsing all type of arrow (#797)
* feat(ofm): parsing all type of arrow

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

* fix: use html value instead of decimal

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

* fix: skip parsing arrow if it is not a valid supported mapping

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-02-04 00:51:55 -05:00
Mara-Li
dbbc672c67 feat: Adding support for i18n (closes #462) (#738)
* fix: alt error mix with height/width

More granular detection of alt and resize in image

* fix: format

* feat: init i18n

* feat: add translation

* style: prettier for test

* fix: build-up the locale to fusion with dateLocale

* style: run prettier

* remove cursed file

* refactor: remove i18n library and use locale way instead

* format with prettier

* forgot to remove test

* prevent merging error

* format

* format

* fix: allow string for locale
- Check during translation if valid / existing locale
- Allow to use "en" and "en-US" for example
- Add fallback directly in the function
- Add default key in the function
- Add docstring to cfg.ts

* forgot item translation

* remove unused locale variable

* forgot to remove fr-FR testing

* format
2024-02-03 19:55:24 -08:00
Jacky Zhao
3fb3930df8 fix: calculate heading after latex (closes #719) 2024-02-03 19:44:24 -08:00
Jacky Zhao
742b883256 fix(search): flex basis and card highlighting 2024-02-02 12:18:02 -08:00
Jacky Zhao
9ff1fdd280 fix(search): oops restore ability to preview on hover lol 2024-02-02 10:52:51 -08:00
Jacky Zhao
a2c46f442d fix(search): dont rely on mouse to manipulate focus 2024-02-02 10:44:19 -08:00
Jacky Zhao
260498a96b fix(style): prevent callout icon from shrinking on long titles (closes #792) 2024-02-02 10:23:24 -08:00
Jacky Zhao
0a3379a853 fix(search): null checks and focus fixes 2024-02-02 10:10:25 -08:00
Luis Michaelis
bece8fcab6 fix: properly handle absolute paths in CreatedModifiedDate (#790)
When providing an absolute path to the content directory (e.g. when using an Obsidian Vault in another directory), the build step would fail with

    Failed to process `/absolute/path/to/file.md`: ENOENT: no such file or directory, stat '/current/working/directory/absolute/path/'

This problem originated in the `CreatedModifiedDate` transformer which tries to construct a native filesystem path to the file to call `fs.stat` on. It did not however, account for the original file path contained in the received `VFile` being an absolute path and so, just concatenated the current working directory with the absolute path producing a nonexistent one.

This patch adds a simple fix for this issue by checking if the original file path is already absolute before concatenating with the current working directory.
2024-02-02 09:51:34 -08:00
Jacky Zhao
18745a9dc6 fix(style): correctly collapse on mobile 2024-02-02 09:36:36 -08:00
Jacky Zhao
34a8dfcd55 pkg: bump to 4.2.1 2024-02-02 01:45:28 -08:00
Jacky Zhao
44da82467e fix(style): remove redundant selector 2024-02-02 01:45:15 -08:00
Jacky Zhao
3231ce6e79 fix: search async ordering, scroll offset 2024-02-02 01:36:17 -08:00
Jacky Zhao
a0b927da4a fix: use display instead of visibility for click handling pasthrough 2024-02-02 01:24:40 -08:00
Jacky Zhao
5ab922f316 fix(revert): font aliasing 2024-02-02 01:15:10 -08:00
Jacky Zhao
d11a0e71a8 fix: font smoothing defaults 2024-02-02 01:01:04 -08:00
Jacky Zhao
2b57a68e1f fix: font weight consistency 2024-02-02 00:53:09 -08:00
Jacky Zhao
18cd58617d fix: parallelize search indexing 2024-02-02 00:53:09 -08:00
Aaron Pham
ee868b2d79 fix(search): set correct attribute on hover icon (#787)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-02-02 00:35:53 -08:00
Jacky Zhao
5a36e5b68d fix(style): reasonable page width for rich search preview 2024-02-02 00:29:45 -08:00
Jacky Zhao
0416c03ae6 fix: be more eager about constructing search index 2024-02-02 00:25:05 -08:00
Jacky Zhao
3b596c9311 fix: flatmap children when highlighting rich preview to avoid body 2024-02-02 00:19:19 -08:00
Jacky Zhao
970a30a139 chore: fmt 2024-02-01 23:57:17 -08:00
Jacky Zhao
dc62aeb213 pkg: bump to 4.2.0 2024-02-01 23:55:40 -08:00
Jacky Zhao
9b8e0c9d1a chore(cleanup): misc refactoring for cleanup, fix some search bugs 2024-02-01 23:55:11 -08:00
Jacky Zhao
45b93a80f4 fix: index setup, styling fixes 2024-02-01 22:22:06 -08:00
Jacky Zhao
e9fb0ecb96 fix: border radius on search preview 2024-02-01 21:19:51 -08:00
Jacky Zhao
c0c0b24138 feat: improve search preview styling and tokenization 2024-02-01 21:19:51 -08:00
Jacky Zhao
c00089bd57 chore: add window.addCleanup() for cleaning up handlers 2024-02-01 21:19:51 -08:00
Justin Fowler
8a6ebd1939 docs: clarity for RecentNotes (#786)
- Removed a word for clarity
- added reference to layout file
2024-02-01 23:17:21 -05:00
Aaron Pham
f78b512436 chore(search): check for input type and assignment of focus (#785)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-02-01 19:25:45 -08:00
Aaron Pham
295b8fc914 fix(search): increase size on fullPageWidth viewport (#784)
* fix(search): increase size on fullPageWidth viewport

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

* chore: fix width size to be consistent on multiple views

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

* chore: set layout to 0 if there is no term

remove flashing by setting max-height

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-02-01 19:44:33 -05:00
Aaron Pham
756acc7f97 feat(search): highlight on preview (#783)
* feat: primitive full-text search on preview

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

* fix: remove invalid regex and unused code path

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-02-01 16:48:27 -05:00
Aaron Pham
9aa6a18be2 fix(search): improve more general usability (closes #781) (#782)
* fix(search): improve more general usability

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

* fix: revert naming

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

* fix: correct check for enter event on no-match cases

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

* Update quartz/components/scripts/search.inline.ts

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

* chore: remove unecessary class for tracking mouse

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-02-01 15:56:42 -05:00
dependabot[bot]
444e05ee21 chore(deps-dev): bump @types/hast from 3.0.3 to 3.0.4 (#780)
Bumps [@types/hast](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/hast) from 3.0.3 to 3.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/hast)

---
updated-dependencies:
- dependency-name: "@types/hast"
  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-01-31 18:35:29 -08:00
dependabot[bot]
1c175b2d09 chore(deps): bump mdast-util-to-hast from 13.0.2 to 13.1.0 (#776)
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.0.2 to 13.1.0.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.0.2...13.1.0)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  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-01-31 15:27:30 -05:00
dependabot[bot]
7b2ce8b4a3 chore(deps): bump async-mutex from 0.4.0 to 0.4.1 (#777)
Bumps [async-mutex](https://github.com/DirtyHairy/async-mutex) from 0.4.0 to 0.4.1.
- [Changelog](https://github.com/DirtyHairy/async-mutex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/DirtyHairy/async-mutex/compare/v0.4.0...v0.4.1)

---
updated-dependencies:
- dependency-name: async-mutex
  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-01-31 15:26:57 -05:00
dependabot[bot]
f2e93c3314 chore(deps-dev): bump @types/node from 20.11.11 to 20.11.14 (#779)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.11 to 20.11.14.
- [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-01-31 15:26:34 -05:00
Jacky Zhao
25e6869d38 deps: reduce dependabot frequency 2024-01-31 12:24:25 -08:00
Jacky Zhao
bfd877133b fix: regression in formatted callout titles 2024-01-31 12:09:04 -08:00
Aaron Pham
422986c98b fix(search): remove background with mouseEvent (#775)
* fix(search): remove background with mouseEvent

make sure when mouseenter we remove all existing background

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

* chore: update logics from suggestions

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

* revert: class is evicted

* fix: address correct type

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-31 15:00:19 -05:00
Jacky Zhao
75d64eac91 fix: fmt 2024-01-31 11:58:54 -08:00
Jacky Zhao
355aa22318 docs: fix outdated comment on rebuild debounce behaviour 2024-01-31 11:52:10 -08:00
Jacky Zhao
7cb1c291c8 fix: allow formatting in callout titles 2024-01-31 11:41:27 -08:00
Jacky Zhao
22de92f6c4 pkg: bump to 4.1.6 2024-01-31 10:01:40 -08:00
Jacky Zhao
e1f12e6cb7 fix(style): search preview consistency 2024-01-31 09:55:23 -08:00
Aaron Pham
50bb1ffd8a feat(usability): update functions for search (#774)
* feat(usability): update functions for search

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

* perf: slightly cleaner variables

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-31 09:38:42 -08:00
Aaron Pham
fee3ef9b3a chore(deps): bump katex to 0.16.9 (#772)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-31 09:25:16 -08:00
Aaron Pham
a29fadb046 feat(search): experimental telescope layout (closes #718) (#722)
* feat(search): telescope-style search

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

* chore(search): cleanup some basis and borders

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

* fix(search): make sure to set overflow-y

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

* feat(search): shows preview on desktop only search

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

* perf: add options to control layout through config

cache memoize results to avoid fetching

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

* chore: use the default configuration

* fix: correct minor type for search

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

* fix: use datasets to query for preview

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

* chore: layout changes

show preview on normal layout, and only show previous layout in list page.

* fix(type): annotate search with types

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

* chore: apply jacky's suggestion

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

* chore: using map API and scss

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

* fix: styling on search container view on phones

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

* Update quartz.layout.ts

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-31 01:16:14 -08:00
Jacky Zhao
4e5643fb49 fix: properly parse tags in body 2024-01-30 23:51:21 -08:00
LUCASTUCIOUS
072ee64127 feat: Feature/custom callout icon (#727)
* Add icons as masks

To handle a simple way to add custom icons, i made it pure css. Icon are now a mask for the callout-icon div, so they always follow the --color form the current callout.

Now to add a custom icon, you simply add

```css
.callout {
  &[data-callout="custom"] {
    --color: #customcolor;
    --border: #custombordercolor;
    --bg: #custombg;
    --callout-icon: url('data:image/svg+xml; utf8, <custom formatted svg>');

  }
```

to custom.scss

* remove now unused code

* Make callouts an enum

* docs: update instructions for custom callouts

* Prettier & run format

* dynamic matching

For maintainability, make dynamic mathching. If we or Obsidian want to support more callouts, we simply add it to the enum

* callout mapping const

Getting ride of the enum entierly as it's not worth here?

* fix callout icon styling

* Add forgotten icons

* Rebase

* harmonize callout icon and fold icon

* fix docs + prettier

* Update docs/features/callouts.md

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

* Update quartz/plugins/transformers/ofm.ts

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

* Suggestions fix

* remove unecessary rules

* comment is always nice

* Update docs/features/callouts.md

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-30 22:10:13 -08:00
dependabot[bot]
90043cd582 chore(deps): bump lightningcss from 1.22.1 to 1.23.0 (#765)
Bumps [lightningcss](https://github.com/parcel-bundler/lightningcss) from 1.22.1 to 1.23.0.
- [Release notes](https://github.com/parcel-bundler/lightningcss/releases)
- [Commits](https://github.com/parcel-bundler/lightningcss/compare/v1.22.1...v1.23.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-01-30 22:05:17 -08:00
dependabot[bot]
e21d50c711 chore(deps): bump @floating-ui/dom from 1.5.3 to 1.6.1 (#766)
Bumps [@floating-ui/dom](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/dom) from 1.5.3 to 1.6.1.
- [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.1/packages/dom)

---
updated-dependencies:
- dependency-name: "@floating-ui/dom"
  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-01-30 22:04:53 -08:00
dependabot[bot]
f3c7211bf0 chore(deps-dev): bump @types/node from 20.3.3 to 20.11.11 (#767)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.3.3 to 20.11.11.
- [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-01-30 22:03:54 -08:00
dependabot[bot]
ead7ee2f50 chore(deps-dev): bump prettier from 3.1.1 to 3.2.4 (#768)
* chore(deps-dev): bump prettier from 3.1.1 to 3.2.4

Bumps [prettier](https://github.com/prettier/prettier) from 3.1.1 to 3.2.4.
- [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.1.1...3.2.4)

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

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

* format

---------

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-01-30 22:03:33 -08:00
1900
6ba138b4fa feat: support selfhost umami (#764)
* feat: support selfhsot umami

* Update quartz/plugins/emitters/componentResources.ts

Co-authored-by: Aaron Pham <29749331+aarnphm@users.noreply.github.com>

* Update quartz/plugins/emitters/componentResources.ts

Co-authored-by: Aaron Pham <29749331+aarnphm@users.noreply.github.com>

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Aaron Pham <29749331+aarnphm@users.noreply.github.com>
2024-01-30 09:58:09 -08:00
Justin Fowler
6ce754bda2 fix(css): improve wrapping when right sidebar has more than two items (#762)
* improve wrapping when right sidebar has more than two items, particularly on mobile

* Adjusted min-width
2024-01-29 21:56:59 -08:00
Aaron Pham
8df74185e9 fix(type): annotate event for nav (#761)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-29 21:55:10 -08:00
Aaron Pham
37c6231e79 fix(div): update class name to remove weird space afterwards (#763)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-29 21:51:13 -08:00
Aaron Pham
9555407f65 fix(type): make sure dispatchEvent also accept UIEvent (#760)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-29 16:26:47 -08:00
dependabot[bot]
fbb4d7e399 chore(deps): bump workerpool from 8.0.0 to 9.1.0 (#757)
* chore(deps): bump workerpool from 8.0.0 to 9.1.0

Bumps [workerpool](https://github.com/josdejong/workerpool) from 8.0.0 to 9.1.0.
- [Changelog](https://github.com/josdejong/workerpool/blob/master/HISTORY.md)
- [Commits](https://github.com/josdejong/workerpool/compare/v8.0.0...v9.1.0)

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

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

* remove @types/workerpool

---------

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-01-29 12:45:37 -08:00
dependabot[bot]
5f624edb38 chore(deps): bump remark-rehype from 11.0.0 to 11.1.0 (#758)
Bumps [remark-rehype](https://github.com/remarkjs/remark-rehype) from 11.0.0 to 11.1.0.
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.0.0...11.1.0)

---
updated-dependencies:
- dependency-name: remark-rehype
  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-01-29 12:41:51 -08:00
dependabot[bot]
b8ddf53aa8 chore(deps): bump rfdc from 1.3.0 to 1.3.1 (#759)
Bumps [rfdc](https://github.com/davidmarkclements/rfdc) from 1.3.0 to 1.3.1.
- [Commits](https://github.com/davidmarkclements/rfdc/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: rfdc
  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-01-29 12:41:33 -08:00
dependabot[bot]
b85a3543f4 chore(deps): bump @napi-rs/simple-git from 0.1.11 to 0.1.14 (#756)
Bumps [@napi-rs/simple-git](https://github.com/Brooooooklyn/simple-git) from 0.1.11 to 0.1.14.
- [Release notes](https://github.com/Brooooooklyn/simple-git/releases)
- [Commits](https://github.com/Brooooooklyn/simple-git/compare/v0.1.11...v0.1.14)

---
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-01-29 12:40:55 -08:00
Jacky Zhao
ebf429a9c6 fix: fmt 2024-01-29 09:38:14 -08:00
Jacky Zhao
2d727443b3 fix: implement regex fix for alt in image wikilinks (closes #753) 2024-01-29 09:36:36 -08:00
Jacky Zhao
76be137283 fix: attempt to merge cached folder state between builds (closes #691) 2024-01-29 00:56:20 -08:00
Aaron Pham
f68872c09f feat(icon): update content for gfm links (#751)
* feat(icon): update content for gfm links

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

* chore: remove unused var

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

* fix: inherit display to remove additional spacing

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

* revert: remove redundant svg attribute

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-28 23:38:59 -08:00
Mara-Li
b7152f743b feat: div that encapsulate PageList component (#750)
* feat: div that encapsulate PageList component

* change class to follow review

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

* apply page-listing div to TagContent

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-28 22:52:04 -08:00
Mara-Li
603c181ad2 feat: allow to config a translation for date (#739)
* fix: alt error mix with height/width

More granular detection of alt and resize in image

* fix: format

* feat: allow to translate the date displayed

* style: format

* fix: rename to fusion dateLocale with locale (i18n support)

* Update quartz/components/PageList.tsx

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

* remove default key as it was already set

* add docstring for locale

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-28 22:13:59 -08:00
Mara-Li
16adbd3011 fix: cssclasses was not applied on folder note (index) (#749)
* docs: improve first-time git setup

* fix: cssClasses was not applied on index page

* refactor: remove vscode files

* fix: format

* fix: cssClasses should be applied on the entire div, not only the article

* feat: support cssClasses for tag-listing

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-28 22:12:48 -08:00
Jacky Zhao
b014d060f3 fix: content-disposition inline should apply to all resource types (closes #728) 2024-01-28 22:12:01 -08:00
Jacky Zhao
85f05ea99b fix: revert parsing dates in frontmatter 2024-01-28 21:27:16 -08:00
Jacky Zhao
bf5a556cc1 docs: improve first-time git setup 2024-01-28 00:20:08 -08:00
Jacky Zhao
c4b756c817 style: remove redundant webkit prefix 2024-01-27 23:13:17 -08:00
Jacky Zhao
211f95c527 fix: allow alt to be defined in wikilinks alongside dims 2024-01-27 22:49:57 -08:00
Jacky Zhao
ba40516c54 fix: fmt 2024-01-27 22:24:13 -08:00
LUCASTUCIOUS
a70078ccdc feat: Option to mask folder count (#734)
* Option to mask folder count

* Update quartz/components/pages/FolderContent.tsx

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-27 22:21:32 -08:00
Jacky Zhao
2b62e29282 fix: revert bad tsconfig change 2024-01-27 22:19:37 -08:00
Jacky Zhao
efdce070e1 deps: bump flexsearch 2024-01-27 22:15:25 -08:00
dependabot[bot]
2739457c86 chore(deps): bump shikiji from 0.9.9 to 0.10.2 (#742)
Bumps [shikiji](https://github.com/antfu/shikiji/tree/HEAD/packages/shikiji) from 0.9.9 to 0.10.2.
- [Release notes](https://github.com/antfu/shikiji/releases)
- [Commits](https://github.com/antfu/shikiji/commits/v0.10.2/packages/shikiji)

---
updated-dependencies:
- dependency-name: shikiji
  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-01-27 22:01:43 -08:00
dependabot[bot]
7695df69e5 chore(deps): bump rehype-mathjax from 5.0.0 to 6.0.0 (#745)
Bumps [rehype-mathjax](https://github.com/remarkjs/remark-math) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@5.0.0...rehype-mathjax@6.0.0)

---
updated-dependencies:
- dependency-name: rehype-mathjax
  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-01-27 22:00:38 -08:00
dependabot[bot]
319dec4245 chore(deps): bump @napi-rs/simple-git from 0.1.9 to 0.1.11 (#746)
Bumps [@napi-rs/simple-git](https://github.com/Brooooooklyn/simple-git) from 0.1.9 to 0.1.11.
- [Release notes](https://github.com/Brooooooklyn/simple-git/releases)
- [Commits](https://github.com/Brooooooklyn/simple-git/compare/v0.1.9...v0.1.11)

---
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-01-27 21:59:02 -08:00
dependabot[bot]
bebd6320b7 chore(deps-dev): bump tsx from 4.6.2 to 4.7.0 (#743)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.6.2 to 4.7.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/develop/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.6.2...v4.7.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-01-27 21:56:51 -08:00
dependabot[bot]
0a2d746e38 chore(deps): bump rehype-pretty-code from 0.12.3 to 0.12.6 (#741)
Bumps [rehype-pretty-code](https://github.com/atomiks/rehype-pretty-code) from 0.12.3 to 0.12.6.
- [Release notes](https://github.com/atomiks/rehype-pretty-code/releases)
- [Commits](https://github.com/atomiks/rehype-pretty-code/compare/v0.12.3...v0.12.6)

---
updated-dependencies:
- dependency-name: rehype-pretty-code
  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-01-27 21:47:04 -08:00
Jacky Zhao
b11fefbbbe feat: enable dependabot 2024-01-27 21:44:38 -08:00
Jacky Zhao
42ee069c1c fix: generalize frontmatter parsing and coercing 2024-01-27 21:39:16 -08:00
LUCASTUCIOUS
b211d49922 feat: Handling cssclasses properties in Quartz (#711)
* Add cssclasses to article

* Prettier

* Update quartz/components/pages/Content.tsx

* Update quartz/components/pages/Content.tsx

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-27 18:34:21 -08:00
Jacky Zhao
af3a4ff9cd docs: i can't type 2024-01-26 20:23:43 -08:00
Jacky Zhao
448ba008e0 docs: fix phrasing 2024-01-26 20:16:54 -08:00
Jacky Zhao
8fa1a1e7b9 fix: allow partial when specifiying layout for emitter plugins 2024-01-26 13:40:37 -08:00
Jacky Zhao
b87c6cd5c7 docs: add nicole van der hoeven's setup guide 2024-01-26 10:55:59 -08:00
Jacky Zhao
a8e1c4abc2 docs: rearrange showcase 2024-01-25 22:22:07 -08:00
Xinyang Yu
d90199c8db fix: code block overflow scroll (#729) 2024-01-25 09:56:26 -08:00
LUCASTUCIOUS
d5b40279bd feat: Enable custom callout (#724)
* Enable custom callout

make a callout custom defaulted to a note one.

* Add a comment

* remove comment from quartz/plugins/transformers/ofm.ts

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

* Update quartz/plugins/transformers/ofm.ts

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

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-24 23:54:24 -08:00
Jacky Zhao
b22bcd17b4 fix: border-box result-card 2024-01-23 20:20:35 -08:00
Jacky Zhao
fa6c02d321 fix: make search result card block 2024-01-23 17:08:56 -08:00
Jacky Zhao
5fb203a6df fix(style): make a not inline-block 2024-01-23 17:08:56 -08:00
kabirgh
0a76707062 feat: Emit custom event when theme changes (#723)
* Emit custom event when theme changes

* Type themechange custom event

* Update darkmode docs
2024-01-23 14:52:41 -08:00
kabirgh
1ce12fc1fc cleanup: Move rebuild function outside startServing function (#715)
* Move rebuild function outside `startServing`

* Move toRebuild and toRemove inside rebuild func

* Revert "Move toRebuild and toRemove inside rebuild func"

This reverts commit 8c4dbb13c7.

* Rename func to rebuildFromEntrypoint
2024-01-23 10:55:37 -08:00
Aaron Pham
eb302c05b8 fix(search): update no results to be a (#721)
Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-23 10:53:28 -08:00
81 changed files with 1850 additions and 1605 deletions

11
.github/dependabot.yml vendored Normal file
View File

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

View File

@@ -156,12 +156,13 @@ document.addEventListener("nav", () => {
// do page specific logic here // do page specific logic here
// e.g. attach event listeners // e.g. attach event listeners
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme) toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
}) })
``` ```
It is best practice to also unmount any existing event handlers to prevent memory leaks. It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
This will get called on page navigation.
#### Importing Code #### Importing Code

View File

@@ -278,7 +278,7 @@ export const ContentPage: QuartzEmitterPlugin = () => {
allFiles, allFiles,
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await emit({ const fp = await emit({
content, content,
slug: file.data.slug!, slug: file.data.slug!,

View File

@@ -28,21 +28,13 @@ The rest of your content lives here. You can use **Markdown** here :)
Some common frontmatter fields that are natively supported by Quartz: Some common frontmatter fields that are natively supported by Quartz:
- `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title. - `title`: Title of the page. If it isn't provided, Quartz will use the name of the file as the title.
- `description`: Description of the page used for link previews.
- `aliases`: Other names for this note. This is a list of strings. - `aliases`: Other names for this note. This is a list of strings.
- `tags`: Tags for this note.
- `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz. - `draft`: Whether to publish the page or not. This is one way to make [[private pages|pages private]] in Quartz.
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format. - `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
## Syncing your Content ## Syncing your Content
When your Quartz is at a point you're happy with, you can save your changes to GitHub by doing `npx quartz sync`. When your Quartz is at a point you're happy with, you can save your changes to GitHub.
First, make sure you've [[setting up your GitHub repository|already setup your GitHub repository]] and then do `npx quartz sync`.
> [!hint] Flags and options
> For full help options, you can run `npx quartz sync --help`.
>
> Most of these have sensible defaults but you can override them if you have a custom setup:
>
> - `-d` or `--directory`: the content folder. This is normally just `content`
> - `-v` or `--verbose`: print out extra logging information
> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes
> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz
> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing

View File

@@ -27,6 +27,7 @@ This part of the configuration concerns anything that can affect the whole site.
- `null`: don't use analytics; - `null`: don't use analytics;
- `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or
- `{ provider: 'google', tagId: <your-google-tag> }`: use Google Analytics - `{ provider: 'google', tagId: <your-google-tag> }`: use Google Analytics
- `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`
- Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it. - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it.

View File

@@ -24,14 +24,32 @@ See [documentation on supported types and syntax here](https://help.obsidian.md
## Customization ## Customization
- Disable callouts: simply pass `callouts: false` to the plugin: `Plugin.ObsidianFlavoredMarkdown({ callouts: false })` - Disable callouts: simply pass `callouts: false` to the plugin: `Plugin.ObsidianFlavoredMarkdown({ callouts: false })`
- Editing icons: `quartz/plugins/transformers/ofm.ts` - Editing icons: `quartz/styles/callouts.scss`
### Add custom callouts
By default, custom callouts are handled by applying the `note` style. To make fancy ones, you have to add these lines to `custom.scss`.
```scss title="quartz/styles/custom.scss"
.callout {
&[data-callout="custom"] {
--color: #customcolor;
--border: #custombordercolor;
--bg: #custombg;
--callout-icon: url("data:image/svg+xml; utf8, <custom formatted svg>"); //SVG icon code
}
}
```
> [!warning]
> Don't forget to ensure that the SVG is URL encoded before putting it in the CSS. You can use tools like [this one](https://yoksel.github.io/url-encoder/) to help you do that.
## Showcase ## Showcase
> [!info] > [!info]
> Default title > Default title
> [!question]+ Can callouts be nested? > [!question]+ Can callouts be _nested_?
> >
> > [!todo]- Yes!, they can. And collapsed! > > [!todo]- Yes!, they can. And collapsed!
> > > >

View File

@@ -12,3 +12,12 @@ Quartz supports darkmode out of the box that respects the user's theme preferenc
- Component: `quartz/components/Darkmode.tsx` - Component: `quartz/components/Darkmode.tsx`
- Style: `quartz/components/styles/darkmode.scss` - Style: `quartz/components/styles/darkmode.scss`
- Script: `quartz/components/scripts/darkmode.inline.ts` - Script: `quartz/components/scripts/darkmode.inline.ts`
You can also listen to the `themechange` event to perform any custom logic when the theme changes.
```js
document.addEventListener("themechange", (e) => {
console.log("Theme changed to " + e.detail.theme) // either "light" or "dark"
// your logic here
})
```

18
docs/features/i18n.md Normal file
View File

@@ -0,0 +1,18 @@
---
title: Internationalization
---
Internationalization allows users to translate text in the Quartz interface into various supported languages without needing to make extensive code changes. This can be changed via the `locale` [[configuration]] field in `quartz.config.ts`.
The locale field generally follows a certain format: `{language}-{REGION}`
- `{language}` is usually a [2-letter lowercase language code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes).
- `{REGION}` is usually a [2-letter uppercase region code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
> [!tip] Interested in contributing?
> We [gladly welcome translation PRs](https://github.com/jackyzha0/quartz/tree/v4/quartz/i18n/locales)! To contribute a translation, do the following things:
>
> 1. In the `quartz/i18n/locales` folder, copy the `en-US.ts` file.
> 2. Rename it to `{language}-{REGION}.ts` so it matches a locale of the format shown above.
> 3. Fill in the translations!
> 4. Add the entry under `TRANSLATIONS` in `quartz/i18n/index.ts`.

View File

@@ -3,7 +3,7 @@ title: Recent Notes
tags: component tags: component
--- ---
Quartz can generate a list of recent notes for based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes`. Quartz can generate a list of recent notes based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes` in `quartz.layout.ts`.
## Customization ## Customization

View File

@@ -23,11 +23,15 @@ This will guide you through initializing your Quartz with content. Once you've d
2. [[configuration|Configure]] Quartz's behaviour 2. [[configuration|Configure]] Quartz's behaviour
3. Change Quartz's [[layout]] 3. Change Quartz's [[layout]]
4. [[build|Build and preview]] Quartz 4. [[build|Build and preview]] Quartz
5. [[hosting|Host]] Quartz online 5. Sync your changes with [[setting up your GitHub repository|GitHub]]
6. [[hosting|Host]] Quartz online
If you prefer instructions in a video format you can try following Nicole van der Hoeven's
[video guide on how to set up Quartz!](https://www.youtube.com/watch?v=6s6DT1yN4dw&t=227s)
## 🔧 Features ## 🔧 Features
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], and [many more](./features) right out of the box - [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]] and [many more](./features) right out of the box
- Hot-reload for both configuration and content - Hot-reload for both configuration and content
- 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

@@ -15,25 +15,34 @@ At the top of your repository on GitHub.com's Quick Setup page, click the clipb
In your terminal of choice, navigate to the root of your Quartz folder. Then, run the following commands, replacing `REMOTE-URL` with the URL you just copied from the previous step. In your terminal of choice, navigate to the root of your Quartz folder. Then, run the following commands, replacing `REMOTE-URL` with the URL you just copied from the previous step.
```bash ```bash
# add your repository # list all the repositories that are tracked
git remote add origin REMOTE-URL git remote -v
# track the main quartz repository for updates # if the origin doesn't match your own repository, set your repository as the origin
git remote set-url origin REMOTE-URL
# if you don't have upstream as a remote, add it so updates work
git remote add upstream https://github.com/jackyzha0/quartz.git git remote add upstream https://github.com/jackyzha0/quartz.git
``` ```
To verify that you set the remote URL correctly, run the following command. Then, you can sync the content to upload it to your repository. This is a helper command that will do the initial push of your content to your repository.
```bash
git remote -v
```
Then, you can sync the content to upload it to your repository.
```bash ```bash
npx quartz sync --no-pull npx quartz sync --no-pull
``` ```
> [!hint] > [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
> If `npx quartz sync` fails with `fatal: --[no-]autostash option is only valid with --rebase`, you > You may have an outdated version of `git`. Updating `git` should fix this issue.
> may have an outdated version of `git`. Updating `git` should fix this issue.
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
> [!hint] Flags and options
> For full help options, you can run `npx quartz sync --help`.
>
> Most of these have sensible defaults but you can override them if you have a custom setup:
>
> - `-d` or `--directory`: the content folder. This is normally just `content`
> - `-v` or `--verbose`: print out extra logging information
> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes
> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz
> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing

View File

@@ -7,13 +7,10 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/) - [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/) - [Jacky Zhao's Garden](https://jzhao.xyz/)
- [Socratica Toolbox](https://toolbox.socratica.info/) - [Socratica Toolbox](https://toolbox.socratica.info/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
- [oldwinter の数字花园](https://garden.oldwinter.top/) - [oldwinter の数字花园](https://garden.oldwinter.top/)
- [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Quantum Garden](https://quantumgardener.blog/)
- [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Matt Dunn's Second Brain](https://mattdunn.info/) - [Matt Dunn's Second Brain](https://mattdunn.info/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) - [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
- [Vince Imbat's Talahardin](https://vinceimbat.com/) - [Vince Imbat's Talahardin](https://vinceimbat.com/)
@@ -22,7 +19,11 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Mau Camargo's Notkesto](https://notes.camargomau.com/) - [Mau Camargo's Notkesto](https://notes.camargomau.com/)
- [Caicai's Novels](https://imoko.cc/blog/caicai/) - [Caicai's Novels](https://imoko.cc/blog/caicai/)
- [🌊 Collapsed Wave](https://collapsedwave.com/) - [🌊 Collapsed Wave](https://collapsedwave.com/)
- [Aaron Pham's Garden](https://aarnphm.xyz/)
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/) - [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)!

3
globals.d.ts vendored
View File

@@ -4,9 +4,10 @@ export declare global {
type: K, type: K,
listener: (this: Document, ev: CustomEventMap[K]) => void, listener: (this: Document, ev: CustomEventMap[K]) => void,
): void ): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
} }
interface Window { interface Window {
spaNavigate(url: URL, isBack: boolean = false) spaNavigate(url: URL, isBack: boolean = false)
addCleanup(fn: (...args: any[]) => void)
} }
} }

1
index.d.ts vendored
View File

@@ -6,6 +6,7 @@ declare module "*.scss" {
// dom custom event // dom custom event
interface CustomEventMap { interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }> nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
} }
declare const fetchData: Promise<ContentIndex> declare const fetchData: Promise<ContentIndex>

1186
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.1.5", "version": "4.2.2",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",
@@ -35,15 +35,15 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.6.1",
"@napi-rs/simple-git": "0.1.9", "@napi-rs/simple-git": "0.1.14",
"async-mutex": "^0.4.0", "async-mutex": "^0.4.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"cli-spinner": "^0.2.10", "cli-spinner": "^0.2.10",
"d3": "^7.8.5", "d3": "^7.8.5",
"esbuild-sass-plugin": "^2.16.0", "esbuild-sass-plugin": "^2.16.0",
"flexsearch": "0.7.21", "flexsearch": "0.7.43",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"globby": "^14.0.0", "globby": "^14.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -52,9 +52,9 @@
"hast-util-to-string": "^3.0.0", "hast-util-to-string": "^3.0.0",
"is-absolute-url": "^4.0.1", "is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lightningcss": "^1.22.1", "lightningcss": "^1.23.0",
"mdast-util-find-and-replace": "^3.0.1", "mdast-util-find-and-replace": "^3.0.1",
"mdast-util-to-hast": "^13.0.2", "mdast-util-to-hast": "^13.1.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.19.3", "preact": "^10.19.3",
@@ -64,8 +64,8 @@
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0", "rehype-katex": "^7.0.0",
"rehype-mathjax": "^5.0.0", "rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.12.3", "rehype-pretty-code": "^0.12.6",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
@@ -74,37 +74,35 @@
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0", "remark-rehype": "^11.1.0",
"remark-smartypants": "^2.0.0", "remark-smartypants": "^2.0.0",
"rfdc": "^1.3.0", "rfdc": "^1.3.1",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"serve-handler": "^6.1.5", "serve-handler": "^6.1.5",
"shikiji": "^0.9.9", "shikiji": "^0.10.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.4",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.1", "vfile": "^6.0.1",
"workerpool": "^8.0.0", "workerpool": "^9.1.0",
"ws": "^8.15.1", "ws": "^8.15.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cli-spinner": "^0.2.3", "@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/flexsearch": "^0.7.3", "@types/hast": "^3.0.4",
"@types/hast": "^3.0.3",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^20.1.2", "@types/node": "^20.11.14",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/workerpool": "^6.4.7",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32", "@types/yargs": "^17.0.32",
"esbuild": "^0.19.9", "esbuild": "^0.19.9",
"prettier": "^3.1.1", "prettier": "^3.2.4",
"tsx": "^4.6.2", "tsx": "^4.7.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }

View File

@@ -9,6 +9,7 @@ const config: QuartzConfig = {
analytics: { analytics: {
provider: "plausible", provider: "plausible",
}, },
locale: "en-US",
baseUrl: "quartz.jzhao.xyz", baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"], ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created", defaultDateType: "created",
@@ -45,7 +46,6 @@ const config: QuartzConfig = {
plugins: { plugins: {
transformers: [ transformers: [
Plugin.FrontMatter(), Plugin.FrontMatter(),
Plugin.TableOfContents(),
Plugin.CreatedModifiedDate({ Plugin.CreatedModifiedDate({
// you can add 'git' here for last modified from Git // you can add 'git' here for last modified from Git
// if you do rely on git for dates, ensure defaultDateType is 'modified' // if you do rely on git for dates, ensure defaultDateType is 'modified'
@@ -55,6 +55,7 @@ const config: QuartzConfig = {
Plugin.SyntaxHighlighting(), Plugin.SyntaxHighlighting(),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(), Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(), Plugin.Description(),
], ],

View File

@@ -3,13 +3,13 @@ sourceMapSupport.install(options)
import path from "path" import path from "path"
import { PerfTimer } from "./util/perf" import { PerfTimer } from "./util/perf"
import { rimraf } from "rimraf" import { rimraf } from "rimraf"
import { isGitIgnored } from "globby" import { GlobbyFilterFunction, isGitIgnored } from "globby"
import chalk from "chalk" import chalk from "chalk"
import { parseMarkdown } from "./processors/parse" import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter" import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit" import { emitContent } from "./processors/emit"
import cfg from "../quartz.config" import cfg from "../quartz.config"
import { FilePath, joinSegments, slugifyFilePath } from "./util/path" import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path"
import chokidar from "chokidar" import chokidar from "chokidar"
import { ProcessedContent } from "./plugins/vfile" import { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx" import { Argv, BuildCtx } from "./util/ctx"
@@ -18,6 +18,19 @@ import { trace } from "./util/trace"
import { options } from "./util/sourcemap" import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex" import { Mutex } from "async-mutex"
type BuildData = {
ctx: BuildCtx
ignored: GlobbyFilterFunction
mut: Mutex
initialSlugs: FullSlug[]
// TODO merge contentMap and trackedAssets
contentMap: Map<FilePath, ProcessedContent>
trackedAssets: Set<FilePath>
toRebuild: Set<FilePath>
toRemove: Set<FilePath>
lastBuildMs: number
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = { const ctx: BuildCtx = {
argv, argv,
@@ -73,92 +86,22 @@ async function startServing(
) { ) {
const { argv } = ctx const { argv } = ctx
const ignored = await isGitIgnored()
const contentMap = new Map<FilePath, ProcessedContent>() const contentMap = new Map<FilePath, ProcessedContent>()
for (const content of initialContent) { for (const content of initialContent) {
const [_tree, vfile] = content const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content) contentMap.set(vfile.data.filePath!, content)
} }
const initialSlugs = ctx.allSlugs const buildData: BuildData = {
let lastBuildMs = 0 ctx,
const toRebuild: Set<FilePath> = new Set() mut,
const toRemove: Set<FilePath> = new Set() contentMap,
const trackedAssets: Set<FilePath> = new Set() ignored: await isGitIgnored(),
async function rebuild(fp: string, action: "add" | "change" | "delete") { initialSlugs: ctx.allSlugs,
// don't do anything for gitignored files toRebuild: new Set<FilePath>(),
if (ignored(fp)) { toRemove: new Set<FilePath>(),
return trackedAssets: new Set<FilePath>(),
} lastBuildMs: 0,
// dont bother rebuilding for non-content files, just track and refresh
fp = toPosixPath(fp)
const filePath = joinSegments(argv.directory, fp) as FilePath
if (path.extname(fp) !== ".md") {
if (action === "add" || action === "change") {
trackedAssets.add(filePath)
} else if (action === "delete") {
trackedAssets.delete(filePath)
}
clientRefresh()
return
}
if (action === "add" || action === "change") {
toRebuild.add(filePath)
} else if (action === "delete") {
toRemove.add(filePath)
}
// debounce rebuilds every 250ms
const buildStart = new Date().getTime()
lastBuildMs = buildStart
const release = await mut.acquire()
if (lastBuildMs > buildStart) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
for (const fp of toRemove) {
contentMap.delete(fp)
}
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(argv.output)
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch (err) {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
if (argv.verbose) {
console.log(chalk.red(err))
}
}
release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
} }
const watcher = chokidar.watch(".", { const watcher = chokidar.watch(".", {
@@ -168,15 +111,101 @@ async function startServing(
}) })
watcher watcher
.on("add", (fp) => rebuild(fp, "add")) .on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
.on("change", (fp) => rebuild(fp, "change")) .on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => rebuild(fp, "delete")) .on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
return async () => { return async () => {
await watcher.close() await watcher.close()
} }
} }
async function rebuildFromEntrypoint(
fp: string,
action: "add" | "change" | "delete",
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
buildData
const { argv } = ctx
// don't do anything for gitignored files
if (ignored(fp)) {
return
}
// dont bother rebuilding for non-content files, just track and refresh
fp = toPosixPath(fp)
const filePath = joinSegments(argv.directory, fp) as FilePath
if (path.extname(fp) !== ".md") {
if (action === "add" || action === "change") {
trackedAssets.add(filePath)
} else if (action === "delete") {
trackedAssets.delete(filePath)
}
clientRefresh()
return
}
if (action === "add" || action === "change") {
toRebuild.add(filePath)
} else if (action === "delete") {
toRemove.add(filePath)
}
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const release = await mut.acquire()
// there's another build after us, release and let them do it
if (buildData.lastBuildMs > buildStart) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
try {
const filesToRebuild = [...toRebuild].filter((fp) => !toRemove.has(fp))
const trackedSlugs = [...new Set([...contentMap.keys(), ...toRebuild, ...trackedAssets])]
.filter((fp) => !toRemove.has(fp))
.map((fp) => slugifyFilePath(path.posix.relative(argv.directory, fp) as FilePath))
ctx.allSlugs = [...new Set([...initialSlugs, ...trackedSlugs])]
const parsedContent = await parseMarkdown(ctx, filesToRebuild)
for (const content of parsedContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
for (const fp of toRemove) {
contentMap.delete(fp)
}
const parsedFiles = [...contentMap.values()]
const filteredContent = filterContent(ctx, parsedFiles)
// TODO: we can probably traverse the link graph to figure out what's safe to delete here
// instead of just deleting everything
await rimraf(argv.output)
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done rebuilding in ${perf.timeSince()}`))
} catch (err) {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
if (argv.verbose) {
console.log(chalk.red(err))
}
}
release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
}
export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => { export default async (argv: Argv, mut: Mutex, clientRefresh: () => void) => {
try { try {
return await buildQuartz(argv, mut, clientRefresh) return await buildQuartz(argv, mut, clientRefresh)

View File

@@ -1,5 +1,6 @@
import { ValidDateType } from "./components/Date" import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types" import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types" import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme" import { Theme } from "./util/theme"
@@ -16,6 +17,7 @@ export type Analytics =
| { | {
provider: "umami" provider: "umami"
websiteId: string websiteId: string
host?: string
} }
export interface GlobalConfiguration { export interface GlobalConfiguration {
@@ -35,6 +37,15 @@ export interface GlobalConfiguration {
*/ */
baseUrl?: string baseUrl?: string
theme: Theme theme: Theme
/**
* Allow to translate the date in the language of your choice.
* Also used for UI translation (default: en-US)
* Need to be formated 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)
* 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
*/
locale: ValidLocale
} }
export interface QuartzConfig { export interface QuartzConfig {

View File

@@ -168,22 +168,20 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
// get a preferred link resolution strategy // get a preferred link resolution strategy
linkResolutionStrategy = exitIfCancel( linkResolutionStrategy = exitIfCancel(
await select({ await select({
message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`,
options: [ options: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{ {
value: "shortest", value: "shortest",
label: "Treat links as shortest path", label: "Treat links as shortest path",
hint: "for most Obsidian vaults", hint: "(default)",
},
{
value: "absolute",
label: "Treat links as absolute path",
}, },
{ {
value: "relative", value: "relative",
label: "Treat links as relative paths", label: "Treat links as relative paths",
hint: "for just normal Markdown files",
}, },
], ],
}), }),
@@ -202,6 +200,7 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
// setup remote // setup remote
execSync( execSync(
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
{ stdio: "ignore" },
) )
outro(`You're all set! Not sure what to do next? Try: outro(`You're all set! Not sure what to do next? Try:
@@ -258,6 +257,7 @@ export async function handleBuild(argv) {
}, },
write: false, write: false,
bundle: true, bundle: true,
minify: true,
platform: "browser", platform: "browser",
format: "esm", format: "esm",
}) })
@@ -347,7 +347,7 @@ export async function handleBuild(argv) {
directoryListing: false, directoryListing: false,
headers: [ headers: [
{ {
source: "**/*.html", source: "**/*.*",
headers: [{ key: "Content-Disposition", value: "inline" }], headers: [{ key: "Content-Disposition", value: "inline" }],
}, },
], ],

View File

@@ -1,13 +1,15 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) { function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
const title = fileData.frontmatter?.title const title = fileData.frontmatter?.title
if (title) { if (title) {
return <h1 class={`article-title ${displayClass ?? ""}`}>{title}</h1> return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
} else { } else {
return null return null
} }
} }
ArticleTitle.css = ` ArticleTitle.css = `
.article-title { .article-title {
margin: 2rem 0 0 0; margin: 2rem 0 0 0;

View File

@@ -1,13 +1,15 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss" import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path" import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) { function Backlinks({ fileData, allFiles, displayClass, cfg }: QuartzComponentProps) {
const slug = simplifySlug(fileData.slug!) const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return ( return (
<div class={`backlinks ${displayClass ?? ""}`}> <div class={classNames(displayClass, "backlinks")}>
<h3>Backlinks</h3> <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
<ul class="overflow"> <ul class="overflow">
{backlinkFiles.length > 0 ? ( {backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => ( backlinkFiles.map((f) => (
@@ -18,7 +20,7 @@ function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
</li> </li>
)) ))
) : ( ) : (
<li>No backlinks found</li> <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
)} )}
</ul> </ul>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss" import breadcrumbsStyle from "./styles/breadcrumbs.scss"
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
type CrumbData = { type CrumbData = {
displayName: string displayName: string
@@ -113,7 +114,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
} }
return ( return (
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs"> <nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => ( {crumbs.map((crumb, index) => (
<div class="breadcrumb-element"> <div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a> <a href={crumb.path}>{crumb.displayName}</a>

View File

@@ -1,6 +1,7 @@
import { formatDate, getDate } from "./Date" import { formatDate, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time" import readingTime from "reading-time"
import { classNames } from "../util/lang"
interface ContentMetaOptions { interface ContentMetaOptions {
/** /**
@@ -24,7 +25,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
const segments: string[] = [] const segments: string[] = []
if (fileData.dates) { if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!)) segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
} }
// Display reading time if enabled // Display reading time if enabled
@@ -33,7 +34,7 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
segments.push(timeTaken) segments.push(timeTaken)
} }
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p> return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
} else { } else {
return null return null
} }

View File

@@ -4,10 +4,12 @@
import darkmodeScript from "./scripts/darkmode.inline" import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss" import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
function Darkmode({ displayClass }: QuartzComponentProps) { function Darkmode({ displayClass, cfg }: QuartzComponentProps) {
return ( return (
<div class={`darkmode ${displayClass ?? ""}`}> <div class={classNames(displayClass, "darkmode")}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} /> <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}> <label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
<svg <svg
@@ -21,7 +23,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
style="enable-background:new 0 0 35 35" style="enable-background:new 0 0 35 35"
xmlSpace="preserve" xmlSpace="preserve"
> >
<title>Light mode</title> <title>{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>
</label> </label>
@@ -37,7 +39,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
style="enable-background:new 0 0 100 100" style="enable-background:new 0 0 100 100"
xmlSpace="preserve" xmlSpace="preserve"
> >
<title>Dark mode</title> <title>{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> <path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
</svg> </svg>
</label> </label>

View File

@@ -1,8 +1,10 @@
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { ValidLocale } from "../i18n"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
interface Props { interface Props {
date: Date date: Date
locale?: ValidLocale
} }
export type ValidDateType = keyof Required<QuartzPluginData>["dates"] export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
@@ -16,14 +18,14 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date
return data.dates?.[cfg.defaultDateType] return data.dates?.[cfg.defaultDateType]
} }
export function formatDate(d: Date): string { export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
return d.toLocaleDateString("en-US", { return d.toLocaleDateString(locale, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "2-digit", day: "2-digit",
}) })
} }
export function Date({ date }: Props) { export function Date({ date, locale }: Props) {
return <>{formatDate(date)}</> return <>{formatDate(date, locale)}</>
} }

View File

@@ -5,10 +5,11 @@ import explorerStyle from "./styles/explorer.scss"
import script from "./scripts/explorer.inline" import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode" import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
// Options interface defined in `ExplorerNode` to avoid circular dependency // Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = { const defaultOptions = {
title: "Explorer",
folderClickBehavior: "collapse", folderClickBehavior: "collapse",
folderDefaultState: "collapsed", folderDefaultState: "collapsed",
useSavedState: true, useSavedState: true,
@@ -69,16 +70,15 @@ export default ((userOpts?: Partial<Options>) => {
} }
// Get all folders of tree. Initialize with collapsed state // Get all folders of tree. Initialize with collapsed state
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
// Stringify to pass json tree as data attribute ([data-tree]) // Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
jsonTree = JSON.stringify(folders) jsonTree = JSON.stringify(folders)
} }
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { function Explorer({ cfg, allFiles, displayClass, fileData }: QuartzComponentProps) {
constructFileTree(allFiles) constructFileTree(allFiles)
return ( return (
<div class={`explorer ${displayClass ?? ""}`}> <div class={classNames(displayClass, "explorer")}>
<button <button
type="button" type="button"
id="explorer" id="explorer"
@@ -87,7 +87,7 @@ export default ((userOpts?: Partial<Options>) => {
data-savestate={opts.useSavedState} data-savestate={opts.useSavedState}
data-tree={jsonTree} data-tree={jsonTree}
> >
<h1>{opts.title}</h1> <h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="14"

View File

@@ -12,7 +12,7 @@ import {
type OrderEntries = "sort" | "filter" | "map" type OrderEntries = "sort" | "filter" | "map"
export interface Options { export interface Options {
title: string title?: string
folderDefaultState: "collapsed" | "open" folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link" folderClickBehavior: "collapse" | "link"
useSavedState: boolean useSavedState: boolean

View File

@@ -1,20 +1,22 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/footer.scss" import style from "./styles/footer.scss"
import { version } from "../../package.json" import { version } from "../../package.json"
import { i18n } from "../i18n"
interface Options { interface Options {
links: Record<string, string> links: Record<string, string>
} }
export default ((opts?: Options) => { export default ((opts?: Options) => {
function Footer({ displayClass }: QuartzComponentProps) { function Footer({ displayClass, cfg }: QuartzComponentProps) {
const year = new Date().getFullYear() const year = new Date().getFullYear()
const links = opts?.links ?? [] const links = opts?.links ?? []
return ( return (
<footer class={`${displayClass ?? ""}`}> <footer class={`${displayClass ?? ""}`}>
<hr /> <hr />
<p> <p>
Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year} {i18n(cfg.locale).components.footer.createdWith}{" "}
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
</p> </p>
<ul> <ul>
{Object.entries(links).map(([text, link]) => ( {Object.entries(links).map(([text, link]) => (

View File

@@ -2,6 +2,8 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
// @ts-ignore // @ts-ignore
import script from "./scripts/graph.inline" import script from "./scripts/graph.inline"
import style from "./styles/graph.scss" import style from "./styles/graph.scss"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
export interface D3Config { export interface D3Config {
drag: boolean drag: boolean
@@ -52,12 +54,12 @@ const defaultOptions: GraphOptions = {
} }
export default ((opts?: GraphOptions) => { export default ((opts?: GraphOptions) => {
function Graph({ displayClass }: QuartzComponentProps) { function Graph({ 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 }
return ( return (
<div class={`graph ${displayClass ?? ""}`}> <div class={classNames(displayClass, "graph")}>
<h3>Graph View</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 id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<svg <svg

View File

@@ -1,11 +1,13 @@
import { i18n } from "../i18n"
import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
import { JSResourceToScriptElement } from "../util/resources" import { JSResourceToScriptElement } from "../util/resources"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
export default (() => { export default (() => {
function Head({ cfg, fileData, externalResources }: QuartzComponentProps) { function Head({ cfg, fileData, externalResources }: QuartzComponentProps) {
const title = fileData.frontmatter?.title ?? "Untitled" const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
const description = fileData.description?.trim() ?? "No description provided" const description =
fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
const { css, js } = externalResources const { css, js } = externalResources
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)

View File

@@ -46,7 +46,7 @@ export function PageList({ cfg, fileData, allFiles, limit }: Props) {
<div class="section"> <div class="section">
{page.dates && ( {page.dates && (
<p class="meta"> <p class="meta">
<Date date={getDate(cfg, page)!} /> <Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p> </p>
)} )}
<div class="desc"> <div class="desc">

View File

@@ -1,11 +1,13 @@
import { pathToRoot } from "../util/path" import { pathToRoot } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) { function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
const title = cfg?.pageTitle ?? "Untitled Quartz" const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
const baseDir = pathToRoot(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
return ( return (
<h1 class={`page-title ${displayClass ?? ""}`}> <h1 class={classNames(displayClass, "page-title")}>
<a href={baseDir}>{title}</a> <a href={baseDir}>{title}</a>
</h1> </h1>
) )

View File

@@ -5,9 +5,11 @@ import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss" import style from "./styles/recentNotes.scss"
import { Date, getDate } from "./Date" import { Date, getDate } from "./Date"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
interface Options { interface Options {
title: string title?: string
limit: number limit: number
linkToMore: SimpleSlug | false linkToMore: SimpleSlug | false
filter: (f: QuartzPluginData) => boolean filter: (f: QuartzPluginData) => boolean
@@ -15,7 +17,6 @@ interface Options {
} }
const defaultOptions = (cfg: GlobalConfiguration): Options => ({ const defaultOptions = (cfg: GlobalConfiguration): Options => ({
title: "Recent Notes",
limit: 3, limit: 3,
linkToMore: false, linkToMore: false,
filter: () => true, filter: () => true,
@@ -28,11 +29,11 @@ export default ((userOpts?: Partial<Options>) => {
const pages = allFiles.filter(opts.filter).sort(opts.sort) const pages = allFiles.filter(opts.filter).sort(opts.sort)
const remaining = Math.max(0, pages.length - opts.limit) const remaining = Math.max(0, pages.length - opts.limit)
return ( return (
<div class={`recent-notes ${displayClass ?? ""}`}> <div class={classNames(displayClass, "recent-notes")}>
<h3>{opts.title}</h3> <h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
<ul class="recent-ul"> <ul class="recent-ul">
{pages.slice(0, opts.limit).map((page) => { {pages.slice(0, opts.limit).map((page) => {
const title = page.frontmatter?.title const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
const tags = page.frontmatter?.tags ?? [] const tags = page.frontmatter?.tags ?? []
return ( return (
@@ -47,7 +48,7 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
{page.dates && ( {page.dates && (
<p class="meta"> <p class="meta">
<Date date={getDate(cfg, page)!} /> <Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p> </p>
)} )}
<ul class="tags"> <ul class="tags">
@@ -69,7 +70,9 @@ export default ((userOpts?: Partial<Options>) => {
</ul> </ul>
{opts.linkToMore && remaining > 0 && ( {opts.linkToMore && remaining > 0 && (
<p> <p>
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>See {remaining} more </a> <a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
</a>
</p> </p>
)} )}
</div> </div>

View File

@@ -2,13 +2,25 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/search.scss" import style from "./styles/search.scss"
// @ts-ignore // @ts-ignore
import script from "./scripts/search.inline" import script from "./scripts/search.inline"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
export default (() => { export interface SearchOptions {
function Search({ displayClass }: QuartzComponentProps) { enablePreview: boolean
}
const defaultOptions: SearchOptions = {
enablePreview: true,
}
export default ((userOpts?: Partial<SearchOptions>) => {
function Search({ displayClass, cfg }: QuartzComponentProps) {
const opts = { ...defaultOptions, ...userOpts }
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
return ( return (
<div class={`search ${displayClass ?? ""}`}> <div class={classNames(displayClass, "search")}>
<div id="search-icon"> <div id="search-icon">
<p>Search</p> <p>{i18n(cfg.locale).components.search.title}</p>
<div></div> <div></div>
<svg <svg
tabIndex={0} tabIndex={0}
@@ -32,10 +44,10 @@ export default (() => {
id="search-bar" id="search-bar"
name="search" name="search"
type="text" type="text"
aria-label="Search for something" aria-label={searchPlaceholder}
placeholder="Search for something" placeholder={searchPlaceholder}
/> />
<div id="results-container"></div> <div id="search-layout" data-preview={opts.enablePreview}></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
function Spacer({ displayClass }: QuartzComponentProps) { function Spacer({ displayClass }: QuartzComponentProps) {
return <div class={`spacer ${displayClass ?? ""}`}></div> return <div class={classNames(displayClass, "spacer")}></div>
} }
export default (() => Spacer) satisfies QuartzComponentConstructor export default (() => Spacer) satisfies QuartzComponentConstructor

View File

@@ -1,9 +1,11 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import legacyStyle from "./styles/legacyToc.scss" import legacyStyle from "./styles/legacyToc.scss"
import modernStyle from "./styles/toc.scss" import modernStyle from "./styles/toc.scss"
import { classNames } from "../util/lang"
// @ts-ignore // @ts-ignore
import script from "./scripts/toc.inline" import script from "./scripts/toc.inline"
import { i18n } from "../i18n"
interface Options { interface Options {
layout: "modern" | "legacy" layout: "modern" | "legacy"
@@ -13,15 +15,15 @@ const defaultOptions: Options = {
layout: "modern", layout: "modern",
} }
function TableOfContents({ fileData, displayClass }: QuartzComponentProps) { function TableOfContents({ fileData, displayClass, cfg }: QuartzComponentProps) {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
return ( return (
<div class={`toc ${displayClass ?? ""}`}> <div class={classNames(displayClass, "toc")}>
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}> <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<h3>Table of Contents</h3> <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
@@ -54,15 +56,14 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
TableOfContents.css = modernStyle TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script TableOfContents.afterDOMLoaded = script
function LegacyTableOfContents({ fileData }: QuartzComponentProps) { function LegacyTableOfContents({ fileData, cfg }: QuartzComponentProps) {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
return ( return (
<details id="toc" open={!fileData.collapseToc}> <details id="toc" open={!fileData.collapseToc}>
<summary> <summary>
<h3>Table of Contents</h3> <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
</summary> </summary>
<ul> <ul>
{fileData.toc.map((tocEntry) => ( {fileData.toc.map((tocEntry) => (

View File

@@ -1,12 +1,13 @@
import { pathToRoot, slugTag } from "../util/path" import { pathToRoot, slugTag } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
function TagList({ fileData, displayClass }: QuartzComponentProps) { function TagList({ fileData, displayClass }: QuartzComponentProps) {
const tags = fileData.frontmatter?.tags const tags = fileData.frontmatter?.tags
const baseDir = pathToRoot(fileData.slug!) const baseDir = pathToRoot(fileData.slug!)
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
return ( return (
<ul class={`tags ${displayClass ?? ""}`}> <ul class={classNames(displayClass, "tags")}>
{tags.map((tag) => { {tags.map((tag) => {
const display = `#${tag}` const display = `#${tag}`
const linkDest = baseDir + `/tags/${slugTag(tag)}` const linkDest = baseDir + `/tags/${slugTag(tag)}`

View File

@@ -1,10 +1,11 @@
import { QuartzComponentConstructor } from "../types" import { i18n } from "../../i18n"
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
function NotFound() { function NotFound({ cfg }: QuartzComponentProps) {
return ( return (
<article class="popover-hint"> <article class="popover-hint">
<h1>404</h1> <h1>404</h1>
<p>Either this page is private or doesn't exist.</p> <p>{i18n(cfg.locale).pages.error.notFound}</p>
</article> </article>
) )
} }

View File

@@ -3,7 +3,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
function Content({ fileData, tree }: QuartzComponentProps) { function Content({ fileData, tree }: QuartzComponentProps) {
const content = htmlToJsx(fileData.filePath!, tree) const content = htmlToJsx(fileData.filePath!, tree)
return <article class="popover-hint">{content}</article> const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const classString = ["popover-hint", ...classes].join(" ")
return <article class={classString}>{content}</article>
} }
export default (() => Content) satisfies QuartzComponentConstructor export default (() => Content) satisfies QuartzComponentConstructor

View File

@@ -5,43 +5,67 @@ import style from "../styles/listPage.scss"
import { PageList } from "../PageList" import { PageList } from "../PageList"
import { _stripSlashes, simplifySlug } from "../../util/path" import { _stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast" import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
function FolderContent(props: QuartzComponentProps) { interface FolderContentOptions {
const { tree, fileData, allFiles } = props /**
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!)) * Whether to display number of folders
const allPagesInFolder = allFiles.filter((file) => { */
const fileSlug = _stripSlashes(simplifySlug(file.slug!)) showFolderCount: boolean
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const folderParts = folderSlug.split(path.posix.sep)
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild
})
const listProps = {
...props,
allFiles: allPagesInFolder,
}
const content =
(tree as Root).children.length === 0
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
return (
<div class="popover-hint">
<article>
<p>{content}</p>
</article>
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
<div>
<PageList {...listProps} />
</div>
</div>
)
} }
FolderContent.css = style + PageList.css const defaultOptions: FolderContentOptions = {
export default (() => FolderContent) satisfies QuartzComponentConstructor showFolderCount: true,
}
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles, cfg } = props
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const folderParts = folderSlug.split(path.posix.sep)
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
return prefixed && isDirectChild
})
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
const listProps = {
...props,
allFiles: allPagesInFolder,
}
const content =
(tree as Root).children.length === 0
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
return (
<div class={classes}>
<article>
<p>{content}</p>
</article>
<div class="page-listing">
{options.showFolderCount && (
<p>
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
count: allPagesInFolder.length,
})}
</p>
)}
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
FolderContent.css = style + PageList.css
return FolderContent
}) satisfies QuartzComponentConstructor

View File

@@ -4,12 +4,12 @@ import { PageList } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast" import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
const numPages = 10 const numPages = 10
function TagContent(props: QuartzComponentProps) { function TagContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props const { tree, fileData, allFiles, cfg } = props
const slug = fileData.slug const slug = fileData.slug
if (!(slug?.startsWith("tags/") || slug === "tags")) { if (!(slug?.startsWith("tags/") || slug === "tags")) {
@@ -26,7 +26,8 @@ function TagContent(props: QuartzComponentProps) {
(tree as Root).children.length === 0 (tree as Root).children.length === 0
? fileData.description ? fileData.description
: htmlToJsx(fileData.filePath!, tree) : htmlToJsx(fileData.filePath!, tree)
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
if (tag === "/") { if (tag === "/") {
const tags = [ const tags = [
...new Set( ...new Set(
@@ -37,13 +38,12 @@ function TagContent(props: QuartzComponentProps) {
for (const tag of tags) { for (const tag of tags) {
tagItemMap.set(tag, allPagesWithTag(tag)) tagItemMap.set(tag, allPagesWithTag(tag))
} }
return ( return (
<div class="popover-hint"> <div class={classes}>
<article> <article>
<p>{content}</p> <p>{content}</p>
</article> </article>
<p>Found {tags.length} total tags.</p> <p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
<div> <div>
{tags.map((tag) => { {tags.map((tag) => {
const pages = tagItemMap.get(tag)! const pages = tagItemMap.get(tag)!
@@ -62,11 +62,17 @@ function TagContent(props: QuartzComponentProps) {
</a> </a>
</h2> </h2>
{content && <p>{content}</p>} {content && <p>{content}</p>}
<p> <div class="page-listing">
{pluralize(pages.length, "item")} with this tag.{" "} <p>
{pages.length > numPages && `Showing first ${numPages}.`} {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
</p> {pages.length > numPages && (
<PageList limit={numPages} {...listProps} /> <span>
{i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
</span>
)}
</p>
<PageList limit={numPages} {...listProps} />
</div>
</div> </div>
) )
})} })}
@@ -81,11 +87,13 @@ function TagContent(props: QuartzComponentProps) {
} }
return ( return (
<div class="popover-hint"> <div class={classes}>
<article>{content}</article> <article>{content}</article>
<p>{pluralize(pages.length, "item")} with this tag.</p> <div class="page-listing">
<div> <p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<PageList {...listProps} /> <div>
<PageList {...listProps} />
</div>
</div> </div>
</div> </div>
) )

View File

@@ -7,6 +7,8 @@ import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../ut
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 { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@@ -63,6 +65,7 @@ function getOrComputeFileIndex(allFiles: QuartzPluginData[]): Map<FullSlug, Quar
} }
export function renderPage( export function renderPage(
cfg: GlobalConfiguration,
slug: FullSlug, slug: FullSlug,
componentData: QuartzComponentProps, componentData: QuartzComponentProps,
components: RenderComponents, components: RenderComponents,
@@ -136,7 +139,9 @@ export function renderPage(
type: "element", type: "element",
tagName: "a", tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] }, properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }], children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
}, },
] ]
} else if (page.htmlAst) { } else if (page.htmlAst) {
@@ -147,7 +152,14 @@ export function renderPage(
tagName: "h1", tagName: "h1",
properties: {}, properties: {},
children: [ children: [
{ type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` }, {
type: "text",
value:
page.frontmatter?.title ??
i18n(cfg.locale).components.transcludes.transcludeOf({
targetSlug: page.slug!,
}),
},
], ],
}, },
...(page.htmlAst.children as ElementContent[]).map((child) => ...(page.htmlAst.children as ElementContent[]).map((child) =>
@@ -157,7 +169,9 @@ export function renderPage(
type: "element", type: "element",
tagName: "a", tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] }, properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }], children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
}, },
] ]
} }

View File

@@ -1,21 +1,21 @@
function toggleCallout(this: HTMLElement) { function toggleCallout(this: HTMLElement) {
const outerBlock = this.parentElement! const outerBlock = this.parentElement!
outerBlock.classList.toggle(`is-collapsed`) outerBlock.classList.toggle("is-collapsed")
const collapsed = outerBlock.classList.contains(`is-collapsed`) const collapsed = outerBlock.classList.contains("is-collapsed")
const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
outerBlock.style.maxHeight = height + `px` outerBlock.style.maxHeight = height + "px"
// walk and adjust height of all parents // walk and adjust height of all parents
let current = outerBlock let current = outerBlock
let parent = outerBlock.parentElement let parent = outerBlock.parentElement
while (parent) { while (parent) {
if (!parent.classList.contains(`callout`)) { if (!parent.classList.contains("callout")) {
return return
} }
const collapsed = parent.classList.contains(`is-collapsed`) const collapsed = parent.classList.contains("is-collapsed")
const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
parent.style.maxHeight = height + `px` parent.style.maxHeight = height + "px"
current = parent current = parent
parent = parent.parentElement parent = parent.parentElement
@@ -30,15 +30,15 @@ function setupCallout() {
const title = div.firstElementChild const title = div.firstElementChild
if (title) { if (title) {
title.removeEventListener(`click`, toggleCallout) title.addEventListener("click", toggleCallout)
title.addEventListener(`click`, toggleCallout) window.addCleanup(() => title.removeEventListener("click", toggleCallout))
const collapsed = div.classList.contains(`is-collapsed`) const collapsed = div.classList.contains("is-collapsed")
const height = collapsed ? title.scrollHeight : div.scrollHeight const height = collapsed ? title.scrollHeight : div.scrollHeight
div.style.maxHeight = height + `px` div.style.maxHeight = height + "px"
} }
} }
} }
document.addEventListener(`nav`, setupCallout) document.addEventListener("nav", setupCallout)
window.addEventListener(`resize`, setupCallout) window.addEventListener("resize", setupCallout)

View File

@@ -14,7 +14,7 @@ document.addEventListener("nav", () => {
button.type = "button" button.type = "button"
button.innerHTML = svgCopy button.innerHTML = svgCopy
button.ariaLabel = "Copy source" button.ariaLabel = "Copy source"
button.addEventListener("click", () => { function onClick() {
navigator.clipboard.writeText(source).then( navigator.clipboard.writeText(source).then(
() => { () => {
button.blur() button.blur()
@@ -26,7 +26,9 @@ document.addEventListener("nav", () => {
}, },
(error) => console.error(error), (error) => console.error(error),
) )
}) }
button.addEventListener("click", onClick)
window.addCleanup(() => button.removeEventListener("click", onClick))
els[i].prepend(button) els[i].prepend(button)
} }
} }

View File

@@ -2,31 +2,39 @@ const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "l
const currentTheme = localStorage.getItem("theme") ?? userPref const currentTheme = localStorage.getItem("theme") ?? userPref
document.documentElement.setAttribute("saved-theme", currentTheme) document.documentElement.setAttribute("saved-theme", currentTheme)
const emitThemeChangeEvent = (theme: "light" | "dark") => {
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
detail: { theme },
})
document.dispatchEvent(event)
}
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const switchTheme = (e: any) => { const switchTheme = (e: Event) => {
if (e.target.checked) { const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", "dark") document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", "dark") localStorage.setItem("theme", newTheme)
} else { emitThemeChangeEvent(newTheme)
document.documentElement.setAttribute("saved-theme", "light") }
localStorage.setItem("theme", "light")
} const themeChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
emitThemeChangeEvent(newTheme)
} }
// Darkmode toggle // Darkmode toggle
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme) toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
if (currentTheme === "dark") { if (currentTheme === "dark") {
toggleSwitch.checked = true toggleSwitch.checked = true
} }
// Listen for changes in prefers-color-scheme // Listen for changes in prefers-color-scheme
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
colorSchemeMediaQuery.addEventListener("change", (e) => { colorSchemeMediaQuery.addEventListener("change", themeChange)
const newTheme = e.matches ? "dark" : "light" window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
})
}) })

View File

@@ -1,132 +1,106 @@
import { FolderState } from "../ExplorerNode" import { FolderState } from "../ExplorerNode"
// Current state of folders type MaybeHTMLElement = HTMLElement | undefined
let explorerState: FolderState[] let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible // If last element is observed, remove gradient of "overflow" class so element is visible
const explorer = document.getElementById("explorer-ul") const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) { for (const entry of entries) {
if (entry.isIntersecting) { if (entry.isIntersecting) {
explorer?.classList.add("no-background") explorerUl.classList.add("no-background")
} else { } else {
explorer?.classList.remove("no-background") explorerUl.classList.remove("no-background")
} }
} }
}) })
function toggleExplorer(this: HTMLElement) { function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
const content = this.nextElementSibling as HTMLElement const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
} }
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
evt.stopPropagation() evt.stopPropagation()
const target = evt.target as MaybeHTMLElement
if (!target) return
// Element that was clicked
const target = evt.target as HTMLElement
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg" const isSvg = target.nodeName === "svg"
const childFolderContainer = (
isSvg
? target.parentElement?.nextSibling
: target.parentElement?.parentElement?.nextElementSibling
) as MaybeHTMLElement
const currentFolderParent = (
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return
// corresponding <ul> element relative to clicked button/folder childFolderContainer.classList.toggle("open")
let childFolderContainer: HTMLElement
// <li> element of folder (stores folder-path dataset)
let currentFolderParent: HTMLElement
// Get correct relative container and toggle collapsed class
if (isSvg) {
childFolderContainer = target.parentElement?.nextSibling as HTMLElement
currentFolderParent = target.nextElementSibling as HTMLElement
childFolderContainer.classList.toggle("open")
} else {
childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
currentFolderParent = target.parentElement as HTMLElement
childFolderContainer.classList.toggle("open")
}
if (!childFolderContainer) return
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open") const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed) setFolderState(childFolderContainer, !isCollapsed)
const fullFolderPath = currentFolderParent.dataset.folderpath as string
// Save folder state to localStorage toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const clickFolderPath = currentFolderParent.dataset.folderpath as string const stringifiedFileTree = JSON.stringify(currentExplorerState)
const fullFolderPath = clickFolderPath
toggleCollapsedByPath(explorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(explorerState)
localStorage.setItem("fileTree", stringifiedFileTree) localStorage.setItem("fileTree", stringifiedFileTree)
} }
function setupExplorer() { function setupExplorer() {
// Set click handler for collapsing entire explorer
const explorer = document.getElementById("explorer") const explorer = document.getElementById("explorer")
if (!explorer) return
if (explorer.dataset.behavior === "collapse") {
for (const item of document.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
}
explorer.addEventListener("click", toggleExplorer)
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
// Get folder state from local storage // Get folder state from local storage
const storageTree = localStorage.getItem("fileTree") const storageTree = localStorage.getItem("fileTree")
// Convert to bool
const useSavedFolderState = explorer?.dataset.savestate === "true" const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
const newExplorerState: FolderState[] = explorer.dataset.tree
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed })
}
if (explorer) { currentExplorerState.map((folderState) => {
// Get config const folderLi = document.querySelector(
const collapseBehavior = explorer.dataset.behavior `[data-folderpath='${folderState.path}']`,
) as MaybeHTMLElement
// Add click handlers for all folders (click handler on folder "label") const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (collapseBehavior === "collapse") { if (folderUl) {
Array.prototype.forEach.call( setFolderState(folderUl, folderState.collapsed)
document.getElementsByClassName("folder-button"),
function (item) {
item.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
},
)
} }
// Add click handler to main explorer
explorer.removeEventListener("click", toggleExplorer)
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon")
Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
item.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
}) })
if (storageTree && useSavedFolderState) {
// Get state from localStorage and set folder state
explorerState = JSON.parse(storageTree)
explorerState.map((folderUl) => {
// grab <li> element for matching folder path
const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
// Get corresponding content <ul> tag and set state
if (folderLi) {
const folderUL = folderLi.parentElement?.nextElementSibling
if (folderUL) {
setFolderState(folderUL as HTMLElement, folderUl.collapsed)
}
}
})
} else if (explorer?.dataset.tree) {
// If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
explorerState = JSON.parse(explorer.dataset.tree)
}
} }
window.addEventListener("resize", setupExplorer) window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
setupExplorer() setupExplorer()
observer.disconnect() observer.disconnect()
// select pseudo element at end of list // select pseudo element at end of list
@@ -142,11 +116,7 @@ document.addEventListener("nav", () => {
* @param collapsed if folder should be set to collapsed or not * @param collapsed if folder should be set to collapsed or not
*/ */
function setFolderState(folderElement: HTMLElement, collapsed: boolean) { function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
if (collapsed) { return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
folderElement?.classList.remove("open")
} else {
folderElement?.classList.add("open")
}
} }
/** /**

View File

@@ -319,12 +319,12 @@ function renderGlobalGraph() {
registerEscapeHandler(container, hideGlobalGraph) registerEscapeHandler(container, hideGlobalGraph)
} }
document.addEventListener("nav", async (e: unknown) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = (e as CustomEventMap["nav"]).detail.url const slug = e.detail.url
addToVisited(slug) addToVisited(slug)
await renderGraph("graph-container", slug) await renderGraph("graph-container", slug)
const containerIcon = document.getElementById("global-graph-icon") const containerIcon = document.getElementById("global-graph-icon")
containerIcon?.removeEventListener("click", renderGlobalGraph)
containerIcon?.addEventListener("click", renderGlobalGraph) containerIcon?.addEventListener("click", renderGlobalGraph)
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
}) })

View File

@@ -76,7 +76,7 @@ async function mouseEnterHandler(
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[] const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
for (const link of links) { for (const link of links) {
link.removeEventListener("mouseenter", mouseEnterHandler)
link.addEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseenter", mouseEnterHandler)
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
} }
}) })

View File

@@ -1,7 +1,7 @@
import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch" import FlexSearch from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex" import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path" import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
interface Item { interface Item {
id: number id: number
@@ -11,23 +11,53 @@ interface Item {
tags: string[] tags: string[]
} }
let index: Document<Item> | undefined = undefined
// Can be expanded with things like "term" in the future // Can be expanded with things like "term" in the future
type SearchType = "basic" | "tags" type SearchType = "basic" | "tags"
// Current searchType
let searchType: SearchType = "basic" let searchType: SearchType = "basic"
let currentSearchTerm: string = ""
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let index = new FlexSearch.Document<Item>({
charset: "latin:extra",
encode: encoder,
document: {
id: "id",
index: [
{
field: "title",
tokenize: "forward",
},
{
field: "content",
tokenize: "forward",
},
{
field: "tags",
tokenize: "forward",
},
],
},
})
const p = new DOMParser()
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
const contextWindowWords = 30 const contextWindowWords = 30
const numSearchResults = 5 const numSearchResults = 8
const numTagResults = 3 const numTagResults = 5
const tokenizeTerm = (term: string) => {
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
const tokenLen = tokens.length
if (tokenLen > 1) {
for (let i = 1; i < tokenLen; i++) {
tokens.push(tokens.slice(0, i + 1).join(" "))
}
}
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
}
function highlight(searchTerm: string, text: string, trim?: boolean) { function highlight(searchTerm: string, text: string, trim?: boolean) {
// try to highlight longest tokens first const tokenizedTerms = tokenizeTerm(searchTerm)
const tokenizedTerms = searchTerm
.split(/\s+/)
.filter((t) => t !== "")
.sort((a, b) => b.length - a.length)
let tokenizedText = text.split(/\s+/).filter((t) => t !== "") let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
let startIndex = 0 let startIndex = 0
@@ -71,20 +101,76 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}` }`
} }
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) function highlightHTML(searchTerm: string, el: HTMLElement) {
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined const p = new DOMParser()
document.addEventListener("nav", async (e: unknown) => { const tokenizedTerms = tokenizeTerm(searchTerm)
const currentSlug = (e as CustomEventMap["nav"]).detail.url const html = p.parseFromString(el.innerHTML, "text/html")
const createHighlightSpan = (text: string) => {
const span = document.createElement("span")
span.className = "highlight"
span.textContent = text
return span
}
const highlightTextNodes = (node: Node, term: string) => {
if (node.nodeType === Node.TEXT_NODE) {
const nodeText = node.nodeValue ?? ""
const regex = new RegExp(term.toLowerCase(), "gi")
const matches = nodeText.match(regex)
if (!matches || matches.length === 0) return
const spanContainer = document.createElement("span")
let lastIndex = 0
for (const match of matches) {
const matchIndex = nodeText.indexOf(match, lastIndex)
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
spanContainer.appendChild(createHighlightSpan(match))
lastIndex = matchIndex + match.length
}
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
node.parentNode?.replaceChild(spanContainer, node)
} else if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as HTMLElement).classList.contains("highlight")) return
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
}
}
for (const term of tokenizedTerms) {
highlightTextNodes(html.body, term)
}
return html.body
}
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
const data = await fetchData const data = await fetchData
const container = document.getElementById("search-container") const container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement const sidebar = container?.closest(".sidebar") as HTMLElement
const searchIcon = document.getElementById("search-icon") const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const results = document.getElementById("results-container") const searchLayout = document.getElementById("search-layout")
const resultCards = document.getElementsByClassName("result-card")
const idDataMap = Object.keys(data) as FullSlug[] const idDataMap = Object.keys(data) as FullSlug[]
const appendLayout = (el: HTMLElement) => {
if (searchLayout?.querySelector(`#${el.id}`) === null) {
searchLayout?.appendChild(el)
}
}
const enablePreview = searchLayout?.dataset?.preview === "true"
let preview: HTMLDivElement | undefined = undefined
let previewInner: HTMLDivElement | undefined = undefined
const results = document.createElement("div")
results.id = "results-container"
appendLayout(results)
if (enablePreview) {
preview = document.createElement("div")
preview.id = "preview-container"
appendLayout(preview)
}
function hideSearch() { function hideSearch() {
container?.classList.remove("active") container?.classList.remove("active")
if (searchBar) { if (searchBar) {
@@ -96,6 +182,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) { if (results) {
removeAllChildren(results) removeAllChildren(results)
} }
if (preview) {
removeAllChildren(preview)
}
if (searchLayout) {
searchLayout.classList.remove("display-results")
}
searchType = "basic" // reset search type after closing searchType = "basic" // reset search type after closing
} }
@@ -109,11 +201,14 @@ document.addEventListener("nav", async (e: unknown) => {
searchBar?.focus() searchBar?.focus()
} }
function shortcutHandler(e: HTMLElementEventMap["keydown"]) { let currentHover: HTMLInputElement | null = null
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
const searchBarOpen = container?.classList.contains("active") const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("basic") searchBarOpen ? hideSearch() : showSearch("basic")
return
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search // Hotkey to open tag search
e.preventDefault() e.preventDefault()
@@ -122,159 +217,205 @@ document.addEventListener("nav", async (e: unknown) => {
// add "#" prefix for tag search // add "#" prefix for tag search
if (searchBar) searchBar.value = "#" if (searchBar) searchBar.value = "#"
return
} }
if (currentHover) {
currentHover.classList.remove("focus")
}
// 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
else 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
await displayPreview(active)
active.click() active.click()
} else { } else {
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
anchor?.click() if (!anchor || anchor?.classList.contains("no-match")) return
await displayPreview(anchor)
anchor.click()
} }
} else if (e.key === "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 prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null const currentResult = currentHover
? currentHover
: (document.activeElement as HTMLInputElement | null)
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
currentResult?.classList.remove("focus")
prevResult?.focus() prevResult?.focus()
if (prevResult) currentHover = prevResult
await displayPreview(prevResult)
} }
} else if (e.key === "ArrowDown" || e.key === "Tab") { } else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault() e.preventDefault()
// When first pressing ArrowDown, results wont contain the active element, so focus first element // The results should already been focused, so we need to find the next one.
if (!results?.contains(document.activeElement)) { // The activeElement is the search bar, so we need to find the first result and focus it.
const firstResult = resultCards[0] as HTMLInputElement | null if (document.activeElement === searchBar || currentHover !== null) {
firstResult?.focus() const firstResult = currentHover
} else { ? currentHover
// If an element in results-container already has focus, focus next one : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
nextResult?.focus() firstResult?.classList.remove("focus")
secondResult?.focus()
if (secondResult) currentHover = secondResult
await displayPreview(secondResult)
} }
} }
} }
function trimContent(content: string) {
// works without escaping html like in `description.ts`
const sentences = content.replace(/\s+/g, " ").split(".")
let finalDesc = ""
let sentenceIdx = 0
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
const len = contextWindowWords * 5
while (finalDesc.length < len) {
const sentence = sentences[sentenceIdx]
if (!sentence) break
finalDesc += sentence + "."
sentenceIdx++
}
// If more content would be available, indicate it by finishing with "..."
if (finalDesc.length < content.length) {
finalDesc += ".."
}
return finalDesc
}
const formatForDisplay = (term: string, id: number) => { const formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id] const slug = idDataMap[id]
return { return {
id, id,
slug, slug,
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
// if searchType is tag, display context from start of file and trim, otherwise use regular highlight content: highlight(term, data[slug].content ?? "", true),
content: tags: highlightTags(term.substring(1), data[slug].tags),
searchType === "tags"
? trimContent(data[slug].content)
: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term, data[slug].tags),
} }
} }
function highlightTags(term: string, tags: string[]) { function highlightTags(term: string, tags: string[]) {
if (tags && searchType === "tags") { if (!tags || searchType !== "tags") {
// Find matching tags
const termLower = term.toLowerCase()
let matching = tags.filter((str) => str.includes(termLower))
// Subtract matching from original tags, then push difference
if (matching.length > 0) {
let difference = tags.filter((x) => !matching.includes(x))
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
matching.push(...difference)
}
// Only allow max of `numTagResults` in preview
if (tags.length > numTagResults) {
matching.splice(numTagResults)
}
return matching
} else {
return [] return []
} }
return tags
.map((tag) => {
if (tag.toLowerCase().includes(term.toLowerCase())) {
return `<li><p class="match-tag">#${tag}</p></li>`
} else {
return `<li><p>#${tag}</p></li>`
}
})
.slice(0, numTagResults)
}
function resolveUrl(slug: FullSlug): URL {
return new URL(resolveRelative(currentSlug, slug), location.toString())
} }
const resultToHTML = ({ slug, title, content, tags }: Item) => { const resultToHTML = ({ slug, title, content, tags }: Item) => {
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : `` const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
const itemTile = document.createElement("a") const itemTile = document.createElement("a")
itemTile.classList.add("result-card") itemTile.classList.add("result-card")
itemTile.id = slug itemTile.id = slug
itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString() itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>` itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${
enablePreview && window.innerWidth > 600 ? "" : `<p>${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()
}) })
const handler = (event: MouseEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
}
async function onMouseEnter(ev: MouseEvent) {
if (!ev.target) return
const target = ev.target as HTMLInputElement
await displayPreview(target)
}
itemTile.addEventListener("mouseenter", onMouseEnter)
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
itemTile.addEventListener("click", handler)
window.addCleanup(() => itemTile.removeEventListener("click", handler))
return itemTile return itemTile
} }
function displayResults(finalResults: Item[]) { async function displayResults(finalResults: Item[]) {
if (!results) return if (!results) return
removeAllChildren(results) removeAllChildren(results)
if (finalResults.length === 0) { if (finalResults.length === 0) {
results.innerHTML = `<button class="result-card"> results.innerHTML = `<a class="result-card no-match">
<h3>No results.</h3> <h3>No results.</h3>
<p>Try another search term?</p> <p>Try another search term?</p>
</button>` </a>`
} else { } else {
results.append(...finalResults.map(resultToHTML)) results.append(...finalResults.map(resultToHTML))
} }
if (finalResults.length === 0 && preview) {
// no results, clear previous preview
removeAllChildren(preview)
} else {
// focus on first result, then also dispatch preview immediately
const firstChild = results.firstElementChild as HTMLElement
firstChild.classList.add("focus")
currentHover = firstChild as HTMLInputElement
await displayPreview(firstChild)
}
}
async function fetchContent(slug: FullSlug): Promise<Element[]> {
if (fetchContentCache.has(slug)) {
return fetchContentCache.get(slug) as Element[]
}
const targetUrl = resolveUrl(slug).toString()
const contents = await fetch(targetUrl)
.then((res) => res.text())
.then((contents) => {
if (contents === undefined) {
throw new Error(`Could not fetch ${targetUrl}`)
}
const html = p.parseFromString(contents ?? "", "text/html")
normalizeRelativeURLs(html, targetUrl)
return [...html.getElementsByClassName("popover-hint")]
})
fetchContentCache.set(slug, contents)
return contents
}
async function displayPreview(el: HTMLElement | null) {
if (!searchLayout || !enablePreview || !el || !preview) return
const slug = el.id as FullSlug
const innerDiv = await fetchContent(slug).then((contents) =>
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
)
previewInner = document.createElement("div")
previewInner.classList.add("preview-inner")
previewInner.append(...innerDiv)
preview.replaceChildren(previewInner)
// scroll to longest
const highlights = [...preview.querySelectorAll(".highlight")].sort(
(a, b) => b.innerHTML.length - a.innerHTML.length,
)
highlights[0]?.scrollIntoView({ block: "start" })
} }
async function onType(e: HTMLElementEventMap["input"]) { async function onType(e: HTMLElementEventMap["input"]) {
let term = (e.target as HTMLInputElement).value if (!searchLayout || !index) return
let searchResults: SimpleDocumentSearchResultSetUnit[] currentSearchTerm = (e.target as HTMLInputElement).value
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
if (term.toLowerCase().startsWith("#")) { let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
searchType = "tags" if (searchType === "tags") {
} else { searchResults = await index.searchAsync({
searchType = "basic" query: currentSearchTerm.substring(1),
} limit: numSearchResults,
index: ["tags"],
switch (searchType) { })
case "tags": { } else if (searchType === "basic") {
term = term.substring(1) searchResults = await index.searchAsync({
searchResults = query: currentSearchTerm,
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ?? limit: numSearchResults,
[] index: ["title", "content"],
break })
}
case "basic":
default: {
searchResults =
(await index?.searchAsync({
query: term,
limit: numSearchResults,
index: ["title", "content"],
})) ?? []
}
} }
const getByField = (field: string): number[] => { const getByField = (field: string): number[] => {
@@ -288,51 +429,19 @@ document.addEventListener("nav", async (e: unknown) => {
...getByField("content"), ...getByField("content"),
...getByField("tags"), ...getByField("tags"),
]) ])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
displayResults(finalResults) await displayResults(finalResults)
}
if (prevShortcutHandler) {
document.removeEventListener("keydown", prevShortcutHandler)
} }
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.removeEventListener("click", () => showSearch("basic"))
searchIcon?.addEventListener("click", () => showSearch("basic")) searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType) window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
searchBar?.addEventListener("input", onType) searchBar?.addEventListener("input", onType)
window.addCleanup(() => searchBar?.removeEventListener("input", onType))
// setup index if it hasn't been already
if (!index) {
index = new Document({
charset: "latin:extra",
optimize: true,
encode: encoder,
document: {
id: "id",
index: [
{
field: "title",
tokenize: "reverse",
},
{
field: "content",
tokenize: "reverse",
},
{
field: "tags",
tokenize: "reverse",
},
],
},
})
fillDocument(index, data)
}
// register handlers
registerEscapeHandler(container, hideSearch) registerEscapeHandler(container, hideSearch)
await fillDocument(data)
}) })
/** /**
@@ -340,16 +449,20 @@ document.addEventListener("nav", async (e: unknown) => {
* @param index index to fill * @param index index to fill
* @param data data to fill index with * @param data data to fill index with
*/ */
async function fillDocument(index: Document<Item, false>, data: any) { async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
let id = 0 let id = 0
const promises: Array<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) { for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, { promises.push(
id, index.addAsync(id++, {
slug: slug as FullSlug, id,
title: fileData.title, slug: slug as FullSlug,
content: fileData.content, title: fileData.title,
tags: fileData.tags, content: fileData.content,
}) tags: fileData.tags,
id++ }),
)
} }
return await Promise.all(promises)
} }

View File

@@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) {
document.dispatchEvent(event) document.dispatchEvent(event)
} }
const cleanupFns: Set<(...args: any[]) => void> = new Set()
window.addCleanup = (fn) => cleanupFns.add(fn)
let p: DOMParser let p: DOMParser
async function navigate(url: URL, isBack: boolean = false) { async function navigate(url: URL, isBack: boolean = false) {
p = p || new DOMParser() p = p || new DOMParser()
@@ -57,6 +60,10 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return if (!contents) return
// cleanup old
cleanupFns.forEach((fn) => fn())
cleanupFns.clear()
const html = p.parseFromString(contents, "text/html") const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, url) normalizeRelativeURLs(html, url)

View File

@@ -16,7 +16,8 @@ const observer = new IntersectionObserver((entries) => {
function toggleToc(this: HTMLElement) { function toggleToc(this: HTMLElement) {
this.classList.toggle("collapsed") this.classList.toggle("collapsed")
const content = this.nextElementSibling as HTMLElement const content = this.nextElementSibling as HTMLElement | undefined
if (!content) return
content.classList.toggle("collapsed") content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
} }
@@ -25,10 +26,11 @@ function setupToc() {
const toc = document.getElementById("toc") const toc = document.getElementById("toc")
if (toc) { if (toc) {
const collapsed = toc.classList.contains("collapsed") const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return
content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px" content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px"
toc.removeEventListener("click", toggleToc)
toc.addEventListener("click", toggleToc) toc.addEventListener("click", toggleToc)
window.addCleanup(() => toc.removeEventListener("click", toggleToc))
} }
} }

View File

@@ -12,10 +12,10 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb:
cb() cb()
} }
outsideContainer?.removeEventListener("click", click)
outsideContainer?.addEventListener("click", click) outsideContainer?.addEventListener("click", click)
document.removeEventListener("keydown", esc) window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
document.addEventListener("keydown", esc) document.addEventListener("keydown", esc)
window.addCleanup(() => document.removeEventListener("keydown", esc))
} }
export function removeAllChildren(node: HTMLElement) { export function removeAllChildren(node: HTMLElement) {

View File

@@ -1,3 +1,5 @@
@use "../../styles/variables.scss" as *;
button#explorer { button#explorer {
all: unset; all: unset;
background-color: transparent; background-color: transparent;
@@ -85,7 +87,7 @@ svg {
color: var(--secondary); color: var(--secondary);
font-family: var(--headerFont); font-family: var(--headerFont);
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: $boldWeight;
line-height: 1.5rem; line-height: 1.5rem;
display: inline-block; display: inline-block;
} }
@@ -110,7 +112,7 @@ svg {
font-size: 0.95rem; font-size: 0.95rem;
display: inline-block; display: inline-block;
color: var(--secondary); color: var(--secondary);
font-weight: 600; font-weight: $boldWeight;
margin: 0; margin: 0;
line-height: 1.5rem; line-height: 1.5rem;
pointer-events: none; pointer-events: none;

View File

@@ -54,18 +54,14 @@
} }
& > #search-space { & > #search-space {
width: 50%; width: 65%;
margin-top: 15vh; margin-top: 12vh;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@media all and (max-width: $fullPageWidth) {
width: 90%;
}
& > * { & > * {
width: 100%; width: 100%;
border-radius: 5px; border-radius: 7px;
background: var(--light); background: var(--light);
box-shadow: box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12), 0 14px 50px rgba(27, 33, 48, 0.12),
@@ -86,91 +82,136 @@
} }
} }
& > #results-container { & > #search-layout {
& .result-card { display: none;
padding: 1em; flex-direction: row;
cursor: pointer; border: 1px solid var(--lightgray);
transition: background 0.2s ease; flex: 0 0 100%;
border: 1px solid var(--lightgray); box-sizing: border-box;
border-bottom: none;
width: 100%;
// normalize card props &.display-results {
display: flex;
}
&[data-preview] > #results-container {
flex: 0 0 min(30%, 450px);
}
@media all and (min-width: $tabletBreakpoint) {
&[data-preview] {
& .result-card > p.preview {
display: none;
}
& > div {
&:first-child {
border-right: 1px solid var(--lightgray);
border-top-right-radius: unset;
border-bottom-right-radius: unset;
}
&:last-child {
border-top-left-radius: unset;
border-bottom-left-radius: unset;
}
}
}
}
& > div {
height: calc(75vh - 12vh);
border-radius: 5px;
}
@media all and (max-width: $tabletBreakpoint) {
& > #preview-container {
display: none !important;
}
&[data-preview] > #results-container {
width: 100%;
height: auto;
flex: 0 0 100%;
}
}
& .highlight {
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
border-radius: 5px;
scroll-margin-top: 2rem;
}
& > #preview-container {
display: block;
overflow: hidden;
font-family: inherit; font-family: inherit;
font-size: 100%; color: var(--dark);
line-height: 1.15; line-height: 1.5em;
margin: 0; font-weight: $normalWeight;
text-transform: none; overflow-y: auto;
text-align: left; padding: 0 2rem;
background: var(--light);
outline: none;
font-weight: inherit;
& .highlight { & .preview-inner {
color: var(--secondary); margin: 0 auto;
font-weight: 700; width: min($pageWidth, 100%);
} }
}
&:hover, & > #results-container {
&:focus { overflow-y: auto;
background: var(--lightgray);
}
&:first-of-type { & .result-card {
border-top-left-radius: 5px; overflow: hidden;
border-top-right-radius: 5px; padding: 1em;
} cursor: pointer;
transition: background 0.2s ease;
&:last-of-type {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-bottom: 1px solid var(--lightgray); border-bottom: 1px solid var(--lightgray);
} width: 100%;
display: block;
box-sizing: border-box;
& > h3 { // normalize card props
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0; margin: 0;
} text-transform: none;
text-align: left;
outline: none;
font-weight: inherit;
& > ul > li { &:hover,
margin: 0; &:focus,
display: inline-block; &.focus {
white-space: nowrap; background: var(--lightgray);
margin: 0; }
overflow-wrap: normal;
}
& > ul { & > h3 {
list-style: none; margin: 0;
display: flex; }
padding-left: 0;
gap: 0.4rem;
margin: 0;
margin-top: 0.45rem;
// Offset border radius
margin-left: -2px;
overflow: hidden;
background-clip: border-box;
}
& > ul > li > p { & > ul.tags {
border-radius: 8px; margin-top: 0.45rem;
background-color: var(--highlight); margin-bottom: 0;
overflow: hidden; }
background-clip: border-box;
padding: 0.03rem 0.4rem;
margin: 0;
color: var(--secondary);
opacity: 0.85;
}
& > ul > li > .match-tag { & > ul > li > p {
color: var(--tertiary); border-radius: 8px;
font-weight: bold; background-color: var(--highlight);
opacity: 1; padding: 0.2rem 0.4rem;
} margin: 0 0.1rem;
line-height: 1.4rem;
font-weight: $boldWeight;
color: var(--secondary);
& > p { &.match-tag {
margin-bottom: 0; color: var(--tertiary);
}
}
& > p {
margin-bottom: 0;
}
} }
} }
} }

11
quartz/i18n/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Translation } from "./locales/definition"
import en from "./locales/en-US"
import fr from "./locales/fr-FR"
export const TRANSLATIONS = {
"en-US": en,
"fr-FR": fr,
} as const
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale]
export type ValidLocale = keyof typeof TRANSLATIONS

View File

@@ -0,0 +1,63 @@
import { FullSlug } from "../../util/path"
export interface Translation {
propertyDefaults: {
title: string
description: string
}
components: {
backlinks: {
title: string
noBacklinksFound: string
}
themeToggle: {
lightMode: string
darkMode: string
}
explorer: {
title: string
}
footer: {
createdWith: string
}
graph: {
title: string
}
recentNotes: {
title: string
seeRemainingMore: (variables: { remaining: number }) => string
}
transcludes: {
transcludeOf: (variables: { targetSlug: FullSlug }) => string
linkToOriginal: string
}
search: {
title: string
searchBarPlaceholder: string
}
tableOfContents: {
title: string
}
}
pages: {
rss: {
recentNotes: string
lastFewNotes: (variables: { count: number }) => string
}
error: {
title: string
notFound: string
}
folderContent: {
folder: string
itemsUnderFolder: (variables: { count: number }) => string
}
tagContent: {
tag: string
tagIndex: string
itemsUnderTag: (variables: { count: number }) => string
showingFirst: (variables: { count: number }) => string
totalTags: (variables: { count: number }) => string
}
}
}

View File

@@ -0,0 +1,65 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Untitled",
description: "No description provided",
},
components: {
backlinks: {
title: "Backlinks",
noBacklinksFound: "No backlinks found",
},
themeToggle: {
lightMode: "Light mode",
darkMode: "Dark mode",
},
explorer: {
title: "Explorer",
},
footer: {
createdWith: "Created with",
},
graph: {
title: "Graph View",
},
recentNotes: {
title: "Recent Notes",
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
linkToOriginal: "Link to original",
},
search: {
title: "Search",
searchBarPlaceholder: "Search for something",
},
tableOfContents: {
title: "Table of Contents",
},
},
pages: {
rss: {
recentNotes: "Recent notes",
lastFewNotes: ({ count }) => `Last ${count} notes`,
},
error: {
title: "Not Found",
notFound: "Either this page is private or doesn't exist.",
},
folderContent: {
folder: "Folder",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 item under this folder" : `${count} items under this folder.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Tag Index",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 item with this tag" : `${count} items with this tag.`,
showingFirst: ({ count }) => `Showing first ${count} tags.`,
totalTags: ({ count }) => `Found ${count} total tags.`,
},
},
} as const satisfies Translation

View File

@@ -0,0 +1,65 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sans titre",
description: "Aucune description fournie",
},
components: {
backlinks: {
title: "Liens retour",
noBacklinksFound: "Aucun lien retour trouvé",
},
themeToggle: {
lightMode: "Mode clair",
darkMode: "Mode sombre",
},
explorer: {
title: "Explorateur",
},
footer: {
createdWith: "Créé avec",
},
graph: {
title: "Vue Graphique",
},
recentNotes: {
title: "Notes Récentes",
seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
linkToOriginal: "Lien vers l'original",
},
search: {
title: "Recherche",
searchBarPlaceholder: "Rechercher quelque chose",
},
tableOfContents: {
title: "Table des Matières",
},
},
pages: {
rss: {
recentNotes: "Notes récentes",
lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
},
error: {
title: "Pas trouvé",
notFound: "Cette page est soit privée, soit elle n'existe pas.",
},
folderContent: {
folder: "Dossier",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 élément sous ce dossier" : `${count} éléments sous ce dossier.`,
},
tagContent: {
tag: "Étiquette",
tagIndex: "Index des étiquettes",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 élément avec cette étiquette" : `${count} éléments avec cette étiquette.`,
showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
},
},
} as const satisfies Translation

View File

@@ -8,6 +8,7 @@ import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components" import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile" import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n"
export const NotFoundPage: QuartzEmitterPlugin = () => { export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
@@ -33,11 +34,12 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
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 externalResources = pageResources(path, resources) const externalResources = pageResources(path, resources)
const notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({ const [tree, vfile] = defaultProcessedContent({
slug, slug,
text: "Not Found", text: notFound,
description: "Not Found", description: notFound,
frontmatter: { title: "Not Found", tags: [] }, frontmatter: { title: notFound, tags: [] },
}) })
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
fileData: vfile.data, fileData: vfile.data,
@@ -51,7 +53,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
return [ return [
await write({ await write({
ctx, ctx,
content: renderPage(slug, componentData, opts, externalResources), content: renderPage(cfg, slug, componentData, opts, externalResources),
slug, slug,
ext: ".html", ext: ".html",
}), }),

View File

@@ -15,12 +15,7 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
for (const [_tree, file] of content) { for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!) const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
const aliases = file.data.frontmatter?.aliases ?? []
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") {
aliases = [aliases]
}
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug) const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const permalink = file.data.frontmatter?.permalink const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") { if (typeof permalink === "string") {

View File

@@ -119,7 +119,7 @@ function addGlobalPageResources(
} else if (cfg.analytics?.provider === "umami") { } else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script") const umamiScript = document.createElement("script")
umamiScript.src = "https://analytics.umami.is/script.js" umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.async = true umamiScript.async = true
@@ -131,9 +131,11 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(spaRouterScript) componentResources.afterDOMLoaded.push(spaRouterScript)
} else { } else {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url) window.spaNavigate = (url, _) => window.location.assign(url)
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) window.addCleanup = () => {}
document.dispatchEvent(event)`) const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)
`)
} }
let wsUrl = `ws://localhost:${ctx.argv.wsPort}` let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
@@ -147,9 +149,9 @@ function addGlobalPageResources(
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "inline", contentType: "inline",
script: ` script: `
const socket = new WebSocket('${wsUrl}') const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload()) socket.addEventListener('message', () => document.location.reload())
`, `,
}) })
} }
} }

View File

@@ -5,8 +5,8 @@ import { escapeHTML } from "../../util/escape"
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import path from "path"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n"
export type ContentIndex = Map<FullSlug, ContentDetails> export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
@@ -39,7 +39,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc> <loc>https://${joinSegments(base, encodeURI(slug))}</loc>
<lastmod>${content.date?.toISOString()}</lastmod> ${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>` </url>`
const urls = Array.from(idx) const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
@@ -79,7 +79,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
<channel> <channel>
<title>${escapeHTML(cfg.pageTitle)}</title> <title>${escapeHTML(cfg.pageTitle)}</title>
<link>https://${base}</link> <link>https://${base}</link>
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML( <description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
cfg.pageTitle, cfg.pageTitle,
)}</description> )}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator> <generator>Quartz -- quartz.jzhao.xyz</generator>

View File

@@ -49,7 +49,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
allFiles, allFiles,
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({ const fp = await write({
ctx, ctx,
content, content,

View File

@@ -18,8 +18,9 @@ import {
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components" import { FolderContent } from "../../components"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n"
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
@@ -57,7 +58,10 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
folder, folder,
defaultProcessedContent({ defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug, slug: joinSegments(folder, "index") as FullSlug,
frontmatter: { title: `Folder: ${folder}`, tags: [] }, frontmatter: {
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
tags: [],
},
}), }),
]), ]),
) )
@@ -82,7 +86,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
allFiles, allFiles,
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({ const fp = await write({
ctx, ctx,
content, content,

View File

@@ -15,8 +15,9 @@ import {
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components" import { TagContent } from "../../components"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n"
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => { export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = { const opts: FullPageLayout = {
...sharedPageComponents, ...sharedPageComponents,
...defaultListPageLayout, ...defaultListPageLayout,
@@ -47,7 +48,10 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries( const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => { [...tags].map((tag) => {
const title = tag === "index" ? "Tag Index" : `Tag: #${tag}` const title =
tag === "index"
? i18n(cfg.locale).pages.tagContent.tagIndex
: `${i18n(cfg.locale).pages.tagContent.tag}: #${tag}`
return [ return [
tag, tag,
defaultProcessedContent({ defaultProcessedContent({
@@ -81,7 +85,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
allFiles, allFiles,
} }
const content = renderPage(slug, componentData, opts, externalResources) const content = renderPage(cfg, slug, componentData, opts, externalResources)
const fp = await write({ const fp = await write({
ctx, ctx,
content, content,

View File

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

View File

@@ -5,26 +5,46 @@ import yaml from "js-yaml"
import toml from "toml" import toml from "toml"
import { slugTag } from "../../util/path" import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n"
export interface Options { export interface Options {
delims: string | string[] delims: string | string[]
language: "yaml" | "toml" language: "yaml" | "toml"
oneLineTagDelim: string
} }
const defaultOptions: Options = { const defaultOptions: Options = {
delims: "---", delims: "---",
language: "yaml", language: "yaml",
oneLineTagDelim: ",", }
function coalesceAliases(data: { [key: string]: any }, aliases: string[]) {
for (const alias of aliases) {
if (data[alias] !== undefined && data[alias] !== null) return data[alias]
}
}
function coerceToArray(input: string | string[]): string[] | undefined {
if (input === undefined || input === null) return undefined
// coerce to array
if (!Array.isArray(input)) {
input = input
.toString()
.split(",")
.map((tag: string) => tag.trim())
}
// remove all non-strings
return input
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number")
.map((tag: string | number) => tag.toString())
} }
export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => { export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts } const opts = { ...defaultOptions, ...userOpts }
return { return {
name: "FrontMatter", name: "FrontMatter",
markdownPlugins() { markdownPlugins({ cfg }) {
const { oneLineTagDelim } = opts
return [ return [
[remarkFrontmatter, ["yaml", "toml"]], [remarkFrontmatter, ["yaml", "toml"]],
() => { () => {
@@ -37,35 +57,19 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
}, },
}) })
// tag is an alias for tags
if (data.tag) {
data.tags = data.tag
}
// coerce title to string
if (data.title) { if (data.title) {
data.title = data.title.toString() data.title = data.title.toString()
} else if (data.title === null || data.title === undefined) { } else if (data.title === null || data.title === undefined) {
data.title = file.stem ?? "Untitled" data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
} }
if (data.tags) { const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
// coerce to array if (tags) data.tags = [...new Set(tags.map((tag: string) => slugTag(tag)))]
if (!Array.isArray(data.tags)) {
data.tags = data.tags
.toString()
.split(oneLineTagDelim)
.map((tag: string) => tag.trim())
}
// remove all non-string tags const aliases = coerceToArray(coalesceAliases(data, ["aliases", "alias"]))
data.tags = data.tags if (aliases) data.aliases = aliases
.filter((tag: unknown) => typeof tag === "string" || typeof tag === "number") const cssclasses = coerceToArray(coalesceAliases(data, ["cssclasses", "cssclass"]))
.map((tag: string | number) => tag.toString()) if (cssclasses) data.cssclasses = cssclasses
}
// slug them all!!
data.tags = [...new Set(data.tags?.map((tag: string) => slugTag(tag)))]
// fill in frontmatter // fill in frontmatter
file.data.frontmatter = data as QuartzPluginData["frontmatter"] file.data.frontmatter = data as QuartzPluginData["frontmatter"]
@@ -78,9 +82,16 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
declare module "vfile" { declare module "vfile" {
interface DataMap { interface DataMap {
frontmatter: { [key: string]: any } & { frontmatter: { [key: string]: unknown } & {
title: string title: string
tags: string[] } & Partial<{
} tags: string[]
aliases: string[]
description: string
publish: boolean
draft: boolean
enableToc: string
cssclasses: string[]
}>
} }
} }

View File

@@ -37,8 +37,36 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
"data-no-popover": true, "data-no-popover": true,
}, },
content: { content: {
type: "text", type: "element",
value: " §", tagName: "svg",
properties: {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
},
children: [],
},
{
type: "element",
tagName: "path",
properties: {
d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
},
children: [],
},
],
}, },
}, },
], ],

View File

@@ -43,18 +43,18 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
let published: MaybeDate = undefined let published: MaybeDate = undefined
const fp = file.data.filePath! const fp = file.data.filePath!
const fullFp = path.posix.join(file.cwd, fp) const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
for (const source of opts.priority) { for (const source of opts.priority) {
if (source === "filesystem") { if (source === "filesystem") {
const st = await fs.promises.stat(fullFp) const st = await fs.promises.stat(fullFp)
created ||= st.birthtimeMs created ||= st.birthtimeMs
modified ||= st.mtimeMs modified ||= st.mtimeMs
} else if (source === "frontmatter" && file.data.frontmatter) { } else if (source === "frontmatter" && file.data.frontmatter) {
created ||= file.data.frontmatter.date created ||= file.data.frontmatter.date as MaybeDate
modified ||= file.data.frontmatter.lastmod modified ||= file.data.frontmatter.lastmod as MaybeDate
modified ||= file.data.frontmatter.updated modified ||= file.data.frontmatter.updated as MaybeDate
modified ||= file.data.frontmatter["last-modified"] modified ||= file.data.frontmatter["last-modified"] as MaybeDate
published ||= file.data.frontmatter.publishDate published ||= file.data.frontmatter.publishDate as MaybeDate
} else if (source === "git") { } else if (source === "git") {
if (!repo) { if (!repo) {
// Get a reference to the main git repo. // Get a reference to the main git repo.

View File

@@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
return { return {
css: [ css: [
// base css // base css
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css",
], ],
js: [ js: [
{ {
// fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md
src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", src: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/copy-tex.min.js",
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
contentType: "external", contentType: "external",
}, },

View File

@@ -44,39 +44,7 @@ const defaultOptions: Options = {
enableVideoEmbed: true, enableVideoEmbed: true,
} }
const icons = { const calloutMapping = {
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
}
const callouts = {
note: icons.pencilIcon,
abstract: icons.clipboardListIcon,
info: icons.infoIcon,
todo: icons.checkCircleIcon,
tip: icons.flameIcon,
success: icons.checkIcon,
question: icons.helpCircleIcon,
warning: icons.alertTriangleIcon,
failure: icons.xIcon,
danger: icons.zapIcon,
bug: icons.bugIcon,
example: icons.listIcon,
quote: icons.quoteIcon,
}
const calloutMapping: Record<string, keyof typeof callouts> = {
note: "note", note: "note",
abstract: "abstract", abstract: "abstract",
summary: "abstract", summary: "abstract",
@@ -104,24 +72,36 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
example: "example", example: "example",
quote: "quote", quote: "quote",
cite: "quote", cite: "quote",
} as const
const arrowMapping: Record<string, string> = {
"->": "&rarr;",
"-->": "&rArr;",
"=>": "&rArr;",
"==>": "&rArr;",
"<-": "&larr;",
"<--": "&lArr;",
"<=": "&lArr;",
"<==": "&lArr;",
} }
function canonicalizeCallout(calloutName: string): keyof typeof callouts { function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
return calloutMapping[callout] ?? "note" // if callout is not recognized, make it a custom one
return calloutMapping[normalizedCallout] ?? calloutName
} }
export const externalLinkRegex = /^https?:\/\//i export const externalLinkRegex = /^https?:\/\//i
export const arrowRegex = new RegExp(/-{1,2}>/, "g") export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g")
// !? -> optional embedding // !? -> optional embedding
// \[\[ -> open brace // \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) // (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias)
export const wikilinkRegex = new RegExp( export const wikilinkRegex = new RegExp(
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
"g", "g",
) )
const highlightRegex = new RegExp(/==([^=]+)==/, "g") const highlightRegex = new RegExp(/==([^=]+)==/, "g")
@@ -137,6 +117,9 @@ const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{E
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g") const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/) const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
const wikilinkImageEmbedRegex = new RegExp(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
@@ -221,10 +204,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
const ext: string = path.extname(fp).toLowerCase() const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath) const url = slugifyFilePath(fp as FilePath)
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
const dims = alias ?? "" const match = wikilinkImageEmbedRegex.exec(alias ?? "")
let [width, height] = dims.split("x", 2) const alt = match?.groups?.alt ?? ""
width ||= "auto" const width = match?.groups?.width ?? "auto"
height ||= "auto" const height = match?.groups?.height ?? "auto"
return { return {
type: "image", type: "image",
url, url,
@@ -232,6 +215,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
hProperties: { hProperties: {
width, width,
height, height,
alt,
}, },
}, },
} }
@@ -298,10 +282,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.parseArrows) { if (opts.parseArrows) {
replacements.push([ replacements.push([
arrowRegex, arrowRegex,
(_value: string, ..._capture: string[]) => { (value: string, ..._capture: string[]) => {
const maybeArrow = arrowMapping[value]
if (maybeArrow === undefined) return SKIP
return { return {
type: "html", type: "html",
value: `<span>&rarr;</span>`, value: `<span>${maybeArrow}</span>`,
} }
}, },
]) ])
@@ -317,8 +303,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
} }
tag = slugTag(tag) tag = slugTag(tag)
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { if (file.data.frontmatter) {
file.data.frontmatter.tags.push(tag) const noteTags = file.data.frontmatter.tags ?? []
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
} }
return { return {
@@ -406,32 +393,31 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
const match = firstLine.match(calloutRegex) const match = firstLine.match(calloutRegex)
if (match && match.input) { if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match const [calloutDirective, typeString, collapseChar] = match
const calloutType = canonicalizeCallout( const calloutType = canonicalizeCallout(typeString.toLowerCase())
typeString.toLowerCase() as keyof typeof calloutMapping,
)
const collapse = collapseChar === "+" || collapseChar === "-" const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded" const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const titleContent = const titleContent = match.input.slice(calloutDirective.length).trim()
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
const titleNode: Paragraph = { const titleNode: Paragraph = {
type: "paragraph", type: "paragraph",
children: children: [
restOfTitle.length === 0 {
? [{ type: "text", value: titleContent + " " }] type: "text",
: restOfTitle, value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
},
...restOfTitle,
],
} }
const title = mdastToHtml(titleNode) const title = mdastToHtml(titleNode)
const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold"> const toggleIcon = `<div class="fold-callout-icon"></div>`
<polyline points="6 9 12 15 18 9"></polyline>
</svg>`
const titleHtml: Html = { const titleHtml: Html = {
type: "html", type: "html",
value: `<div value: `<div
class="callout-title" class="callout-title"
> >
<div class="callout-icon">${callouts[calloutType]}</div> <div class="callout-icon"></div>
<div class="callout-title-inner">${title}</div> <div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""} ${collapse ? toggleIcon : ""}
</div>`, </div>`,
@@ -457,7 +443,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.data = { node.data = {
hProperties: { hProperties: {
...(node.data?.hProperties ?? {}), ...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${ className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : "" defaultState === "collapsed" ? "is-collapsed" : ""
}`, }`,
"data-callout": calloutType, "data-callout": calloutType,

View File

@@ -3,7 +3,6 @@ import { Root } from "mdast"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string" import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger" import Slugger from "github-slugger"
import { wikilinkRegex } from "./ofm"
export interface Options { export interface Options {
maxDepth: 1 | 2 | 3 | 4 | 5 | 6 maxDepth: 1 | 2 | 3 | 4 | 5 | 6
@@ -25,7 +24,7 @@ interface TocEntry {
slug: string // this is just the anchor (#some-slug), not the canonical slug slug: string // this is just the anchor (#some-slug), not the canonical slug
} }
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g") const slugAnchor = new Slugger()
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = ( export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts, userOpts,
) => { ) => {
@@ -38,21 +37,12 @@ export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefin
return async (tree: Root, file) => { return async (tree: Root, file) => {
const display = file.data.frontmatter?.enableToc ?? opts.showByDefault const display = file.data.frontmatter?.enableToc ?? opts.showByDefault
if (display) { if (display) {
const slugAnchor = new Slugger() slugAnchor.reset()
const toc: TocEntry[] = [] const toc: TocEntry[] = []
let highestDepth: number = opts.maxDepth let highestDepth: number = opts.maxDepth
visit(tree, "heading", (node) => { visit(tree, "heading", (node) => {
if (node.depth <= opts.maxDepth) { if (node.depth <= opts.maxDepth) {
let text = toString(node) const text = toString(node)
// strip link formatting from toc entries
text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
const fp = rawFp?.trim() ?? ""
const alias = rawAlias?.slice(1).trim()
return alias ?? fp
})
text = text.replace(regexMdLinks, "$1")
highestDepth = Math.min(highestDepth, node.depth) highestDepth = Math.min(highestDepth, node.depth)
toc.push({ toc.push({
depth: node.depth, depth: node.depth,

View File

@@ -2,7 +2,7 @@ import { PluggableList } from "unified"
import { StaticResources } from "../util/resources" import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile" import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types" import { QuartzComponent } from "../components/types"
import { FilePath, FullSlug } from "../util/path" import { FilePath } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
export interface PluginTypes { export interface PluginTypes {

View File

@@ -4,7 +4,6 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
-webkit-text-size-adjust: none;
text-size-adjust: none; text-size-adjust: none;
overflow-x: hidden; overflow-x: hidden;
width: 100vw; width: 100vw;
@@ -27,7 +26,7 @@ section {
} }
::selection { ::selection {
background: color-mix(in srgb, var(--tertiary) 75%, transparent); background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
color: var(--darkgray); color: var(--darkgray);
} }
@@ -55,11 +54,10 @@ ul,
} }
a { a {
font-weight: 600; font-weight: $boldWeight;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
color: var(--secondary); color: var(--secondary);
display: inline-block;
&:hover { &:hover {
color: var(--tertiary) !important; color: var(--tertiary) !important;
@@ -173,9 +171,11 @@ a {
& .sidebar.right { & .sidebar.right {
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth); right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
flex-wrap: wrap;
& > * { & > * {
@media all and (max-width: $fullPageWidth) { @media all and (max-width: $fullPageWidth) {
flex: 1; flex: 1;
min-width: 140px;
} }
} }
} }
@@ -278,7 +278,6 @@ h6 {
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
transform: translateY(-0.1rem); transform: translateY(-0.1rem);
display: inline-block;
font-family: var(--codeFont); font-family: var(--codeFont);
user-select: none; user-select: none;
} }
@@ -357,6 +356,7 @@ pre {
counter-increment: line 0; counter-increment: line 0;
display: grid; display: grid;
padding: 0.5rem 0; padding: 0.5rem 0;
overflow-x: scroll;
& [data-highlighted-chars] { & [data-highlighted-chars] {
background-color: var(--highlight); background-color: var(--highlight);

View File

@@ -1,3 +1,4 @@
@use "./variables.scss" as *;
@use "sass:color"; @use "sass:color";
.callout { .callout {
@@ -13,16 +14,33 @@
margin-top: 0; margin-top: 0;
} }
&[data-callout="note"] { --callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');
--callout-icon-abstract: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>');
--callout-icon-info: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>');
--callout-icon-todo: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>');
--callout-icon-tip: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg> ');
--callout-icon-success: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> ');
--callout-icon-question: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> ');
--callout-icon-warning: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>');
--callout-icon-failure: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> ');
--callout-icon-danger: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg> ');
--callout-icon-bug: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>');
--callout-icon-example: url('data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg> ');
--callout-icon-quote: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>');
--callout-icon-fold: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E');
&[data-callout] {
--color: #448aff; --color: #448aff;
--border: #448aff44; --border: #448aff44;
--bg: #448aff10; --bg: #448aff10;
--callout-icon: var(--callout-icon-note);
} }
&[data-callout="abstract"] { &[data-callout="abstract"] {
--color: #00b0ff; --color: #00b0ff;
--border: #00b0ff44; --border: #00b0ff44;
--bg: #00b0ff10; --bg: #00b0ff10;
--callout-icon: var(--callout-icon-abstract);
} }
&[data-callout="info"], &[data-callout="info"],
@@ -30,30 +48,39 @@
--color: #00b8d4; --color: #00b8d4;
--border: #00b8d444; --border: #00b8d444;
--bg: #00b8d410; --bg: #00b8d410;
--callout-icon: var(--callout-icon-info);
}
&[data-callout="todo"] {
--callout-icon: var(--callout-icon-todo);
} }
&[data-callout="tip"] { &[data-callout="tip"] {
--color: #00bfa5; --color: #00bfa5;
--border: #00bfa544; --border: #00bfa544;
--bg: #00bfa510; --bg: #00bfa510;
--callout-icon: var(--callout-icon-tip);
} }
&[data-callout="success"] { &[data-callout="success"] {
--color: #09ad7a; --color: #09ad7a;
--border: #09ad7144; --border: #09ad7144;
--bg: #09ad7110; --bg: #09ad7110;
--callout-icon: var(--callout-icon-success);
} }
&[data-callout="question"] { &[data-callout="question"] {
--color: #dba642; --color: #dba642;
--border: #dba64244; --border: #dba64244;
--bg: #dba64210; --bg: #dba64210;
--callout-icon: var(--callout-icon-question);
} }
&[data-callout="warning"] { &[data-callout="warning"] {
--color: #db8942; --color: #db8942;
--border: #db894244; --border: #db894244;
--bg: #db894210; --bg: #db894210;
--callout-icon: var(--callout-icon-warning);
} }
&[data-callout="failure"], &[data-callout="failure"],
@@ -62,50 +89,74 @@
--color: #db4242; --color: #db4242;
--border: #db424244; --border: #db424244;
--bg: #db424210; --bg: #db424210;
--callout-icon: var(--callout-icon-failure);
}
&[data-callout="bug"] {
--callout-icon: var(--callout-icon-bug);
}
&[data-callout="danger"] {
--callout-icon: var(--callout-icon-danger);
} }
&[data-callout="example"] { &[data-callout="example"] {
--color: #7a43b5; --color: #7a43b5;
--border: #7a43b544; --border: #7a43b544;
--bg: #7a43b510; --bg: #7a43b510;
--callout-icon: var(--callout-icon-example);
} }
&[data-callout="quote"] { &[data-callout="quote"] {
--color: var(--secondary); --color: var(--secondary);
--border: var(--lightgray); --border: var(--lightgray);
--callout-icon: var(--callout-icon-quote);
} }
&.is-collapsed > .callout-title > .fold { &.is-collapsed > .callout-title > .fold-callout-icon {
transform: rotateZ(-90deg); transform: rotateZ(-90deg);
} }
} }
.callout-title { .callout-title {
display: flex; display: flex;
align-items: flex-start;
gap: 5px; gap: 5px;
padding: 1rem 0; padding: 1rem 0;
color: var(--color); color: var(--color);
& .fold { --icon-size: 18px;
margin-left: 0.5rem;
transition: transform 0.3s ease; & .fold-callout-icon {
transition: transform 0.15s ease;
opacity: 0.8; opacity: 0.8;
cursor: pointer; cursor: pointer;
--callout-icon: var(--callout-icon-fold);
} }
& > .callout-title-inner > p { & > .callout-title-inner > p {
color: var(--color); color: var(--color);
margin: 0; margin: 0;
} }
}
.callout-icon { .callout-icon,
width: 18px; & .fold-callout-icon {
height: 18px; width: var(--icon-size);
flex: 0 0 18px; height: var(--icon-size);
padding-top: 4px; flex: 0 0 var(--icon-size);
}
.callout-title-inner { // icon support
font-weight: 700; background-size: var(--icon-size) var(--icon-size);
background-position: center;
background-color: var(--color);
mask-image: var(--callout-icon);
mask-size: var(--icon-size) var(--icon-size);
mask-position: center;
mask-repeat: no-repeat;
padding: 0.2rem 0;
}
.callout-title-inner {
font-weight: $boldWeight;
}
} }

View File

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

View File

@@ -1,11 +1,13 @@
export function pluralize(count: number, s: string): string {
if (count === 1) {
return `1 ${s}`
} else {
return `${count} ${s}s`
}
}
export function capitalize(s: string): string { export function capitalize(s: string): string {
return s.substring(0, 1).toUpperCase() + s.substring(1) return s.substring(0, 1).toUpperCase() + s.substring(1)
} }
export function classNames(
displayClass?: "mobile-only" | "desktop-only",
...classes: string[]
): string {
if (displayClass) {
classes.push(displayClass)
}
return classes.join(" ")
}

View File

@@ -106,8 +106,9 @@ describe("transforms", () => {
["test.mp4", "test.mp4"], ["test.mp4", "test.mp4"],
["note with spaces.md", "note-with-spaces"], ["note with spaces.md", "note-with-spaces"],
["notes.with.dots.md", "notes.with.dots"], ["notes.with.dots.md", "notes.with.dots"],
["test/special chars?.md", "test/special-chars-q"], ["test/special chars?.md", "test/special-chars"],
["test/special chars #3.md", "test/special-chars-3"], ["test/special chars #3.md", "test/special-chars-3"],
["cool/what about r&d?.md", "cool/what-about-r-and-d"],
], ],
path.slugifyFilePath, path.slugifyFilePath,
path.isFilePath, path.isFilePath,

View File

@@ -51,8 +51,13 @@ function sluggify(s: string): string {
return s return s
.split("/") .split("/")
.map((segment) => .map((segment) =>
segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").replace(/#/g, ""), segment
) // slugify all segments .replace(/\s/g, "-")
.replace(/&/g, "-and-")
.replace(/%/g, "-percent")
.replace(/\?/g, "")
.replace(/#/g, ""),
)
.join("/") // always use / as sep .join("/") // always use / as sep
.replace(/\/$/, "") .replace(/\/$/, "")
} }

View File

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