Merge branch 'v4' of github-bfahrenfort:jackyzha0/quartz into v4

This commit is contained in:
bfahrenfort 2024-02-13 20:14:54 -06:00
commit 3d19660b11
70 changed files with 2014 additions and 337 deletions

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

@ -25,14 +25,17 @@ This part of the configuration concerns anything that can affect the whole site.
- `enablePopovers`: whether to enable [[popover previews]] on your site.
- `analytics`: what to use for analytics on your site. Values can be
- `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
- `{ provider: 'google', tagId: '<your-google-tag>' }`: use Google Analytics;
- `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: '<your-plausible-host>' }` (self-hosted): use [Plausible](https://plausible.io/);
- `{ provider: 'umami', host: '<your-umami-host>', websiteId: '<your-umami-website-id>' }`: use [Umami](https://umami.is/);
- `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.
- `ignorePatterns`: a list of [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details.
- `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings.
- `theme`: configure how the site looks.
- `cdnCaching`: Whether to use Google CDN to cache the fonts (generally will be faster). Disable this if you want Quartz to be self-contained. Default to `true`
- `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here.
- `header`: Font to use for headers
- `code`: Font for inline and block quotes.

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

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

144
package-lock.json generated
View File

@ -1,23 +1,23 @@
{
"name": "@jackyzha0/quartz",
"version": "4.2.1",
"version": "4.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackyzha0/quartz",
"version": "4.2.1",
"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",
"@floating-ui/dom": "^1.6.3",
"@napi-rs/simple-git": "0.1.16",
"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",
"esbuild-sass-plugin": "^2.16.1",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.0",
@ -32,7 +32,7 @@
"mdast-util-to-hast": "^13.1.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"preact": "^10.19.3",
"preact": "^10.19.4",
"preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0",
@ -73,14 +73,14 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.14",
"@types/node": "^20.11.16",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"esbuild": "^0.19.9",
"prettier": "^3.2.4",
"tsx": "^4.7.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
},
"engines": {
@ -486,12 +486,12 @@
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz",
"integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.1"
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/utils": {
@ -516,30 +516,30 @@
}
},
"node_modules/@napi-rs/simple-git": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git/-/simple-git-0.1.14.tgz",
"integrity": "sha512-2cDnsT0nKpQ7yg5u/Zf8/ibp9YFIKhpcfMAGATYuqdJoHuBo6P6UArZ0RDOOtfFC5b9FXuYcGw2ApbM4eWdnbQ==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git/-/simple-git-0.1.16.tgz",
"integrity": "sha512-C5wRPw9waqL2jk3jEDeJv+f7ScuO3N0a39HVdyFLkwKxHH4Sya4ZbzZsu2JLi6eEqe7RuHipHL6mC7B2OfYZZw==",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/simple-git-android-arm-eabi": "0.1.14",
"@napi-rs/simple-git-android-arm64": "0.1.14",
"@napi-rs/simple-git-darwin-arm64": "0.1.14",
"@napi-rs/simple-git-darwin-x64": "0.1.14",
"@napi-rs/simple-git-linux-arm-gnueabihf": "0.1.14",
"@napi-rs/simple-git-linux-arm64-gnu": "0.1.14",
"@napi-rs/simple-git-linux-arm64-musl": "0.1.14",
"@napi-rs/simple-git-linux-x64-gnu": "0.1.14",
"@napi-rs/simple-git-linux-x64-musl": "0.1.14",
"@napi-rs/simple-git-win32-arm64-msvc": "0.1.14",
"@napi-rs/simple-git-win32-x64-msvc": "0.1.14"
"@napi-rs/simple-git-android-arm-eabi": "0.1.16",
"@napi-rs/simple-git-android-arm64": "0.1.16",
"@napi-rs/simple-git-darwin-arm64": "0.1.16",
"@napi-rs/simple-git-darwin-x64": "0.1.16",
"@napi-rs/simple-git-linux-arm-gnueabihf": "0.1.16",
"@napi-rs/simple-git-linux-arm64-gnu": "0.1.16",
"@napi-rs/simple-git-linux-arm64-musl": "0.1.16",
"@napi-rs/simple-git-linux-x64-gnu": "0.1.16",
"@napi-rs/simple-git-linux-x64-musl": "0.1.16",
"@napi-rs/simple-git-win32-arm64-msvc": "0.1.16",
"@napi-rs/simple-git-win32-x64-msvc": "0.1.16"
}
},
"node_modules/@napi-rs/simple-git-android-arm-eabi": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-android-arm-eabi/-/simple-git-android-arm-eabi-0.1.14.tgz",
"integrity": "sha512-fAJ/Hxc9DhtSHOcB3dPCRW1YcVsqAnbNoOOnHir4aDCtqTP64HrFa7A/675v3vQZpI0u3fXHRcYqW8NF0O/zcg==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-android-arm-eabi/-/simple-git-android-arm-eabi-0.1.16.tgz",
"integrity": "sha512-dbrCL0Pl5KZG7x7tXdtVsA5CO6At5ohDX3myf5xIYn9kN4jDFxsocl8bNt6Vb/hZQoJd8fI+k5VlJt+rFhbdVw==",
"cpu": [
"arm"
],
@ -552,9 +552,9 @@
}
},
"node_modules/@napi-rs/simple-git-android-arm64": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-android-arm64/-/simple-git-android-arm64-0.1.14.tgz",
"integrity": "sha512-dav730MRAR142DoyNDafuwKXcUCYwlbxxxxOarDph7bbN0mZZnKHOQohvRCD/Uz4aJLaj6khCavXSjLDWArEUg==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-android-arm64/-/simple-git-android-arm64-0.1.16.tgz",
"integrity": "sha512-xYz+TW5J09iK8SuTAKK2D5MMIsBUXVSs8nYp7HcMi8q6FCRO7yJj96YfP9PvKsc/k64hOyqGmL5DhCzY9Cu1FQ==",
"cpu": [
"arm64"
],
@ -567,9 +567,9 @@
}
},
"node_modules/@napi-rs/simple-git-darwin-arm64": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-darwin-arm64/-/simple-git-darwin-arm64-0.1.14.tgz",
"integrity": "sha512-f6+DqRnI+vFvnsAyw66mWhwl0vw1TOieHV07hvKbg4PU5j+RBI+lVqwY2L+IEAxDFlPirTWKKvGY1Lr7M/yi/A==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-darwin-arm64/-/simple-git-darwin-arm64-0.1.16.tgz",
"integrity": "sha512-XfgsYqxhUE022MJobeiX563TJqyQyX4FmYCnqrtJwAfivESVeAJiH6bQIum8dDEYMHXCsG7nL8Ok0Dp8k2m42g==",
"cpu": [
"arm64"
],
@ -582,9 +582,9 @@
}
},
"node_modules/@napi-rs/simple-git-darwin-x64": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-darwin-x64/-/simple-git-darwin-x64-0.1.14.tgz",
"integrity": "sha512-x/EnwJdDWJAFay8TQt09byJoBlVZhPEaTAPmRR0fUPzWTjrr28bOy8UW1ysszd9ylBxlyIhuWjOHMHu9CBigTQ==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-darwin-x64/-/simple-git-darwin-x64-0.1.16.tgz",
"integrity": "sha512-tkEVBhD6vgRCbeWsaAQqM3bTfpIVGeitamPPRVSbsq8qgzJ5Dx6ZedH27R7KSsA/uao7mZ3dsrNLXbu1Wy5MzA==",
"cpu": [
"x64"
],
@ -597,9 +597,9 @@
}
},
"node_modules/@napi-rs/simple-git-linux-arm-gnueabihf": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm-gnueabihf/-/simple-git-linux-arm-gnueabihf-0.1.14.tgz",
"integrity": "sha512-k0JZaXBl031gP5VOnoMa1I3lCHlBG7QvtunX5rxnRjx2kZ+JgUyT12s/qle/4xkJ0MnmfKTeiD7hs4Cc4Z3Tzw==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm-gnueabihf/-/simple-git-linux-arm-gnueabihf-0.1.16.tgz",
"integrity": "sha512-R6VAyNnp/yRaT7DV1Ao3r67SqTWDa+fNq2LrNy0Z8gXk2wB9ZKlrxFtLPE1WSpWknWtyRDLpRlsorh7Evk7+7w==",
"cpu": [
"arm"
],
@ -612,9 +612,9 @@
}
},
"node_modules/@napi-rs/simple-git-linux-arm64-gnu": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm64-gnu/-/simple-git-linux-arm64-gnu-0.1.14.tgz",
"integrity": "sha512-CsmKP6tSIxau10ZKxV1Q1kem2QcJ/Qlov7pxp1Q7kMErcouW0H6vliVniewicaXRVDYV9wK18iD2t5GoJttwlA==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm64-gnu/-/simple-git-linux-arm64-gnu-0.1.16.tgz",
"integrity": "sha512-LAGI0opFKw/HBMCV2qIBK3uWSEW9h4xd2ireZKLJy8DBPymX6NrWIamuxYNyCuACnFdPRxR4LaRFy4J5ZwuMdw==",
"cpu": [
"arm64"
],
@ -627,9 +627,9 @@
}
},
"node_modules/@napi-rs/simple-git-linux-arm64-musl": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm64-musl/-/simple-git-linux-arm64-musl-0.1.14.tgz",
"integrity": "sha512-krfEckZQ3myoHwmGmqY0aHBnqAzzV66+jFNLQEKaVMSGsXA2P+UcGo0coGzmB13rFRWC2eZpZRNB3MrfrStHkw==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-arm64-musl/-/simple-git-linux-arm64-musl-0.1.16.tgz",
"integrity": "sha512-I57Ph0F0Yn2KW93ep+V1EzKhACqX0x49vvSiapqIsdDA2PifdEWLc1LJarBolmK7NKoPqKmf6lAKKO9lhiZzkg==",
"cpu": [
"arm64"
],
@ -642,9 +642,9 @@
}
},
"node_modules/@napi-rs/simple-git-linux-x64-gnu": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-x64-gnu/-/simple-git-linux-x64-gnu-0.1.14.tgz",
"integrity": "sha512-4T2Q2QdO6t3OawkwdVmdqLz2EH8lfAw2cMT/zdjfTMfhNKjJgSg3kTgRnu/tf8TLCb+wu80fFvafwE0laB2VTQ==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-x64-gnu/-/simple-git-linux-x64-gnu-0.1.16.tgz",
"integrity": "sha512-AZYYFY2V7hlcQASPEOWyOa3e1skzTct9QPzz0LiDM3f/hCFY/wBaU2M6NC5iG3d2Kr38heuyFS/+JqxLm5WaKA==",
"cpu": [
"x64"
],
@ -657,9 +657,9 @@
}
},
"node_modules/@napi-rs/simple-git-linux-x64-musl": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-x64-musl/-/simple-git-linux-x64-musl-0.1.14.tgz",
"integrity": "sha512-RaTGW8u+RXJbfRF4QN2Dcr5r5DrFh4wLjOvFeOy7sGX3Q9m3IKuw5AjRxTJqIw6xD/AAPKKNzOvPjrIF7728Lw==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-linux-x64-musl/-/simple-git-linux-x64-musl-0.1.16.tgz",
"integrity": "sha512-9TyMcYSBJwjT8jwjY9m24BZbu7ozyWTjsmYBYNtK3B0Um1Ov6jthSNneLVvouQ6x+k3Ow+00TiFh6bvmT00r8g==",
"cpu": [
"x64"
],
@ -672,9 +672,9 @@
}
},
"node_modules/@napi-rs/simple-git-win32-arm64-msvc": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-win32-arm64-msvc/-/simple-git-win32-arm64-msvc-0.1.14.tgz",
"integrity": "sha512-kb9bKG9t79HJMuRMqbUJFLfWRf952O2Ea4VFwoRA2d/Uwtowm85Ol3JV9E6oeurguRLqdMLrUKyduCW6Hc9Jsg==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-win32-arm64-msvc/-/simple-git-win32-arm64-msvc-0.1.16.tgz",
"integrity": "sha512-uslJ1WuAHCYJWui6xjsyT47SjX6KOHDtClmNO8hqKz1pmDSNY7AjyUY8HxvD1lK9bDnWwc4JYhikS9cxCqHybw==",
"cpu": [
"arm64"
],
@ -687,9 +687,9 @@
}
},
"node_modules/@napi-rs/simple-git-win32-x64-msvc": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-win32-x64-msvc/-/simple-git-win32-x64-msvc-0.1.14.tgz",
"integrity": "sha512-3835xy0e2gOaZ3SPt1pINBFSBBL3dOx3cChyAzQU0TnMU4Ye/YOh1qa5pO7BOJlCSnOh7iWt782blxCT0HH61w==",
"version": "0.1.16",
"resolved": "https://registry.npmjs.org/@napi-rs/simple-git-win32-x64-msvc/-/simple-git-win32-x64-msvc-0.1.16.tgz",
"integrity": "sha512-SoEaVeCZCDF1MP+M9bMSXsZWgEjk4On9GWADO5JOulvzR1bKjk0s9PMHwe/YztR9F0sJzrCxwtvBZowhSJsQPg==",
"cpu": [
"x64"
],
@ -1088,9 +1088,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.14.tgz",
"integrity": "sha512-w3yWCcwULefjP9DmDDsgUskrMoOy5Z8MiwKHr1FvqGPtx7CvJzQvxD7eKpxNtklQxLruxSXWddyeRtyud0RcXQ==",
"version": "20.11.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz",
"integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@ -2052,9 +2052,9 @@
}
},
"node_modules/esbuild-sass-plugin": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.0.tgz",
"integrity": "sha512-mGCe9MxNYvZ+j77Q/QFO+rwUGA36mojDXkOhtVmoyz1zwYbMaNrtVrmXwwYDleS/UMKTNU3kXuiTtPiAD3K+Pw==",
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.1.tgz",
"integrity": "sha512-mBB2aEF0xk7yo+Q9pSUh8xYED/1O2wbAM6IauGkDrqy6pl9SbJNakLeLGXiNpNujWIudu8TJTZCv2L5AQYRXtA==",
"dependencies": {
"resolve": "^1.22.6",
"sass": "^1.7.3"
@ -4454,9 +4454,9 @@
}
},
"node_modules/preact": {
"version": "10.19.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz",
"integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==",
"version": "10.19.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.4.tgz",
"integrity": "sha512-dwaX5jAh0Ga8uENBX1hSOujmKWgx9RtL80KaKUFLc6jb4vCEAc3EeZ0rnQO/FO4VgjfPMfoLFWnNG8bHuZ9VLw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -5647,9 +5647,9 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tsx": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.0.tgz",
"integrity": "sha512-I+t79RYPlEYlHn9a+KzwrvEwhJg35h/1zHsLC2JXvhC2mdynMv6Zxzvhv5EMV6VF5qJlLlkSnMVvdZV3PSIGcg==",
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz",
"integrity": "sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==",
"dev": true,
"dependencies": {
"esbuild": "~0.19.10",

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.2.1",
"version": "4.2.2",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
@ -15,7 +15,7 @@
"docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
"test": "tsx ./quartz/util/path.test.ts",
"test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
},
"engines": {
@ -35,14 +35,14 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.1",
"@napi-rs/simple-git": "0.1.14",
"@floating-ui/dom": "^1.6.3",
"@napi-rs/simple-git": "0.1.16",
"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",
"esbuild-sass-plugin": "^2.16.1",
"flexsearch": "0.7.43",
"github-slugger": "^2.0.0",
"globby": "^14.0.0",
@ -57,7 +57,7 @@
"mdast-util-to-hast": "^13.1.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"preact": "^10.19.3",
"preact": "^10.19.4",
"preact-render-to-string": "^6.3.1",
"pretty-bytes": "^6.1.1",
"pretty-time": "^1.1.0",
@ -95,14 +95,14 @@
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.11.14",
"@types/node": "^20.11.16",
"@types/pretty-time": "^1.1.5",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"esbuild": "^0.19.9",
"prettier": "^3.2.4",
"tsx": "^4.7.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
}
}

