diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md index 65209a2ca..565f5bdba 100644 --- a/docs/advanced/making plugins.md +++ b/docs/advanced/making plugins.md @@ -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!, diff --git a/docs/configuration.md b/docs/configuration.md index 047f6ca6b..366430cc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: }`: use Google Analytics + - `{ provider: 'google', tagId: '' }`: use Google Analytics; + - `{ provider: 'plausible' }` (managed) or `{ provider: 'plausible', host: '' }` (self-hosted): use [Plausible](https://plausible.io/); + - `{ provider: 'umami', host: '', websiteId: '' }`: 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]() 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. diff --git a/docs/features/i18n.md b/docs/features/i18n.md new file mode 100644 index 000000000..57547ddad --- /dev/null +++ b/docs/features/i18n.md @@ -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`. diff --git a/docs/index.md b/docs/index.md index cbf8719d1..f25b6e244 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 66a772cde..7d1d9737e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d9f0f7bf2..f89bb2a0b 100644 --- a/package.json +++ b/package.json @@ -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 ", "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" } } diff --git a/quartz.config.ts b/quartz.config.ts index 47d952105..794a66a2e 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -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 }), diff --git a/quartz/build.ts b/quartz/build.ts index 1f90301e9..452a2f1ae 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -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 | null> type BuildData = { ctx: BuildCtx @@ -29,8 +33,11 @@ type BuildData = { toRebuild: Set toRemove: Set 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 | 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() 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() + + 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 ) { diff --git a/quartz/cfg.ts b/quartz/cfg.ts index a7f79e3b8..a477db057 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -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 { diff --git a/quartz/cli/args.js b/quartz/cli/args.js index 7ed5b078e..123d0ac55 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -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: "", diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx index 2484c946a..7768de6cb 100644 --- a/quartz/components/ArticleTitle.tsx +++ b/quartz/components/ArticleTitle.tsx @@ -9,6 +9,7 @@ function ArticleTitle({ fileData, displayClass }: QuartzComponentProps) { return null } } + ArticleTitle.css = ` .article-title { margin: 2rem 0 0 0; diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index d5bdc0b95..573c1c391 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -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 (
-

Backlinks

+

