Compare commits

...

48 Commits

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

* finish

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

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

* fix: use html value instead of decimal

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

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

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

---------

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

More granular detection of alt and resize in image

* fix: format

* feat: init i18n

* feat: add translation

* style: prettier for test

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

* style: run prettier

* remove cursed file

* refactor: remove i18n library and use locale way instead

* format with prettier

* forgot to remove test

* prevent merging error

* format

* format

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

* forgot item translation

* remove unused locale variable

* forgot to remove fr-FR testing

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

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

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

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

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

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

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

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

remove flashing by setting max-height

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

---------

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

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

* fix: remove invalid regex and unused code path

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

---------

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

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

* fix: revert naming

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

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

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

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

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

* chore: remove unecessary class for tracking mouse

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

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 18:35:29 -08:00
dependabot[bot]
1c175b2d09 chore(deps): bump mdast-util-to-hast from 13.0.2 to 13.1.0 (#776)
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.0.2 to 13.1.0.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.0.2...13.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 15:27:30 -05:00
dependabot[bot]
7b2ce8b4a3 chore(deps): bump async-mutex from 0.4.0 to 0.4.1 (#777)
Bumps [async-mutex](https://github.com/DirtyHairy/async-mutex) from 0.4.0 to 0.4.1.
- [Changelog](https://github.com/DirtyHairy/async-mutex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/DirtyHairy/async-mutex/compare/v0.4.0...v0.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 15:26:57 -05:00
dependabot[bot]
f2e93c3314 chore(deps-dev): bump @types/node from 20.11.11 to 20.11.14 (#779)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.11 to 20.11.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 15:26:34 -05:00
Jacky Zhao
25e6869d38 deps: reduce dependabot frequency 2024-01-31 12:24:25 -08:00
Jacky Zhao
bfd877133b fix: regression in formatted callout titles 2024-01-31 12:09:04 -08:00
Aaron Pham
422986c98b fix(search): remove background with mouseEvent (#775)
* fix(search): remove background with mouseEvent

make sure when mouseenter we remove all existing background

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

* chore: update logics from suggestions

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

* revert: class is evicted

* fix: address correct type

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

---------

Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
2024-01-31 15:00:19 -05:00
Jacky Zhao
75d64eac91 fix: fmt 2024-01-31 11:58:54 -08:00
Jacky Zhao
355aa22318 docs: fix outdated comment on rebuild debounce behaviour 2024-01-31 11:52:10 -08:00
Jacky Zhao
7cb1c291c8 fix: allow formatting in callout titles 2024-01-31 11:41:27 -08:00
63 changed files with 759 additions and 485 deletions

View File

@@ -8,4 +8,4 @@ updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"

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

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

View File

@@ -27,6 +27,7 @@ This part of the configuration concerns anything that can affect the whole site.
- `null`: don't use analytics;
- `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or
- `{ provider: 'google', tagId: <your-google-tag> }`: use Google Analytics
- `locale`: used for [[i18n]] and date formatting
- `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes.
- This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`
- Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it.

View File

@@ -36,7 +36,8 @@ By default, custom callouts are handled by applying the `note` style. To make fa
--color: #customcolor;
--border: #custombordercolor;
--bg: #custombg;
--callout-icon: url('data:image/svg+xml; utf8, <custom formatted svg>'); //SVG icon code
--callout-icon: url("data:image/svg+xml; utf8, <custom formatted svg>"); //SVG icon code
}
}
```
@@ -48,7 +49,7 @@ By default, custom callouts are handled by applying the `note` style. To make fa
> [!info]
> Default title
> [!question]+ Can callouts be nested?
> [!question]+ Can callouts be _nested_?
>
> > [!todo]- Yes!, they can. And collapsed!
> >

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

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

View File

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

@@ -31,7 +31,7 @@ If you prefer instructions in a video format you can try following Nicole van de
## 🔧 Features
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], and [many more](./features) right out of the box
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]] and [many more](./features) right out of the box
- Hot-reload for both configuration and content
- Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes

1
globals.d.ts vendored
View File

@@ -8,5 +8,6 @@ export declare global {
}
interface Window {
spaNavigate(url: URL, isBack: boolean = false)
addCleanup(fn: (...args: any[]) => void)
}
}

39
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@jackyzha0/quartz",
"version": "4.1.6",
"version": "4.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackyzha0/quartz",
"version": "4.1.6",
"version": "4.2.2",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.1",
"@napi-rs/simple-git": "0.1.14",
"async-mutex": "^0.4.0",
"async-mutex": "^0.4.1",
"chalk": "^5.3.0",
"chokidar": "^3.5.3",
"cli-spinner": "^0.2.10",
@@ -29,7 +29,7 @@
"js-yaml": "^4.1.0",
"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",
"preact": "^10.19.3",
@@ -71,9 +71,9 @@
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.11",
"@types/node": "^20.11.14",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10",
@@ -1043,9 +1043,9 @@
"dev": true
},
"node_modules/@types/hast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.3.tgz",
"integrity": "sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"dependencies": {
"@types/unist": "*"
}
@@ -1088,9 +1088,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.11.tgz",
"integrity": "sha512-PlJCXfb57Jrman0H1BxO2+Q7qwih2Mwk7T6Gvixj+SK4mqs4RWOGMMoP6p/LFa3UrP2CZOO6ai6otd7J/TB6Ug==",
"version": "20.11.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.14.tgz",
"integrity": "sha512-w3yWCcwULefjP9DmDDsgUskrMoOy5Z8MiwKHr1FvqGPtx7CvJzQvxD7eKpxNtklQxLruxSXWddyeRtyud0RcXQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -1208,9 +1208,9 @@
}
},
"node_modules/async-mutex": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz",
"integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz",
"integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==",
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3610,9 +3610,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz",
"integrity": "sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og==",
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz",
"integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -3621,7 +3621,8 @@
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",