View File

@ -11,6 +11,7 @@ const config: QuartzConfig = {
baseUrl: "be-far.com",
ignorePatterns: ["private", "**/templates"],
theme: {
cdnCaching: true,
typography: {
header: "Lora",
body: "Inter",
@ -43,7 +44,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'
@ -53,6 +53,7 @@ const config: QuartzConfig = {
Plugin.SyntaxHighlighting(),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Remark42({ host: "https://be-far.com/comments", site_id: "remark", theme: "dark", no_footer: true }),

View File

@ -17,6 +17,10 @@ import { glob, toPosixPath } from "./util/glob"
import { trace } from "./util/trace"
import { options } from "./util/sourcemap"
import { Mutex } from "async-mutex"
import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins"
type Dependencies = Record<string, DepGraph<FilePath> | null>
type BuildData = {
ctx: BuildCtx
@ -29,8 +33,11 @@ type BuildData = {
toRebuild: Set<FilePath>
toRemove: Set<FilePath>
lastBuildMs: number
dependencies: Dependencies
}
type FileEvent = "add" | "change" | "delete"
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const ctx: BuildCtx = {
argv,
@ -68,12 +75,24 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const parsedFiles = await parseMarkdown(ctx, filePaths)
const filteredContent = filterContent(ctx, parsedFiles)
const dependencies: Record<string, DepGraph<FilePath> | null> = {}
// Only build dependency graphs if we're doing a fast rebuild
if (argv.fastRebuild) {
const staticResources = getStaticResourcesFromPlugins(ctx)
for (const emitter of cfg.plugins.emitters) {
dependencies[emitter.name] =
(await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null
}
}
await emitContent(ctx, filteredContent)
console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`))
release()
if (argv.serve) {
return startServing(ctx, mut, parsedFiles, clientRefresh)
return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies)
}
}
@ -83,9 +102,11 @@ async function startServing(
mut: Mutex,
initialContent: ProcessedContent[],
clientRefresh: () => void,
dependencies: Dependencies, // emitter name: dep graph
) {
const { argv } = ctx
// cache file parse results
const contentMap = new Map<FilePath, ProcessedContent>()
for (const content of initialContent) {
const [_tree, vfile] = content
@ -95,6 +116,7 @@ async function startServing(
const buildData: BuildData = {
ctx,
mut,
dependencies,
contentMap,
ignored: await isGitIgnored(),
initialSlugs: ctx.allSlugs,
@ -110,19 +132,182 @@ async function startServing(
ignoreInitial: true,
})
const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint
watcher
.on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData))
.on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData))
.on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData))
.on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData))
.on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData))
return async () => {
await watcher.close()
}
}
async function partialRebuildFromEntrypoint(
filepath: string,
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {
const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData
const { argv, cfg } = ctx
// don't do anything for gitignored files
if (ignored(filepath)) {
return
}
const buildStart = new Date().getTime()
buildData.lastBuildMs = buildStart
const release = await mut.acquire()
if (buildData.lastBuildMs > buildStart) {
release()
return
}
const perf = new PerfTimer()
console.log(chalk.yellow("Detected change, rebuilding..."))
// UPDATE DEP GRAPH
const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath
const staticResources = getStaticResourcesFromPlugins(ctx)
let processedFiles: ProcessedContent[] = []
switch (action) {
case "add":
// add to cache when new file is added
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// update the dep graph by asking all emitters whether they depend on this file
for (const emitter of cfg.plugins.emitters) {
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
// emmiter may not define a dependency graph. nothing to update if so
if (emitterGraph) {
dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
}
}
break
case "change":
// invalidate cache when file is changed
processedFiles = await parseMarkdown(ctx, [fp])
processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile]))
// only content files can have added/removed dependencies because of transclusions
if (path.extname(fp) === ".md") {
for (const emitter of cfg.plugins.emitters) {
// get new dependencies from all emitters for this file
const emitterGraph =
(await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null
// only update the graph if the emitter plugin uses the changed file
// eg. Assets plugin ignores md files, so we skip updating the graph
if (emitterGraph?.hasNode(fp)) {
// merge the new dependencies into the dep graph
dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp)
}
}
}
break
case "delete":
toRemove.add(fp)
break
}
if (argv.verbose) {
console.log(`Updated dependency graphs in ${perf.timeSince()}`)
}
// EMIT
perf.addEvent("rebuild")
let emittedFiles = 0
const destinationsToDelete = new Set<FilePath>()
for (const emitter of cfg.plugins.emitters) {
const depGraph = dependencies[emitter.name]
// emitter hasn't defined a dependency graph. call it with all processed files
if (depGraph === null) {
if (argv.verbose) {
console.log(
`Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`,
)
}
const files = [...contentMap.values()].filter(
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
)
const emittedFps = await emitter.emit(ctx, files, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
emittedFiles += emittedFps.length
continue
}
// only call the emitter if it uses this file
if (depGraph.hasNode(fp)) {
// re-emit using all files that are needed for the downstream of this file
// eg. for ContentIndex, the dep graph could be:
// a.md --> contentIndex.json
// b.md ------^
//
// if a.md changes, we need to re-emit contentIndex.json,
// and supply [a.md, b.md] to the emitter
const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[]
if (action === "delete" && upstreams.length === 1) {
// if there's only one upstream, the destination is solely dependent on this file
destinationsToDelete.add(upstreams[0])
}
const upstreamContent = upstreams
// filter out non-markdown files
.filter((file) => contentMap.has(file))
// if file was deleted, don't give it to the emitter
.filter((file) => !toRemove.has(file))
.map((file) => contentMap.get(file)!)
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
emittedFiles += emittedFps.length
}
}
console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`)
// CLEANUP
// delete files that are solely dependent on this file
await rimraf([...destinationsToDelete])
for (const file of toRemove) {
// remove from cache
contentMap.delete(file)
// remove the node from dependency graphs
Object.values(dependencies).forEach((depGraph) => depGraph?.removeNode(file))
}
toRemove.clear()
release()
clientRefresh()
}
async function rebuildFromEntrypoint(
fp: string,
action: "add" | "change" | "delete",
action: FileEvent,
clientRefresh: () => void,
buildData: BuildData, // note: this function mutates buildData
) {

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

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

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

@ -68,13 +68,9 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
folderIndex = new Map()
// construct the index for the first time
for (const file of allFiles) {
if (file.slug?.endsWith("index")) {
const folderParts = file.slug?.split("/")
// 2nd last to exclude the /index
const folderName = folderParts?.at(-2)
if (folderName) {
folderIndex.set(folderName, file)
}
const folderParts = file.slug?.split("/")
if (folderParts?.at(-1) === "index") {
folderIndex.set(folderParts.slice(0, -1).join("/"), file)
}
}
}
@ -88,7 +84,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
let curPathSegment = slugParts[i]
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(curPathSegment)
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
if (currentFile) {
const title = currentFile.frontmatter!.title
if (title !== "index") {

View File

@ -2,6 +2,7 @@ import { formatDate, getDate } from "./Date"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import readingTime from "reading-time"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
interface ContentMetaOptions {
/**
@ -30,8 +31,11 @@ export default ((opts?: Partial<ContentMetaOptions>) => {
// Display reading time if enabled
if (options.showReadingTime) {
const { text: timeTaken, words: _words } = readingTime(text)
segments.push(timeTaken)
const { minutes, words: _words } = readingTime(text)
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
minutes: Math.ceil(minutes),
})
segments.push(displayedTime)
}
return <p class={classNames(displayClass, "content-meta")}>{segments.join(", ")}</p>

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,6 +6,7 @@ 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 = {
@ -75,7 +76,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 +88,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

@ -2,6 +2,7 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { OptionType } from "../plugins/types"
import style from "./styles/footer.scss"
import { version } from "../../package.json"
import { i18n } from "../i18n"
interface Optionss {
links: Record<string, string>

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 { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path"
import { i18n } from "../i18n"
import { FullSlug, 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"}`)
@ -28,8 +30,12 @@ export default (() => {
<link rel="icon" href={iconPath} />
<meta name="description" content={description} />
<meta name="generator" content="Quartz" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
{cfg.theme.cdnCaching && (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
</>
)}
{css.map((href) => (
<link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />
))}

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

@ -3,10 +3,10 @@ import path from "path"
import style from "../styles/listPage.scss"
import { PageList } from "../PageList"
import { _stripSlashes, simplifySlug } from "../../util/path"
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,10 +23,10 @@ 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 { tree, fileData, allFiles, cfg } = props
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const allPagesInFolder = allFiles.filter((file) => {
const fileSlug = _stripSlashes(simplifySlug(file.slug!))
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)
@ -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,
@ -100,8 +103,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
}
@ -135,8 +140,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
} else if (page.htmlAst) {
@ -147,7 +154,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) =>
@ -156,8 +170,10 @@ export function renderPage(
{
type: "element",
tagName: "a",
properties: { href: inner.properties?.href, class: ["internal"] },
children: [{ type: "text", value: `Link to original` }],
properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] },
children: [
{ type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
],
},
]
}
@ -193,8 +209,9 @@ export function renderPage(
</div>
)
const lang = componentData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const doc = (
<html>
<html lang={lang}>
<Head {...componentData} />
<body data-slug={slug}>
<div id="quartz-root" class="page">

View File

@ -0,0 +1,23 @@
import { getFullSlug } from "../../util/path"
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
document.addEventListener("nav", () => {
const checkboxes = document.querySelectorAll(
"input.checkbox-toggle",
) as NodeListOf<HTMLInputElement>
checkboxes.forEach((el, index) => {
const elId = checkboxId(index)
const switchState = (e: Event) => {
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
localStorage.setItem(elId, newCheckboxState)
}
el.addEventListener("change", switchState)
window.addCleanup(() => el.removeEventListener("change", switchState))
if (localStorage.getItem(elId) === "true") {
el.checked = true
}
})
})

View File

@ -163,13 +163,11 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
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)
}
@ -224,12 +222,11 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
if (currentHover) {
currentHover.classList.remove("focus")
currentHover.blur()
}
// 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 (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
@ -252,7 +249,7 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
currentResult?.classList.remove("focus")
prevResult?.focus()
currentHover = prevResult
if (prevResult) currentHover = prevResult
await displayPreview(prevResult)
}
} else if (e.key === "ArrowDown" || e.key === "Tab") {
@ -266,18 +263,8 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
firstResult?.classList.remove("focus")
secondResult?.focus()
currentHover = secondResult
if (secondResult) currentHover = secondResult
await displayPreview(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
nextResult?.focus()
currentHover = nextResult
await displayPreview(nextResult)
}
}
}
@ -319,40 +306,29 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
itemTile.classList.add("result-card")
itemTile.id = slug
itemTile.href = resolveUrl(slug).toString()
itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>`
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()
})
const handler = (event: MouseEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
hideSearch()
}
async function onMouseEnter(ev: MouseEvent) {
if (!ev.target) return
currentHover?.classList.remove("focus")
currentHover?.blur()
const target = ev.target as HTMLInputElement
currentHover = target
currentHover.classList.add("focus")
await displayPreview(target)
}
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))
})
itemTile.addEventListener("mouseenter", onMouseEnter)
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
itemTile.addEventListener("click", handler)
window.addCleanup(() => itemTile.removeEventListener("click", handler))
return itemTile
}

View File

@ -59,9 +59,13 @@
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),
@ -86,79 +90,86 @@
display: none;
flex-direction: row;
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 {
&:first-child {
border-right: 1px solid var(--lightgray);
border-top-right-radius: unset;
border-bottom-right-radius: unset;
}
&:last-child {
border-top-left-radius: unset;
border-bottom-left-radius: unset;
}
}
}
}
& > div {
// vh - #search-space.margin-top
height: calc(75vh - 12vh);
background: none;
&:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
border-right: 1px solid var(--lightgray);
}
&:last-child {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
border-radius: 5px;
}
@media all and (max-width: $tabletBreakpoint) {
display: block;
& > *:not(#results-container) {
& > #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%, transparent);
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;
box-sizing: border-box;
font-family: inherit;
color: var(--dark);
line-height: 1.5em;
font-weight: $normalWeight;
background: var(--light);
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
overflow-y: auto;
padding: 1rem;
padding: 0 2rem;
& .preview-inner {
margin: 0 auto;
width: min($pageWidth, 100%);
}
a[role="anchor"] {
background-color: transparent;
}
}
& > #results-container {
overflow-y: auto;
& .result-card {
overflow: hidden;
padding: 1em;
cursor: pointer;
transition: background 0.2s ease;
@ -174,10 +185,10 @@
margin: 0;
text-transform: none;
text-align: left;
background: var(--light);
outline: none;
font-weight: inherit;
&:hover,
&:focus,
&.focus {
background: var(--lightgray);

96
quartz/depgraph.test.ts Normal file
View File

@ -0,0 +1,96 @@
import test, { describe } from "node:test"
import DepGraph from "./depgraph"
import assert from "node:assert"
describe("DepGraph", () => {
test("getLeafNodes", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("D", "C")
assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
})
describe("getLeafNodeAncestors", () => {
test("gets correct ancestors in a graph without cycles", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("D", "B")
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
})
test("gets correct ancestors in a graph with cycles", () => {
const graph = new DepGraph<string>()
graph.addEdge("A", "B")
graph.addEdge("B", "C")
graph.addEdge("C", "A")
graph.addEdge("C", "D")
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
})
})
describe("updateIncomingEdgesForNode", () => {
test("merges when node exists", () => {
// A.md -> B.md -> B.html
const graph = new DepGraph<string>()
graph.addEdge("A.md", "B.md")
graph.addEdge("B.md", "B.html")
// B.md is edited so it removes the A.md transclusion
// and adds C.md transclusion
// C.md -> B.md
const other = new DepGraph<string>()
other.addEdge("C.md", "B.md")
other.addEdge("B.md", "B.html")
// A.md -> B.md removed, C.md -> B.md added
// C.md -> B.md -> B.html
graph.updateIncomingEdgesForNode(other, "B.md")
const expected = {
nodes: ["A.md", "B.md", "B.html", "C.md"],
edges: [
["B.md", "B.html"],
["C.md", "B.md"],
],
}
assert.deepStrictEqual(graph.export(), expected)
})
test("adds node if it does not exist", () => {
// A.md -> B.md
const graph = new DepGraph<string>()
graph.addEdge("A.md", "B.md")
// Add a new file C.md that transcludes B.md
// B.md -> C.md
const other = new DepGraph<string>()
other.addEdge("B.md", "C.md")
// B.md -> C.md added
// A.md -> B.md -> C.md
graph.updateIncomingEdgesForNode(other, "C.md")
const expected = {
nodes: ["A.md", "B.md", "C.md"],
edges: [
["A.md", "B.md"],
["B.md", "C.md"],
],
}
assert.deepStrictEqual(graph.export(), expected)
})
})
})

187
quartz/depgraph.ts Normal file
View File

@ -0,0 +1,187 @@
export default class DepGraph<T> {
// node: incoming and outgoing edges
_graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
constructor() {
this._graph = new Map()
}
export(): Object {
return {
nodes: this.nodes,
edges: this.edges,
}
}
toString(): string {
return JSON.stringify(this.export(), null, 2)
}
// BASIC GRAPH OPERATIONS
get nodes(): T[] {
return Array.from(this._graph.keys())
}
get edges(): [T, T][] {
let edges: [T, T][] = []
this.forEachEdge((edge) => edges.push(edge))
return edges
}
hasNode(node: T): boolean {
return this._graph.has(node)
}
addNode(node: T): void {
if (!this._graph.has(node)) {
this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
}
}
removeNode(node: T): void {
if (this._graph.has(node)) {
this._graph.delete(node)
}
}
hasEdge(from: T, to: T): boolean {
return Boolean(this._graph.get(from)?.outgoing.has(to))
}
addEdge(from: T, to: T): void {
this.addNode(from)
this.addNode(to)
this._graph.get(from)!.outgoing.add(to)
this._graph.get(to)!.incoming.add(from)
}
removeEdge(from: T, to: T): void {
if (this._graph.has(from) && this._graph.has(to)) {
this._graph.get(from)!.outgoing.delete(to)
this._graph.get(to)!.incoming.delete(from)
}
}
// returns -1 if node does not exist
outDegree(node: T): number {
return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
}
// returns -1 if node does not exist
inDegree(node: T): number {
return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
}
forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
this._graph.get(node)?.outgoing.forEach(callback)
}
forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
this._graph.get(node)?.incoming.forEach(callback)
}
forEachEdge(callback: (edge: [T, T]) => void): void {
for (const [source, { outgoing }] of this._graph.entries()) {
for (const target of outgoing) {
callback([source, target])
}
}
}
// DEPENDENCY ALGORITHMS
// For the node provided:
// If node does not exist, add it
// If an incoming edge was added in other, it is added in this graph
// If an incoming edge was deleted in other, it is deleted in this graph
updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
this.addNode(node)
// Add edge if it is present in other
other.forEachInNeighbor(node, (neighbor) => {
this.addEdge(neighbor, node)
})
// For node provided, remove incoming edge if it is absent in other
this.forEachEdge(([source, target]) => {
if (target === node && !other.hasEdge(source, target)) {
this.removeEdge(source, target)
}
})
}
// Get all leaf nodes (i.e. destination paths) reachable from the node provided
// Eg. if the graph is A -> B -> C
// D ---^
// and the node is B, this function returns [C]
getLeafNodes(node: T): Set<T> {
let stack: T[] = [node]
let visited = new Set<T>()
let leafNodes = new Set<T>()
// DFS
while (stack.length > 0) {
let node = stack.pop()!
// If the node is already visited, skip it
if (visited.has(node)) {
continue
}
visited.add(node)
// Check if the node is a leaf node (i.e. destination path)
if (this.outDegree(node) === 0) {
leafNodes.add(node)
}
// Add all unvisited neighbors to the stack
this.forEachOutNeighbor(node, (neighbor) => {
if (!visited.has(neighbor)) {
stack.push(neighbor)
}
})
}
return leafNodes
}
// Get all ancestors of the leaf nodes reachable from the node provided
// Eg. if the graph is A -> B -> C
// D ---^
// and the node is B, this function returns [A, B, D]
getLeafNodeAncestors(node: T): Set<T> {
const leafNodes = this.getLeafNodes(node)
let visited = new Set<T>()
let upstreamNodes = new Set<T>()
// Backwards DFS for each leaf node
leafNodes.forEach((leafNode) => {
let stack: T[] = [leafNode]
while (stack.length > 0) {
let node = stack.pop()!
if (visited.has(node)) {
continue
}
visited.add(node)
// Add node if it's not a leaf node (i.e. destination path)
// Assumes destination file cannot depend on another destination file
if (this.outDegree(node) !== 0) {
upstreamNodes.add(node)
}
// Add all unvisited parents to the stack
this.forEachInNeighbor(node, (parentNode) => {
if (!visited.has(parentNode)) {
stack.push(parentNode)
}
})
}
})
return upstreamNodes
}
}

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

@ -0,0 +1,48 @@
import { Translation, CalloutTranslation } from "./locales/definition"
import en from "./locales/en-US"
import fr from "./locales/fr-FR"
import ja from "./locales/ja-JP"
import de from "./locales/de-DE"
import nl from "./locales/nl-NL"
import ro from "./locales/ro-RO"
import es from "./locales/es-ES"
import ar from "./locales/ar-SA"
import uk from "./locales/uk-UA"
export const TRANSLATIONS = {
"en-US": en,
"fr-FR": fr,
"ja-JP": ja,
"de-DE": de,
"nl-NL": nl,
"nl-BE": nl,
"ro-RO": ro,
"ro-MD": ro,
"es-ES": es,
"ar-SA": ar,
"ar-AE": ar,
"ar-QA": ar,
"ar-BH": ar,
"ar-KW": ar,
"ar-OM": ar,
"ar-YE": ar,
"ar-IR": ar,
"ar-SY": ar,
"ar-IQ": ar,
"ar-JO": ar,
"ar-PL": ar,
"ar-LB": ar,
"ar-EG": ar,
"ar-SD": ar,
"ar-LY": ar,
"ar-MA": ar,
"ar-TN": ar,
"ar-DZ": ar,
"ar-MR": ar,
"uk-UA": uk,
} as const
export const defaultTranslation = "en-US"
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation]
export type ValidLocale = keyof typeof TRANSLATIONS
export type ValidCallout = keyof CalloutTranslation

View File

@ -0,0 +1,88 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "غير معنون",
description: "لم يتم تقديم أي وصف",
},
components: {
callout: {
note: "ملاحظة",
abstract: "ملخص",
info: "معلومات",
todo: "للقيام",
tip: "نصيحة",
success: "نجاح",
question: "سؤال",
warning: "تحذير",
failure: "فشل",
danger: "خطر",
bug: "خلل",
example: "مثال",
quote: "اقتباس",
},
backlinks: {
title: "وصلات العودة",
noBacklinksFound: "لا يوجد وصلات عودة",
},
themeToggle: {
lightMode: "الوضع النهاري",
darkMode: "الوضع الليلي",
},
explorer: {
title: "المستعرض",
},
footer: {
createdWith: "أُنشئ باستخدام",
},
graph: {
title: "التمثيل التفاعلي",
},
recentNotes: {
title: "آخر الملاحظات",
seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
linkToOriginal: "وصلة للملاحظة الرئيسة",
},
search: {
title: "بحث",
searchBarPlaceholder: "ابحث عن شيء ما",
},
tableOfContents: {
title: "فهرس المحتويات",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes == 1
? `دقيقة أو أقل للقراءة`
: minutes == 2
? `دقيقتان للقراءة`
: `${minutes} دقائق للقراءة`,
},
},
pages: {
rss: {
recentNotes: "آخر الملاحظات",
lastFewNotes: ({ count }) => `آخر ${count} ملاحظة`,
},
error: {
title: "غير موجود",
notFound: "إما أن هذه الصفحة خاصة أو غير موجودة.",
},
folderContent: {
folder: "مجلد",
itemsUnderFolder: ({ count }) =>
count === 1 ? "يوجد عنصر واحد فقط تحت هذا المجلد" : `يوجد ${count} عناصر تحت هذا المجلد.`,
},
tagContent: {
tag: "الوسم",
tagIndex: "مؤشر الوسم",
itemsUnderTag: ({ count }) =>
count === 1 ? "يوجد عنصر واحد فقط تحت هذا الوسم" : `يوجد ${count} عناصر تحت هذا الوسم.`,
showingFirst: ({ count }) => `إظهار أول ${count} أوسمة.`,
totalTags: ({ count }) => `يوجد ${count} أوسمة.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,83 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Unbenannt",
description: "Keine Beschreibung angegeben",
},
components: {
callout: {
note: "Hinweis",
abstract: "Zusammenfassung",
info: "Info",
todo: "Zu erledigen",
tip: "Tipp",
success: "Erfolg",
question: "Frage",
warning: "Warnung",
failure: "Misserfolg",
danger: "Gefahr",
bug: "Fehler",
example: "Beispiel",
quote: "Zitat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Keine Backlinks gefunden",
},
themeToggle: {
lightMode: "Light Mode",
darkMode: "Dark Mode",
},
explorer: {
title: "Explorer",
},
footer: {
createdWith: "Erstellt mit",
},
graph: {
title: "Graphansicht",
},
recentNotes: {
title: "Zuletzt bearbeitete Seiten",
seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`,
linkToOriginal: "Link zum Original",
},
search: {
title: "Suche",
searchBarPlaceholder: "Suche nach etwas",
},
tableOfContents: {
title: "Inhaltsverzeichnis",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Zuletzt bearbeitete Seiten",
lastFewNotes: ({ count }) => `Letzte ${count} Seiten`,
},
error: {
title: "Nicht gefunden",
notFound: "Diese Seite ist entweder nicht öffentlich oder existiert nicht.",
},
folderContent: {
folder: "Ordner",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 Datei in diesem Ordner" : `${count} Dateien in diesem Ordner.`,
},
tagContent: {
tag: "Tag",
tagIndex: "Tag-Übersicht",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 Datei mit diesem Tag" : `${count} Dateien mit diesem Tag.`,
showingFirst: ({ count }) => `Die ersten ${count} Tags werden angezeigt.`,
totalTags: ({ count }) => `${count} Tags insgesamt.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,83 @@
import { FullSlug } from "../../util/path"
export interface CalloutTranslation {
note: string
abstract: string
info: string
todo: string
tip: string
success: string
question: string
warning: string
failure: string
danger: string
bug: string
example: string
quote: string
}
export interface Translation {
propertyDefaults: {
title: string
description: string
}
components: {
callout: CalloutTranslation
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
}
contentMeta: {
readingTime: (variables: { minutes: number }) => 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,83 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Untitled",
description: "No description provided",
},
components: {
callout: {
note: "Note",
abstract: "Abstract",
info: "Info",
todo: "Todo",
tip: "Tip",
success: "Success",
question: "Question",
warning: "Warning",
failure: "Failure",
danger: "Danger",
bug: "Bug",
example: "Example",
quote: "Quote",
},
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",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
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,83 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sin título",
description: "Sin descripción",
},
components: {
callout: {
note: "Nota",
abstract: "Resumen",
info: "Información",
todo: "Por hacer",
tip: "Consejo",
success: "Éxito",
question: "Pregunta",
warning: "Advertencia",
failure: "Fallo",
danger: "Peligro",
bug: "Error",
example: "Ejemplo",
quote: "Cita",
},
backlinks: {
title: "Enlaces de Retroceso",
noBacklinksFound: "No se han encontrado enlaces traseros",
},
themeToggle: {
lightMode: "Modo claro",
darkMode: "Modo oscuro",
},
explorer: {
title: "Explorador",
},
footer: {
createdWith: "Creado con",
},
graph: {
title: "Vista Gráfica",
},
recentNotes: {
title: "Notas Recientes",
seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`,
linkToOriginal: "Enlace al original",
},
search: {
title: "Buscar",
searchBarPlaceholder: "Busca algo",
},
tableOfContents: {
title: "Tabla de Contenidos",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Notas recientes",
lastFewNotes: ({ count }) => `Últimás ${count} notas`,
},
error: {
title: "No se encontró.",
notFound: "Esta página es privada o no existe.",
},
folderContent: {
folder: "Carpeta",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 artículo en esta carpeta" : `${count} artículos en esta carpeta.`,
},
tagContent: {
tag: "Etiqueta",
tagIndex: "Índice de Etiquetas",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 artículo con esta etiqueta" : `${count} artículos con esta etiqueta.`,
showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`,
totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,83 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Sans titre",
description: "Aucune description fournie",
},
components: {
callout: {
note: "Note",
abstract: "Résumé",
info: "Info",
todo: "À faire",
tip: "Conseil",
success: "Succès",
question: "Question",
warning: "Avertissement",
failure: "Échec",
danger: "Danger",
bug: "Bogue",
example: "Exemple",
quote: "Citation",
},
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",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
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

@ -0,0 +1,81 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "無題",
description: "説明なし",
},
components: {
callout: {
note: "ノート",
abstract: "抄録",
info: "情報",
todo: "やるべきこと",
tip: "ヒント",
success: "成功",
question: "質問",
warning: "警告",
failure: "失敗",
danger: "危険",
bug: "バグ",
example: "例",
quote: "引用",
},
backlinks: {
title: "バックリンク",
noBacklinksFound: "バックリンクはありません",
},
themeToggle: {
lightMode: "ライトモード",
darkMode: "ダークモード",
},
explorer: {
title: "エクスプローラー",
},
footer: {
createdWith: "作成",
},
graph: {
title: "グラフビュー",
},
recentNotes: {
title: "最近の記事",
seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`,
linkToOriginal: "元記事へのリンク",
},
search: {
title: "検索",
searchBarPlaceholder: "検索ワードを入力",
},
tableOfContents: {
title: "目次",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "最近の記事",
lastFewNotes: ({ count }) => `最新の${count}`,
},
error: {
title: "Not Found",
notFound: "ページが存在しないか、非公開設定になっています。",
},
folderContent: {
folder: "フォルダ",
itemsUnderFolder: ({ count }) => `${count}件のページ`,
},
tagContent: {
tag: "タグ",
tagIndex: "タグ一覧",
itemsUnderTag: ({ count }) => `${count}件のページ`,
showingFirst: ({ count }) => `のうち最初の${count}件を表示しています`,
totalTags: ({ count }) => `${count}個のタグを表示中`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,85 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Naamloos",
description: "Geen beschrijving gegeven.",
},
components: {
callout: {
note: "Notitie",
abstract: "Samenvatting",
info: "Info",
todo: "Te doen",
tip: "Tip",
success: "Succes",
question: "Vraag",
warning: "Waarschuwing",
failure: "Mislukking",
danger: "Gevaar",
bug: "Bug",
example: "Voorbeeld",
quote: "Citaat",
},
backlinks: {
title: "Backlinks",
noBacklinksFound: "Geen backlinks gevonden",
},
themeToggle: {
lightMode: "Lichte modus",
darkMode: "Donkere modus",
},
explorer: {
title: "Verkenner",
},
footer: {
createdWith: "Gemaakt met",
},
graph: {
title: "Grafiekweergave",
},
recentNotes: {
title: "Recente notities",
seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`,
linkToOriginal: "Link naar origineel",
},
search: {
title: "Zoeken",
searchBarPlaceholder: "Doorzoek de website",
},
tableOfContents: {
title: "Inhoudsopgave",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes === 1 ? "1 minuut leestijd" : `${minutes} minuten leestijd`,
},
},
pages: {
rss: {
recentNotes: "Recente notities",
lastFewNotes: ({ count }) => `Laatste ${count} notities`,
},
error: {
title: "Niet gevonden",
notFound: "Deze pagina is niet zichtbaar of bestaat niet.",
},
folderContent: {
folder: "Map",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 item in deze map" : `${count} items in deze map.`,
},
tagContent: {
tag: "Label",
tagIndex: "Label-index",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 item met dit label." : `${count} items met dit label.`,
showingFirst: ({ count }) =>
count === 1 ? "Eerste label tonen." : `Eerste ${count} labels tonen.`,
totalTags: ({ count }) => `${count} labels gevonden.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,84 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Fără titlu",
description: "Nici o descriere furnizată",
},
components: {
callout: {
note: "Notă",
abstract: "Rezumat",
info: "Informație",
todo: "De făcut",
tip: "Sfat",
success: "Succes",
question: "Întrebare",
warning: "Avertisment",
failure: "Eșec",
danger: "Pericol",
bug: "Bug",
example: "Exemplu",
quote: "Citat",
},
backlinks: {
title: "Legături înapoi",
noBacklinksFound: "Nu s-au găsit legături înapoi",
},
themeToggle: {
lightMode: "Modul luminos",
darkMode: "Modul întunecat",
},
explorer: {
title: "Explorator",
},
footer: {
createdWith: "Creat cu",
},
graph: {
title: "Graf",
},
recentNotes: {
title: "Notițe recente",
seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`,
linkToOriginal: "Legătură către original",
},
search: {
title: "Căutare",
searchBarPlaceholder: "Introduceți termenul de căutare...",
},
tableOfContents: {
title: "Cuprins",
},
contentMeta: {
readingTime: ({ minutes }) =>
minutes == 1 ? `lectură de 1 minut` : `lectură de ${minutes} minute`,
},
},
pages: {
rss: {
recentNotes: "Notițe recente",
lastFewNotes: ({ count }) => `Ultimele ${count} notițe`,
},
error: {
title: "Pagina nu a fost găsită",
notFound: "Fie această pagină este privată, fie nu există.",
},
folderContent: {
folder: "Dosar",
itemsUnderFolder: ({ count }) =>
count === 1 ? "1 articol în acest dosar." : `${count} elemente în acest dosar.`,
},
tagContent: {
tag: "Etichetă",
tagIndex: "Indexul etichetelor",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 articol cu această etichetă." : `${count} articole cu această etichetă.`,
showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`,
totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`,
},
},
} as const satisfies Translation

View File

@ -0,0 +1,83 @@
import { Translation } from "./definition"
export default {
propertyDefaults: {
title: "Без назви",
description: "Опис не надано",
},
components: {
callout: {
note: "Примітка",
abstract: "Абстракт",
info: "Інформація",
todo: "Завдання",
tip: "Порада",
success: "Успіх",
question: "Питання",
warning: "Попередження",
failure: "Невдача",
danger: "Небезпека",
bug: "Баг",
example: "Приклад",
quote: "Цитата",
},
backlinks: {
title: "Зворотні посилання",
noBacklinksFound: "Зворотних посилань не знайдено",
},
themeToggle: {
lightMode: "Світлий режим",
darkMode: "Темний режим",
},
explorer: {
title: "Провідник",
},
footer: {
createdWith: "Створено за допомогою",
},
graph: {
title: "Вигляд графа",
},
recentNotes: {
title: "Останні нотатки",
seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining}`,
},
transcludes: {
transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`,
linkToOriginal: "Посилання на оригінал",
},
search: {
title: "Пошук",
searchBarPlaceholder: "Шукати щось",
},
tableOfContents: {
title: "Зміст",
},
contentMeta: {
readingTime: ({ minutes }) => `${minutes} min read`,
},
},
pages: {
rss: {
recentNotes: "Останні нотатки",
lastFewNotes: ({ count }) => `Останні нотатки: ${count}`,
},
error: {
title: "Не знайдено",
notFound: "Ця сторінка або приватна, або не існує.",
},
folderContent: {
folder: "Папка",
itemsUnderFolder: ({ count }) =>
count === 1 ? "У цій папці 1 елемент" : `Елементів у цій папці: ${count}.`,
},
tagContent: {
tag: "Тег",
tagIndex: "Індекс тегу",
itemsUnderTag: ({ count }) =>
count === 1 ? "1 елемент з цим тегом" : `Елементів з цим тегом: ${count}.`,
showingFirst: ({ count }) => `Показ перших ${count} тегів.`,
totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`,
},
},
} as const satisfies Translation

View File

@ -8,6 +8,8 @@ import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import DepGraph from "../../depgraph"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
@ -26,6 +28,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return [Head, Body, pageBody, Footer]
},
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<FilePath>()
},
async emit(ctx, _content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug
@ -33,11 +38,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 +57,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

@ -2,12 +2,17 @@ import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from
import { QuartzEmitterPlugin } from "../types"
import path from "path"
import { write } from "./helpers"
import DepGraph from "../../depgraph"
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
getQuartzComponents() {
return []
},
async getDependencyGraph(_ctx, _content, _resources) {
// TODO implement
return new DepGraph<FilePath>()
},
async emit(ctx, content, _resources): Promise<FilePath[]> {
const { argv } = ctx
const fps: FilePath[] = []

View File

@ -3,6 +3,14 @@ import { QuartzEmitterPlugin } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
import DepGraph from "../../depgraph"
import { Argv } from "../../util/ctx"
import { QuartzConfig } from "../../cfg"
const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
// glob all non MD files in content folder and copy it over
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
}
export const Assets: QuartzEmitterPlugin = () => {
return {
@ -10,10 +18,27 @@ export const Assets: QuartzEmitterPlugin = () => {
getQuartzComponents() {
return []
},
async getDependencyGraph(ctx, _content, _resources) {
const { argv, cfg } = ctx
const graph = new DepGraph<FilePath>()
const fps = await filesToCopy(argv, cfg)
for (const fp of fps) {
const ext = path.extname(fp)
const src = joinSegments(argv.directory, fp) as FilePath
const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath
const dest = joinSegments(argv.output, name) as FilePath
graph.addEdge(src, dest)
}
return graph
},
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])
const fps = await filesToCopy(argv, cfg)
const res: FilePath[] = []
for (const fp of fps) {
const ext = path.extname(fp)

View File

@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import chalk from "chalk"
import DepGraph from "../../depgraph"
export function extractDomainFromBaseUrl(baseUrl: string) {
const url = new URL(`https://${baseUrl}`)
@ -13,6 +14,9 @@ export const CNAME: QuartzEmitterPlugin = () => ({
getQuartzComponents() {
return []
},
async getDependencyGraph(_ctx, _content, _resources) {
return new DepGraph<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"))

View File

@ -1,4 +1,4 @@
import { FilePath, FullSlug } from "../../util/path"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
@ -14,6 +14,7 @@ import { googleFontHref, joinStyles } from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
import DepGraph from "../../depgraph"
type ComponentResources = {
css: string[]
@ -119,7 +120,7 @@ function addGlobalPageResources(
} else if (cfg.analytics?.provider === "umami") {
componentResources.afterDOMLoaded.push(`
const umamiScript = document.createElement("script")
umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js"
umamiScript.src = "${cfg.analytics.host}" ?? "https://analytics.umami.is/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.async = true
@ -149,9 +150,10 @@ function addGlobalPageResources(
loadTime: "afterDOMReady",
contentType: "inline",
script: `
const socket = new WebSocket('${wsUrl}')
socket.addEventListener('message', () => document.location.reload())
`,
const socket = new WebSocket('${wsUrl}')
// reload(true) ensures resources like images and scripts are fetched again in firefox
socket.addEventListener('message', () => document.location.reload(true))
`,
})
}
}
@ -171,28 +173,94 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
getQuartzComponents() {
return []
},
async getDependencyGraph(ctx, content, _resources) {
// This emitter adds static resources to the `resources` parameter. One
// important resource this emitter adds is the code to start a websocket
// connection and listen to rebuild messages, which triggers a page reload.
// The resources parameter with the reload logic is later used by the
// ContentPage emitter while creating the final html page. In order for
// the reload logic to be included, and so for partial rebuilds to work,
// we need to run this emitter for all markdown files.
const graph = new DepGraph<FilePath>()
for (const [_tree, file] of content) {
const sourcePath = file.data.filePath!
const slug = file.data.slug!
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
}
return graph
},
async emit(ctx, _content, resources): Promise<FilePath[]> {
const promises: Promise<FilePath>[] = []
const cfg = ctx.cfg.configuration
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
// important that this goes *after* component scripts
// as the "nav" event gets triggered here and we should make sure
// that everyone else had the chance to register a listener for it
if (fontOrigin === "googleFonts") {
resources.css.push(googleFontHref(ctx.cfg.configuration.theme))
} else if (fontOrigin === "local") {
let googleFontsStyleSheet = ""
if (fontOrigin === "local") {
// let the user do it themselves in css
} else if (fontOrigin === "googleFonts") {
if (cfg.theme.cdnCaching) {
resources.css.push(googleFontHref(cfg.theme))
} else {
let match
const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g
googleFontsStyleSheet = await (
await fetch(googleFontHref(ctx.cfg.configuration.theme))
).text()
while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) {
// match[0] is the `url(path)`, match[1] is the `path`
const url = match[1]
// the static name of this file.
const [filename, ext] = url.split("/").pop()!.split(".")
googleFontsStyleSheet = googleFontsStyleSheet.replace(
url,
`/static/fonts/${filename}.ttf`,
)
promises.push(
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch font`)
}
return res.arrayBuffer()
})
.then((buf) =>
write({
ctx,
slug: joinSegments("static", "fonts", filename) as FullSlug,
ext: `.${ext}`,
content: Buffer.from(buf),
}),
),
)
}
}
}
addGlobalPageResources(ctx, resources, componentResources)
const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles)
const stylesheet = joinStyles(
ctx.cfg.configuration.theme,
...componentResources.css,
googleFontsStyleSheet,
styles,
)
const [prescript, postscript] = await Promise.all([
joinScripts(componentResources.beforeDOMLoaded),
joinScripts(componentResources.afterDOMLoaded),
])
const fps = await Promise.all([
promises.push(
write({
ctx,
slug: "index" as FullSlug,
@ -223,8 +291,9 @@ export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<
ext: ".js",
content: postscript,
}),
])
return fps
)
return await Promise.all(promises)
},
}
}

View File

@ -6,6 +6,8 @@ import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../.
import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import DepGraph from "../../depgraph"
export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = {
@ -38,7 +40,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))
@ -91,6 +93,26 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts }
return {
name: "ContentIndex",
async getDependencyGraph(ctx, content, _resources) {
const graph = new DepGraph<FilePath>()
for (const [_tree, file] of content) {
const sourcePath = file.data.filePath!
graph.addEdge(
sourcePath,
joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
)
if (opts?.enableSiteMap) {
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
}
if (opts?.enableRSS) {
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
}
}
return graph
},
async emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const emitted: FilePath[] = []

View File

@ -4,11 +4,12 @@ import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { FilePath, pathToRoot } from "../../util/path"
import { FilePath, joinSegments, pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
import chalk from "chalk"
import { write } from "./helpers"
import DepGraph from "../../depgraph"
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
@ -27,6 +28,18 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async getDependencyGraph(ctx, content, _resources) {
// TODO handle transclusions
const graph = new DepGraph<FilePath>()
for (const [_tree, file] of content) {
const sourcePath = file.data.filePath!
const slug = file.data.slug!
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath)
}
return graph
},
async emit(ctx, content, resources): Promise<FilePath[]> {
const cfg = ctx.cfg.configuration
const fps: FilePath[] = []
@ -49,7 +62,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,
@ -60,7 +73,7 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
fps.push(fp)
}
if (!containsIndex) {
if (!containsIndex && !ctx.argv.fastRebuild) {
console.log(
chalk.yellow(
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`,

View File

@ -10,7 +10,7 @@ import {
FilePath,
FullSlug,
SimpleSlug,
_stripSlashes,
stripSlashes,
joinSegments,
pathToRoot,
simplifySlug,
@ -18,6 +18,8 @@ import {
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import DepGraph from "../../depgraph"
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
@ -36,6 +38,13 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpt
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async getDependencyGraph(_ctx, _content, _resources) {
// Example graph:
// nested/file.md --> nested/file.html
// \-------> nested/index.html
// TODO implement
return new DepGraph<FilePath>()
},
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
@ -57,13 +66,16 @@ 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: [],
},
}),
]),
)
for (const [tree, file] of content) {
const slug = _stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
folderDescriptions[slug] = [tree, file]
}
@ -82,7 +94,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

@ -7,7 +7,7 @@ type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string
content: string | Buffer
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {

View File

@ -2,12 +2,27 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
import DepGraph from "../../depgraph"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
getQuartzComponents() {
return []
},
async getDependencyGraph({ argv, cfg }, _content, _resources) {
const graph = new DepGraph<FilePath>()
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
for (const fp of fps) {
graph.addEdge(
joinSegments("static", fp) as FilePath,
joinSegments(argv.output, "static", fp) as FilePath,
)
}
return graph
},
async emit({ argv, cfg }, _content, _resources): Promise<FilePath[]> {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)

View File

@ -15,6 +15,8 @@ import {
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import DepGraph from "../../depgraph"
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
@ -33,6 +35,10 @@ export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts)
getQuartzComponents() {
return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer]
},
async getDependencyGraph(ctx, _content, _resources) {
// TODO implement
return new DepGraph<FilePath>()
},
async emit(ctx, content, resources): Promise<FilePath[]> {
const fps: FilePath[] = []
const allFiles = content.map((c) => c[1].data)
@ -47,7 +53,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 +90,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

@ -32,6 +32,7 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> |
{
behavior: "append",
properties: {
role: "anchor",
ariaHidden: true,
tabIndex: -1,
"data-no-popover": true,

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

@ -4,10 +4,11 @@ import {
RelativeURL,
SimpleSlug,
TransformOptions,
_stripSlashes,
stripSlashes,
simplifySlug,
splitAnchor,
transformLink,
joinSegments,
} from "../../util/path"
import path from "path"
import { visit } from "unist-util-visit"
@ -107,7 +108,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
// url.resolve is considered legacy
// WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to
const url = new URL(dest, `https://base.com/${curSlug}`)
const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true))
const canonicalDest = url.pathname
let [destCanonical, _destAnchor] = splitAnchor(canonicalDest)
if (destCanonical.endsWith("/")) {
@ -115,7 +116,7 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<Options> | undefined> =
}
// need to decodeURIComponent here as WHATWG URL percent-encodes everything
const full = decodeURIComponent(_stripSlashes(destCanonical, true)) as FullSlug
const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug
const simple = simplifySlug(full)
outgoing.add(simple)
node.properties["data-slug"] = full

View File

@ -1,5 +1,5 @@
import { QuartzTransformerPlugin } from "../types"
import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast"
import { Blockquote, 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"
@ -9,12 +9,15 @@ import path from "path"
import { JSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts"
// @ts-ignore
import checkboxScript from "../../components/scripts/checkbox.inline.ts"
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html"
import { PhrasingContent } from "mdast-util-find-and-replace/lib"
import { capitalize } from "../../util/lang"
import { PluggableList } from "unified"
import { ValidCallout, i18n } from "../../i18n"
export interface Options {
comments: boolean
@ -28,6 +31,7 @@ export interface Options {
enableInHtmlEmbed: boolean
enableYouTubeEmbed: boolean
enableVideoEmbed: boolean
enableCheckbox: boolean
}
const defaultOptions: Options = {
@ -42,6 +46,7 @@ const defaultOptions: Options = {
enableInHtmlEmbed: false,
enableYouTubeEmbed: true,
enableVideoEmbed: true,
enableCheckbox: false,
}
const calloutMapping = {
@ -74,6 +79,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 +98,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
@ -102,7 +118,10 @@ 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}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu")
const tagRegex = new RegExp(
/(?:^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\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)$/)
@ -170,8 +189,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
return src
},
markdownPlugins() {
markdownPlugins(ctx) {
const plugins: PluggableList = []
const cfg = ctx.cfg.configuration
// regex replacements
plugins.push(() => {
@ -271,10 +291,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>`,
}
},
])
@ -390,7 +412,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
children: [
{
type: "text",
value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ",
value: useDefaultTitle
? capitalize(
i18n(cfg.locale).components.callout[calloutType as ValidCallout] ??
calloutType,
)
: titleContent + " ",
},
...restOfTitle,
],
@ -426,13 +453,19 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
// replace first line of blockquote with title and rest of the paragraph text
node.children.splice(0, 1, ...blockquoteContent)
const classNames = ["callout", calloutType]
if (collapse) {
classNames.push("is-collapsible")
}
if (defaultState === "collapsed") {
classNames.push("is-collapsed")
}
// add properties to base blockquote
node.data = {
hProperties: {
...(node.data?.hProperties ?? {}),
className: `callout ${calloutType} ${collapse ? "is-collapsible" : ""} ${
defaultState === "collapsed" ? "is-collapsed" : ""
}`,
className: classNames.join(" "),
"data-callout": calloutType,
"data-callout-fold": collapse,
},
@ -541,11 +574,37 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
})
}
if (opts.enableCheckbox) {
plugins.push(() => {
return (tree: HtmlRoot, _file) => {
visit(tree, "element", (node) => {
if (node.tagName === "input" && node.properties.type === "checkbox") {
const isChecked = node.properties?.checked ?? false
node.properties = {
type: "checkbox",
disabled: false,
checked: isChecked,
class: "checkbox-toggle",
}
}
})
}
})
}
return plugins
},
externalResources() {
const js: JSResource[] = []
if (opts.enableCheckbox) {
js.push({
script: checkboxScript,
loadTime: "afterDOMReady",
contentType: "inline",
})
}
if (opts.callouts) {
js.push({
script: calloutScript,
@ -557,17 +616,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
if (opts.mermaid) {
js.push({
script: `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: darkMode ? 'dark' : 'default'
});
let mermaidImport = undefined
document.addEventListener('nav', async () => {
await mermaid.run({
querySelector: '.mermaid'
})
if (document.querySelector("code.mermaid")) {
mermaidImport ||= await import('https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs')
const mermaid = mermaidImport.default
const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: darkMode ? 'dark' : 'default'
})
await mermaid.run({
querySelector: '.mermaid'
})
}
});
`,
loadTime: "afterDOMReady",

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

@ -41,4 +41,9 @@ export type QuartzEmitterPluginInstance = {
name: string
emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise<FilePath[]>
getQuartzComponents(ctx: BuildCtx): QuartzComponent[]
getDependencyGraph?(
ctx: BuildCtx,
content: ProcessedContent[],
resources: StaticResources,
): Promise<DepGraph<FilePath>>
}

View File

@ -26,7 +26,7 @@ section {
}
::selection {
background: color-mix(in srgb, var(--tertiary) 60%, transparent);
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
color: var(--darkgray);
}
@ -259,11 +259,9 @@ thead {
font-weight: revert;
margin-bottom: 0;
article > & > a {
article > & > a[role="anchor"] {
color: var(--dark);
&.internal {
background-color: transparent;
}
background-color: transparent;
}
}

View File

@ -120,7 +120,7 @@
.callout-title {
display: flex;
align-items: center;
align-items: flex-start;
gap: 5px;
padding: 1rem 0;
color: var(--color);
@ -131,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);
}
@ -145,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);
@ -154,6 +153,7 @@
mask-size: var(--icon-size) var(--icon-size);
mask-position: center;
mask-repeat: no-repeat;
padding: 0.2rem 0;
}
.callout-title-inner {

View File

@ -6,6 +6,7 @@ export interface Argv {
verbose: boolean
output: string
serve: boolean
fastRebuild: boolean
port: number
wsPort: number
remoteDevHost?: string

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

@ -23,22 +23,22 @@ export type FullSlug = SlugLike<"full">
export function isFullSlug(s: string): s is FullSlug {
const validStart = !(s.startsWith(".") || s.startsWith("/"))
const validEnding = !s.endsWith("/")
return validStart && validEnding && !_containsForbiddenCharacters(s)
return validStart && validEnding && !containsForbiddenCharacters(s)
}
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
export type SimpleSlug = SlugLike<"simple">
export function isSimpleSlug(s: string): s is SimpleSlug {
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")))
const validEnding = !(s.endsWith("/index") || s === "index")
return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
const validEnding = !endsWith(s, "index")
return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s)
}
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
export type RelativeURL = SlugLike<"relative">
export function isRelativeURL(s: string): s is RelativeURL {
const validStart = /^\.{1,2}/.test(s)
const validEnding = !(s.endsWith("/index") || s === "index")
const validEnding = !endsWith(s, "index")
return validStart && validEnding && ![".md", ".html"].includes(_getFileExtension(s) ?? "")
}
@ -51,14 +51,19 @@ 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(/\/$/, "")
}
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
fp = _stripSlashes(fp) as FilePath
fp = stripSlashes(fp) as FilePath
let ext = _getFileExtension(fp)
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "")
if (excludeExt || [".md", ".html", undefined].includes(ext)) {
@ -68,7 +73,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
let slug = sluggify(withoutFileExt)
// treat _index as index
if (_endsWith(slug, "_index")) {
if (endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index")
}
@ -76,21 +81,21 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
}
export function simplifySlug(fp: FullSlug): SimpleSlug {
const res = _stripSlashes(_trimSuffix(fp, "index"), true)
const res = stripSlashes(trimSuffix(fp, "index"), true)
return (res.length === 0 ? "/" : res) as SimpleSlug
}
export function transformInternalLink(link: string): RelativeURL {
let [fplike, anchor] = splitAnchor(decodeURI(link))
const folderPath = _isFolderPath(fplike)
const folderPath = isFolderPath(fplike)
let segments = fplike.split("/").filter((x) => x.length > 0)
let prefix = segments.filter(_isRelativeSegment).join("/")
let fp = segments.filter((seg) => !_isRelativeSegment(seg) && seg !== "").join("/")
let prefix = segments.filter(isRelativeSegment).join("/")
let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== "").join("/")
// manually add ext here as we want to not strip 'index' if it has an extension
const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath))
const joined = joinSegments(_stripSlashes(prefix), _stripSlashes(simpleSlug))
const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug))
const trail = folderPath ? "/" : ""
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL
return res
@ -201,8 +206,8 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
if (opts.strategy === "relative") {
return targetSlug as RelativeURL
} else {
const folderTail = _isFolderPath(targetSlug) ? "/" : ""
const canonicalSlug = _stripSlashes(targetSlug.slice(".".length))
const folderTail = isFolderPath(targetSlug) ? "/" : ""
const canonicalSlug = stripSlashes(targetSlug.slice(".".length))
let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug)
if (opts.strategy === "shortest") {
@ -225,28 +230,29 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
}
}
function _isFolderPath(fplike: string): boolean {
// path helpers
function isFolderPath(fplike: string): boolean {
return (
fplike.endsWith("/") ||
_endsWith(fplike, "index") ||
_endsWith(fplike, "index.md") ||
_endsWith(fplike, "index.html")
endsWith(fplike, "index") ||
endsWith(fplike, "index.md") ||
endsWith(fplike, "index.html")
)
}
function _endsWith(s: string, suffix: string): boolean {
export function endsWith(s: string, suffix: string): boolean {
return s === suffix || s.endsWith("/" + suffix)
}
function _trimSuffix(s: string, suffix: string): string {
if (_endsWith(s, suffix)) {
function trimSuffix(s: string, suffix: string): string {
if (endsWith(s, suffix)) {
s = s.slice(0, -suffix.length)
}
return s
}
function _containsForbiddenCharacters(s: string): boolean {
return s.includes(" ") || s.includes("#") || s.includes("?")
function containsForbiddenCharacters(s: string): boolean {
return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&")
}
function _hasFileExtension(s: string): boolean {
@ -257,11 +263,11 @@ function _getFileExtension(s: string): string | undefined {
return s.match(/\.[A-Za-z0-9]+$/)?.[0]
}
function _isRelativeSegment(s: string): boolean {
function isRelativeSegment(s: string): boolean {
return /^\.{0,2}$/.test(s)
}
export function _stripSlashes(s: string, onlyStripPrefix?: boolean): string {
export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
if (s.startsWith("/")) {
s = s.substring(1)
}

View File

@ -15,6 +15,7 @@ export interface Theme {
body: string
code: string
}
cdnCaching: boolean
colors: {
lightMode: ColorScheme
darkMode: ColorScheme