{i18n(cfg.locale).components.backlinks.title}

    {backlinkFiles.length > 0 ? ( backlinkFiles.map((f) => ( @@ -19,7 +20,7 @@ function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) { )) ) : ( -
  • No backlinks found
  • +
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • )}
diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 3875f5e55..eab8a34e2 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -68,13 +68,9 @@ export default ((opts?: Partial) => { 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) => { 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") { diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx index 6cd083e69..bcbe4285d 100644 --- a/quartz/components/ContentMeta.tsx +++ b/quartz/components/ContentMeta.tsx @@ -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) => { // 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

{segments.join(", ")}

diff --git a/quartz/components/Darkmode.tsx b/quartz/components/Darkmode.tsx index 6d10bb99e..62d3c2382 100644 --- a/quartz/components/Darkmode.tsx +++ b/quartz/components/Darkmode.tsx @@ -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 (
@@ -22,7 +23,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) { style="enable-background:new 0 0 35 35" xmlSpace="preserve" > - Light mode + {i18n(cfg.locale).components.themeToggle.darkMode} @@ -38,7 +39,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) { style="enable-background:new 0 0 100 100" xmlSpace="preserve" > - Dark mode + {i18n(cfg.locale).components.themeToggle.lightMode} diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 6feac178c..26b59647c 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -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["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", diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index f0c9094e9..27e0b5729 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -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) => { jsonTree = JSON.stringify(folders) } - function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { + function Explorer({ cfg, allFiles, displayClass, fileData }: QuartzComponentProps) { constructFileTree(allFiles) return (
@@ -87,7 +88,7 @@ export default ((userOpts?: Partial) => { data-savestate={opts.useSavedState} data-tree={jsonTree} > -

{opts.title}

+

{opts.title ?? i18n(cfg.locale).components.explorer.title}

diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index 756a46bb7..9fce9bd8f 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -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 (
-

Graph View

+

{i18n(cfg.locale).components.graph.title}

{ 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 (() => { - - + {cfg.theme.cdnCaching && ( + <> + + + + )} {css.map((href) => ( ))} diff --git a/quartz/components/PageTitle.tsx b/quartz/components/PageTitle.tsx index fb1660a7c..d12960264 100644 --- a/quartz/components/PageTitle.tsx +++ b/quartz/components/PageTitle.tsx @@ -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 (

diff --git a/quartz/components/RecentNotes.tsx b/quartz/components/RecentNotes.tsx index 81b354d77..f8f6de41f 100644 --- a/quartz/components/RecentNotes.tsx +++ b/quartz/components/RecentNotes.tsx @@ -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) => { const remaining = Math.max(0, pages.length - opts.limit) return (
-

{opts.title}

+

{opts.title ?? i18n(cfg.locale).components.recentNotes.title}

    {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) => {
{opts.linkToMore && remaining > 0 && (

- See {remaining} more → + + {i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })} +

)}
diff --git a/quartz/components/Search.tsx b/quartz/components/Search.tsx index 239bc033b..a07dbc4fd 100644 --- a/quartz/components/Search.tsx +++ b/quartz/components/Search.tsx @@ -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) => { - function Search({ displayClass }: QuartzComponentProps) { + function Search({ displayClass, cfg }: QuartzComponentProps) { const opts = { ...defaultOptions, ...userOpts } - + const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder return (
-

Search

+

{i18n(cfg.locale).components.search.title}

) => { id="search-bar" name="search" type="text" - aria-label="Search for something" - placeholder="Search for something" + aria-label={searchPlaceholder} + placeholder={searchPlaceholder} />
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index 564ede2c8..71db58cf9 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -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 (
-

Table of Contents

+

{i18n(cfg.locale).components.tableOfContents.title}

    {fileData.toc.map((tocEntry) => ( diff --git a/quartz/components/pages/404.tsx b/quartz/components/pages/404.tsx index c276f568d..276d5e561 100644 --- a/quartz/components/pages/404.tsx +++ b/quartz/components/pages/404.tsx @@ -1,10 +1,11 @@ -import { QuartzComponentConstructor } from "../types" +import { i18n } from "../../i18n" +import { QuartzComponentConstructor, QuartzComponentProps } from "../types" -function NotFound() { +function NotFound({ cfg }: QuartzComponentProps) { return (

    404

    -

    Either this page is private or doesn't exist.

    +

    {i18n(cfg.locale).pages.error.notFound}

    ) } diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index 47fb02f1b..d3f28ddf1 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -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) => { 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) => {
    {options.showFolderCount && ( -

    {pluralize(allPagesInFolder.length, "item")} under this folder.

    +

    + {i18n(cfg.locale).pages.folderContent.itemsUnderFolder({ + count: allPagesInFolder.length, + })} +

    )}
    diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx index ec30c5ff9..19452ec44 100644 --- a/quartz/components/pages/TagContent.tsx +++ b/quartz/components/pages/TagContent.tsx @@ -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) {

    {content}

    -

    Found {tags.length} total tags.

    +

    {i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}

    {tags.map((tag) => { const pages = tagItemMap.get(tag)! @@ -64,8 +64,12 @@ function TagContent(props: QuartzComponentProps) { {content &&

    {content}

    }

    - {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 && ( + + {i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })} + + )}

    @@ -86,7 +90,7 @@ function TagContent(props: QuartzComponentProps) {
    {content}
    -

    {pluralize(pages.length, "item")} with this tag.

    +

    {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}

    diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index cdc750cf0..3383db738 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -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 @@ -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(
    ) + const lang = componentData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en" const doc = ( - +
    diff --git a/quartz/components/scripts/checkbox.inline.ts b/quartz/components/scripts/checkbox.inline.ts new file mode 100644 index 000000000..50ab0425a --- /dev/null +++ b/quartz/components/scripts/checkbox.inline.ts @@ -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 + 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 + } + }) +}) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index ec55f96b5..a75f4ff46 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -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 = `

    ${title}

    ${htmlTags}

    ${content}

    ` + itemTile.innerHTML = `

    ${title}

    ${htmlTags}${ + enablePreview && window.innerWidth > 600 ? "" : `

    ${content}

    ` + }` + 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 } diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 23289d2c1..8a9ec6714 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -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); diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts new file mode 100644 index 000000000..43eb4024f --- /dev/null +++ b/quartz/depgraph.test.ts @@ -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() + 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() + 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() + 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() + 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() + 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() + graph.addEdge("A.md", "B.md") + + // Add a new file C.md that transcludes B.md + // B.md -> C.md + const other = new DepGraph() + 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) + }) + }) +}) diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts new file mode 100644 index 000000000..1efad0770 --- /dev/null +++ b/quartz/depgraph.ts @@ -0,0 +1,187 @@ +export default class DepGraph { + // node: incoming and outgoing edges + _graph = new Map; outgoing: Set }>() + + 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, 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 { + let stack: T[] = [node] + let visited = new Set() + let leafNodes = new Set() + + // 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 { + const leafNodes = this.getLeafNodes(node) + let visited = new Set() + let upstreamNodes = new Set() + + // 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 + } +} diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts new file mode 100644 index 000000000..c4fa4251c --- /dev/null +++ b/quartz/i18n/index.ts @@ -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 diff --git a/quartz/i18n/locales/ar-SA.ts b/quartz/i18n/locales/ar-SA.ts new file mode 100644 index 000000000..f7048103f --- /dev/null +++ b/quartz/i18n/locales/ar-SA.ts @@ -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 diff --git a/quartz/i18n/locales/de-DE.ts b/quartz/i18n/locales/de-DE.ts new file mode 100644 index 000000000..e3821944b --- /dev/null +++ b/quartz/i18n/locales/de-DE.ts @@ -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 diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts new file mode 100644 index 000000000..1d5d3dda6 --- /dev/null +++ b/quartz/i18n/locales/definition.ts @@ -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 + } + } +} diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts new file mode 100644 index 000000000..4a308d79a --- /dev/null +++ b/quartz/i18n/locales/en-US.ts @@ -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 diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts new file mode 100644 index 000000000..f59d201a3 --- /dev/null +++ b/quartz/i18n/locales/es-ES.ts @@ -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 diff --git a/quartz/i18n/locales/fr-FR.ts b/quartz/i18n/locales/fr-FR.ts new file mode 100644 index 000000000..8b7229201 --- /dev/null +++ b/quartz/i18n/locales/fr-FR.ts @@ -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 diff --git a/quartz/i18n/locales/ja-JP.ts b/quartz/i18n/locales/ja-JP.ts new file mode 100644 index 000000000..d429db411 --- /dev/null +++ b/quartz/i18n/locales/ja-JP.ts @@ -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 diff --git a/quartz/i18n/locales/nl-NL.ts b/quartz/i18n/locales/nl-NL.ts new file mode 100644 index 000000000..e239be0e1 --- /dev/null +++ b/quartz/i18n/locales/nl-NL.ts @@ -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 diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts new file mode 100644 index 000000000..556b18995 --- /dev/null +++ b/quartz/i18n/locales/ro-RO.ts @@ -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 diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts new file mode 100644 index 000000000..c997a6972 --- /dev/null +++ b/quartz/i18n/locales/uk-UA.ts @@ -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 diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index 58ae59a4b..f9d7a8620 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -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() + }, async emit(ctx, _content, resources): Promise { 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", }), diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index d407629db..fb25a448e 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -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() + }, async emit(ctx, content, _resources): Promise { const { argv } = ctx const fps: FilePath[] = [] diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index cc97b2e3e..036b27da4 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -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() + + 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 { - // 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) diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index 3e17fea2e..cbed2a8b4 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -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() + }, async emit({ argv, cfg }, _content, _resources): Promise { if (!cfg.configuration.baseUrl) { console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 5eb9718a9..67bc69c3d 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -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 = (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() + + 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 { + const promises: Promise[] = [] + 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 = (opts?: Partial< ext: ".js", content: postscript, }), - ]) - return fps + ) + + return await Promise.all(promises) }, } } diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 694311f5e..7e44e2393 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -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 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 => ` https://${joinSegments(base, encodeURI(slug))} - ${content.date?.toISOString()} + ${content.date && `${content.date.toISOString()}`} ` const urls = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) @@ -91,6 +93,26 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { opts = { ...defaultOptions, ...opts } return { name: "ContentIndex", + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph() + + 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[] = [] diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index f8e640473..e531b36e8 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -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> = (userOpts) => { const opts: FullPageLayout = { @@ -27,6 +28,18 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, + async getDependencyGraph(ctx, content, _resources) { + // TODO handle transclusions + const graph = new DepGraph() + + 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 { const cfg = ctx.cfg.configuration const fps: FilePath[] = [] @@ -49,7 +62,7 @@ export const ContentPage: QuartzEmitterPlugin> = (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> = (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.`, diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 04a5a0086..690fa56f7 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -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> = (userOpts) => { const opts: FullPageLayout = { @@ -36,6 +38,13 @@ export const FolderPage: QuartzEmitterPlugin> = (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() + }, async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) @@ -57,13 +66,16 @@ export const FolderPage: QuartzEmitterPlugin> = (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> = (userOpt allFiles, } - const content = renderPage(slug, componentData, opts, externalResources) + const content = renderPage(cfg, slug, componentData, opts, externalResources) const fp = await write({ ctx, content, diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts index ef1d1c35c..523151c2c 100644 --- a/quartz/plugins/emitters/helpers.ts +++ b/quartz/plugins/emitters/helpers.ts @@ -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 => { diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index 9f93d9b0a..c52c62879 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -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() + + 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 { const staticPath = joinSegments(QUARTZ, "static") const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 3e450f6a8..332c758d5 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -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> = (userOpts) => { const opts: FullPageLayout = { @@ -33,6 +35,10 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, + async getDependencyGraph(ctx, _content, _resources) { + // TODO implement + return new DepGraph() + }, async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) @@ -47,7 +53,10 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) const tagDescriptions: Record = 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> = (userOpts) allFiles, } - const content = renderPage(slug, componentData, opts, externalResources) + const content = renderPage(cfg, slug, componentData, opts, externalResources) const fp = await write({ ctx, content, diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index eae359e1e..9371df81e 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -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 | undefined> const opts = { ...defaultOptions, ...userOpts } return { name: "FrontMatter", - markdownPlugins() { + markdownPlugins({ cfg }) { return [ [remarkFrontmatter, ["yaml", "toml"]], () => { @@ -59,7 +60,7 @@ export const FrontMatter: QuartzTransformerPlugin | 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"])) diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts index 7860f851c..48681ff77 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/gfm.ts @@ -32,6 +32,7 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin | { behavior: "append", properties: { + role: "anchor", ariaHidden: true, tabIndex: -1, "data-no-popover": true, diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 31c8c2084..2c7b9ce47 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -43,7 +43,7 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | 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) diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 1ba0c8e58..f89d367d7 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -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 | 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 | 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 diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 44df3fa9e..e110e403f 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -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 = { + "->": "→", + "-->": "⇒", + "=>": "⇒", + "==>": "⇒", + "<-": "←", + "<--": "⇐", + "<=": "⇐", + "<==": "⇐", +} + 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 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 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: ``, + value: `${maybeArrow}`, } }, ]) @@ -390,7 +412,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin 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 // 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 }) } + 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 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", diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts index d0781ec20..4c31d2062 100644 --- a/quartz/plugins/transformers/toc.ts +++ b/quartz/plugins/transformers/toc.ts @@ -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 | undefined> = ( userOpts, ) => { @@ -38,21 +37,12 @@ export const TableOfContents: QuartzTransformerPlugin | 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, diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index c65a81fc4..11ed88e8d 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -41,4 +41,9 @@ export type QuartzEmitterPluginInstance = { name: string emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise getQuartzComponents(ctx: BuildCtx): QuartzComponent[] + getDependencyGraph?( + ctx: BuildCtx, + content: ProcessedContent[], + resources: StaticResources, + ): Promise> } diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index a4fde0639..f0e7c14d1 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -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; } } diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss index d4f7069a0..7fa52c506 100644 --- a/quartz/styles/callouts.scss +++ b/quartz/styles/callouts.scss @@ -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 { diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index 13e0bf864..e05611418 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -6,6 +6,7 @@ export interface Argv { verbose: boolean output: string serve: boolean + fastRebuild: boolean port: number wsPort: number remoteDevHost?: string diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts index 31e8c02c1..6fb046996 100644 --- a/quartz/util/lang.ts +++ b/quartz/util/lang.ts @@ -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) } diff --git a/quartz/util/path.test.ts b/quartz/util/path.test.ts index 6a8aa3d1d..7e9c4c84a 100644 --- a/quartz/util/path.test.ts +++ b/quartz/util/path.test.ts @@ -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, diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 07f25a59d..dceb89bfa 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -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) } diff --git a/quartz/util/theme.ts b/quartz/util/theme.ts index 47951c4f4..bd0da5fb0 100644 --- a/quartz/util/theme.ts +++ b/quartz/util/theme.ts @@ -15,6 +15,7 @@ export interface Theme { body: string code: string } + cdnCaching: boolean colors: { lightMode: ColorScheme darkMode: ColorScheme