View File

@@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.1.6",
"version": "4.2.2",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
@@ -37,7 +37,7 @@
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.1",
"@napi-rs/simple-git": "0.1.14",
"async-mutex": "^0.4.0",
"async-mutex": "^0.4.1",
"chalk": "^5.3.0",
"chokidar": "^3.5.3",
"cli-spinner": "^0.2.10",
@@ -54,7 +54,7 @@
"js-yaml": "^4.1.0",
"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",
"preact": "^10.19.3",
@@ -93,9 +93,9 @@
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.11",
"@types/node": "^20.11.14",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10",

View File

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

View File

@@ -126,17 +126,8 @@ async function rebuildFromEntrypoint(
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const {
ctx,
ignored,
mut,
initialSlugs,
contentMap,
toRebuild,
toRemove,
trackedAssets,
lastBuildMs,
} = buildData
const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } =
buildData
const { argv } = ctx
@@ -164,12 +155,12 @@ async function rebuildFromEntrypoint(
toRemove.add(filePath)
}
// debounce rebuilds every 250ms
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const release = await mut.acquire()
if (lastBuildMs > buildStart) {
// there's another build after us, release and let them do it
if (buildData.lastBuildMs > buildStart) {
release()
return
}

View File

@@ -1,5 +1,6 @@
import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme"
@@ -37,11 +38,14 @@ 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)
* Also used for UI translation (default: en-US)
* Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
* The first part is the language (en) and the second part is the script/region (US)
* Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
* Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
locale?: string
locale: ValidLocale
}
export interface QuartzConfig {

View File

@@ -9,6 +9,7 @@ function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) {
return null
}
}
ArticleTitle.css = `
.article-title {
margin: 2rem 0 0 0;

View File

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

View File

@@ -4,9 +4,10 @@
import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
function Darkmode({ displayClass }: QuartzComponentProps) {
function Darkmode({ displayClass, cfg }: QuartzComponentProps) {
return (
<div class={classNames(displayClass, "darkmode")}>
<input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} />
@@ -22,7 +23,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
style="enable-background:new 0 0 35 35"
xmlSpace="preserve"
>
<title>Light mode</title>
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
</svg>
</label>
@@ -38,7 +39,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) {
style="enable-background:new 0 0 100 100"
xmlSpace="preserve"
>
<title>Dark mode</title>
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
</svg>
</label>

View File

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

View File

@@ -6,10 +6,10 @@ import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
// Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = {
title: "Explorer",
folderClickBehavior: "collapse",
folderDefaultState: "collapsed",
useSavedState: true,
@@ -75,7 +75,7 @@ export default ((userOpts?: Partial<Options>) => {
jsonTree = JSON.stringify(folders)
}
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
function Explorer({ cfg, allFiles, displayClass, fileData }: QuartzComponentProps) {
constructFileTree(allFiles)
return (
<div class={classNames(displayClass, "explorer")}>
@@ -87,7 +87,7 @@ export default ((userOpts?: Partial<Options>) => {
data-savestate={opts.useSavedState}
data-tree={jsonTree}
>
<h1>{opts.title}</h1>
<h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"

View File

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

View File

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

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 { i18n } from "../i18n"
import { classNames } from "../util/lang"
export interface D3Config {
@@ -53,12 +54,12 @@ const defaultOptions: GraphOptions = {
}
export default ((opts?: GraphOptions) => {
function Graph({ displayClass }: QuartzComponentProps) {
function Graph({ displayClass, cfg }: QuartzComponentProps) {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
return (
<div class={classNames(displayClass, "graph")}>
<h3>Graph View</h3>
<h3>{i18n(cfg.locale).components.graph.title}</h3>
<div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<svg

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { _stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
interface FolderContentOptions {
/**
@@ -23,7 +23,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
function FolderContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props
const { tree, fileData, allFiles, cfg } = props
const folderSlug = _stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
@@ -52,7 +52,11 @@ export default ((opts?: Partial<FolderContentOptions>) => {
</article>
<div class="page-listing">
{options.showFolderCount && (
<p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p>
<p>
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
count: allPagesInFolder.length,
})}
</p>
)}
<div>
<PageList {...listProps} />

View File

@@ -4,12 +4,12 @@ import { PageList } from "../PageList"
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
const numPages = 10
function TagContent(props: QuartzComponentProps) {
const { tree, fileData, allFiles } = props
const { tree, fileData, allFiles, cfg } = props
const slug = fileData.slug
if (!(slug?.startsWith("tags/") || slug === "tags")) {
@@ -43,7 +43,7 @@ function TagContent(props: QuartzComponentProps) {
<article>
<p>{content}</p>
</article>
<p>Found {tags.length} total tags.</p>
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
<div>
{tags.map((tag) => {
const pages = tagItemMap.get(tag)!
@@ -64,8 +64,12 @@ function TagContent(props: QuartzComponentProps) {
{content && <p>{content}</p>}
<div class="page-listing">
<p>
{pluralize(pages.length, "item")} with this tag.{" "}
{pages.length > numPages && `Showing first ${numPages}.`}
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
{pages.length > numPages && (
<span>
{i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
</span>
)}
</p>
<PageList limit={numPages} {...listProps} />
</div>
@@ -86,7 +90,7 @@ function TagContent(props: QuartzComponentProps) {
<div class={classes}>
<article>{content}</article>
<div class="page-listing">
<p>{pluralize(pages.length, "item")} with this tag.</p>
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
<div>
<PageList {...listProps} />
</div>

View File

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

View File

@@ -1,21 +1,21 @@
function toggleCallout(this: HTMLElement) {
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

@@ -10,28 +10,31 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => {
}
document.addEventListener("nav", () => {
const switchTheme = (e: any) => {
const newTheme = e.target.checked ? "dark" : "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
emitThemeChangeEvent(newTheme)
})
colorSchemeMediaQuery.addEventListener("change", themeChange)
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
})

View File

@@ -57,20 +57,20 @@ function setupExplorer() {
for (const item of document.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
item.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
}
explorer.removeEventListener("click", toggleExplorer)
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.removeEventListener("click", toggleFolder)
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
// Get folder state from local storage

View File

@@ -325,6 +325,6 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
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

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

@@ -11,23 +11,53 @@ interface Item {
tags: string[]
}
let index: FlexSearch.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 = ""
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let index = new FlexSearch.Document<Item>({
charset: "latin:extra",
encode: encoder,
document: {
id: "id",
index: [
{
field: "title",
tokenize: "forward",
},
{
field: "content",
tokenize: "forward",
},
{
field: "tags",
tokenize: "forward",
},
],
},
})
const p = new DOMParser()
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
const contextWindowWords = 30
const 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
@@ -71,15 +101,49 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
}`
}
const p = new DOMParser()
const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/)
let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined
function highlightHTML(searchTerm: string, el: HTMLElement) {
const p = new DOMParser()
const tokenizedTerms = tokenizeTerm(searchTerm)
const html = p.parseFromString(el.innerHTML, "text/html")
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
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
@@ -96,15 +160,14 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
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 ? "30%" : "100%"
appendLayout(results)
if (enablePreview) {
preview = document.createElement("div")
preview.id = "preview-container"
preview.style.flexBasis = "70%"
appendLayout(preview)
}
@@ -122,6 +185,9 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
if (preview) {
removeAllChildren(preview)
}
if (searchLayout) {
searchLayout.classList.remove("display-results")
}
searchType = "basic" // reset search type after closing
}
@@ -135,11 +201,14 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
searchBar?.focus()
}
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()
@@ -148,120 +217,83 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
// add "#" prefix for tag search
if (searchBar) searchBar.value = "#"
return
}
const resultCards = document.getElementsByClassName("result-card")
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 (results?.contains(document.activeElement)) {
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)
if (e.key === "Enter") {
active.click()
}
} else {
const anchor = resultCards[0] as HTMLInputElement | null
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
if (!anchor || anchor?.classList.contains("no-match")) return
await displayPreview(anchor)
if (e.key === "Enter") {
anchor?.click()
anchor.click()
}
}
if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
} 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 currentResult = document.activeElement 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()
if (prevResult) currentHover = prevResult
await displayPreview(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 (!results?.contains(document.activeElement)) {
const firstResult = resultCards[0] as HTMLInputElement | null
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()
} else {
// If an element in results-container already has focus, focus next one
const active = document.activeElement as HTMLInputElement | null
active?.classList.remove("focus")
const nextResult = active?.nextElementSibling as HTMLInputElement | null
await displayPreview(nextResult)
nextResult?.focus()
if (secondResult) currentHover = secondResult
await displayPreview(secondResult)
}
}
}
function trimContent(content: string) {
// works without escaping html like in `description.ts`
const sentences = content.replace(/\s+/g, " ").split(".")
let finalDesc = ""
let sentenceIdx = 0
// Roughly estimate characters by (words * 5). Matches description length in `description.ts`.
const len = contextWindowWords * 5
while (finalDesc.length < len) {
const sentence = sentences[sentenceIdx]
if (!sentence) break
finalDesc += sentence + "."
sentenceIdx++
}
// If more content would be available, indicate it by finishing with "..."
if (finalDesc.length < content.length) {
finalDesc += ".."
}
return finalDesc
}
const formatForDisplay = (term: string, id: number) => {
const 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))
// Subtract matching from original tags, then push difference
if (matching.length > 0) {
let difference = tags.filter((x) => !matching.includes(x))
// Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`)
matching = matching.map((tag) => `<li><p class="match-tag">#${tag}</p></li>`)
difference = difference.map((tag) => `<li><p>#${tag}</p></li>`)
matching.push(...difference)
}
// Only allow max of `numTagResults` in preview
if (tags.length > numTagResults) {
matching.splice(numTagResults)
}
return matching
} else {
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 {
@@ -269,46 +301,34 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
}
const resultToHTML = ({ slug, title, content, tags }: Item) => {
const htmlTags = tags.length > 0 ? `<ul>${tags.join("")}</ul>` : ``
const resultContent = enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
const itemTile = document.createElement("a")
itemTile.classList.add("result-card")
Object.assign(itemTile, {
id: slug,
href: resolveUrl(slug).toString(),
innerHTML: `<h3>${title}</h3>${htmlTags}${resultContent}`,
itemTile.id = slug
itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${
enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>`
}`
itemTile.addEventListener("click", (event) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
})
async function onMouseEnter(ev: MouseEvent) {
// When search is active, the first element is in focus, so we need to remove focus if given target is not the first element
const firstEl = document.getElementsByClassName("result-card")[0] as HTMLAnchorElement | null
const target = ev.target as HTMLAnchorElement
if (firstEl !== target) {
firstEl?.classList.remove("focus")
const handler = (event: MouseEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
}
target.classList.add("focus")
async function onMouseEnter(ev: MouseEvent) {
if (!ev.target) return
const target = ev.target as HTMLInputElement
await displayPreview(target)
}
async function onMouseLeave(ev: MouseEvent) {
const target = ev.target as HTMLAnchorElement
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 [keyof HTMLElementEventMap, (this: HTMLElement) => void][]
events.forEach(([event, handler]) => itemTile.addEventListener(event, handler))
itemTile.addEventListener("mouseenter", onMouseEnter)
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
itemTile.addEventListener("click", handler)
window.addCleanup(() => itemTile.removeEventListener("click", handler))
return itemTile
}
@@ -318,17 +338,23 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
removeAllChildren(results)
if (finalResults.length === 0) {
results.innerHTML = `<a class="result-card">
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
if (results?.firstElementChild) {
results?.firstElementChild?.classList.add("focus")
await displayPreview(results?.firstElementChild as HTMLElement)
const firstChild = results.firstElementChild as HTMLElement
firstChild.classList.add("focus")
currentHover = firstChild as HTMLInputElement
await displayPreview(firstChild)
}
}
@@ -354,51 +380,42 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
}
async function displayPreview(el: HTMLElement | null) {
if (!searchLayout || !enablePreview || !el) return
if (!searchLayout || !enablePreview || !el || !preview) return
const slug = el.id as FullSlug
el.classList.add("focus")
removeAllChildren(preview as HTMLElement)
const contentDetails = await fetchContent(slug)
const previewInner = document.createElement("div")
const innerDiv = await fetchContent(slug).then((contents) =>
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
)
previewInner = document.createElement("div")
previewInner.classList.add("preview-inner")
preview?.appendChild(previewInner)
contentDetails?.forEach((elt) => previewInner.appendChild(elt))
previewInner.append(...innerDiv)
preview.replaceChildren(previewInner)
// scroll to longest
const highlights = [...preview.querySelectorAll(".highlight")].sort(
(a, b) => b.innerHTML.length - a.innerHTML.length,
)
highlights[0]?.scrollIntoView({ block: "start" })
}
async function onType(e: HTMLElementEventMap["input"]) {
let term = (e.target as HTMLInputElement).value
if (!searchLayout || !index) return
currentSearchTerm = (e.target as HTMLInputElement).value
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[]
if (searchLayout) {
searchLayout.style.opacity = "1"
}
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,
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[] => {
@@ -412,50 +429,19 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
...getByField("content"),
...getByField("tags"),
])
const finalResults = [...allIds].map((id) => formatForDisplay(term, id))
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
await displayResults(finalResults)
}
if (prevShortcutHandler) {
document.removeEventListener("keydown", prevShortcutHandler)
}
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 FlexSearch.Document({
charset: "latin:extra",
encode: encoder,
document: {
id: "id",
index: [
{
field: "title",
tokenize: "forward",
},
{
field: "content",
tokenize: "forward",
},
{
field: "tags",
tokenize: "forward",
},
],
},
})
fillDocument(index, data)
}
// register handlers
registerEscapeHandler(container, hideSearch)
await fillDocument(data)
})
/**
@@ -463,16 +449,20 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
* @param index index to fill
* @param data data to fill index with
*/
async function fillDocument(index: FlexSearch.Document<Item, false>, data: any) {
async function fillDocument(data: { [key: FullSlug]: ContentDetails }) {
let id = 0
const promises: Array<Promise<unknown>> = []
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
await index.addAsync(id, {
promises.push(
index.addAsync(id++, {
id,
slug: slug as FullSlug,
title: fileData.title,
content: fileData.content,
tags: fileData.tags,
})
id++
}),
)
}
return await Promise.all(promises)
}

View File

@@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) {
document.dispatchEvent(event)
}
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

@@ -29,8 +29,8 @@ function setupToc() {
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

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

View File

@@ -54,18 +54,14 @@
}
& > #search-space {
width: 50%;
width: 65%;
margin-top: 12vh;
margin-left: auto;
margin-right: auto;
@media all and (max-width: $fullPageWidth) {
width: 90%;
}
& > * {
width: 100%;
border-radius: 5px;
border-radius: 7px;
background: var(--light);
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
@@ -87,59 +83,77 @@
}
& > #search-layout {
display: flex;
display: none;
flex-direction: row;
justify-content: space-between;
opacity: 0;
border: 1px solid var(--lightgray);
flex: 0 0 100%;
box-sizing: border-box;
&.display-results {
display: flex;
}
&[data-preview] > #results-container {
flex: 0 0 min(30%, 450px);
}
@media all and (min-width: $tabletBreakpoint) {
&[data-preview] {
& .result-card > p.preview {
display: none;
}
& > div {
height: calc(75vh - 20em);
background: none;
&:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
border-right: 1px solid var(--lightgray);
border-top-right-radius: unset;
border-bottom-right-radius: unset;
}
&:last-child {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
border-top-left-radius: unset;
border-bottom-left-radius: unset;
}
}
}
}
@media all and (max-width: $mobileBreakpoint) {
display: block;
& > *:not(#results-container) {
& > div {
height: calc(75vh - 12vh);
border-radius: 5px;
}
@media all and (max-width: $tabletBreakpoint) {
& > #preview-container {
display: none !important;
}
& > #results-container {
&[data-preview] > #results-container {
width: 100%;
height: auto;
flex: 0 0 100%;
}
}
& .highlight {
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
border-radius: 5px;
scroll-margin-top: 2rem;
}
& > #preview-container {
display: block;
box-sizing: border-box;
overflow: hidden;
& .preview-inner {
padding: 1em;
height: 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-radius: 5px;
box-shadow:
0 14px 50px rgba(27, 33, 48, 0.12),
0 10px 30px rgba(27, 33, 48, 0.16);
font-weight: $normalWeight;
overflow-y: auto;
padding: 0 2rem;
& .preview-inner {
margin: 0 auto;
width: min($pageWidth, 100%);
}
}
@@ -147,6 +161,7 @@
overflow-y: auto;
& .result-card {
overflow: hidden;
padding: 1em;
cursor: pointer;
transition: background 0.2s ease;
@@ -162,15 +177,9 @@
margin: 0;
text-transform: none;
text-align: left;
background: var(--light);
outline: none;
font-weight: inherit;
& .highlight {
color: var(--secondary);
font-weight: 700;
}
&:hover,
&:focus,
&.focus {
@@ -181,41 +190,23 @@
margin: 0;
}
& > ul > li {
margin: 0;
display: inline-block;
white-space: nowrap;
margin: 0;
overflow-wrap: normal;
}
& > ul {
list-style: none;
display: flex;
padding-left: 0;
gap: 0.4rem;
margin: 0;
& > ul.tags {
margin-top: 0.45rem;
box-sizing: border-box;
overflow: hidden;
background-clip: border-box;
margin-bottom: 0;
}
& > ul > li > p {
border-radius: 8px;
background-color: var(--highlight);
overflow: hidden;
background-clip: border-box;
padding: 0.03rem 0.4rem;
margin: 0;
padding: 0.2rem 0.4rem;
margin: 0 0.1rem;
line-height: 1.4rem;
font-weight: $boldWeight;
color: var(--secondary);
opacity: 0.85;
}
& > ul > li > .match-tag {
&.match-tag {
color: var(--tertiary);
font-weight: bold;
opacity: 1;
}
}
& > p {

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -132,8 +132,10 @@ function addGlobalPageResources(
} else {
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)`)
document.dispatchEvent(event)
`)
}
let wsUrl = `ws://localhost:${ctx.argv.wsPort}`

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import yaml from "js-yaml"
import toml from "toml"
import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n"
export interface Options {
delims: string | string[]
@@ -43,7 +44,7 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
const opts = { ...defaultOptions, ...userOpts }
return {
name: "FrontMatter",
markdownPlugins() {
markdownPlugins({ cfg }) {
return [
[remarkFrontmatter, ["yaml", "toml"]],
() => {
@@ -59,7 +60,7 @@ export const FrontMatter: QuartzTransformerPlugin<Partial<Options> | undefined>
if (data.title) {
data.title = data.title.toString()
} else if (data.title === null || data.title === undefined) {
data.title = file.stem ?? "Untitled"
data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
}
const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))

View File

@@ -43,7 +43,7 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | und
let published: MaybeDate = undefined
const fp = file.data.filePath!
const fullFp = path.posix.join(file.cwd, fp)
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
for (const source of opts.priority) {
if (source === "filesystem") {
const st = await fs.promises.stat(fullFp)

View File

@@ -74,6 +74,17 @@ const calloutMapping = {
cite: "quote",
} as const
const arrowMapping: Record<string, string> = {
"->": "&rarr;",
"-->": "&rArr;",
"=>": "&rArr;",
"==>": "&rArr;",
"<-": "&larr;",
"<--": "&lArr;",
"<=": "&lArr;",
"<==": "&lArr;",
}
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
@@ -82,7 +93,7 @@ function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
export const externalLinkRegex = /^https?:\/\//i
export const arrowRegex = new RegExp(/-{1,2}>/, "g")
export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/, "g")
// !? -> optional embedding
// \[\[ -> open brace
@@ -271,10 +282,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.parseArrows) {
replacements.push([
arrowRegex,
(_value: string, ..._capture: string[]) => {
(value: string, ..._capture: string[]) => {
const maybeArrow = arrowMapping[value]
if (maybeArrow === undefined) return SKIP
return {
type: "html",
value: `<span>&rarr;</span>`,
value: `<span>${maybeArrow}</span>`,
}
},
])
@@ -383,14 +396,17 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
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:
restOfTitle.length === 0
? [{ type: "text", value: titleContent + " " }]
: restOfTitle,
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
},
...restOfTitle,
],
}
const title = mdastToHtml(titleNode)

View File

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

View File

@@ -26,7 +26,7 @@ section {
}
::selection {
background: color-mix(in srgb, var(--tertiary) 75%, transparent);
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
color: var(--darkgray);
}
@@ -54,7 +54,7 @@ ul,
}
a {
font-weight: 600;
font-weight: $boldWeight;
text-decoration: none;
transition: color 0.2s ease;
color: var(--secondary);

View File

@@ -1,3 +1,4 @@
@use "./variables.scss" as *;
@use "sass:color";
.callout {
@@ -119,7 +120,7 @@
.callout-title {
display: flex;
align-items: center;
align-items: flex-start;
gap: 5px;
padding: 1rem 0;
color: var(--color);
@@ -130,8 +131,6 @@
transition: transform 0.15s ease;
opacity: 0.8;
cursor: pointer;
width: var(--icon-size);
height: var(--icon-size);
--callout-icon: var(--callout-icon-fold);
}
@@ -144,6 +143,7 @@
& .fold-callout-icon {
width: var(--icon-size);
height: var(--icon-size);
flex: 0 0 var(--icon-size);
// icon support
background-size: var(--icon-size) var(--icon-size);
@@ -153,9 +153,10 @@
mask-size: var(--icon-size) var(--icon-size);
mask-position: center;
mask-repeat: no-repeat;
padding: 0.2rem 0;
}
.callout-title-inner {
font-weight: 700;
font-weight: $boldWeight;
}
}

View File

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

View File

@@ -1,11 +1,3 @@
export function pluralize(count: number, s: string): string {
if (count === 1) {
return `1 ${s}`
} else {
return `${count} ${s}s`
}
}
export function capitalize(s: string): string {
return s.substring(0, 1).toUpperCase() + s.substring(1)
}

View File

@@ -106,8 +106,9 @@ describe("transforms", () => {
["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?.md", "test/special-chars"],
["test/special chars #3.md", "test/special-chars-3"],
["cool/what about r&d?.md", "cool/what-about-r-and-d"],
],
path.slugifyFilePath,
path.isFilePath,

View File

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