diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc108f27f..42adb4474 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,4 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "daily" + interval: "weekly" diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md index ad3ada3c2..22e2fd0d1 100644 --- a/docs/advanced/creating components.md +++ b/docs/advanced/creating components.md @@ -161,7 +161,8 @@ document.addEventListener("nav", () => { }) ``` -It is best practice to also unmount any existing event handlers to prevent memory leaks. +It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. +This will get called on page navigation. #### Importing Code diff --git a/docs/features/callouts.md b/docs/features/callouts.md index 27de687eb..d73979284 100644 --- a/docs/features/callouts.md +++ b/docs/features/callouts.md @@ -24,14 +24,32 @@ See [documentation on supported types and syntax here](https://help.obsidian.md ## Customization - Disable callouts: simply pass `callouts: false` to the plugin: `Plugin.ObsidianFlavoredMarkdown({ callouts: false })` -- Editing icons: `quartz/plugins/transformers/ofm.ts` +- Editing icons: `quartz/styles/callouts.scss` + +### Add custom callouts + +By default, custom callouts are handled by applying the `note` style. To make fancy ones, you have to add these lines to `custom.scss`. + +```scss title="quartz/styles/custom.scss" +.callout { + &[data-callout="custom"] { + --color: #customcolor; + --border: #custombordercolor; + --bg: #custombg; + --callout-icon: url("data:image/svg+xml; utf8, "); //SVG icon code + } +} +``` + +> [!warning] +> Don't forget to ensure that the SVG is URL encoded before putting it in the CSS. You can use tools like [this one](https://yoksel.github.io/url-encoder/) to help you do that. ## Showcase > [!info] > Default title -> [!question]+ Can callouts be nested? +> [!question]+ Can callouts be _nested_? > > > [!todo]- Yes!, they can. And collapsed! > > diff --git a/docs/features/recent notes.md b/docs/features/recent notes.md index 439d6d050..9236b7ce2 100644 --- a/docs/features/recent notes.md +++ b/docs/features/recent notes.md @@ -3,7 +3,7 @@ title: Recent Notes tags: component --- -Quartz can generate a list of recent notes for based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes`. +Quartz can generate a list of recent notes based on some filtering and sorting criteria. Though this component isn't included in any [[layout]] by default, you can add it by using `Component.RecentNotes` in `quartz.layout.ts`. ## Customization diff --git a/globals.d.ts b/globals.d.ts index 0509f2665..ee13005c9 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -8,5 +8,6 @@ export declare global { } interface Window { spaNavigate(url: URL, isBack: boolean = false) + addCleanup(fn: (...args: any[]) => void) } } diff --git a/package-lock.json b/package-lock.json index 22dc37f1f..66a772cde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@jackyzha0/quartz", - "version": "4.1.5", + "version": "4.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.1.5", + "version": "4.2.1", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", - "@floating-ui/dom": "^1.5.3", + "@floating-ui/dom": "^1.6.1", "@napi-rs/simple-git": "0.1.14", - "async-mutex": "^0.4.0", + "async-mutex": "^0.4.1", "chalk": "^5.3.0", "chokidar": "^3.5.3", "cli-spinner": "^0.2.10", @@ -27,9 +27,9 @@ "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", - "lightningcss": "^1.22.1", + "lightningcss": "^1.23.0", "mdast-util-find-and-replace": "^3.0.1", - "mdast-util-to-hast": "^13.0.2", + "mdast-util-to-hast": "^13.1.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "preact": "^10.19.3", @@ -71,15 +71,15 @@ "devDependencies": { "@types/cli-spinner": "^0.2.3", "@types/d3": "^7.4.3", - "@types/hast": "^3.0.3", + "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^20.1.2", + "@types/node": "^20.11.14", "@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.1.1", + "prettier": "^3.2.4", "tsx": "^4.7.0", "typescript": "^5.3.3" }, @@ -478,26 +478,26 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", + "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "dependencies": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -1043,9 +1043,9 @@ "dev": true }, "node_modules/@types/hast": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.3.tgz", - "integrity": "sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "dependencies": { "@types/unist": "*" } @@ -1088,10 +1088,13 @@ } }, "node_modules/@types/node": { - "version": "20.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", - "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==", - "dev": true + "version": "20.11.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.14.tgz", + "integrity": "sha512-w3yWCcwULefjP9DmDDsgUskrMoOy5Z8MiwKHr1FvqGPtx7CvJzQvxD7eKpxNtklQxLruxSXWddyeRtyud0RcXQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/pretty-time": { "version": "1.1.5", @@ -1205,9 +1208,9 @@ } }, "node_modules/async-mutex": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", "dependencies": { "tslib": "^2.4.0" } @@ -3027,9 +3030,9 @@ } }, "node_modules/lightningcss": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.22.1.tgz", - "integrity": "sha512-Fy45PhibiNXkm0cK5FJCbfO8Y6jUpD/YcHf/BtuI+jvYYqSXKF4muk61jjE8YxCR9y+hDYIWSzHTc+bwhDE6rQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.23.0.tgz", + "integrity": "sha512-SEArWKMHhqn/0QzOtclIwH5pXIYQOUEkF8DgICd/105O+GCgd7jxjNod/QPnBCSWvpRHQBGVz5fQ9uScby03zA==", "dependencies": { "detect-libc": "^1.0.3" }, @@ -3041,21 +3044,21 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.22.1", - "lightningcss-darwin-x64": "1.22.1", - "lightningcss-freebsd-x64": "1.22.1", - "lightningcss-linux-arm-gnueabihf": "1.22.1", - "lightningcss-linux-arm64-gnu": "1.22.1", - "lightningcss-linux-arm64-musl": "1.22.1", - "lightningcss-linux-x64-gnu": "1.22.1", - "lightningcss-linux-x64-musl": "1.22.1", - "lightningcss-win32-x64-msvc": "1.22.1" + "lightningcss-darwin-arm64": "1.23.0", + "lightningcss-darwin-x64": "1.23.0", + "lightningcss-freebsd-x64": "1.23.0", + "lightningcss-linux-arm-gnueabihf": "1.23.0", + "lightningcss-linux-arm64-gnu": "1.23.0", + "lightningcss-linux-arm64-musl": "1.23.0", + "lightningcss-linux-x64-gnu": "1.23.0", + "lightningcss-linux-x64-musl": "1.23.0", + "lightningcss-win32-x64-msvc": "1.23.0" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.22.1.tgz", - "integrity": "sha512-ldvElu+R0QimNTjsKpaZkUv3zf+uefzLy/R1R19jtgOfSRM+zjUCUgDhfEDRmVqJtMwYsdhMI2aJtJChPC6Osg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.23.0.tgz", + "integrity": "sha512-kl4Pk3Q2lnE6AJ7Qaij47KNEfY2/UXRZBT/zqGA24B8qwkgllr/j7rclKOf1axcslNXvvUdztjo4Xqh39Yq1aA==", "cpu": [ "arm64" ], @@ -3072,9 +3075,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.22.1.tgz", - "integrity": "sha512-5p2rnlVTv6Gpw4PlTLq925nTVh+HFh4MpegX8dPDYJae+NFVjQ67gY7O6iHIzQjLipDiYejFF0yHrhjU3XgLBQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.23.0.tgz", + "integrity": "sha512-KeRFCNoYfDdcolcFXvokVw+PXCapd2yHS1Diko1z1BhRz/nQuD5XyZmxjWdhmhN/zj5sH8YvWsp0/lPLVzqKpg==", "cpu": [ "x64" ], @@ -3091,9 +3094,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.22.1.tgz", - "integrity": "sha512-1FaBtcFrZqB2hkFbAxY//Pnp8koThvyB6AhjbdVqKD4/pu13Rl91fKt2N9qyeQPUt3xy7ORUvSO+dPk3J6EjXg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.23.0.tgz", + "integrity": "sha512-xhnhf0bWPuZxcqknvMDRFFo2TInrmQRWZGB0f6YoAsZX8Y+epfjHeeOIGCfAmgF0DgZxHwYc8mIR5tQU9/+ROA==", "cpu": [ "x64" ], @@ -3110,9 +3113,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.22.1.tgz", - "integrity": "sha512-6rub98tYGfE5I5j0BP8t/2d4BZyu1S7Iz9vUkm0H26snAFHYxLfj3RbQn0xHHIePSetjLnhcg3QlfwUAkD/FYg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.23.0.tgz", + "integrity": "sha512-fBamf/bULvmWft9uuX+bZske236pUZEoUlaHNBjnueaCTJ/xd8eXgb0cEc7S5o0Nn6kxlauMBnqJpF70Bgq3zg==", "cpu": [ "arm" ], @@ -3129,9 +3132,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.22.1.tgz", - "integrity": "sha512-nYO5qGtb/1kkTZu3FeTiM+2B2TAb7m2DkLCTgQIs2bk2o9aEs7I96fwySKcoHWQAiQDGR9sMux9vkV4KQXqPaQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.23.0.tgz", + "integrity": "sha512-RS7sY77yVLOmZD6xW2uEHByYHhQi5JYWmgVumYY85BfNoVI3DupXSlzbw+b45A9NnVKq45+oXkiN6ouMMtTwfg==", "cpu": [ "arm64" ], @@ -3148,9 +3151,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.22.1.tgz", - "integrity": "sha512-MCV6RuRpzXbunvzwY644iz8cw4oQxvW7oer9xPkdadYqlEyiJJ6wl7FyJOH7Q6ZYH4yjGAUCvxDBxPbnDu9ZVg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.23.0.tgz", + "integrity": "sha512-cU00LGb6GUXCwof6ACgSMKo3q7XYbsyTj0WsKHLi1nw7pV0NCq8nFTn6ZRBYLoKiV8t+jWl0Hv8KkgymmK5L5g==", "cpu": [ "arm64" ], @@ -3167,9 +3170,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.22.1.tgz", - "integrity": "sha512-RjNgpdM20VUXgV7us/VmlO3Vn2ZRiDnc3/bUxCVvySZWPiVPprpqW/QDWuzkGa+NCUf6saAM5CLsZLSxncXJwg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.23.0.tgz", + "integrity": "sha512-q4jdx5+5NfB0/qMbXbOmuC6oo7caPnFghJbIAV90cXZqgV8Am3miZhC4p+sQVdacqxfd+3nrle4C8icR3p1AYw==", "cpu": [ "x64" ], @@ -3186,9 +3189,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.22.1.tgz", - "integrity": "sha512-ZgO4C7Rd6Hv/5MnyY2KxOYmIlzk4rplVolDt3NbkNR8DndnyX0Q5IR4acJWNTBICQ21j3zySzKbcJaiJpk/4YA==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.23.0.tgz", + "integrity": "sha512-G9Ri3qpmF4qef2CV/80dADHKXRAQeQXpQTLx7AiQrBYQHqBjB75oxqj06FCIe5g4hNCqLPnM9fsO4CyiT1sFSQ==", "cpu": [ "x64" ], @@ -3205,9 +3208,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.22.1.tgz", - "integrity": "sha512-4pozV4eyD0MDET41ZLHAeBo+H04Nm2UEYIk5w/ts40231dRFV7E0cjwbnZvSoc1DXFgecAhiC0L16ruv/ZDCpg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.23.0.tgz", + "integrity": "sha512-1rcBDJLU+obPPJM6qR5fgBUiCdZwZLafZM5f9kwjFLkb/UBNIzmae39uCSmh71nzPCTXZqHbvwu23OWnWEz+eg==", "cpu": [ "x64" ], @@ -3607,9 +3610,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz", - "integrity": "sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz", + "integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -3618,7 +3621,8 @@ "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -4470,9 +4474,9 @@ } }, "node_modules/prettier": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", - "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", + "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -5674,6 +5678,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unherit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/unherit/-/unherit-3.0.1.tgz", diff --git a/package.json b/package.json index d2c36a6ed..4ed606ec2 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.1.5", + "version": "4.2.1", "type": "module", "author": "jackyzha0 ", "license": "MIT", @@ -39,9 +39,9 @@ }, "dependencies": { "@clack/prompts": "^0.7.0", - "@floating-ui/dom": "^1.5.3", + "@floating-ui/dom": "^1.6.1", "@napi-rs/simple-git": "0.1.14", - "async-mutex": "^0.4.0", + "async-mutex": "^0.4.1", "chalk": "^5.3.0", "chokidar": "^3.5.3", "cli-spinner": "^0.2.10", @@ -56,9 +56,9 @@ "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", - "lightningcss": "^1.22.1", + "lightningcss": "^1.23.0", "mdast-util-find-and-replace": "^3.0.1", - "mdast-util-to-hast": "^13.0.2", + "mdast-util-to-hast": "^13.1.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "preact": "^10.19.3", @@ -97,15 +97,15 @@ "devDependencies": { "@types/cli-spinner": "^0.2.3", "@types/d3": "^7.4.3", - "@types/hast": "^3.0.3", + "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^20.1.2", + "@types/node": "^20.11.14", "@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.1.1", + "prettier": "^3.2.4", "tsx": "^4.7.0", "typescript": "^5.3.3" } diff --git a/quartz/build.ts b/quartz/build.ts index b78ff2bc6..1f90301e9 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -126,17 +126,8 @@ async function rebuildFromEntrypoint( clientRefresh: () => void, buildData: BuildData, // note: this function mutates buildData ) { - const { - ctx, - ignored, - mut, - initialSlugs, - contentMap, - toRebuild, - toRemove, - trackedAssets, - lastBuildMs, - } = buildData + const { ctx, ignored, mut, initialSlugs, contentMap, toRebuild, toRemove, trackedAssets } = + buildData const { argv } = ctx @@ -164,12 +155,12 @@ async function rebuildFromEntrypoint( toRemove.add(filePath) } - // debounce rebuilds every 250ms - const buildStart = new Date().getTime() buildData.lastBuildMs = buildStart const release = await mut.acquire() - if (lastBuildMs > buildStart) { + + // there's another build after us, release and let them do it + if (buildData.lastBuildMs > buildStart) { release() return } diff --git a/quartz/components/Search.tsx b/quartz/components/Search.tsx index 1ac1a4e42..1b386adc7 100644 --- a/quartz/components/Search.tsx +++ b/quartz/components/Search.tsx @@ -4,8 +4,18 @@ import style from "./styles/search.scss" import script from "./scripts/search.inline" import { classNames } from "../util/lang" -export default (() => { +export interface SearchOptions { + enablePreview: boolean +} + +const defaultOptions: SearchOptions = { + enablePreview: true, +} + +export default ((userOpts?: Partial) => { function Search({ displayClass }: QuartzComponentProps) { + const opts = { ...defaultOptions, ...userOpts } + return (
@@ -36,7 +46,7 @@ export default (() => { aria-label="Search for something" placeholder="Search for something" /> -
+
diff --git a/quartz/components/scripts/callout.inline.ts b/quartz/components/scripts/callout.inline.ts index d8cf5180a..8f63df36f 100644 --- a/quartz/components/scripts/callout.inline.ts +++ b/quartz/components/scripts/callout.inline.ts @@ -1,21 +1,21 @@ function toggleCallout(this: HTMLElement) { const outerBlock = this.parentElement! - outerBlock.classList.toggle(`is-collapsed`) - const collapsed = outerBlock.classList.contains(`is-collapsed`) + outerBlock.classList.toggle("is-collapsed") + const collapsed = outerBlock.classList.contains("is-collapsed") const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight - outerBlock.style.maxHeight = height + `px` + outerBlock.style.maxHeight = height + "px" // walk and adjust height of all parents let current = outerBlock let parent = outerBlock.parentElement while (parent) { - if (!parent.classList.contains(`callout`)) { + if (!parent.classList.contains("callout")) { return } - const collapsed = parent.classList.contains(`is-collapsed`) + const collapsed = parent.classList.contains("is-collapsed") const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight - parent.style.maxHeight = height + `px` + parent.style.maxHeight = height + "px" current = parent parent = parent.parentElement @@ -30,15 +30,15 @@ function setupCallout() { const title = div.firstElementChild if (title) { - title.removeEventListener(`click`, toggleCallout) - title.addEventListener(`click`, toggleCallout) + title.addEventListener("click", toggleCallout) + window.addCleanup(() => title.removeEventListener("click", toggleCallout)) - const collapsed = div.classList.contains(`is-collapsed`) + const collapsed = div.classList.contains("is-collapsed") const height = collapsed ? title.scrollHeight : div.scrollHeight - div.style.maxHeight = height + `px` + div.style.maxHeight = height + "px" } } } -document.addEventListener(`nav`, setupCallout) -window.addEventListener(`resize`, setupCallout) +document.addEventListener("nav", setupCallout) +window.addEventListener("resize", setupCallout) diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index c604c9bc5..87182a154 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -14,7 +14,7 @@ document.addEventListener("nav", () => { button.type = "button" button.innerHTML = svgCopy button.ariaLabel = "Copy source" - button.addEventListener("click", () => { + function onClick() { navigator.clipboard.writeText(source).then( () => { button.blur() @@ -26,7 +26,9 @@ document.addEventListener("nav", () => { }, (error) => console.error(error), ) - }) + } + button.addEventListener("click", onClick) + window.addCleanup(() => button.removeEventListener("click", onClick)) els[i].prepend(button) } } diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts index 86735e396..48e0aa1f5 100644 --- a/quartz/components/scripts/darkmode.inline.ts +++ b/quartz/components/scripts/darkmode.inline.ts @@ -10,28 +10,31 @@ const emitThemeChangeEvent = (theme: "light" | "dark") => { } document.addEventListener("nav", () => { - const switchTheme = (e: any) => { - const newTheme = e.target.checked ? "dark" : "light" + const switchTheme = (e: Event) => { + const newTheme = (e.target as HTMLInputElement)?.checked ? "dark" : "light" document.documentElement.setAttribute("saved-theme", newTheme) localStorage.setItem("theme", newTheme) emitThemeChangeEvent(newTheme) } + const themeChange = (e: MediaQueryListEvent) => { + const newTheme = e.matches ? "dark" : "light" + document.documentElement.setAttribute("saved-theme", newTheme) + localStorage.setItem("theme", newTheme) + toggleSwitch.checked = e.matches + emitThemeChangeEvent(newTheme) + } + // Darkmode toggle const toggleSwitch = document.querySelector("#darkmode-toggle") as HTMLInputElement - toggleSwitch.removeEventListener("change", switchTheme) toggleSwitch.addEventListener("change", switchTheme) + window.addCleanup(() => toggleSwitch.removeEventListener("change", switchTheme)) if (currentTheme === "dark") { toggleSwitch.checked = true } // Listen for changes in prefers-color-scheme const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - colorSchemeMediaQuery.addEventListener("change", (e) => { - const newTheme = e.matches ? "dark" : "light" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - toggleSwitch.checked = e.matches - emitThemeChangeEvent(newTheme) - }) + colorSchemeMediaQuery.addEventListener("change", themeChange) + window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)) }) diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 12546bbb0..3eb25ead4 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -57,20 +57,20 @@ function setupExplorer() { for (const item of document.getElementsByClassName( "folder-button", ) as HTMLCollectionOf) { - item.removeEventListener("click", toggleFolder) item.addEventListener("click", toggleFolder) + window.addCleanup(() => item.removeEventListener("click", toggleFolder)) } } - explorer.removeEventListener("click", toggleExplorer) explorer.addEventListener("click", toggleExplorer) + window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) // Set up click handlers for each folder (click handler on folder "icon") for (const item of document.getElementsByClassName( "folder-icon", ) as HTMLCollectionOf) { - item.removeEventListener("click", toggleFolder) item.addEventListener("click", toggleFolder) + window.addCleanup(() => item.removeEventListener("click", toggleFolder)) } // Get folder state from local storage diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index a76409c13..c991e163e 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -325,6 +325,6 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { await renderGraph("graph-container", slug) const containerIcon = document.getElementById("global-graph-icon") - containerIcon?.removeEventListener("click", renderGlobalGraph) containerIcon?.addEventListener("click", renderGlobalGraph) + window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) }) diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts index 4d51e2a6f..0251834cb 100644 --- a/quartz/components/scripts/popover.inline.ts +++ b/quartz/components/scripts/popover.inline.ts @@ -76,7 +76,7 @@ async function mouseEnterHandler( document.addEventListener("nav", () => { const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[] for (const link of links) { - link.removeEventListener("mouseenter", mouseEnterHandler) link.addEventListener("mouseenter", mouseEnterHandler) + window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler)) } }) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 941d35bb3..ec55f96b5 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,7 +1,7 @@ import FlexSearch from "flexsearch" import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" -import { FullSlug, resolveRelative } from "../../util/path" +import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path" interface Item { id: number @@ -11,23 +11,53 @@ interface Item { tags: string[] } -let index: FlexSearch.Document | undefined = undefined - // Can be expanded with things like "term" in the future type SearchType = "basic" | "tags" - -// Current searchType let searchType: SearchType = "basic" +let currentSearchTerm: string = "" +const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) +let index = new FlexSearch.Document({ + charset: "latin:extra", + encode: encoder, + document: { + id: "id", + index: [ + { + field: "title", + tokenize: "forward", + }, + { + field: "content", + tokenize: "forward", + }, + { + field: "tags", + tokenize: "forward", + }, + ], + }, +}) +const p = new DOMParser() +const fetchContentCache: Map = new Map() const contextWindowWords = 30 -const numSearchResults = 5 -const numTagResults = 3 +const numSearchResults = 8 +const numTagResults = 5 + +const tokenizeTerm = (term: string) => { + const tokens = term.split(/\s+/).filter((t) => t.trim() !== "") + const tokenLen = tokens.length + if (tokenLen > 1) { + for (let i = 1; i < tokenLen; i++) { + tokens.push(tokens.slice(0, i + 1).join(" ")) + } + } + + return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first +} + function highlight(searchTerm: string, text: string, trim?: boolean) { - // try to highlight longest tokens first - const tokenizedTerms = searchTerm - .split(/\s+/) - .filter((t) => t !== "") - .sort((a, b) => b.length - a.length) + const tokenizedTerms = tokenizeTerm(searchTerm) let tokenizedText = text.split(/\s+/).filter((t) => t !== "") let startIndex = 0 @@ -71,20 +101,78 @@ function highlight(searchTerm: string, text: string, trim?: boolean) { }` } -const encoder = (str: string) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])/) -let prevShortcutHandler: ((e: HTMLElementEventMap["keydown"]) => void) | undefined = undefined -document.addEventListener("nav", async (e: unknown) => { - const currentSlug = (e as CustomEventMap["nav"]).detail.url +function highlightHTML(searchTerm: string, el: HTMLElement) { + const p = new DOMParser() + const tokenizedTerms = tokenizeTerm(searchTerm) + const html = p.parseFromString(el.innerHTML, "text/html") + const createHighlightSpan = (text: string) => { + const span = document.createElement("span") + span.className = "highlight" + span.textContent = text + return span + } + + const highlightTextNodes = (node: Node, term: string) => { + if (node.nodeType === Node.TEXT_NODE) { + const nodeText = node.nodeValue ?? "" + const regex = new RegExp(term.toLowerCase(), "gi") + const matches = nodeText.match(regex) + if (!matches || matches.length === 0) return + const spanContainer = document.createElement("span") + let lastIndex = 0 + for (const match of matches) { + const matchIndex = nodeText.indexOf(match, lastIndex) + spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex))) + spanContainer.appendChild(createHighlightSpan(match)) + lastIndex = matchIndex + match.length + } + spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex))) + node.parentNode?.replaceChild(spanContainer, node) + } else if (node.nodeType === Node.ELEMENT_NODE) { + if ((node as HTMLElement).classList.contains("highlight")) return + Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term)) + } + } + + for (const term of tokenizedTerms) { + highlightTextNodes(html.body, term) + } + + return html.body +} + +document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { + const currentSlug = e.detail.url const data = await fetchData const container = document.getElementById("search-container") const sidebar = container?.closest(".sidebar") as HTMLElement const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null - const results = document.getElementById("results-container") - const resultCards = document.getElementsByClassName("result-card") + const searchLayout = document.getElementById("search-layout") const idDataMap = Object.keys(data) as FullSlug[] + const appendLayout = (el: HTMLElement) => { + if (searchLayout?.querySelector(`#${el.id}`) === null) { + searchLayout?.appendChild(el) + } + } + + const enablePreview = searchLayout?.dataset?.preview === "true" + let preview: HTMLDivElement | undefined = undefined + let previewInner: HTMLDivElement | undefined = undefined + const results = document.createElement("div") + results.id = "results-container" + results.style.flexBasis = enablePreview ? "min(30%, 450px)" : "100%" + appendLayout(results) + + if (enablePreview) { + preview = document.createElement("div") + preview.id = "preview-container" + preview.style.flexBasis = "100%" + appendLayout(preview) + } + function hideSearch() { container?.classList.remove("active") if (searchBar) { @@ -96,6 +184,12 @@ document.addEventListener("nav", async (e: unknown) => { if (results) { removeAllChildren(results) } + if (preview) { + removeAllChildren(preview) + } + if (searchLayout) { + searchLayout.classList.remove("display-results") + } searchType = "basic" // reset search type after closing } @@ -109,11 +203,14 @@ document.addEventListener("nav", async (e: unknown) => { searchBar?.focus() } - function shortcutHandler(e: HTMLElementEventMap["keydown"]) { + let currentHover: HTMLInputElement | null = null + + async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault() const searchBarOpen = container?.classList.contains("active") searchBarOpen ? hideSearch() : showSearch("basic") + return } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { // Hotkey to open tag search e.preventDefault() @@ -122,159 +219,227 @@ document.addEventListener("nav", async (e: unknown) => { // add "#" prefix for tag search if (searchBar) searchBar.value = "#" + return } + 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 result has focus, navigate to that one, otherwise pick first result if (results?.contains(document.activeElement)) { const active = document.activeElement as HTMLInputElement + if (active.classList.contains("no-match")) return + await displayPreview(active) active.click() } else { const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null - anchor?.click() + if (!anchor || anchor?.classList.contains("no-match")) return + await displayPreview(anchor) + anchor.click() } } else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) { e.preventDefault() if (results?.contains(document.activeElement)) { // If an element in results-container already has focus, focus previous one - const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null + const currentResult = currentHover + ? currentHover + : (document.activeElement as HTMLInputElement | null) + const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null + currentResult?.classList.remove("focus") prevResult?.focus() + currentHover = prevResult + await displayPreview(prevResult) } } else if (e.key === "ArrowDown" || e.key === "Tab") { e.preventDefault() - // When first pressing ArrowDown, results wont contain the active element, so focus first element - if (!results?.contains(document.activeElement)) { - const firstResult = resultCards[0] as HTMLInputElement | null - firstResult?.focus() + // The results should already been focused, so we need to find the next one. + // The activeElement is the search bar, so we need to find the first result and focus it. + if (document.activeElement === searchBar || currentHover !== null) { + const firstResult = currentHover + ? currentHover + : (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null) + const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null + firstResult?.classList.remove("focus") + secondResult?.focus() + currentHover = secondResult + await displayPreview(secondResult) } else { // If an element in results-container already has focus, focus next one - const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null + 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) } } } - function trimContent(content: string) { - // works without escaping html like in `description.ts` - const sentences = content.replace(/\s+/g, " ").split(".") - let finalDesc = "" - let sentenceIdx = 0 - - // Roughly estimate characters by (words * 5). Matches description length in `description.ts`. - const len = contextWindowWords * 5 - while (finalDesc.length < len) { - const sentence = sentences[sentenceIdx] - if (!sentence) break - finalDesc += sentence + "." - sentenceIdx++ - } - - // If more content would be available, indicate it by finishing with "..." - if (finalDesc.length < content.length) { - finalDesc += ".." - } - - return finalDesc - } - const formatForDisplay = (term: string, id: number) => { const slug = idDataMap[id] return { id, slug, title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), - // if searchType is tag, display context from start of file and trim, otherwise use regular highlight - content: - searchType === "tags" - ? trimContent(data[slug].content) - : highlight(term, data[slug].content ?? "", true), - tags: highlightTags(term, data[slug].tags), + content: highlight(term, data[slug].content ?? "", true), + tags: highlightTags(term.substring(1), data[slug].tags), } } function highlightTags(term: string, tags: string[]) { - if (tags && searchType === "tags") { - // Find matching tags - const termLower = term.toLowerCase() - let matching = tags.filter((str) => str.includes(termLower)) - - // Subtract matching from original tags, then push difference - if (matching.length > 0) { - let difference = tags.filter((x) => !matching.includes(x)) - - // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`) - matching = matching.map((tag) => `
  • #${tag}

  • `) - difference = difference.map((tag) => `
  • #${tag}

  • `) - matching.push(...difference) - } - - // Only allow max of `numTagResults` in preview - if (tags.length > numTagResults) { - matching.splice(numTagResults) - } - - return matching - } else { + if (!tags || searchType !== "tags") { return [] } + + return tags + .map((tag) => { + if (tag.toLowerCase().includes(term.toLowerCase())) { + return `
  • #${tag}

  • ` + } else { + return `
  • #${tag}

  • ` + } + }) + .slice(0, numTagResults) + } + + function resolveUrl(slug: FullSlug): URL { + return new URL(resolveRelative(currentSlug, slug), location.toString()) } const resultToHTML = ({ slug, title, content, tags }: Item) => { - const htmlTags = tags.length > 0 ? `
      ${tags.join("")}
    ` : `` + const htmlTags = tags.length > 0 ? `
      ${tags.join("")}
    ` : `` const itemTile = document.createElement("a") itemTile.classList.add("result-card") itemTile.id = slug - itemTile.href = new URL(resolveRelative(currentSlug, slug), location.toString()).toString() - itemTile.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` - itemTile.addEventListener("click", (event) => { - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - hideSearch() + itemTile.href = resolveUrl(slug).toString() + itemTile.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` + + 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)) }) + return itemTile } - function displayResults(finalResults: Item[]) { + async function displayResults(finalResults: Item[]) { if (!results) return removeAllChildren(results) if (finalResults.length === 0) { - results.innerHTML = ` -

    No results.

    -

    Try another search term?

    -
    ` + results.innerHTML = ` +

    No results.

    +

    Try another search term?

    +
    ` } else { results.append(...finalResults.map(resultToHTML)) } + + if (finalResults.length === 0 && preview) { + // no results, clear previous preview + removeAllChildren(preview) + } else { + // focus on first result, then also dispatch preview immediately + const firstChild = results.firstElementChild as HTMLElement + firstChild.classList.add("focus") + currentHover = firstChild as HTMLInputElement + await displayPreview(firstChild) + } + } + + async function fetchContent(slug: FullSlug): Promise { + if (fetchContentCache.has(slug)) { + return fetchContentCache.get(slug) as Element[] + } + + const targetUrl = resolveUrl(slug).toString() + const contents = await fetch(targetUrl) + .then((res) => res.text()) + .then((contents) => { + if (contents === undefined) { + throw new Error(`Could not fetch ${targetUrl}`) + } + const html = p.parseFromString(contents ?? "", "text/html") + normalizeRelativeURLs(html, targetUrl) + return [...html.getElementsByClassName("popover-hint")] + }) + + fetchContentCache.set(slug, contents) + return contents + } + + async function displayPreview(el: HTMLElement | null) { + if (!searchLayout || !enablePreview || !el || !preview) return + const slug = el.id as FullSlug + const innerDiv = await fetchContent(slug).then((contents) => + contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]), + ) + previewInner = document.createElement("div") + previewInner.classList.add("preview-inner") + previewInner.append(...innerDiv) + preview.replaceChildren(previewInner) + + // scroll to longest + const highlights = [...preview.querySelectorAll(".highlight")].sort( + (a, b) => b.innerHTML.length - a.innerHTML.length, + ) + highlights[0]?.scrollIntoView({ block: "start" }) } async function onType(e: HTMLElementEventMap["input"]) { - let term = (e.target as HTMLInputElement).value + if (!searchLayout || !index) return + currentSearchTerm = (e.target as HTMLInputElement).value + searchLayout.classList.toggle("display-results", currentSearchTerm !== "") + searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic" + let searchResults: FlexSearch.SimpleDocumentSearchResultSetUnit[] - - if (term.toLowerCase().startsWith("#")) { - searchType = "tags" - } else { - searchType = "basic" - } - - switch (searchType) { - case "tags": { - term = term.substring(1) - searchResults = - (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ?? - [] - break - } - case "basic": - default: { - searchResults = - (await index?.searchAsync({ - query: term, - limit: numSearchResults, - index: ["title", "content"], - })) ?? [] - } + if (searchType === "tags") { + searchResults = await index.searchAsync({ + query: currentSearchTerm.substring(1), + limit: numSearchResults, + index: ["tags"], + }) + } else if (searchType === "basic") { + searchResults = await index.searchAsync({ + query: currentSearchTerm, + limit: numSearchResults, + index: ["title", "content"], + }) } const getByField = (field: string): number[] => { @@ -288,50 +453,19 @@ document.addEventListener("nav", async (e: unknown) => { ...getByField("content"), ...getByField("tags"), ]) - const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) - displayResults(finalResults) - } - - if (prevShortcutHandler) { - document.removeEventListener("keydown", prevShortcutHandler) + const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id)) + await displayResults(finalResults) } document.addEventListener("keydown", shortcutHandler) - prevShortcutHandler = shortcutHandler - searchIcon?.removeEventListener("click", () => showSearch("basic")) + window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) searchIcon?.addEventListener("click", () => showSearch("basic")) - searchBar?.removeEventListener("input", onType) + window.addCleanup(() => searchIcon?.removeEventListener("click", () => showSearch("basic"))) searchBar?.addEventListener("input", onType) + window.addCleanup(() => searchBar?.removeEventListener("input", onType)) - // setup index if it hasn't been already - if (!index) { - index = new FlexSearch.Document({ - charset: "latin:extra", - encode: encoder, - document: { - id: "id", - index: [ - { - field: "title", - tokenize: "forward", - }, - { - field: "content", - tokenize: "forward", - }, - { - field: "tags", - tokenize: "forward", - }, - ], - }, - }) - - fillDocument(index, data) - } - - // register handlers registerEscapeHandler(container, hideSearch) + await fillDocument(data) }) /** @@ -339,16 +473,20 @@ document.addEventListener("nav", async (e: unknown) => { * @param index index to fill * @param data data to fill index with */ -async function fillDocument(index: FlexSearch.Document, data: any) { +async function fillDocument(data: { [key: FullSlug]: ContentDetails }) { let id = 0 + const promises: Array> = [] for (const [slug, fileData] of Object.entries(data)) { - await index.addAsync(id, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - tags: fileData.tags, - }) - id++ + promises.push( + index.addAsync(id++, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }), + ) } + + return await Promise.all(promises) } diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index c2a44c9a8..1790bcabc 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -39,6 +39,9 @@ function notifyNav(url: FullSlug) { document.dispatchEvent(event) } +const cleanupFns: Set<(...args: any[]) => void> = new Set() +window.addCleanup = (fn) => cleanupFns.add(fn) + let p: DOMParser async function navigate(url: URL, isBack: boolean = false) { p = p || new DOMParser() @@ -57,6 +60,10 @@ async function navigate(url: URL, isBack: boolean = false) { if (!contents) return + // cleanup old + cleanupFns.forEach((fn) => fn()) + cleanupFns.clear() + const html = p.parseFromString(contents, "text/html") normalizeRelativeURLs(html, url) diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index 2e1e52b0e..546859ed3 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -29,8 +29,8 @@ function setupToc() { const content = toc.nextElementSibling as HTMLElement | undefined if (!content) return content.style.maxHeight = collapsed ? "0px" : content.scrollHeight + "px" - toc.removeEventListener("click", toggleToc) toc.addEventListener("click", toggleToc) + window.addCleanup(() => toc.removeEventListener("click", toggleToc)) } } diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index 5fcabadc1..4ffff29e2 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -12,10 +12,10 @@ export function registerEscapeHandler(outsideContainer: HTMLElement | null, cb: cb() } - outsideContainer?.removeEventListener("click", click) outsideContainer?.addEventListener("click", click) - document.removeEventListener("keydown", esc) + window.addCleanup(() => outsideContainer?.removeEventListener("click", click)) document.addEventListener("keydown", esc) + window.addCleanup(() => document.removeEventListener("keydown", esc)) } export function removeAllChildren(node: HTMLElement) { diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index 304fd4500..34f180cf2 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -1,3 +1,5 @@ +@use "../../styles/variables.scss" as *; + button#explorer { all: unset; background-color: transparent; @@ -85,7 +87,7 @@ svg { color: var(--secondary); font-family: var(--headerFont); font-size: 0.95rem; - font-weight: 600; + font-weight: $boldWeight; line-height: 1.5rem; display: inline-block; } @@ -110,7 +112,7 @@ svg { font-size: 0.95rem; display: inline-block; color: var(--secondary); - font-weight: 600; + font-weight: $boldWeight; margin: 0; line-height: 1.5rem; pointer-events: none; diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 1f61cd76d..328a9754b 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -57,15 +57,11 @@ } & > #search-space { - width: 50%; - margin-top: 15vh; + width: 65%; + margin-top: 12vh; margin-left: auto; margin-right: auto; - @media all and (max-width: $fullPageWidth) { - width: 90%; - } - & > * { width: 100%; border-radius: 5px; @@ -89,93 +85,133 @@ } } - & > #results-container { - & .result-card { - padding: 1em; - cursor: pointer; - transition: background 0.2s ease; - border: 1px solid var(--lightgray); - border-bottom: none; - width: 100%; + & > #search-layout { + display: none; + flex-direction: row; + border: 1px solid var(--lightgray); + + &.display-results { + display: flex; + } + + @media all and (min-width: $tabletBreakpoint) { + &[data-preview] { + & .result-card > p.preview { + display: none; + } + } + } + + & > 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; + } + } + + @media all and (max-width: $tabletBreakpoint) { + display: block; + & > *:not(#results-container) { + display: none !important; + } + + & > #results-container { + width: 100%; + height: auto; + } + } + + & .highlight { + background: color-mix(in srgb, var(--tertiary) 60%, transparent); + border-radius: 5px; + scroll-margin-top: 2rem; + } + + & > #preview-container { display: block; box-sizing: border-box; - - // normalize card props + overflow: hidden; + box-sizing: border-box; font-family: inherit; - font-size: 100%; - line-height: 1.15; - margin: 0; - text-transform: none; - text-align: left; + color: var(--dark); + line-height: 1.5em; + font-weight: $normalWeight; background: var(--light); - outline: none; - font-weight: inherit; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + overflow-y: auto; + padding: 1rem; - & .highlight { - color: var(--secondary); - font-weight: 700; + & .preview-inner { + margin: 0 auto; + width: min($pageWidth, 100%); } + } - &:hover, - &:focus { - background: var(--lightgray); - } + & > #results-container { + overflow-y: auto; - &:first-of-type { - border-top-left-radius: 5px; - border-top-right-radius: 5px; - } - - &:last-of-type { - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; + & .result-card { + padding: 1em; + cursor: pointer; + transition: background 0.2s ease; border-bottom: 1px solid var(--lightgray); - } + width: 100%; + display: block; + box-sizing: border-box; - & > h3 { + // normalize card props + font-family: inherit; + font-size: 100%; + line-height: 1.15; margin: 0; - } + text-transform: none; + text-align: left; + background: var(--light); + outline: none; + font-weight: inherit; - & > ul > li { - margin: 0; - display: inline-block; - white-space: nowrap; - margin: 0; - overflow-wrap: normal; - } + &:focus, + &.focus { + background: var(--lightgray); + } - & > ul { - list-style: none; - display: flex; - padding-left: 0; - gap: 0.4rem; - margin: 0; - margin-top: 0.45rem; - // Offset border radius - margin-left: -2px; - overflow: hidden; - background-clip: border-box; - } + & > h3 { + margin: 0; + } - & > ul > li > p { - border-radius: 8px; - background-color: var(--highlight); - overflow: hidden; - background-clip: border-box; - padding: 0.03rem 0.4rem; - margin: 0; - color: var(--secondary); - opacity: 0.85; - } + & > ul.tags { + margin-top: 0.45rem; + margin-bottom: 0; + } - & > ul > li > .match-tag { - color: var(--tertiary); - font-weight: bold; - opacity: 1; - } + & > ul > li > p { + border-radius: 8px; + background-color: var(--highlight); + padding: 0.2rem 0.4rem; + margin: 0 0.1rem; + line-height: 1.4rem; + font-weight: $boldWeight; + color: var(--secondary); - & > p { - margin-bottom: 0; + &.match-tag { + color: var(--tertiary); + } + } + + & > p { + margin-bottom: 0; + } } } } diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index accc2611e..5eb9718a9 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -131,9 +131,11 @@ function addGlobalPageResources( componentResources.afterDOMLoaded.push(spaRouterScript) } else { componentResources.afterDOMLoaded.push(` - window.spaNavigate = (url, _) => window.location.assign(url) - const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) - document.dispatchEvent(event)`) + window.spaNavigate = (url, _) => window.location.assign(url) + window.addCleanup = () => {} + const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } }) + document.dispatchEvent(event) + `) } let wsUrl = `ws://localhost:${ctx.argv.wsPort}` @@ -147,9 +149,9 @@ function addGlobalPageResources( loadTime: "afterDOMReady", contentType: "inline", script: ` - const socket = new WebSocket('${wsUrl}') - socket.addEventListener('message', () => document.location.reload()) - `, + const socket = new WebSocket('${wsUrl}') + socket.addEventListener('message', () => document.location.reload()) + `, }) } } diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 31e1d3e2a..5a0bed914 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -5,7 +5,6 @@ import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" -import path from "path" import { write } from "./helpers" export type ContentIndex = Map diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts index cb459f743..ab10a4fbb 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/latex.ts @@ -26,12 +26,12 @@ export const Latex: QuartzTransformerPlugin = (opts?: Options) => { return { css: [ // base css - "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", + "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css", ], js: [ { // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md - src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", + src: "https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/copy-tex.min.js", loadTime: "afterDOMReady", contentType: "external", }, diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index fc98bb2a3..44df3fa9e 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -44,39 +44,7 @@ const defaultOptions: Options = { enableVideoEmbed: true, } -const icons = { - infoIcon: ``, - pencilIcon: ``, - clipboardListIcon: ``, - checkCircleIcon: ``, - flameIcon: ``, - checkIcon: ``, - helpCircleIcon: ``, - alertTriangleIcon: ``, - xIcon: ``, - zapIcon: ``, - bugIcon: ``, - listIcon: ``, - quoteIcon: ``, -} - -const callouts = { - note: icons.pencilIcon, - abstract: icons.clipboardListIcon, - info: icons.infoIcon, - todo: icons.checkCircleIcon, - tip: icons.flameIcon, - success: icons.checkIcon, - question: icons.helpCircleIcon, - warning: icons.alertTriangleIcon, - failure: icons.xIcon, - danger: icons.zapIcon, - bug: icons.bugIcon, - example: icons.listIcon, - quote: icons.quoteIcon, -} - -const calloutMapping: Record = { +const calloutMapping = { note: "note", abstract: "abstract", summary: "abstract", @@ -104,12 +72,12 @@ const calloutMapping: Record = { example: "example", quote: "quote", cite: "quote", -} +} as const -function canonicalizeCallout(calloutName: string): keyof typeof callouts { - let callout = calloutName.toLowerCase() as keyof typeof calloutMapping +function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping { + const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping // if callout is not recognized, make it a custom one - return calloutMapping[callout] ?? calloutName + return calloutMapping[normalizedCallout] ?? calloutName } export const externalLinkRegex = /^https?:\/\//i @@ -322,8 +290,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } tag = slugTag(tag) - if (file.data.frontmatter?.tags?.includes(tag)) { - file.data.frontmatter.tags.push(tag) + if (file.data.frontmatter) { + const noteTags = file.data.frontmatter.tags ?? [] + file.data.frontmatter.tags = [...new Set([...noteTags, tag])] } return { @@ -411,32 +380,31 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const match = firstLine.match(calloutRegex) if (match && match.input) { const [calloutDirective, typeString, collapseChar] = match - const calloutType = canonicalizeCallout( - typeString.toLowerCase() as keyof typeof calloutMapping, - ) + const calloutType = canonicalizeCallout(typeString.toLowerCase()) const collapse = collapseChar === "+" || collapseChar === "-" const defaultState = collapseChar === "-" ? "collapsed" : "expanded" - const titleContent = - match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) + const titleContent = match.input.slice(calloutDirective.length).trim() + const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 const titleNode: Paragraph = { type: "paragraph", - children: - restOfTitle.length === 0 - ? [{ type: "text", value: titleContent + " " }] - : restOfTitle, + children: [ + { + type: "text", + value: useDefaultTitle ? capitalize(calloutType) : titleContent + " ", + }, + ...restOfTitle, + ], } const title = mdastToHtml(titleNode) - const toggleIcon = ` - - ` + const toggleIcon = `
    ` const titleHtml: Html = { type: "html", value: `
    -
    ${callouts[calloutType] ?? callouts.note}
    +
    ${title}
    ${collapse ? toggleIcon : ""}
    `, diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index f84b3a71e..1d9731038 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -30,7 +30,7 @@ section { } ::selection { - background: color-mix(in srgb, var(--tertiary) 75%, transparent); + background: color-mix(in srgb, var(--tertiary) 60%, transparent); color: var(--darkgray); } @@ -58,7 +58,7 @@ ul, } a { - font-weight: 600; + font-weight: $boldWeight; text-decoration: none; transition: color 0.2s ease; color: var(--secondary); diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss index 34d3a4560..d4f7069a0 100644 --- a/quartz/styles/callouts.scss +++ b/quartz/styles/callouts.scss @@ -1,3 +1,4 @@ +@use "./variables.scss" as *; @use "sass:color"; .callout { @@ -13,16 +14,33 @@ margin-top: 0; } + --callout-icon-note: url('data:image/svg+xml; utf8, '); + --callout-icon-abstract: url('data:image/svg+xml; utf8, '); + --callout-icon-info: url('data:image/svg+xml; utf8, '); + --callout-icon-todo: url('data:image/svg+xml; utf8, '); + --callout-icon-tip: url('data:image/svg+xml; utf8, '); + --callout-icon-success: url('data:image/svg+xml; utf8, '); + --callout-icon-question: url('data:image/svg+xml; utf8, '); + --callout-icon-warning: url('data:image/svg+xml; utf8, '); + --callout-icon-failure: url('data:image/svg+xml; utf8, '); + --callout-icon-danger: url('data:image/svg+xml; utf8, '); + --callout-icon-bug: url('data:image/svg+xml; utf8, '); + --callout-icon-example: url('data:image/svg+xml; utf8, '); + --callout-icon-quote: url('data:image/svg+xml; utf8, '); + --callout-icon-fold: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E'); + &[data-callout] { --color: #448aff; --border: #448aff44; --bg: #448aff10; + --callout-icon: var(--callout-icon-note); } &[data-callout="abstract"] { --color: #00b0ff; --border: #00b0ff44; --bg: #00b0ff10; + --callout-icon: var(--callout-icon-abstract); } &[data-callout="info"], @@ -30,30 +48,39 @@ --color: #00b8d4; --border: #00b8d444; --bg: #00b8d410; + --callout-icon: var(--callout-icon-info); + } + + &[data-callout="todo"] { + --callout-icon: var(--callout-icon-todo); } &[data-callout="tip"] { --color: #00bfa5; --border: #00bfa544; --bg: #00bfa510; + --callout-icon: var(--callout-icon-tip); } &[data-callout="success"] { --color: #09ad7a; --border: #09ad7144; --bg: #09ad7110; + --callout-icon: var(--callout-icon-success); } &[data-callout="question"] { --color: #dba642; --border: #dba64244; --bg: #dba64210; + --callout-icon: var(--callout-icon-question); } &[data-callout="warning"] { --color: #db8942; --border: #db894244; --bg: #db894210; + --callout-icon: var(--callout-icon-warning); } &[data-callout="failure"], @@ -62,50 +89,74 @@ --color: #db4242; --border: #db424244; --bg: #db424210; + --callout-icon: var(--callout-icon-failure); + } + + &[data-callout="bug"] { + --callout-icon: var(--callout-icon-bug); + } + + &[data-callout="danger"] { + --callout-icon: var(--callout-icon-danger); } &[data-callout="example"] { --color: #7a43b5; --border: #7a43b544; --bg: #7a43b510; + --callout-icon: var(--callout-icon-example); } &[data-callout="quote"] { --color: var(--secondary); --border: var(--lightgray); + --callout-icon: var(--callout-icon-quote); } - &.is-collapsed > .callout-title > .fold { + &.is-collapsed > .callout-title > .fold-callout-icon { transform: rotateZ(-90deg); } } .callout-title { display: flex; + align-items: center; gap: 5px; padding: 1rem 0; color: var(--color); - & .fold { - margin-left: 0.5rem; - transition: transform 0.3s ease; + --icon-size: 18px; + + & .fold-callout-icon { + transition: transform 0.15s ease; opacity: 0.8; cursor: pointer; + width: var(--icon-size); + height: var(--icon-size); + --callout-icon: var(--callout-icon-fold); } & > .callout-title-inner > p { color: var(--color); margin: 0; } -} -.callout-icon { - width: 18px; - height: 18px; - flex: 0 0 18px; - padding-top: 4px; -} + .callout-icon, + & .fold-callout-icon { + width: var(--icon-size); + height: var(--icon-size); -.callout-title-inner { - font-weight: 700; + // icon support + background-size: var(--icon-size) var(--icon-size); + background-position: center; + background-color: var(--color); + mask-image: var(--callout-icon); + mask-size: var(--icon-size) var(--icon-size); + mask-position: center; + mask-repeat: no-repeat; + } + + .callout-title-inner { + font-weight: $boldWeight; + } } diff --git a/quartz/styles/variables.scss b/quartz/styles/variables.scss index 30004aa7b..8384b9c4e 100644 --- a/quartz/styles/variables.scss +++ b/quartz/styles/variables.scss @@ -1,6 +1,8 @@ $pageWidth: 750px; $mobileBreakpoint: 600px; -$tabletBreakpoint: 1200px; +$tabletBreakpoint: 1000px; $sidePanelWidth: 380px; $topSpacing: 6rem; $fullPageWidth: $pageWidth + 2 * $sidePanelWidth; +$boldWeight: 700; +$normalWeight: 400; diff --git a/tsconfig.json b/tsconfig.json index 784ab231b..306204b5d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,8 @@ "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "jsx": "react-jsx", - "jsxImportSource": "preact" + "jsxImportSource": "preact", }, "include": ["**/*.ts", "**/*.tsx", "./package.json"], - "exclude": ["build/**/*.d.ts"] + "exclude": ["build/**/*.d.ts"], }