Compare commits

...

142 Commits

Author SHA1 Message Date
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
Jacky Zhao
c9ac2a7507 pkg: bump to 4.1.5 2024-01-22 10:56:58 -08:00
Jacky Zhao
7ca491bc1d fix: add polyfill for broken tabindex on mac 2024-01-22 10:55:15 -08:00
Jacky Zhao
4edd27d3f9 fix: font weight in search 2024-01-22 10:48:23 -08:00
Jacky Zhao
2c8d0f8ab6 fix: more robust ofm comment handling 2024-01-22 10:29:57 -08:00
Jacky Zhao
cd826fb477 fix: process comments at a text level rather than a markdown level 2024-01-22 10:03:59 -08:00
Jacky Zhao
273931d25c fix: breadcrumbs on non-folder pages 2024-01-21 21:14:16 -08:00
Aaron Pham
0403fa70aa fix(search): use anchor element (closes #698) (#717)
* fix(search): use anchor element

This addresses #698 to allow search title to include links for SPA

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

* fix: formatter

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

* chore: move itemTile to `a`

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

* chore: remove nested a title

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

* chore(search): remove spaNavigate

since now searchResult is an `a` item

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-21 20:50:00 -08:00
Jacky Zhao
015b4f6a15 fix: remove quartz 3 references, update font style in popovers 2024-01-21 12:39:20 -08:00
Aaron Pham
4d338cec13 feat(ofm): add options to parse arrows (#713)
* feat(ofm): add options to parse arrows

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

* feat(ofm): add options to parse arrows

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
2024-01-21 11:33:32 -08:00
LUCASTUCIOUS
c11395e7bc feat: Add an option to display or not reading time from notes (#707)
* add an option to display or not reading time from notes

* Prettier (?)

* Remove ContentMeta override from quartz.layout.ts

* Make it positive ! 🌞

* Update quartz/components/ContentMeta.tsx

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-20 13:18:35 -08:00
Jacky Zhao
1f2ea96ae0 fix: allow dashes and underscores in block references (closes #712) 2024-01-20 00:33:14 -08:00
kabirgh
ce3dd0923b refactor: move emit from callback to helper file function (#704)
* Change emit from callback to helpers file function

* Update docs, remove commented code, improve type sig
2024-01-18 10:56:14 -08:00
Jacky Zhao
af811d824f style: make internal link have less visual padding (closes #706) 2024-01-17 20:03:14 -08:00
Jacky Zhao
129e0c60a9 fix: remove extra console log 2024-01-17 09:46:01 -08:00
Jacky Zhao
d7d5d8253c fix: clean up ofm code for video parsing 2024-01-17 09:45:05 -08:00
Matthew Bailin
f6299da182 feat: add ofm option to transform <img> tags with video exts into <video> (closes #463) (#664)
* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* enableVideoEmbed plugin

* cleaned up index validation, regex, conditional, no autoplay

* Update 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>

* Update quartz/plugins/transformers/ofm.ts

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

* Update ofm.ts

* Update ofm.ts

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-17 09:32:02 -08:00
kabirgh
e17ff20244 fix: use joinSegments for contentIndex.json file path (#702) 2024-01-16 08:24:01 -08:00
sean
107d9b8dff fix: external link icon shouldn't be vertical aligned (#699) 2024-01-16 08:18:55 -08:00
sean
fa7d139ce5 feat: External link icons (#697) 2024-01-15 23:55:32 -08:00
Jacky Zhao
f31cabbbf9 fix: dont use default callout title if theres additional title children left (closes #693) 2024-01-15 12:37:56 -08:00
kabirgh
30640e3441 Revert "fix: rebuild errors on windows (#692)" (#695)
This reverts commit 8eec47c340.
2024-01-15 11:51:46 -08:00
kabirgh
8eec47c340 fix: rebuild errors on windows (#692) 2024-01-15 08:39:16 -08:00
Jacky Zhao
f36376503a fix: allow transcludes of notes with dots (closes #682) 2024-01-13 14:47:39 -08:00
Jacky Zhao
a40dbd55a4 fix: unbork search shortcut 2024-01-13 13:56:03 -08:00
Jacky Zhao
e70312320f feat: improve default layout 2024-01-13 09:47:56 -08:00
Jacky Zhao
4e82b0d8ce docs: add sidneys artist handbook to showcase 2024-01-13 09:37:24 -08:00
Jacky Zhao
783b9b219c fix: dont hijack handlers when search is not focused (closes #680) 2024-01-13 09:29:43 -08:00
Jacky Zhao
4014c4d6d6 fix: add another test for notes with dots 2024-01-13 09:27:00 -08:00
Jacky Zhao
6babb788ed fix: sluggify pound (closes #681) 2024-01-13 09:22:27 -08:00
ikorihn
0a8c38dc21 fix: small typos (#686) 2024-01-13 09:09:41 -08:00
ikorihn
52e6c03730 fix: broken RSS item's link, which were set to https:/${base}. (#687) 2024-01-13 09:08:21 -08:00
Jacky Zhao
1a8aedf5f5 docs: clarify git only sets modified 2024-01-07 15:39:38 -08:00
Aaron Pham
a4d6f701bf fix(showcase): markdown link (#673) 2024-01-07 11:47:53 -08:00
Aaron Pham
60017164ad chore: add my garden 😃 (#672) 2024-01-07 11:35:52 -08:00
Jacky Zhao
5ccc48a172 style: div -> li for explorer 2024-01-04 11:05:05 -08:00
Nate Silva
707124cbd6 fix: allow publish property to be a string (ExplicitPublish) (#667)
* fix: allow publish property to be a string (ExplicitPublish)

Previously, the ExplicitPublish filter would publish if the `publish`
property was truthy.

The filter expects the `publish` property to be a boolean:

```
---
publish: true
---
```

However, Obsidian only shows the above if you are viewing a page in
“Source” mode.

If you are not in Source view, and you choose Three Dots Menu (...),
“Add file property”, you will get a string, not a boolean. It seems
likely that many users will do this and get:

```
publish: "true"
```

Notice that `"true"` is a string, not the boolean value `true`. If the
user changes this to `"false"`, the page will still be published:

```
publish: "false"
```

That is because the string value `"false"` is truthy.

This PR does the following:

- Allows the `publish` property to be either a boolean or a string.
- If it’s a string, it’s considered `true` if the string is `"true"`
  (not case-sensitive; it will also work if it is `"True"`, `"TRUE"`,
  etc.)
- Guarantees that the returned value from `shouldPublish` is a `boolean`
  -- previously it could be any truthy value even though it was cast to
  `boolean`

* style: use double-quotes everywhere

* style: format according to project style guide
2024-01-02 15:19:19 -08:00
jeff
88194ac348 feat: allow embedding youtube videos with the obsidian markdown syntax (#665)
* Add option to allow embedding YouTube videos with Obsidian Markdown syntax

* Update Obsidian compatability doc page

* Switch to converting YT links as an html plugin
2024-01-02 10:49:14 -08:00
Olivér Falvai
65d75b8bdc feat: support modification date reading from parent git repo (#661)
* feat: support modification date reading from parent git repo

* Print warning

* Fix formatting

* Update quartz/plugins/transformers/lastmod.ts

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

---------

Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-02 09:23:28 -08:00
Mats Fangohr
6e34844114 feat: embed webp images (#666) 2024-01-02 08:03:05 -08:00
Jacky Zhao
b33f13ccaf fix: dont show last page if folder 2024-01-01 14:20:34 -08:00
Jimmy He
002bbc37b1 fix: Continue setup even if a file to delete is not found (#663)
* Continue setup even if a file to delete is not found

For various reasons, `.gitkeep` may be deleted already.

(In my case, even though I followed the [Getting Started](https://quartz.jzhao.xyz) instructions exactly, my first run resulted in an `fatal: 'upstream' does not appear to be a git repository`)

If we try to delete `.gitkeep` again and don't ignore `ENOENT`, then the whole setup fails.

* Use fs.existsSync
2024-01-01 14:14:37 -08:00
Jacky Zhao
e603d7396b fix: parse emoji tags in body (closes #659) 2024-01-01 08:58:25 -08:00
Jacky Zhao
40cfccdc77 style: relative back on pre 2023-12-28 15:07:59 -08:00
Jacky Zhao
e758cbe1ee pkg: bump version to 4.1.4 2023-12-28 14:00:15 -08:00
Jacky Zhao
4b6c7aeffe feat: lazyLoading specifier in link transformer 2023-12-28 13:56:20 -08:00
Jacky Zhao
e277ed5c30 fix: use joinSegment instead of joining via slash in sitemap (closes #658) 2023-12-28 08:54:09 -08:00
Olivér Falvai
68f53352e7 feat: Self-hosted Plausible support (#656)
* Self-hosted Plausible support

* Remove leftover import
2023-12-28 08:49:35 -08:00
Jacky Zhao
359484c139 fix: more robust tags parsing 2023-12-28 08:48:14 -08:00
Jacky Zhao
dafc9f318e feat: minify js scripts (closes #655) (#657) 2023-12-28 08:02:04 -08:00
Sidney
e1b6a0014c docs: add explorer example for advanced sortFn (#564)
* Added doc example to explorer sortFn

* Prettier fixed formatting

* Let Prettier fix the formatting of the entire markdown file

* Updated example

* Added extra commentary and fixed example

* Update docs/features/explorer.md

* doc fixes

* docs: remove leftover TODO

* docs: move example to `advanced`

---------

Co-authored-by: Sidney <85735034+Epicrex@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
Co-authored-by: Ben Schlegel <ben5.schlegel@gmail.com>
2023-12-28 12:04:15 +01:00
Hydrophobefireman
233d4b2f2c fix: fix invalid html output (#642)
* fix: fix invalid html output

* fix: HTML structure w/ nested <li>
2023-12-28 11:20:07 +01:00
Jacky Zhao
504b447162 fix: use slugs instead of title as basis for explorer (#652)
* use slugs instead of title as basis for explorer

* fix folder persist state, better default behaviour

* use relative path instead of full path as full path is affected by -d

* dont use title in breadcrumb if it's just index lol
2023-12-27 16:44:14 -08:00
Jacky Zhao
63bf1e14b5 style: remove relative from base pre 2023-12-20 19:55:28 -08:00
migueltorrescosta
be76da9e95 docs: Add CollapsedWave to showcase.md (#643)
Thank you so much for a beautiful setup
2023-12-20 12:09:48 -08:00
Jacky Zhao
8fe37cc5e5 docs: update issue template 2023-12-20 10:05:00 -08:00
Jacky Zhao
2e9896c893 fix: deep clone before relativizing urls in transclude (closes #640) 2023-12-20 09:52:17 -08:00
Jacky Zhao
7bcf27241f fix: latex before syntax highlighting 2023-12-19 19:03:40 -08:00
Jacky Zhao
b44a79eeba fix: wikilinks should allow external links (closes #639) 2023-12-19 11:40:59 -08:00
Jacky Zhao
9b9d86474b fix: mermaid rendering fix from upstream 2023-12-19 11:01:55 -08:00
Jacky Zhao
4c83251f8e feat: -v flag should log exact error on parse failure 2023-12-19 09:07:52 -08:00
Jacky Zhao
984ab1c578 fix: change backtick to regular after making script loading less hacky 2023-12-18 23:13:37 -08:00
Jacky Zhao
443cd53a1a fix: mermaid rendering broken after rehype-pretty-code bump (closes #638) 2023-12-18 23:09:49 -08:00
Jacky Zhao
5152d32fbd pkg: bump version to 4.1.3 2023-12-18 09:50:14 -08:00
89 changed files with 2053 additions and 1760 deletions

View File

@@ -20,12 +20,19 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
**Screenshots and Source**
If applicable, add screenshots to help explain your problem.
You can help speed up fixing the problem by either
1. providing a simple reproduction
2. linking to your Quartz repository where the problem can be observed
**Desktop (please complete the following information):**
- Device: [e.g. iPhone6]
- Quartz Version: [e.g. v4.1.2]
- `node` Version: [e.g. v18.16]
- `npm` version: [e.g. v10.1.0]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]

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

@@ -5,8 +5,6 @@
Quartz is a set of tools that helps you publish your [digital garden](https://jzhao.xyz/posts/networked-thought) and notes as a website for free.
Quartz v4 features a from-the-ground rewrite focusing on end-user extensibility and ease-of-use.
**If you are looking for Quartz v3, you can find it on the [`hugo` branch](https://github.com/jackyzha0/quartz/tree/hugo).**
🔗 Read the documentation and get started: https://quartz.jzhao.xyz/
[Join the Discord Community](https://discord.gg/cRFFHYye7t)

View File

@@ -156,12 +156,13 @@ document.addEventListener("nav", () => {
// do page specific logic here
// e.g. attach event listeners
const toggleSwitch = document.querySelector("#switch") as HTMLInputElement
toggleSwitch.removeEventListener("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

View File

@@ -216,22 +216,19 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
export type QuartzEmitterPluginInstance = {
name: string
emit(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
emitCallback: EmitCallback,
): Promise<FilePath[]>
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
}
```
An emitter plugin must define a `name` field an `emit` function and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
An emitter plugin must define a `name` field, an `emit` function, and a `getQuartzComponents` function. `emit` is responsible for looking at all the parsed and filtered content and then appropriately creating files and returning a list of paths to files the plugin created.
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `emitCallback` if you are creating files that contain text. The `emitCallback` function is the 4th argument of the emit function. Its interface looks something like this:
Creating new files can be done via regular Node [fs module](https://nodejs.org/api/fs.html) (i.e. `fs.cp` or `fs.writeFile`) or via the `write` function in `quartz/plugins/emitters/helpers.ts` if you are creating files that contain text. `write` has the following signature:
```ts
export type EmitCallback = (data: {
export type WriteOptions = (data: {
// the build context
ctx: BuildCtx
// the name of the file to emit (not including the file extension)
slug: ServerSlug
// the file extension

View File

@@ -2,7 +2,7 @@
title: Authoring Content
---
All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initailized. Any Markdown in this folder will get processed by Quartz.
All of the content in your Quartz should go in the `/content` folder. The content for the home page of your Quartz lives in `content/index.md`. If you've [[index#🪴 Get Started|setup Quartz]] already, this folder should already be initialized. Any Markdown in this folder will get processed by Quartz.
It is recommended that you use [Obsidian](https://obsidian.md/) as a way to edit and maintain your Quartz. It comes with a nice editor and graphical interface to preview, edit, and link your local files and attachments.
@@ -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:
- `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.
- `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.
- `date`: A string representing the day the note was published. Normally uses `YYYY-MM-DD` format.
## 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`.
> [!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
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`.

View File

@@ -25,7 +25,9 @@ Finally, Quartz also provides `Plugin.CrawlLinks` which allows you to customize
- `callouts`: whether to enable [[callouts]]. Defaults to `true`
- `mermaid`: whether to enable [[Mermaid diagrams]]. Defaults to `true`
- `parseTags`: whether to try and parse tags in the content body. Defaults to `true`
- `parseArrows`: whether to try and parse arrows in the content body. Defaults to `true`.
- `enableInHtmlEmbed`: whether to try and parse Obsidian flavoured markdown in raw HTML. Defaults to `false`
- `enableYouTubeEmbed`: whether to enable embedded YouTube videos using external image Markdown syntax. Defaults to `false`
- Link resolution behaviour:
- Disabling: remove all instances of `Plugin.CrawlLinks()` from `quartz.config.ts`
- Changing link resolution preference: set `markdownLinkResolution` to one of `absolute`, `relative` or `shortest`

View File

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

View File

@@ -24,14 +24,32 @@ See [documentation on supported types and syntax here](https://help.obsidian.md
## Customization
- 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
> [!info]
> Default title
> [!question]+ Can callouts be nested?
> [!question]+ Can callouts be _nested_?
>
> > [!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`
- Style: `quartz/components/styles/darkmode.scss`
- Script: `quartz/components/scripts/darkmode.inline.ts`
You can also listen to the `themechange` event to perform any custom logic when the theme changes.
```js
document.addEventListener("themechange", (e) => {
console.log("Theme changed to " + e.detail.theme) // either "light" or "dark"
// your logic here
})
```

View File

@@ -26,7 +26,7 @@ Component.Explorer({
title: "Explorer", // title of the explorer component
folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click)
folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer
useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer
// Sort order: folders first, then files. Sort folders and files alphabetically
sortFn: (a, b) => {
... // default implementation shown later
@@ -179,6 +179,34 @@ Component.Explorer({
## Advanced examples
> [!tip]
> When writing more complicated functions, the `layout` file can start to look very cramped.
> You can fix this by defining your functions in another file.
>
> ```ts title="functions.ts"
> import { Options } from "./quartz/components/ExplorerNode"
> export const mapFn: Options["mapFn"] = (node) => {
> // implement your function here
> }
> export const filterFn: Options["filterFn"] = (node) => {
> // implement your function here
> }
> export const sortFn: Options["sortFn"] = (a, b) => {
> // implement your function here
> }
> ```
>
> You can then import them like this:
>
> ```ts title="quartz.layout.ts"
> import { mapFn, filterFn, sortFn } from "./functions.ts"
> Component.Explorer({
> mapFn: mapFn,
> filterFn: filterFn,
> sortFn: sortFn,
> })
> ```
### Add emoji prefix
To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this:
@@ -216,30 +244,63 @@ Notice how we customized the `order` array here. This is done because the defaul
To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function.
> [!tip]
> When writing more complicated functions, the `layout` file can start to look very cramped.
> You can fix this by defining your functions in another file.
>
> ```ts title="functions.ts"
> import { Options } from "./quartz/components/ExplorerNode"
> export const mapFn: Options["mapFn"] = (node) => {
> // implement your function here
> }
> export const filterFn: Options["filterFn"] = (node) => {
> // implement your function here
> }
> export const sortFn: Options["sortFn"] = (a, b) => {
> // implement your function here
> }
> ```
>
> You can then import them like this:
>
> ```ts title="quartz.layout.ts"
> import { mapFn, filterFn, sortFn } from "./functions.ts"
> Component.Explorer({
> mapFn: mapFn,
> filterFn: filterFn,
> sortFn: sortFn,
> })
> ```
### Use `sort` with pre-defined sort order
Here's another example where a map containing file/folder names (as slugs) is used to define the sort order of the explorer in quartz. All files/folders that aren't listed inside of `nameOrderMap` will appear at the top of that folders hierarchy level.
It's also worth mentioning, that the smaller the number set in `nameOrderMap`, the higher up the entry will be in the explorer. Incrementing every folder/file by 100, makes ordering files in their folders a lot easier. Lastly, this example still allows you to use a `mapFn` or frontmatter titles to change display names, as it uses slugs for `nameOrderMap` (which is unaffected by display name changes).
```ts title="quartz.layout.ts"
Component.Explorer({
sortFn: (a, b) => {
const nameOrderMap: Record<string, number> = {
"poetry-folder": 100,
"essay-folder": 200,
"research-paper-file": 201,
"dinosaur-fossils-file": 300,
"other-folder": 400,
}
let orderA = 0
let orderB = 0
if (a.file && a.file.slug) {
orderA = nameOrderMap[a.file.slug] || 0
} else if (a.name) {
orderA = nameOrderMap[a.name] || 0
}
if (b.file && b.file.slug) {
orderB = nameOrderMap[b.file.slug] || 0
} else if (b.name) {
orderB = nameOrderMap[b.name] || 0
}
return orderA - orderB
},
})
```
For reference, this is how the quartz explorer window would look like with that example:
```
📖 Poetry Folder
📑 Essay Folder
⚗️ Research Paper File
🦴 Dinosaur Fossils File
🔮 Other Folder
```
And this is how the file structure would look like:
```
index.md
poetry-folder
index.md
essay-folder
index.md
research-paper-file.md
dinosaur-fossils-file.md
other-folder
index.md
```

View File

@@ -3,7 +3,7 @@ title: Recent Notes
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

View File

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

View File

@@ -23,10 +23,11 @@ This will guide you through initializing your Quartz with content. Once you've d
2. [[configuration|Configure]] Quartz's behaviour
3. Change Quartz's [[layout]]
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
> [!info]
> Coming from Quartz 3? See the [[migrating from Quartz 3|migration guide]] for the differences between Quartz 3 and Quartz 4 and how to migrate.
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

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.
```bash
# add your repository
git remote add origin REMOTE-URL
# list all the repositories that are tracked
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
```
To verify that you set the remote URL correctly, run the following command.
```bash
git remote -v
```
Then, you can sync the content to upload it to your repository.
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
npx quartz sync --no-pull
```
> [!hint]
> If `npx quartz sync` fails with `fatal: --[no-]autostash option is only valid with --rebase`, you
> may have an outdated version of `git`. Updating `git` should fix this issue.
> [!warning]- `fatal: --[no-]autostash option is only valid with --rebase`
> You may have an outdated version of `git`. Updating `git` should fix this issue.
In future updates, you can simply run `npx quartz sync` every time you want to push updates to your repository.
> [!hint] Flags and options
> For full help options, you can run `npx quartz sync --help`.
>
> Most of these have sensible defaults but you can override them if you have a custom setup:
>
> - `-d` or `--directory`: the content folder. This is normally just `content`
> - `-v` or `--verbose`: print out extra logging information
> - `--commit` or `--no-commit`: whether to make a `git` commit for your changes
> - `--push` or `--no-push`: whether to push updates to your GitHub fork of Quartz
> - `--pull` or `--no-pull`: whether to try and pull in any updates from your GitHub fork (i.e. from other devices) before pushing

View File

@@ -7,13 +7,10 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Quartz Documentation (this site!)](https://quartz.jzhao.xyz/)
- [Jacky Zhao's Garden](https://jzhao.xyz/)
- [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/)
- [Aaron Pham's Garden](https://aarnphm.xyz/)
- [The Quantum Garden](https://quantumgardener.blog/)
- [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/)
- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/)
- [Vince Imbat's Talahardin](https://vinceimbat.com/)
@@ -21,5 +18,12 @@ Want to see what Quartz can do? Here are some cool community gardens:
- [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/)
- [Mau Camargo's Notkesto](https://notes.camargomau.com/)
- [Caicai's Novels](https://imoko.cc/blog/caicai/)
- [🌊 Collapsed Wave](https://collapsedwave.com/)
- [Sideny's 3D Artist's Handbook](https://sidney-eliot.github.io/3d-artists-handbook/)
- [Mike's AI Garden 🤖🪴](https://mwalton.me/)
- [Brandon Boswell's Garden](https://brandonkboswell.com)
- [Scaling Synthesis - A hypertext research notebook](https://scalingsynthesis.com/)
- [Data Dictionary 🧠](https://glossary.airbyte.com/)
- [sspaeti.com's Second Brain](https://brain.sspaeti.com/)
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,
listener: (this: Document, ev: CustomEventMap[K]) => void,
): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K] | UIEvent): void
}
interface Window {
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
interface CustomEventMap {
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}
declare const fetchData: Promise<ContentIndex>

1192
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.1.2",
"version": "4.2.0",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
@@ -35,15 +35,15 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.5.3",
"@napi-rs/simple-git": "0.1.9",
"async-mutex": "^0.4.0",
"@floating-ui/dom": "^1.6.1",
"@napi-rs/simple-git": "0.1.14",
"async-mutex": "^0.4.1",
"chalk": "^5.3.0",
"chokidar": "^3.5.3",
"cli-spinner": "^0.2.10",
"d3": "^7.8.5",
"esbuild-sass-plugin": "^2.16.0",
"flexsearch": "0.7.21",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.0",
"gray-matter": "^4.0.3",
@@ -52,12 +52,11 @@
"hast-util-to-string": "^3.0.0",
"is-absolute-url": "^4.0.1",
"js-yaml": "^4.1.0",
"lightningcss": "^1.22.1",
"lightningcss": "^1.23.0",
"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",
"micromorph": "^0.4.5",
"plausible-tracker": "^0.3.8",
"preact": "^10.19.3",
"preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1",
@@ -65,8 +64,8 @@
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0",
"rehype-mathjax": "^5.0.0",
"rehype-pretty-code": "^0.12.1",
"rehype-mathjax": "^6.0.0",
"rehype-pretty-code": "^0.12.6",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark": "^15.0.1",
@@ -75,36 +74,35 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"remark-rehype": "^11.1.0",
"remark-smartypants": "^2.0.0",
"rfdc": "^1.3.1",
"rimraf": "^5.0.5",
"serve-handler": "^6.1.5",
"shikiji": "^0.8.7",
"shikiji": "^0.10.2",
"source-map-support": "^0.5.21",
"to-vfile": "^8.0.0",
"toml": "^3.0.0",
"unified": "^11.0.4",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.1",
"workerpool": "^8.0.0",
"workerpool": "^9.1.0",
"ws": "^8.15.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/flexsearch": "^0.7.3",
"@types/hast": "^3.0.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.1.2",
"@types/node": "^20.11.14",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/workerpool": "^6.4.7",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"esbuild": "^0.19.9",
"prettier": "^3.1.1",
"tsx": "^4.6.2",
"prettier": "^3.2.4",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

View File

@@ -47,13 +47,15 @@ const config: QuartzConfig = {
Plugin.FrontMatter(),
Plugin.TableOfContents(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower
// you can add 'git' here for last modified from Git
// if you do rely on git for dates, ensure defaultDateType is 'modified'
priority: ["frontmatter", "filesystem"],
}),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.SyntaxHighlighting(),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.Description(),
],
filters: [Plugin.RemoveDrafts()],

View File

@@ -37,12 +37,13 @@ export const defaultContentPageLayout: PageLayout = {
// components for pages that display lists of pages (e.g. tags or folders)
export const defaultListPageLayout: PageLayout = {
beforeBody: [Component.ArticleTitle()],
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()),
],
right: [],
}

View File

@@ -3,13 +3,13 @@ sourceMapSupport.install(options)
import path from "path"
import { PerfTimer } from "./util/perf"
import { rimraf } from "rimraf"
import { isGitIgnored } from "globby"
import { GlobbyFilterFunction, isGitIgnored } from "globby"
import chalk from "chalk"
import { parseMarkdown } from "./processors/parse"
import { filterContent } from "./processors/filter"
import { emitContent } from "./processors/emit"
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 { ProcessedContent } from "./plugins/vfile"
import { Argv, BuildCtx } from "./util/ctx"
@@ -18,6 +18,19 @@ import { trace } from "./util/trace"
import { options } from "./util/sourcemap"
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) {
const ctx: BuildCtx = {
argv,
@@ -73,89 +86,22 @@ async function startServing(
) {
const { argv } = ctx
const ignored = await isGitIgnored()
const contentMap = new Map<FilePath, ProcessedContent>()
for (const content of initialContent) {
const [_tree, vfile] = content
contentMap.set(vfile.data.filePath!, content)
}
const initialSlugs = ctx.allSlugs
let lastBuildMs = 0
const toRebuild: Set<FilePath> = new Set()
const toRemove: Set<FilePath> = new Set()
const trackedAssets: Set<FilePath> = new Set()
async function rebuild(fp: string, action: "add" | "change" | "delete") {
// 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)
}
// 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 {
console.log(chalk.yellow(`Rebuild failed. Waiting on a change to fix the error...`))
}
release()
clientRefresh()
toRebuild.clear()
toRemove.clear()
const buildData: BuildData = {
ctx,
mut,
contentMap,
ignored: await isGitIgnored(),
initialSlugs: ctx.allSlugs,
toRebuild: new Set<FilePath>(),
toRemove: new Set<FilePath>(),
trackedAssets: new Set<FilePath>(),
lastBuildMs: 0,
}
const watcher = chokidar.watch(".", {
@@ -165,15 +111,101 @@ async function startServing(
})
watcher
.on("add", (fp) => rebuild(fp, "add"))
.on("change", (fp) => rebuild(fp, "change"))
.on("unlink", (fp) => rebuild(fp, "delete"))
.on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
.on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
return async () => {
await watcher.close()
}
}
async function rebuildFromEntrypoint(
fp: string,
action: "add" | "change" | "delete",
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
buildData
const { argv } = ctx
// don't do anything for gitignored files
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) => {
try {
return await buildQuartz(argv, mut, clientRefresh)

View File

@@ -7,6 +7,7 @@ export type Analytics =
| null
| {
provider: "plausible"
host?: string
}
| {
provider: "google"
@@ -15,6 +16,7 @@ export type Analytics =
| {
provider: "umami"
websiteId: string
host?: string
}
export interface GlobalConfiguration {
@@ -34,6 +36,12 @@ export interface GlobalConfiguration {
*/
baseUrl?: string
theme: Theme
/**
* The locale to use for date formatting. Default to "en-US"
* Allow to translate the date in the language of your choice.
* Need to be formated following the IETF language tag format (https://en.wikipedia.org/wiki/IETF_language_tag)
*/
locale?: string
}
export interface QuartzConfig {

View File

@@ -113,7 +113,10 @@ export async function handleCreate(argv) {
}
}
await fs.promises.unlink(path.join(contentFolder, ".gitkeep"))
const gitkeepPath = path.join(contentFolder, ".gitkeep")
if (fs.existsSync(gitkeepPath)) {
await fs.promises.unlink(gitkeepPath)
}
if (setupStrategy === "copy" || setupStrategy === "symlink") {
let originalFolder = sourceDirectory
@@ -165,22 +168,20 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
// get a preferred link resolution strategy
linkResolutionStrategy = exitIfCancel(
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: [
{
value: "absolute",
label: "Treat links as absolute path",
hint: "for content made for Quartz 3 and Hugo",
},
{
value: "shortest",
label: "Treat links as shortest path",
hint: "for most Obsidian vaults",
hint: "(default)",
},
{
value: "absolute",
label: "Treat links as absolute path",
},
{
value: "relative",
label: "Treat links as relative paths",
hint: "for just normal Markdown files",
},
],
}),
@@ -199,6 +200,7 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
// setup remote
execSync(
`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:
@@ -255,6 +257,7 @@ export async function handleBuild(argv) {
},
write: false,
bundle: true,
minify: true,
platform: "browser",
format: "esm",
})
@@ -344,7 +347,7 @@ export async function handleBuild(argv) {
directoryListing: false,
headers: [
{
source: "**/*.html",
source: "**/*.*",
headers: [{ key: "Content-Disposition", value: "inline" }],
},
],
@@ -447,7 +450,7 @@ export async function handleUpdate(argv) {
try {
gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
console.log(chalk.red("An error occurred above while pulling updates."))
await popContentFolder(contentFolder)
return
}
@@ -519,7 +522,7 @@ export async function handleSync(argv) {
try {
gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH)
} catch {
console.log(chalk.red("An error occured above while pulling updates."))
console.log(chalk.red("An error occurred above while pulling updates."))
await popContentFolder(contentFolder)
return
}

View File

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

View File

@@ -1,12 +1,13 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path"
import { classNames } from "../util/lang"
function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) {
const slug = simplifySlug(fileData.slug!)
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
return (
<div class={`backlinks ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "backlinks")}>
<h3>Backlinks</h3>
<ul class="overflow">
{backlinkFiles.length > 0 ? (

View File

@@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
type CrumbData = {
displayName: string
@@ -18,15 +19,15 @@ interface BreadcrumbOptions {
*/
rootName: string
/**
* wether to look up frontmatter title for folders (could cause performance problems with big vaults)
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
*/
resolveFrontmatterTitle: boolean
/**
* Wether to display breadcrumbs on root `index.md`
* Whether to display breadcrumbs on root `index.md`
*/
hideOnRoot: boolean
/**
* Wether to display the current page in the breadcrumbs.
* Whether to display the current page in the breadcrumbs.
*/
showCurrentPage: boolean
}
@@ -68,9 +69,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
// construct the index for the first time
for (const file of allFiles) {
if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/")
if (folderParts) {
const folderName = folderParts[folderParts?.length - 2]
const folderParts = file.slug?.split("/")
// 2nd last to exclude the /index
const folderName = folderParts?.at(-2)
if (folderName) {
folderIndex.set(folderName, file)
}
}
@@ -88,7 +90,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(curPathSegment)
if (currentFile) {
curPathSegment = currentFile.frontmatter!.title
const title = currentFile.frontmatter!.title
if (title !== "index") {
curPathSegment = title
}
}
// Add current slug to full path
@@ -100,15 +105,16 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
}
// Add current file to crumb (can directly use frontmatter title)
if (options.showCurrentPage) {
if (options.showCurrentPage && slugParts.at(-1) !== "index") {
crumbs.push({
displayName: fileData.frontmatter!.title,
path: "",
})
}
}
return (
<nav class={`breadcrumb-container ${displayClass ?? ""}`} aria-label="breadcrumbs">
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => (
<div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a>

View File

@@ -1,20 +1,40 @@
import { formatDate, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time"
import { classNames } from "../util/lang"
interface ContentMetaOptions {
/**
* Whether to display reading time
*/
showReadingTime: boolean
}
const defaultOptions: ContentMetaOptions = {
showReadingTime: true,
}
export default ((opts?: Partial<ContentMetaOptions>) => {
// Merge options with defaults
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
export default (() => {
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
const text = fileData.text
if (text) {
const segments: string[] = []
const { text: timeTaken, words: _words } = readingTime(text)
if (fileData.dates) {
segments.push(formatDate(getDate(cfg, fileData)!))
segments.push(formatDate(getDate(cfg, fileData)!, cfg.locale))
}
segments.push(timeTaken)
return <p class={`content-meta ${displayClass ?? ""}`}>{segments.join(", ")}</p>
// Display reading time if enabled
if (options.showReadingTime) {
const { text: timeTaken, words: _words } = readingTime(text)
segments.push(timeTaken)
}
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>
} else {
return null
}

View File

@@ -4,10 +4,11 @@
import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
function Darkmode({ displayClass }: QuartzComponentProps) {
return (
<div class={`darkmode ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "darkmode")}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
<label id="toggle-label-light" for="darkmode-toggle" tabIndex={-1}>
<svg
@@ -18,7 +19,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
x="0px"
y="0px"
viewBox="0 0 35 35"
style="enable-background:new 0 0 35 35;"
style="enable-background:new 0 0 35 35"
xmlSpace="preserve"
>
<title>Light mode</title>
@@ -34,7 +35,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
x="0px"
y="0px"
viewBox="0 0 100 100"
style="enable-background='new 0 0 100 100'"
style="enable-background:new 0 0 100 100"
xmlSpace="preserve"
>
<title>Dark mode</title>

View File

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

View File

@@ -5,6 +5,7 @@ import explorerStyle from "./styles/explorer.scss"
import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
// Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = {
@@ -12,6 +13,9 @@ const defaultOptions = {
folderClickBehavior: "collapse",
folderDefaultState: "collapsed",
useSavedState: true,
mapFn: (node) => {
return node
},
sortFn: (a, b) => {
// Sort order: folders first, then files. Sort folders and files alphabetically
if ((!a.file && !b.file) || (a.file && b.file)) {
@@ -22,6 +26,7 @@ const defaultOptions = {
sensitivity: "base",
})
}
if (a.file && !b.file) {
return 1
} else {
@@ -41,52 +46,39 @@ export default ((userOpts?: Partial<Options>) => {
let jsonTree: string
function constructFileTree(allFiles: QuartzPluginData[]) {
if (!fileTree) {
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file, 1))
if (fileTree) {
return
}
/**
* Keys of this object must match corresponding function name of `FileNode`,
* while values must be the argument that will be passed to the function.
*
* e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
*/
const functions = {
map: opts.mapFn,
sort: opts.sortFn,
filter: opts.filterFn,
}
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functions[functionName]) {
// for every entry in order, call matching function in FileNode and pass matching argument
// e.g. i = 0; functionName = "filter"
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
// @ts-ignore
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
fileTree[functionName].call(fileTree, functions[functionName])
}
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functionName === "map") {
fileTree.map(opts.mapFn)
} else if (functionName === "sort") {
fileTree.sort(opts.sortFn)
} else if (functionName === "filter") {
fileTree.filter(opts.filterFn)
}
}
// Get all folders of tree. Initialize with collapsed state
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
// Stringify to pass json tree as data attribute ([data-tree])
jsonTree = JSON.stringify(folders)
}
// Get all folders of tree. Initialize with collapsed state
// Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
jsonTree = JSON.stringify(folders)
}
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
constructFileTree(allFiles)
return (
<div class={`explorer ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "explorer")}>
<button
type="button"
id="explorer"
@@ -120,6 +112,7 @@ export default ((userOpts?: Partial<Options>) => {
</div>
)
}
Explorer.css = explorerStyle
Explorer.afterDOMLoaded = script
return Explorer

View File

@@ -1,6 +1,13 @@
// @ts-ignore
import { QuartzPluginData } from "../plugins/vfile"
import { resolveRelative } from "../util/path"
import {
joinSegments,
resolveRelative,
clone,
simplifySlug,
SimpleSlug,
FilePath,
} from "../util/path"
type OrderEntries = "sort" | "filter" | "map"
@@ -10,9 +17,9 @@ export interface Options {
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileNode, b: FileNode) => number
filterFn?: (node: FileNode) => boolean
mapFn?: (node: FileNode) => void
order?: OrderEntries[]
filterFn: (node: FileNode) => boolean
mapFn: (node: FileNode) => void
order: OrderEntries[]
}
type DataWrapper = {
@@ -25,59 +32,74 @@ export type FolderState = {
collapsed: boolean
}
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
if (!fp) {
return undefined
}
return fp.split("/").at(idx)
}
// Structure to add all files into a tree
export class FileNode {
children: FileNode[]
name: string
children: Array<FileNode>
name: string // this is the slug segment
displayName: string
file: QuartzPluginData | null
depth: number
constructor(name: string, file?: QuartzPluginData, depth?: number) {
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
this.children = []
this.name = name
this.displayName = name
this.file = file ? structuredClone(file) : null
this.name = slugSegment
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
this.file = file ? clone(file) : null
this.depth = depth ?? 0
}
private insert(file: DataWrapper) {
if (file.path.length === 1) {
if (file.path[0] !== "index.md") {
this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
} else {
const title = file.file.frontmatter?.title
if (title && title !== "index" && file.path[0] === "index.md") {
private insert(fileData: DataWrapper) {
if (fileData.path.length === 0) {
return
}
const nextSegment = fileData.path[0]
// base case, insert here
if (fileData.path.length === 1) {
if (nextSegment === "") {
// index case (we are the root and we just found index.md), set our data appropriately
const title = fileData.file.frontmatter?.title
if (title && title !== "index") {
this.displayName = title
}
}
} else {
const next = file.path[0]
file.path = file.path.splice(1)
for (const child of this.children) {
if (child.name === next) {
child.insert(file)
return
}
} else {
// direct child
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
}
const newChild = new FileNode(next, undefined, this.depth + 1)
newChild.insert(file)
this.children.push(newChild)
return
}
// find the right child to insert into
fileData.path = fileData.path.splice(1)
const child = this.children.find((c) => c.name === nextSegment)
if (child) {
child.insert(fileData)
return
}
const newChild = new FileNode(
nextSegment,
getPathSegment(fileData.file.relativePath, this.depth),
undefined,
this.depth + 1,
)
newChild.insert(fileData)
this.children.push(newChild)
}
// Add new file to tree
add(file: QuartzPluginData, splice: number = 0) {
this.insert({ file, path: file.filePath!.split("/").splice(splice) })
}
// Print tree structure (for debugging)
print(depth: number = 0) {
let folderChar = ""
if (!this.file) folderChar = "|"
console.log("-".repeat(depth), folderChar, this.name, this.depth)
this.children.forEach((e) => e.print(depth + 1))
add(file: QuartzPluginData) {
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
}
/**
@@ -95,7 +117,6 @@ export class FileNode {
*/
map(mapFn: (node: FileNode) => void) {
mapFn(this)
this.children.forEach((child) => child.map(mapFn))
}
@@ -110,16 +131,16 @@ export class FileNode {
const traverse = (node: FileNode, currentPath: string) => {
if (!node.file) {
const folderPath = currentPath + (currentPath ? "/" : "") + node.name
const folderPath = joinSegments(currentPath, node.name)
if (folderPath !== "") {
folderPaths.push({ path: folderPath, collapsed })
}
node.children.forEach((child) => traverse(child, folderPath))
}
}
traverse(this, "")
return folderPaths
}
@@ -147,14 +168,13 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath
let pathOld = fullPath ? fullPath : ""
let folderPath = ""
if (node.name !== "") {
folderPath = `${pathOld}/${node.name}`
folderPath = joinSegments(fullPath ?? "", node.name)
}
return (
<li>
<>
{node.file ? (
// Single file node
<li key={node.file.slug}>
@@ -163,7 +183,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
</a>
</li>
) : (
<div>
<li>
{node.name !== "" && (
// Node with entire folder
// Render svg button + folder name, then children
@@ -185,12 +205,16 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
<div key={node.name} data-folderpath={folderPath}>
{folderBehavior === "link" ? (
<a href={`${folderPath}`} data-for={node.name} class="folder-title">
<a
href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
data-for={node.name}
class="folder-title"
>
{node.displayName}
</a>
) : (
<button class="folder-button">
<p class="folder-title">{node.displayName}</p>
<span class="folder-title">{node.displayName}</span>
</button>
)}
</div>
@@ -217,8 +241,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
))}
</ul>
</div>
</div>
</li>
)}
</li>
</>
)
}

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { byDateAndAlphabetical } from "./PageList"
import style from "./styles/recentNotes.scss"
import { Date, getDate } from "./Date"
import { GlobalConfiguration } from "../cfg"
import { classNames } from "../util/lang"
interface Options {
title: string
@@ -28,7 +29,7 @@ export default ((userOpts?: Partial<Options>) => {
const pages = allFiles.filter(opts.filter).sort(opts.sort)
const remaining = Math.max(0, pages.length - opts.limit)
return (
<div class={`recent-notes ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "recent-notes")}>
<h3>{opts.title}</h3>
<ul class="recent-ul">
{pages.slice(0, opts.limit).map((page) => {
@@ -47,7 +48,7 @@ export default ((userOpts?: Partial<Options>) => {
</div>
{page.dates && (
<p class="meta">
<Date date={getDate(cfg, page)!} />
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
</p>
)}
<ul class="tags">

View File

@@ -2,11 +2,22 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/search.scss"
// @ts-ignore
import script from "./scripts/search.inline"
import { classNames } from "../util/lang"
export default (() => {
export interface SearchOptions {
enablePreview: boolean
}
const defaultOptions: SearchOptions = {
enablePreview: true,
}
export default ((userOpts?: Partial<SearchOptions>) => {
function Search({ displayClass }: QuartzComponentProps) {
const opts = { ...defaultOptions, ...userOpts }
return (
<div class={`search ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "search")}>
<div id="search-icon">
<p>Search</p>
<div></div>
@@ -35,7 +46,7 @@ export default (() => {
aria-label="Search for something"
placeholder="Search for something"
/>
<div id="results-container"></div>
<div id="search-layout" data-preview={opts.enablePreview}></div>
</div>
</div>
</div>

View File

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

View File

@@ -1,6 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import legacyStyle from "./styles/legacyToc.scss"
import modernStyle from "./styles/toc.scss"
import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/toc.inline"
@@ -19,7 +20,7 @@ function TableOfContents({ fileData, displayClass }: QuartzComponentProps) {
}
return (
<div class={`toc ${displayClass ?? ""}`}>
<div class={classNames(displayClass, "toc")}>
<button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
<h3>Table of Contents</h3>
<svg

View File

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

View File

@@ -3,7 +3,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
function Content({ fileData, tree }: QuartzComponentProps) {
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

View File

@@ -8,40 +8,60 @@ import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = 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 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>
)
interface FolderContentOptions {
/**
* Whether to display number of folders
*/
showFolderCount: boolean
}
FolderContent.css = style + PageList.css
export default (() => FolderContent) satisfies QuartzComponentConstructor
const defaultOptions: FolderContentOptions = {
showFolderCount: true,
}
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = 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>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
)}
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)
}
FolderContent.css = style + PageList.css
return FolderContent
}) satisfies QuartzComponentConstructor

View File

@@ -26,7 +26,8 @@ function TagContent(props: QuartzComponentProps) {
(tree as Root).children.length === 0
? fileData.description
: htmlToJsx(fileData.filePath!, tree)
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = ["popover-hint", ...cssClasses].join(" ")
if (tag === "/") {
const tags = [
...new Set(
@@ -37,9 +38,8 @@ function TagContent(props: QuartzComponentProps) {
for (const tag of tags) {
tagItemMap.set(tag, allPagesWithTag(tag))
}
return (
<div class="popover-hint">
<div class={classes}>
<article>
<p>{content}</p>
</article>
@@ -62,11 +62,13 @@ function TagContent(props: QuartzComponentProps) {
</a>
</h2>
{content && <p>{content}</p>}
<p>
{pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`}
</p>
<PageList limit={numPages} {...listProps} />
<div class="page-listing">
<p>
{pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`}
</p>
<PageList limit={numPages} {...listProps} />
</div>
</div>
)
})}
@@ -81,11 +83,13 @@ function TagContent(props: QuartzComponentProps) {
}
return (
<div class="popover-hint">
<div class={classes}>
<article>{content}</article>
<p>{pluralize(pages.length, "item")} with this tag.</p>
<div>
<PageList {...listProps} />
<div class="page-listing">
<p>{pluralize(pages.length, "item")} with this tag.</p>
<div>
<PageList {...listProps} />
</div>
</div>
</div>
)

View File

@@ -23,7 +23,7 @@ export function pageResources(
staticResources: StaticResources,
): StaticResources {
const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
return {
css: [joinSegments(baseDir, "index.css"), ...staticResources.css],

View File

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

View File

@@ -14,7 +14,7 @@ document.addEventListener("nav", () => {
button.type = "button"
button.innerHTML = svgCopy
button.ariaLabel = "Copy source"
button.addEventListener("click", () => {
function onClick() {
navigator.clipboard.writeText(source).then(
() => {
button.blur()
@@ -26,7 +26,9 @@ document.addEventListener("nav", () => {
},
(error) => console.error(error),
)
})
}
button.addEventListener("click", onClick)
window.addCleanup(() => button.removeEventListener("click", onClick))
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
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", () => {
const switchTheme = (e: any) => {
if (e.target.checked) {
document.documentElement.setAttribute("saved-theme", "dark")
localStorage.setItem("theme", "dark")
} else {
document.documentElement.setAttribute("saved-theme", "light")
localStorage.setItem("theme", "light")
}
const switchTheme = (e: Event) => {
const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
emitThemeChangeEvent(newTheme)
}
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
const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement
toggleSwitch.removeEventListener("change", switchTheme)
toggleSwitch.addEventListener("change", switchTheme)
window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme))
if (currentTheme === "dark") {
toggleSwitch.checked = true
}
// Listen for changes in prefers-color-scheme
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
colorSchemeMediaQuery.addEventListener("change", (e) => {
const newTheme = e.matches ? "dark" : "light"
document.documentElement.setAttribute("saved-theme", newTheme)
localStorage.setItem("theme", newTheme)
toggleSwitch.checked = e.matches
})
colorSchemeMediaQuery.addEventListener("change", themeChange)
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
})

View File

@@ -1,135 +1,106 @@
import { FolderState } from "../ExplorerNode"
// Current state of folders
let explorerState: FolderState[]
type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorer = document.getElementById("explorer-ul")
const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
explorer?.classList.add("no-background")
explorerUl.classList.add("no-background")
} else {
explorer?.classList.remove("no-background")
explorerUl.classList.remove("no-background")
}
}
})
function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
const content = this.nextElementSibling as HTMLElement
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
function toggleFolder(evt: MouseEvent) {
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 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
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
childFolderContainer.classList.toggle("open")
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
const clickFolderPath = currentFolderParent.dataset.folderpath as string
// Remove leading "/"
const fullFolderPath = clickFolderPath.substring(1)
toggleCollapsedByPath(explorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(explorerState)
const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
function setupExplorer() {
// Set click handler for collapsing entire 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
const storageTree = localStorage.getItem("fileTree")
// Convert to bool
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) {
// Get config
const collapseBehavior = explorer.dataset.behavior
// Add click handlers for all folders (click handler on folder "label")
if (collapseBehavior === "collapse") {
Array.prototype.forEach.call(
document.getElementsByClassName("folder-button"),
function (item) {
item.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
},
)
currentExplorerState.map((folderState) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
setFolderState(folderUl, folderState.collapsed)
}
// 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)
document.addEventListener("nav", () => {
setupExplorer()
observer.disconnect()
// select pseudo element at end of list
@@ -145,11 +116,7 @@ document.addEventListener("nav", () => {
* @param collapsed if folder should be set to collapsed or not
*/
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
if (collapsed) {
folderElement?.classList.remove("open")
} else {
folderElement?.classList.add("open")
}
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
/**

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ async function mouseEnterHandler(
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
for (const link of links) {
link.removeEventListener("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 { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, resolveRelative } from "../../util/path"
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
interface Item {
id: number
@@ -11,23 +11,33 @@ interface Item {
tags: string[]
}
let index: Document<Item> | undefined = undefined
// Can be expanded with things like "term" in the future
type SearchType = "basic" | "tags"
// Current searchType
let searchType: SearchType = "basic"
let currentSearchTerm: string = ""
let index: FlexSearch.Document<Item> | undefined = undefined
const p = new DOMParser()
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
const contextWindowWords = 30
const numSearchResults = 5
const numTagResults = 3
const numSearchResults = 8
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) {
// try to highlight longest tokens first
const tokenizedTerms = searchTerm
.split(/\s+/)
.filter((t) => t !== "")
.sort((a, b) => b.length - a.length)
const tokenizedTerms = tokenizeTerm(searchTerm)
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
let startIndex = 0
@@ -35,12 +45,12 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
if (trim) {
const includesCheck = (tok: string) =>
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
const occurencesIndices = tokenizedText.map(includesCheck)
const occurrencesIndices = tokenizedText.map(includesCheck)
let bestSum = 0
let bestIndex = 0
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
const window = occurencesIndices.slice(i, i + contextWindowWords)
const window = occurrencesIndices.slice(i, i + contextWindowWords)
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
if (windowSum >= bestSum) {
bestSum = windowSum
@@ -71,20 +81,78 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}`
}
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
document.addEventListener("nav", async (e: unknown) => {
const currentSlug = (e as CustomEventMap["nav"]).detail.url
function highlightHTML(searchTerm: string, innerHTML: string) {
const p = new DOMParser()
const tokenizedTerms = tokenizeTerm(searchTerm)
const html = p.parseFromString(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 container = document.getElementById("search-container")
const sidebar = container?.closest(".sidebar") as HTMLElement
const searchIcon = document.getElementById("search-icon")
const searchBar = document.getElementById("search-bar") as HTMLInputElement | null
const results = document.getElementById("results-container")
const resultCards = document.getElementsByClassName("result-card")
const searchLayout = document.getElementById("search-layout")
const idDataMap = Object.keys(data) as FullSlug[]
const appendLayout = (el: HTMLElement) => {
if (searchLayout?.querySelector(`#${el.id}`) === null) {
searchLayout?.appendChild(el)
}
}
const enablePreview = searchLayout?.dataset?.preview === "true"
let preview: HTMLDivElement | undefined = undefined
let previewInner: HTMLDivElement | undefined = undefined
const results = document.createElement("div")
results.id = "results-container"
results.style.flexBasis = enablePreview ? "min(30%, 450px)" : "100%"
appendLayout(results)
if (enablePreview) {
preview = document.createElement("div")
preview.id = "preview-container"
preview.style.flexBasis = "100%"
appendLayout(preview)
}
function hideSearch() {
container?.classList.remove("active")
if (searchBar) {
@@ -96,6 +164,12 @@ document.addEventListener("nav", async (e: unknown) => {
if (results) {
removeAllChildren(results)
}
if (preview) {
removeAllChildren(preview)
}
if (searchLayout) {
searchLayout.style.visibility = "hidden"
}
searchType = "basic" // reset search type after closing
}
@@ -109,11 +183,14 @@ document.addEventListener("nav", async (e: unknown) => {
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) {
e.preventDefault()
const searchBarOpen = container?.classList.contains("active")
searchBarOpen ? hideSearch() : showSearch("basic")
return
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
// Hotkey to open tag search
e.preventDefault()
@@ -122,156 +199,227 @@ document.addEventListener("nav", async (e: unknown) => {
// add "#" prefix for tag search
if (searchBar) searchBar.value = "#"
} else if (e.key === "Enter") {
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
else if (e.key === "Enter") {
// If result has focus, navigate to that one, otherwise pick first result
if (results?.contains(document.activeElement)) {
const active = document.activeElement as HTMLInputElement
if (active.classList.contains("no-match")) return
await displayPreview(active)
active.click()
} else {
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 === "ArrowDown") {
e.preventDefault()
// When first pressing ArrowDown, results wont contain the active element, so focus first element
if (!results?.contains(document.activeElement)) {
const firstResult = resultCards[0] as HTMLInputElement | null
firstResult?.focus()
} else {
// If an element in results-container already has focus, focus next one
const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null
nextResult?.focus()
}
} else if (e.key === "ArrowUp") {
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
e.preventDefault()
if (results?.contains(document.activeElement)) {
// 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")
await displayPreview(prevResult)
prevResult?.focus()
currentHover = prevResult
}
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault()
// The results should already been focused, so we need to find the next one.
// The activeElement is the search bar, so we need to find the first result and focus it.
if (document.activeElement === searchBar || currentHover !== null) {
const firstResult = currentHover
? currentHover
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
firstResult?.classList.remove("focus")
await displayPreview(secondResult)
secondResult?.focus()
currentHover = secondResult
} else {
// If an element in results-container already has focus, focus next one
const active = currentHover
? currentHover
: (document.activeElement as HTMLInputElement | null)
active?.classList.remove("focus")
const nextResult = active?.nextElementSibling as HTMLInputElement | null
await displayPreview(nextResult)
nextResult?.focus()
currentHover = nextResult
}
}
}
function trimContent(content: string) {
// works without escaping html like in `description.ts`
const sentences = content.replace(/\s+/g, " ").split(".")
let finalDesc = ""
let sentenceIdx = 0
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
const len = contextWindowWords * 5
while (finalDesc.length < len) {
const sentence = sentences[sentenceIdx]
if (!sentence) break
finalDesc += sentence + "."
sentenceIdx++
}
// If more content would be available, indicate it by finishing with "..."
if (finalDesc.length < content.length) {
finalDesc += ".."
}
return finalDesc
}
const formatForDisplay = (term: string, id: number) => {
const slug = idDataMap[id]
return {
id,
slug,
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:
searchType === "tags"
? trimContent(data[slug].content)
: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term, data[slug].tags),
content: highlight(term, data[slug].content ?? "", true),
tags: highlightTags(term.substring(1), data[slug].tags),
}
}
function highlightTags(term: string, tags: string[]) {
if (tags && searchType === "tags") {
// Find matching tags
const termLower = term.toLowerCase()
let matching = tags.filter((str) => str.includes(termLower))
// Substract matching from original tags, then push difference
if (matching.length > 0) {
let difference = tags.filter((x) => !matching.includes(x))
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
matching.push(...difference)
}
// Only allow max of `numTagResults` in preview
if (tags.length > numTagResults) {
matching.splice(numTagResults)
}
return matching
} else {
if (!tags || searchType !== "tags") {
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 htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
const button = document.createElement("button")
button.classList.add("result-card")
button.id = slug
button.innerHTML = `<h3>${title}</h3>${htmlTags}<p>${content}</p>`
button.addEventListener("click", () => {
const targ = resolveRelative(currentSlug, slug)
window.spaNavigate(new URL(targ, window.location.toString()))
hideSearch()
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
const itemTile = document.createElement("a")
itemTile.classList.add("result-card")
itemTile.id = slug
itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>`
async function onMouseEnter(ev: MouseEvent) {
if (!ev.target) return
currentHover?.classList.remove("focus")
currentHover?.blur()
const target = ev.target as HTMLInputElement
await displayPreview(target)
currentHover = target
currentHover.classList.add("focus")
}
async function onMouseLeave(ev: MouseEvent) {
if (!ev.target) return
const target = ev.target as HTMLElement
target.classList.remove("focus")
}
const events = [
["mouseenter", onMouseEnter],
["mouseleave", onMouseLeave],
[
"click",
(event: MouseEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
},
],
] as const
events.forEach(([event, handler]) => {
itemTile.addEventListener(event, handler)
window.addCleanup(() => itemTile.removeEventListener(event, handler))
})
return button
return itemTile
}
function displayResults(finalResults: Item[]) {
async function displayResults(finalResults: Item[]) {
if (!results) return
removeAllChildren(results)
if (finalResults.length === 0) {
results.innerHTML = `<button class="result-card">
<h3>No results.</h3>
<p>Try another search term?</p>
</button>`
results.innerHTML = `<a class="result-card no-match">
<h3>No results.</h3>
<p>Try another search term?</p>
</a>`
} else {
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
el.classList.add("focus")
previewInner = document.createElement("div")
previewInner.classList.add("preview-inner")
const innerDiv = await fetchContent(slug).then((contents) =>
contents.map((el) => highlightHTML(currentSearchTerm, el.innerHTML)),
)
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()
}
async function onType(e: HTMLElementEventMap["input"]) {
let term = (e.target as HTMLInputElement).value
let searchResults: SimpleDocumentSearchResultSetUnit[]
if (!searchLayout || !index) return
currentSearchTerm = (e.target as HTMLInputElement).value
searchLayout.style.visibility = currentSearchTerm === "" ? "hidden" : "visible"
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
if (term.toLowerCase().startsWith("#")) {
searchType = "tags"
} else {
searchType = "basic"
}
switch (searchType) {
case "tags": {
term = term.substring(1)
searchResults =
(await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ??
[]
break
}
case "basic":
default: {
searchResults =
(await index?.searchAsync({
query: term,
limit: numSearchResults,
index: ["title", "content"],
})) ?? []
}
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
if (searchType === "tags") {
searchResults = await index.searchAsync({
query: currentSearchTerm.substring(1),
limit: numSearchResults,
index: ["tags"],
})
} else if (searchType === "basic") {
searchResults = await index.searchAsync({
query: currentSearchTerm,
limit: numSearchResults,
index: ["title", "content"],
})
}
const getByField = (field: string): number[] => {
@@ -285,50 +433,18 @@ document.addEventListener("nav", async (e: unknown) => {
...getByField("content"),
...getByField("tags"),
])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
displayResults(finalResults)
}
if (prevShortcutHandler) {
document.removeEventListener("keydown", prevShortcutHandler)
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
await displayResults(finalResults)
}
document.addEventListener("keydown", shortcutHandler)
prevShortcutHandler = shortcutHandler
searchIcon?.removeEventListener("click", () => showSearch("basic"))
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
searchIcon?.addEventListener("click", () => showSearch("basic"))
searchBar?.removeEventListener("input", onType)
window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic")))
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
index ??= await fillDocument(data)
registerEscapeHandler(container, hideSearch)
})
@@ -337,16 +453,38 @@ document.addEventListener("nav", async (e: unknown) => {
* @param index index to fill
* @param data data to fill index with
*/
async function fillDocument(index: Document<Item, false>, data: any) {
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
const 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",
},
],
},
})
let id = 0
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, {
await index.addAsync(id++, {
id,
slug: slug as FullSlug,
title: fileData.title,
content: fileData.content,
tags: fileData.tags,
})
id++
}
return index
}

View File

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

View File

@@ -16,7 +16,8 @@ const observer = new IntersectionObserver((entries) => {
function toggleToc(this: HTMLElement) {
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.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
}
@@ -25,10 +26,11 @@ function setupToc() {
const toc = document.getElementById("toc")
if (toc) {
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"
toc.removeEventListener("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()
}
outsideContainer?.removeEventListener("click", click)
outsideContainer?.addEventListener("click", click)
document.removeEventListener("keydown", esc)
window.addCleanup(() => outsideContainer?.removeEventListener("click", click))
document.addEventListener("keydown", esc)
window.addCleanup(() => document.removeEventListener("keydown", esc))
}
export function removeAllChildren(node: HTMLElement) {

View File

@@ -106,7 +106,7 @@ svg {
align-items: center;
font-family: var(--headerFont);
& p {
& span {
font-size: 0.95rem;
display: inline-block;
color: var(--secondary);
@@ -126,7 +126,7 @@ svg {
backface-visibility: visible;
}
div:has(> .folder-outer:not(.open)) > .folder-container > svg {
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg);
}

View File

@@ -26,6 +26,7 @@
max-height: 20rem;
padding: 0 1rem 1rem 1rem;
font-weight: initial;
font-style: initial;
line-height: normal;
font-size: initial;
font-family: var(--bodyFont);

View File

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

View File

@@ -7,6 +7,7 @@ import { FilePath, FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
@@ -25,7 +26,7 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return [Head, Body, pageBody, Footer]
},
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
async emit(ctx, _content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug
@@ -48,7 +49,8 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
}
return [
await emit({
await write({
ctx,
content: renderPage(slug, componentData, opts, externalResources),
slug,
ext: ".html",

View File

@@ -1,24 +1,21 @@
import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
import { write } from "./helpers"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
getQuartzComponents() {
return []
},
async emit({ argv }, content, _resources, emit): Promise<FilePath[]> {
async emit(ctx, content, _resources): Promise<FilePath[]> {
const { argv } = ctx
const fps: FilePath[] = []
for (const [_tree, file] of content) {
const ogSlug = simplifySlug(file.data.slug!)
const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!))
let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? []
if (typeof aliases === "string") {
aliases = [aliases]
}
const aliases = file.data.frontmatter?.aliases ?? []
const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug)
const permalink = file.data.frontmatter?.permalink
if (typeof permalink === "string") {
@@ -32,7 +29,8 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({
}
const redirUrl = resolveRelative(slug, file.data.slug!)
const fp = await emit({
const fp = await write({
ctx,
content: `
<!DOCTYPE html>
<html lang="en-us">

View File

@@ -10,7 +10,7 @@ export const Assets: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
// glob all non MD/MDX/HTML files in content folder and copy it over
const assetsPath = argv.output
const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])

View File

@@ -13,7 +13,7 @@ export const CNAME: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
if (!cfg.configuration.baseUrl) {
console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration"))
return []

View File

@@ -4,8 +4,6 @@ import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
import plausibleScript from "../../components/scripts/plausible.inline"
// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
@@ -14,6 +12,8 @@ import { StaticResources } from "../../util/resources"
import { QuartzComponent } from "../../components/types"
import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
type ComponentResources = {
css: string[]
@@ -56,9 +56,16 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
}
}
function joinScripts(scripts: string[]): string {
async function joinScripts(scripts: string[]): Promise<string> {
// wrap with iife to prevent scope collision
return scripts.map((script) => `(function () {${script}})();`).join("\n")
const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
// minify with esbuild
const res = await transpile(script, {
minify: true,
})
return res.code
}
function addGlobalPageResources(
@@ -85,24 +92,37 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(`
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag(\`js\`, new Date());
gtag(\`config\`, \`${tagId}\`, { send_page_view: false });
document.addEventListener(\`nav\`, () => {
gtag(\`event\`, \`page_view\`, {
gtag("js", new Date());
gtag("config", "${tagId}", { send_page_view: false });
document.addEventListener("nav", () => {
gtag("event", "page_view", {
page_title: document.title,
page_location: location.href,
});
});`)
} else if (cfg.analytics?.provider === "plausible") {
componentResources.afterDOMLoaded.push(plausibleScript)
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
componentResources.afterDOMLoaded.push(`
const plausibleScript = document.createElement("script")
plausibleScript.src = "${plausibleHost}/js/script.manual.js"
plausibleScript.setAttribute("data-domain", location.hostname)
plausibleScript.defer = true
document.head.appendChild(plausibleScript)
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
document.addEventListener("nav", () => {
plausible("pageview")
})
`)
} else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(`
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.async = true
document.head.appendChild(umamiScript)
`)
}
@@ -111,9 +131,11 @@ function addGlobalPageResources(
componentResources.afterDOMLoaded.push(spaRouterScript)
} else {
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)`)
window.spaNavigate = (url, _) => window.location.assign(url)
window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)
`)
}
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`
@@ -127,9 +149,9 @@ function addGlobalPageResources(
loadTime: "afterDOMReady",
contentType: "inline",
script: `
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
})
}
}
@@ -149,7 +171,7 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
getQuartzComponents() {
return []
},
async emit(ctx, _content, resources, emit): Promise<FilePath[]> {
async emit(ctx, _content, resources): Promise<FilePath[]> {
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
// important that this goes *after* component scripts
@@ -165,10 +187,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
addGlobalPageResources(ctx, resources, componentResources)
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
const prescript = joinScripts(componentResources.beforeDOMLoaded)
const postscript = joinScripts(componentResources.afterDOMLoaded)
const [prescript, postscript] = await Promise.all([
joinScripts(componentResources.beforeDOMLoaded),
joinScripts(componentResources.afterDOMLoaded),
])
const fps = await Promise.all([
emit({
write({
ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
@@ -185,12 +211,14 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
include: Features.MediaQueries,
}).code.toString(),
}),
emit({
write({
ctx,
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
}),
emit({
write({
ctx,
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,

View File

@@ -2,10 +2,10 @@ import { Root } from "hast"
import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date"
import { escapeHTML } from "../../util/escape"
import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import path from "path"
import { write } from "./helpers"
export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = {
@@ -37,7 +37,7 @@ const defaultOptions: Options = {
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
<loc>https://${base}/${encodeURI(slug)}</loc>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
<lastmod>${content.date?.toISOString()}</lastmod>
</url>`
const urls = Array.from(idx)
@@ -48,12 +48,11 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
const base = cfg.baseUrl ?? ""
const root = `https://${base}`
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
<title>${escapeHTML(content.title)}</title>
<link>${root}/${encodeURI(slug)}</link>
<guid>${root}/${encodeURI(slug)}</guid>
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toUTCString()}</pubDate>
</item>`
@@ -78,7 +77,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
<rss version="2.0">
<channel>
<title>${escapeHTML(cfg.pageTitle)}</title>
<link>${root}</link>
<link>https://${base}</link>
<description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
cfg.pageTitle,
)}</description>
@@ -92,7 +91,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
name: "ContentIndex",
async emit(ctx, content, _resources, emit) {
async emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map()
@@ -116,7 +115,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableSiteMap) {
emitted.push(
await emit({
await write({
ctx,
content: generateSiteMap(cfg, linkIndex),
slug: "sitemap" as FullSlug,
ext: ".xml",
@@ -126,7 +126,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.enableRSS) {
emitted.push(
await emit({
await write({
ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
slug: "index" as FullSlug,
ext: ".xml",
@@ -134,7 +135,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
)
}
const fp = path.join("static", "contentIndex") as FullSlug
const fp = joinSegments("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries(
Array.from(linkIndex).map(([slug, content]) => {
// remove description and from content index as nothing downstream
@@ -147,7 +148,8 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
)
emitted.push(
await emit({
await write({
ctx,
content: JSON.stringify(simplifiedIndex),
slug: fp,
ext: ".json",

View File

@@ -8,6 +8,7 @@ import { FilePath, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
import chalk from "chalk"
import { write } from "./helpers"
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
@@ -26,7 +27,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
@@ -49,7 +50,8 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const fp = await write({
ctx,
content,
slug,
ext: ".html",

View File

@@ -17,8 +17,9 @@ import {
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@@ -35,7 +36,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@@ -82,7 +83,8 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const fp = await write({
ctx,
content,
slug,
ext: ".html",

View File

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

View File

@@ -8,7 +8,7 @@ export const Static: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async emit({ argv, cfg }, _content, _resources, _emit): Promise<FilePath[]> {
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
await fs.promises.cp(staticPath, joinSegments(argv.output, "static"), {

View File

@@ -14,8 +14,9 @@ import {
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
@@ -32,7 +33,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async emit(ctx, content, resources, emit): Promise<FilePath[]> {
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
@@ -81,7 +82,8 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (userOpts) => {
}
const content = renderPage(slug, componentData, opts, externalResources)
const fp = await emit({
const fp = await write({
ctx,
content,
slug: file.data.slug!,
ext: ".html",

View File

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

View File

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

View File

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

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

View File

@@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin<Options> = (opts?: Options) => {
return {
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: [
{
// 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",
contentType: "external",
},

View File

@@ -12,6 +12,7 @@ import {
import path from "path"
import { visit } from "unist-util-visit"
import isAbsoluteUrl from "is-absolute-url"
import { Root } from "hast"
interface Options {
/** How to resolve Markdown paths */
@@ -19,12 +20,16 @@ interface Options {
/** Strips folders from a link so that it looks nice */
prettyLinks: boolean
openLinksInNewTab: boolean
lazyLoad: boolean
externalLinkIcon: boolean
}
const defaultOptions: Options = {
markdownLinkResolution: "absolute",
prettyLinks: true,
openLinksInNewTab: false,
lazyLoad: false,
externalLinkIcon: true,
}
export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> = (userOpts) => {
@@ -34,7 +39,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
htmlPlugins(ctx) {
return [
() => {
return (tree, file) => {
return (tree: Root, file) => {
const curSlug = simplifySlug(file.data.slug!)
const outgoing: Set<SimpleSlug> = new Set()
@@ -51,8 +56,30 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
typeof node.properties.href === "string"
) {
let dest = node.properties.href as RelativeURL
node.properties.className ??= []
node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal")
const classes = (node.properties.className ?? []) as string[]
const isExternal = isAbsoluteUrl(dest)
classes.push(isExternal ? "external" : "internal")
if (isExternal && opts.externalLinkIcon) {
node.children.push({
type: "element",
tagName: "svg",
properties: {
class: "external-icon",
viewBox: "0 0 512 512",
},
children: [
{
type: "element",
tagName: "path",
properties: {
d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z",
},
children: [],
},
],
})
}
// Check if the link has alias text
if (
@@ -61,8 +88,9 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
node.children[0].value !== dest
) {
// Add the 'alias' class if the text content is not the same as the href
node.properties.className.push("alias")
classes.push("alias")
}
node.properties.className = classes
if (opts.openLinksInNewTab) {
node.properties.target = "_blank"
@@ -111,6 +139,10 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
node.properties &&
typeof node.properties.src === "string"
) {
if (opts.lazyLoad) {
node.properties.loading = "lazy"
}
if (!isAbsoluteUrl(node.properties.src)) {
let dest = node.properties.src as RelativeURL
dest = node.properties.src = transformLink(

View File

@@ -1,10 +1,10 @@
import { QuartzTransformerPlugin } from "../types"
import { Root, Html, BlockContent, DefinitionContent, Code, Paragraph } from "mdast"
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
import { slug as slugAnchor } from "github-slugger"
import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit"
import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { JSResource } from "../../util/resources"
// @ts-ignore
@@ -23,8 +23,11 @@ export interface Options {
callouts: boolean
mermaid: boolean
parseTags: boolean
parseArrows: boolean
parseBlockReferences: boolean
enableInHtmlEmbed: boolean
enableYouTubeEmbed: boolean
enableVideoEmbed: boolean
}
const defaultOptions: Options = {
@@ -34,43 +37,14 @@ const defaultOptions: Options = {
callouts: true,
mermaid: true,
parseTags: true,
parseArrows: true,
parseBlockReferences: true,
enableInHtmlEmbed: false,
enableYouTubeEmbed: true,
enableVideoEmbed: true,
}
const icons = {
infoIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`,
pencilIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>`,
clipboardListIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`,
checkCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>`,
flameIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`,
checkIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
helpCircleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
alertTriangleIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
xIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
zapIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
bugIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="14" x="8" y="6" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>`,
listIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
quoteIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`,
}
const callouts = {
note: icons.pencilIcon,
abstract: icons.clipboardListIcon,
info: icons.infoIcon,
todo: icons.checkCircleIcon,
tip: icons.flameIcon,
success: icons.checkIcon,
question: icons.helpCircleIcon,
warning: icons.alertTriangleIcon,
failure: icons.xIcon,
danger: icons.zapIcon,
bug: icons.bugIcon,
example: icons.listIcon,
quote: icons.quoteIcon,
}
const calloutMapping: Record<string, keyof typeof callouts> = {
const calloutMapping = {
note: "note",
abstract: "abstract",
summary: "abstract",
@@ -98,24 +72,29 @@ const calloutMapping: Record<string, keyof typeof callouts> = {
example: "example",
quote: "quote",
cite: "quote",
} as const
function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
// if callout is not recognized, make it a custom one
return calloutMapping[normalizedCallout] ?? calloutName
}
function canonicalizeCallout(calloutName: string): keyof typeof callouts {
let callout = calloutName.toLowerCase() as keyof typeof calloutMapping
return calloutMapping[callout] ?? "note"
}
export const externalLinkRegex = /^https?:\/\//i
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias)
export const arrowRegex = new RegExp(/-{1,2}>/, "g")
// !? -> optional embedding
// \[\[ -> open brace
// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name)
// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link)
// (\|[^\[\]\#]+)? -> | then one or more non-special characters (alias)
export const wikilinkRegex = new RegExp(
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/,
/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\#]+)?\]\]/,
"g",
)
const highlightRegex = new RegExp(/==([^=]+)==/, "g")
const commentRegex = new RegExp(/%%(.+)%%/, "g")
const commentRegex = new RegExp(/%%[\s\S]*?%%/, "g")
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/)
const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
@@ -123,8 +102,13 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm")
// #(...) -> capturing group, tag itself must start with #
// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores
// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/"
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu")
const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g")
const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/, "g")
const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/
const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/)
const wikilinkImageEmbedRegex = new RegExp(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
userOpts,
@@ -139,13 +123,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return {
name: "ObsidianFlavoredMarkdown",
textTransform(_ctx, src) {
// do comments at text level
if (opts.comments) {
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replace(commentRegex, "")
}
// pre-transform blockquotes
if (opts.callouts) {
if (src instanceof Buffer) {
src = src.toString()
}
src = src.replaceAll(calloutLineRegex, (value) => {
src = src.replace(calloutLineRegex, (value) => {
// force newline after title of callout
return value + "\n> "
})
@@ -157,14 +150,20 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
src = src.toString()
}
src = src.replaceAll(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias] = capture
src = src.replace(wikilinkRegex, (value, ...capture) => {
const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture
const fp = rawFp ?? ""
const anchor = rawHeader?.trim().replace(/^#+/, "")
const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : ""
const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : ""
const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? ""
const embedDisplay = value.startsWith("!") ? "!" : ""
if (rawFp?.match(externalLinkRegex)) {
return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})`
}
return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]`
})
}
@@ -193,11 +192,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (value.startsWith("!")) {
const ext: string = path.extname(fp).toLowerCase()
const url = slugifyFilePath(fp as FilePath)
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
const dims = alias ?? ""
let [width, height] = dims.split("x", 2)
width ||= "auto"
height ||= "auto"
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) {
const match = wikilinkImageEmbedRegex.exec(alias ?? "")
const alt = match?.groups?.alt ?? ""
const width = match?.groups?.width ?? "auto"
const height = match?.groups?.height ?? "auto"
return {
type: "image",
url,
@@ -205,6 +204,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
hProperties: {
width,
height,
alt,
},
},
}
@@ -225,7 +225,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
type: "html",
value: `<iframe src="${url}"></iframe>`,
}
} else if (ext === "") {
} else {
const block = anchor
return {
type: "html",
@@ -268,13 +268,13 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
])
}
if (opts.comments) {
if (opts.parseArrows) {
replacements.push([
commentRegex,
arrowRegex,
(_value: string, ..._capture: string[]) => {
return {
type: "text",
value: "",
type: "html",
value: `<span>&rarr;</span>`,
}
},
])
@@ -290,8 +290,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
tag = slugTag(tag)
if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) {
file.data.frontmatter.tags.push(tag)
if (file.data.frontmatter) {
const noteTags = file.data.frontmatter.tags ?? []
file.data.frontmatter.tags = [...new Set([...noteTags, tag])]
}
return {
@@ -319,7 +320,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (typeof replace === "string") {
node.value = node.value.replace(regex, replace)
} else {
node.value = node.value.replaceAll(regex, (substring: string, ...args) => {
node.value = node.value.replace(regex, (substring: string, ...args) => {
const replaceValue = replace(substring, ...args)
if (typeof replaceValue === "string") {
return replaceValue
@@ -335,11 +336,28 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
})
}
mdastFindReplace(tree, replacements)
}
})
if (opts.enableVideoEmbed) {
plugins.push(() => {
return (tree: Root, _file) => {
visit(tree, "image", (node, index, parent) => {
if (parent && index != undefined && videoExtensionRegex.test(node.url)) {
const newNode: Html = {
type: "html",
value: `<video controls src="${node.url}"></video>`,
}
parent.children.splice(index, 1, newNode)
return SKIP
}
})
}
})
}
if (opts.callouts) {
plugins.push(() => {
return (tree: Root, _file) => {
@@ -355,36 +373,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
}
const text = firstChild.children[0].value
const restChildren = firstChild.children.slice(1)
const restOfTitle = firstChild.children.slice(1)
const [firstLine, ...remainingLines] = text.split("\n")
const remainingText = remainingLines.join("\n")
const match = firstLine.match(calloutRegex)
if (match && match.input) {
const [calloutDirective, typeString, collapseChar] = match
const calloutType = canonicalizeCallout(
typeString.toLowerCase() as keyof typeof calloutMapping,
)
const calloutType = canonicalizeCallout(typeString.toLowerCase())
const collapse = collapseChar === "+" || collapseChar === "-"
const defaultState = collapseChar === "-" ? "collapsed" : "expanded"
const titleContent =
match.input.slice(calloutDirective.length).trim() || capitalize(calloutType)
const titleContent = match.input.slice(calloutDirective.length).trim()
const useDefaultTitle = titleContent === "" && restOfTitle.length === 0
const titleNode: Paragraph = {
type: "paragraph",
children: [{ type: "text", value: titleContent + " " }, ...restChildren],
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
},
...restOfTitle,
],
}
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">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>`
const toggleIcon = `<div class="fold-callout-icon"></div>`
const titleHtml: Html = {
type: "html",
value: `<div
class="callout-title"
>
<div class="callout-icon">${callouts[calloutType]}</div>
<div class="callout-icon"></div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`,
@@ -410,7 +430,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${collapse ? "is-collapsible" : ""} ${
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
"data-callout": calloutType,
@@ -443,11 +463,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
},
htmlPlugins() {
const plugins: PluggableList = [rehypeRaw]
if (opts.parseBlockReferences) {
plugins.push(() => {
const inlineTagTypes = new Set(["p", "li"])
const blockTagTypes = new Set(["blockquote"])
return (tree, file) => {
return (tree: HtmlRoot, file) => {
file.data.blocks = {}
visit(tree, "element", (node, index, parent) => {
@@ -496,6 +517,30 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
})
}
if (opts.enableYouTubeEmbed) {
plugins.push(() => {
return (tree: HtmlRoot) => {
visit(tree, "element", (node) => {
if (node.tagName === "img" && typeof node.properties.src === "string") {
const match = node.properties.src.match(ytLinkRegex)
const videoId = match && match[2].length == 11 ? match[2] : null
if (videoId) {
node.tagName = "iframe"
node.properties = {
class: "external-embed",
allow: "fullscreen",
frameborder: 0,
width: "600px",
height: "350px",
src: `https://www.youtube.com/embed/${videoId}`,
}
}
}
})
}
})
}
return plugins
},
externalResources() {

View File

@@ -2,7 +2,7 @@ import { PluggableList } from "unified"
import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile"
import { QuartzComponent } from "../components/types"
import { FilePath, FullSlug } from "../util/path"
import { FilePath } from "../util/path"
import { BuildCtx } from "../util/ctx"
export interface PluginTypes {
@@ -36,19 +36,6 @@ export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
) => QuartzEmitterPluginInstance
export type QuartzEmitterPluginInstance = {
name: string
emit(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
emitCallback: EmitCallback,
): Promise<FilePath[]>
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
}
export interface EmitOptions {
slug: FullSlug
ext: `.${string}` | ""
content: string
}
export type EmitCallback = (data: EmitOptions) => Promise<FilePath>

View File

@@ -1,10 +1,6 @@
import path from "path"
import fs from "fs"
import { PerfTimer } from "../util/perf"
import { getStaticResourcesFromPlugins } from "../plugins"
import { EmitCallback } from "../plugins/types"
import { ProcessedContent } from "../plugins/vfile"
import { FilePath, joinSegments } from "../util/path"
import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
@@ -15,19 +11,12 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const log = new QuartzLogger(ctx.argv.verbose)
log.start(`Emitting output files`)
const emit: EmitCallback = async ({ slug, ext, content }) => {
const pathToPage = joinSegments(argv.output, slug + ext) as FilePath
const dir = path.dirname(pathToPage)
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(pathToPage, content)
return pathToPage
}
let emittedFiles = 0
const staticResources = getStaticResourcesFromPlugins(ctx)
for (const emitter of cfg.plugins.emitters) {
try {
const emitted = await emitter.emit(ctx, content, staticResources, emit)
const emitted = await emitter.emit(ctx, content, staticResources)
emittedFiles += emitted.length
if (ctx.argv.verbose) {

View File

@@ -91,8 +91,9 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
}
// base data properties that plugins may use
file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
file.data.filePath = fp
file.data.filePath = file.path as FilePath
file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
file.data.slug = slugifyFilePath(file.data.relativePath)
const ast = processor.parse(file)
const newAst = await processor.run(ast, file)

View File

@@ -4,7 +4,6 @@
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: none;
text-size-adjust: none;
overflow-x: hidden;
width: 100vw;
@@ -27,7 +26,7 @@ section {
}
::selection {
background: color-mix(in srgb, var(--tertiary) 75%, transparent);
background: color-mix(in srgb, var(--tertiary) 60%, transparent);
color: var(--darkgray);
}
@@ -69,6 +68,7 @@ a {
background-color: var(--highlight);
padding: 0 0.1rem;
border-radius: 5px;
line-height: 1.4rem;
&:has(> img) {
background-color: none;
@@ -76,6 +76,15 @@ a {
padding: 0;
}
}
&.external .external-icon {
height: 1ex;
margin: 0 0.15em;
> path {
fill: var(--dark);
}
}
}
.desktop-only {
@@ -162,9 +171,11 @@ a {
& .sidebar.right {
right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
flex-wrap: wrap;
& > * {
@media all and (max-width: $fullPageWidth) {
flex: 1;
min-width: 140px;
}
}
}
@@ -267,7 +278,6 @@ h6 {
opacity: 0;
transition: opacity 0.2s ease;
transform: translateY(-0.1rem);
display: inline-block;
font-family: var(--codeFont);
user-select: none;
}
@@ -328,10 +338,11 @@ figure[data-rehype-pretty-code-figure] {
pre {
font-family: var(--codeFont);
padding: 0.5rem;
padding: 0 0.5rem;
border-radius: 5px;
overflow-x: auto;
border: 1px solid var(--lightgray);
position: relative;
&:has(> code.mermaid) {
border: none;
@@ -345,6 +356,7 @@ pre {
counter-increment: line 0;
display: grid;
padding: 0.5rem 0;
overflow-x: scroll;
& [data-highlighted-chars] {
background-color: var(--highlight);

View File

@@ -13,16 +13,33 @@
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;
--border: #448aff44;
--bg: #448aff10;
--callout-icon: var(--callout-icon-note);
}
&[data-callout="abstract"] {
--color: #00b0ff;
--border: #00b0ff44;
--bg: #00b0ff10;
--callout-icon: var(--callout-icon-abstract);
}
&[data-callout="info"],
@@ -30,30 +47,39 @@
--color: #00b8d4;
--border: #00b8d444;
--bg: #00b8d410;
--callout-icon: var(--callout-icon-info);
}
&[data-callout="todo"] {
--callout-icon: var(--callout-icon-todo);
}
&[data-callout="tip"] {
--color: #00bfa5;
--border: #00bfa544;
--bg: #00bfa510;
--callout-icon: var(--callout-icon-tip);
}
&[data-callout="success"] {
--color: #09ad7a;
--border: #09ad7144;
--bg: #09ad7110;
--callout-icon: var(--callout-icon-success);
}
&[data-callout="question"] {
--color: #dba642;
--border: #dba64244;
--bg: #dba64210;
--callout-icon: var(--callout-icon-question);
}
&[data-callout="warning"] {
--color: #db8942;
--border: #db894244;
--bg: #db894210;
--callout-icon: var(--callout-icon-warning);
}
&[data-callout="failure"],
@@ -62,50 +88,74 @@
--color: #db4242;
--border: #db424244;
--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"] {
--color: #7a43b5;
--border: #7a43b544;
--bg: #7a43b510;
--callout-icon: var(--callout-icon-example);
}
&[data-callout="quote"] {
--color: var(--secondary);
--border: var(--lightgray);
--callout-icon: var(--callout-icon-quote);
}
&.is-collapsed > .callout-title > .fold {
&.is-collapsed > .callout-title > .fold-callout-icon {
transform: rotateZ(-90deg);
}
}
.callout-title {
display: flex;
align-items: center;
gap: 5px;
padding: 1rem 0;
color: var(--color);
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
--icon-size: 18px;
& .fold-callout-icon {
transition: transform 0.15s ease;
opacity: 0.8;
cursor: pointer;
width: var(--icon-size);
height: var(--icon-size);
--callout-icon: var(--callout-icon-fold);
}
& > .callout-title-inner > p {
color: var(--color);
margin: 0;
}
}
.callout-icon {
width: 18px;
height: 18px;
flex: 0 0 18px;
padding-top: 4px;
}
.callout-icon,
& .fold-callout-icon {
width: var(--icon-size);
height: var(--icon-size);
.callout-title-inner {
font-weight: 700;
// icon support
background-size: var(--icon-size) var(--icon-size);
background-position: center;
background-color: var(--color);
mask-image: var(--callout-icon);
mask-size: var(--icon-size) var(--icon-size);
mask-position: center;
mask-repeat: no-repeat;
}
.callout-title-inner {
font-weight: 700;
}
}

View File

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

View File

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

View File

@@ -105,6 +105,9 @@ describe("transforms", () => {
["index.md", "index"],
["test.mp4", "test.mp4"],
["note with spaces.md", "note-with-spaces"],
["notes.with.dots.md", "notes.with.dots"],
["test/special chars?.md", "test/special-chars-q"],
["test/special chars #3.md", "test/special-chars-3"],
],
path.slugifyFilePath,
path.isFilePath,

View File

@@ -1,5 +1,9 @@
import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
import rfdc from "rfdc"
export const clone = rfdc()
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
@@ -46,7 +50,9 @@ export function getFullSlug(window: Window): FullSlug {
function sluggify(s: string): string {
return s
.split("/")
.map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments
.map((segment) =>
segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q").replace(/#/g, ""),
)
.join("/") // always use / as sep
.replace(/\/$/, "")
}
@@ -121,7 +127,8 @@ const _rebaseHastElement = (
}
}
export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) {
export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
const el = clone(rawEl) // clone so we dont modify the original page
_rebaseHastElement(el, "src", curBase, newBase)
_rebaseHastElement(el, "href", curBase, newBase)
if (el.children) {

View File

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

View File

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