feat(plugins): explorer as community plugin

This commit is contained in:
saberzero1 2026-02-07 03:10:50 +01:00
parent 4066ffdace
commit e4ea2c96d4
No known key found for this signature in database
9 changed files with 117 additions and 1215 deletions

View File

@ -6,6 +6,36 @@ tags:
Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and is highly customizable.
> [!info]
> The Explorer is now a community plugin. This demonstrates how external plugins can extend Quartz functionality while serving as a reference implementation for plugin developers.
## Installation
The Explorer is available as a community plugin from GitHub:
```bash
npm install github:quartz-community/explorer --legacy-peer-deps
```
Then import it in your `quartz.layout.ts`:
```typescript title="quartz.layout.ts"
import { Explorer } from "@quartz-community/explorer/components"
// Create once and reuse
const explorerComponent = Explorer()
export const defaultContentPageLayout: PageLayout = {
// ... other layout config
left: [
// ... other components
explorerComponent,
],
}
```
## Features
By default, it shows all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]].
Display names for folders get determined by the `title` frontmatter field in `folder/index.md` (more detail in [[authoring content | Authoring Content]]). If this file does not exist or does not contain frontmatter, the local folder name will be used instead.
@ -17,12 +47,12 @@ Display names for folders get determined by the `title` frontmatter field in `fo
## Customization
Most configuration can be done by passing in options to `Component.Explorer()`.
Most configuration can be done by passing in options to `Explorer()`.
For example, here's what the default configuration looks like:
```typescript title="quartz.layout.ts"
Component.Explorer({
Explorer({
title: "Explorer", // title of the explorer component
folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click)
folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
@ -40,19 +70,16 @@ When passing in your own options, you can omit any or all of these fields if you
Want to customize it even more?
- Removing explorer: remove `Component.Explorer()` from `quartz.layout.ts`
- Removing explorer: remove `explorerComponent` from `quartz.layout.ts`
- (optional): After removing the explorer component, you can move the [[table of contents | Table of Contents]] component back to the `left` part of the layout
- Changing `sort`, `filter` and `map` behavior: explained in [[#Advanced customization]]
- Component: `quartz/components/Explorer.tsx`
- Style: `quartz/components/styles/explorer.scss`
- Script: `quartz/components/scripts/explorer.inline.ts`
## Advanced customization
This component allows you to fully customize all of its behavior. You can pass a custom `sort`, `filter` and `map` function.
All functions you can pass work with the `FileTrieNode` class, which has the following properties:
```ts title="quartz/components/Explorer.tsx"
```ts title="@quartz-community/explorer"
class FileTrieNode {
isFolder: boolean
children: Array<FileTrieNode>
@ -60,7 +87,7 @@ class FileTrieNode {
}
```
```ts title="quartz/plugins/emitters/contentIndex.tsx"
```ts
export type ContentDetails = {
slug: FullSlug
title: string
@ -74,7 +101,7 @@ Every function you can pass is optional. By default, only a `sort` function will
```ts title="Default sort function"
// Sort order: folders first, then files. Sort folders and files alphabetically
Component.Explorer({
Explorer({
sortFn: (a, b) => {
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
return a.displayName.localeCompare(b.displayName, undefined, {
@ -115,7 +142,7 @@ These examples show the basic usage of `sort`, `map` and `filter`.
Using this example, the explorer will alphabetically sort everything.
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
sortFn: (a, b) => {
return a.displayName.localeCompare(b.displayName)
},
@ -127,7 +154,7 @@ Component.Explorer({
Using this example, the display names of all `FileNodes` (folders + files) will be converted to full upper case.
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
mapFn: (node) => {
node.displayName = node.displayName.toUpperCase()
return node
@ -141,7 +168,7 @@ Using this example, you can remove elements from your explorer by providing an a
Note that this example filters on the title but you can also do it via slug or any other field available on `FileTrieNode`.
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
filterFn: (node) => {
// set containing names of everything you want to filter out
const omit = new Set(["authoring content", "tags", "advanced"])
@ -159,7 +186,7 @@ Component.Explorer({
You can access the tags of a file by `node.data.tags`.
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
filterFn: (node) => {
// exclude files with the tag "explorerexclude"
return node.data?.tags?.includes("explorerexclude") !== true
@ -173,7 +200,7 @@ By default, the explorer will filter out the `tags` folder.
To override the default filter function, you can set the filter function to `undefined`.
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
filterFn: undefined, // apply no filter function, every file and folder will visible
})
```
@ -186,19 +213,19 @@ Component.Explorer({
> and passing it in.
>
> ```ts title="quartz.layout.ts"
> import { Options } from "./quartz/components/Explorer"
> import { ExplorerOptions } from "@quartz-community/explorer/components"
>
> export const mapFn: Options["mapFn"] = (node) => {
> export const mapFn: ExplorerOptions["mapFn"] = (node) => {
> // implement your function here
> }
> export const filterFn: Options["filterFn"] = (node) => {
> export const filterFn: ExplorerOptions["filterFn"] = (node) => {
> // implement your function here
> }
> export const sortFn: Options["sortFn"] = (a, b) => {
> export const sortFn: ExplorerOptions["sortFn"] = (a, b) => {
> // implement your function here
> }
>
> Component.Explorer({
> Explorer({
> // ... your other options
> mapFn,
> filterFn,
@ -211,7 +238,7 @@ Component.Explorer({
To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this:
```ts title="quartz.layout.ts"
Component.Explorer({
Explorer({
mapFn: (node) => {
if (node.isFolder) {
node.displayName = "📁 " + node.displayName

501
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@floating-ui/dom": "^1.7.4",
"@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.22",
"@quartz-community/explorer": "github:quartz-community/explorer",
"@tweenjs/tween.js": "^25.0.0",
"ansi-truncate": "^1.4.0",
"async-mutex": "^0.5.0",
@ -93,13 +94,6 @@
"npm": ">=10.9.2"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@citation-js/core": {
"version": "0.7.14",
"resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.14.tgz",
@ -214,6 +208,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -230,6 +225,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -246,6 +242,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -262,6 +259,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -278,6 +276,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -294,6 +293,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -310,6 +310,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -326,6 +327,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -342,6 +344,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -358,6 +361,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -374,6 +378,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -390,6 +395,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -406,6 +412,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -422,6 +429,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -438,6 +446,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -454,6 +463,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -470,6 +480,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -486,6 +497,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -502,6 +514,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -518,6 +531,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -534,6 +548,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -550,6 +565,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -566,6 +582,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -582,6 +599,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -598,6 +616,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -614,6 +633,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1964,6 +1984,37 @@
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@quartz-community/explorer": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/quartz-community/explorer.git#bb0f48f00f0486120b1216c78dd424127401bf4d",
"license": "MIT",
"dependencies": {
"mdast-util-find-and-replace": "^3.0.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3"
},
"engines": {
"node": ">=22",
"npm": ">=10.9.2"
},
"peerDependencies": {
"@jackyzha0/quartz": "^4.5.2",
"preact": "^10.0.0"
},
"peerDependenciesMeta": {
"@jackyzha0/quartz": {
"optional": true
},
"preact": {
"optional": false
}
}
},
"node_modules/@shikijs/core": {
"version": "1.26.2",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.26.2.tgz",
@ -2607,13 +2658,6 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-builder": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"license": "MIT/X11",
"peer": true
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -2728,13 +2772,6 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"license": "MIT",
"peer": true
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@ -3282,6 +3319,7 @@
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -3691,16 +3729,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6410,25 +6438,15 @@
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sass": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"version": "1.97.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz",
"integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
@ -6445,355 +6463,6 @@
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sass-embedded": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.2.tgz",
"integrity": "sha512-lKJcskySwAtJ4QRirKrikrWMFa2niAuaGenY2ElHjd55IwHUiur5IdKu6R1hEmGYMs4Qm+6rlRW0RvuAkmcryg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
"colorjs.io": "^0.5.0",
"immutable": "^5.0.2",
"rxjs": "^7.4.0",
"supports-color": "^8.1.1",
"sync-child-process": "^1.0.2",
"varint": "^6.0.0"
},
"bin": {
"sass": "dist/bin/sass.js"
},
"engines": {
"node": ">=16.0.0"
},
"optionalDependencies": {
"sass-embedded-all-unknown": "1.97.2",
"sass-embedded-android-arm": "1.97.2",
"sass-embedded-android-arm64": "1.97.2",
"sass-embedded-android-riscv64": "1.97.2",
"sass-embedded-android-x64": "1.97.2",
"sass-embedded-darwin-arm64": "1.97.2",
"sass-embedded-darwin-x64": "1.97.2",
"sass-embedded-linux-arm": "1.97.2",
"sass-embedded-linux-arm64": "1.97.2",
"sass-embedded-linux-musl-arm": "1.97.2",
"sass-embedded-linux-musl-arm64": "1.97.2",
"sass-embedded-linux-musl-riscv64": "1.97.2",
"sass-embedded-linux-musl-x64": "1.97.2",
"sass-embedded-linux-riscv64": "1.97.2",
"sass-embedded-linux-x64": "1.97.2",
"sass-embedded-unknown-all": "1.97.2",
"sass-embedded-win32-arm64": "1.97.2",
"sass-embedded-win32-x64": "1.97.2"
}
},
"node_modules/sass-embedded-all-unknown": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.2.tgz",
"integrity": "sha512-Fj75+vOIDv1T/dGDwEpQ5hgjXxa2SmMeShPa8yrh2sUz1U44bbmY4YSWPCdg8wb7LnwiY21B2KRFM+HF42yO4g==",
"cpu": [
"!arm",
"!arm64",
"!riscv64",
"!x64"
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"sass": "1.97.2"
}
},
"node_modules/sass-embedded-android-arm": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.2.tgz",
"integrity": "sha512-BPT9m19ttY0QVHYYXRa6bmqmS3Fa2EHByNUEtSVcbm5PkIk1ntmYkG9fn5SJpIMbNmFDGwHx+pfcZMmkldhnRg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-arm64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.2.tgz",
"integrity": "sha512-pF6I+R5uThrscd3lo9B3DyNTPyGFsopycdx0tDAESN6s+dBbiRgNgE4Zlpv50GsLocj/lDLCZaabeTpL3ubhYA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-riscv64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.2.tgz",
"integrity": "sha512-fprI8ZTJdz+STgARhg8zReI2QhhGIT9G8nS7H21kc3IkqPRzhfaemSxEtCqZyvDbXPcgYiDLV7AGIReHCuATog==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-android-x64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.2.tgz",
"integrity": "sha512-RswwSjURZxupsukEmNt2t6RGvuvIw3IAD5sDq1Pc65JFvWFY3eHqCmH0lG0oXqMg6KJcF0eOxHOp2RfmIm2+4w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-arm64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.2.tgz",
"integrity": "sha512-xcsZNnU1XZh21RE/71OOwNqPVcGBU0qT9A4k4QirdA34+ts9cDIaR6W6lgHOBR/Bnnu6w6hXJR4Xth7oFrefPA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-darwin-x64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.2.tgz",
"integrity": "sha512-T/9DTMpychm6+H4slHCAsYJRJ6eM+9H9idKlBPliPrP4T8JdC2Cs+ZOsYqrObj6eOtAD0fGf+KgyNhnW3xVafA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.2.tgz",
"integrity": "sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-arm64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.2.tgz",
"integrity": "sha512-Wh+nQaFer9tyE5xBPv5murSUZE/+kIcg8MyL5uqww6be9Iq+UmZpcJM7LUk+q8klQ9LfTmoDSNFA74uBqxD6IA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.2.tgz",
"integrity": "sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.2.tgz",
"integrity": "sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-riscv64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.2.tgz",
"integrity": "sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-musl-x64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.2.tgz",
"integrity": "sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-riscv64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.2.tgz",
"integrity": "sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-linux-x64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.2.tgz",
"integrity": "sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-unknown-all": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.2.tgz",
"integrity": "sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg==",
"license": "MIT",
"optional": true,
"os": [
"!android",
"!darwin",
"!linux",
"!win32"
],
"peer": true,
"dependencies": {
"sass": "1.97.2"
}
},
"node_modules/sass-embedded-win32-arm64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.2.tgz",
"integrity": "sha512-Cv28q8qNjAjZfqfzTrQvKf4JjsZ6EOQ5FxyHUQQeNzm73R86nd/8ozDa1Vmn79Hq0kwM15OCM9epanDuTG1ksA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-embedded-win32-x64": {
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.2.tgz",
"integrity": "sha512-DVxLxkeDCGIYeyHLAvWW3yy9sy5Ruk5p472QWiyfyyG1G1ASAR8fgfIY5pT0vE6Rv+VAKVLwF3WTspUYu7S1/Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -7125,22 +6794,6 @@
"inline-style-parser": "0.2.4"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -7153,19 +6806,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sync-child-process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz",
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"sync-message-port": "^1.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/sync-fetch": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz",
@ -7178,16 +6818,6 @@
"node": ">=14"
}
},
"node_modules/sync-message-port": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz",
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@ -7488,13 +7118,6 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz",
"integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT",
"peer": true
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@ -39,6 +39,7 @@
"@floating-ui/dom": "^1.7.4",
"@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.22",
"@quartz-community/explorer": "github:quartz-community/explorer",
"@tweenjs/tween.js": "^25.0.0",
"ansi-truncate": "^1.4.0",
"async-mutex": "^0.5.0",

View File

@ -92,6 +92,7 @@ const config: QuartzConfig = {
Plugin.CustomOgImages(),
],
},
externalPlugins: ["@quartz-community/explorer"],
}
export default config

View File

@ -1,5 +1,9 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
import { Explorer } from "@quartz-community/explorer/components"
// Create Explorer once and reuse for both layouts
const explorerComponent = Explorer()
// components shared across all pages
export const sharedPageComponents: SharedLayout = {
@ -38,7 +42,7 @@ export const defaultContentPageLayout: PageLayout = {
{ Component: Component.ReaderMode() },
],
}),
Component.Explorer(),
explorerComponent,
],
right: [
Component.Graph(),
@ -62,7 +66,7 @@ export const defaultListPageLayout: PageLayout = {
{ Component: Component.Darkmode() },
],
}),
Component.Explorer(),
explorerComponent,
],
right: [],
}

View File

@ -1,165 +0,0 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie"
import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
type OrderEntries = "sort" | "filter" | "map"
export interface Options {
title?: string
folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: OrderEntries[]
}
const defaultOptions: Options = {
folderDefaultState: "collapsed",
folderClickBehavior: "link",
useSavedState: true,
mapFn: (node) => {
return node
},
sortFn: (a, b) => {
// Sort order: folders first, then files. Sort folders and files alphabeticall
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
return a.displayName.localeCompare(b.displayName, undefined, {
numeric: true,
sensitivity: "base",
})
}
if (!a.isFolder && b.isFolder) {
return 1
} else {
return -1
}
},
filterFn: (node) => node.slugSegment !== "tags",
order: ["filter", "map", "sort"],
}
export type FolderState = {
path: string
collapsed: boolean
}
let numExplorers = 0
export default ((userOpts?: Partial<Options>) => {
const opts: Options = { ...defaultOptions, ...userOpts }
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
const id = `explorer-${numExplorers++}`
return (
<div
class={classNames(displayClass, "explorer")}
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-data-fns={JSON.stringify({
order: opts.order,
sortFn: opts.sortFn.toString(),
filterFn: opts.filterFn.toString(),
mapFn: opts.mapFn.toString(),
})}
>
<button
type="button"
class="explorer-toggle mobile-explorer hide-until-loaded"
data-mobile={true}
aria-controls={id}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<button
type="button"
class="title-button explorer-toggle desktop-explorer"
data-mobile={false}
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="fold"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id={id} class="explorer-content" aria-expanded={false} role="group">
<OverflowList class="explorer-ul" />
</div>
<template id="template-file">
<li>
<a href="#"></a>
</li>
</template>
<template id="template-folder">
<li>
<div class="folder-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="folder-icon"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div>
<button class="folder-button">
<span class="folder-title"></span>
</button>
</div>
</div>
<div class="folder-outer">
<ul class="content"></ul>
</div>
</li>
</template>
</div>
)
}
Explorer.css = style
Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
return Explorer
}) satisfies QuartzComponentConstructor

View File

@ -10,7 +10,6 @@ import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta"
import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents"
import Explorer from "./Explorer"
import TagList from "./TagList"
import Graph from "./Graph"
import Backlinks from "./Backlinks"
@ -41,7 +40,6 @@ export {
ContentMeta,
Spacer,
TableOfContents,
Explorer,
TagList,
Graph,
Backlinks,

View File

@ -1,305 +0,0 @@
import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
type MaybeHTMLElement = HTMLElement | undefined
interface ParsedOptions {
folderClickBehavior: "collapse" | "link"
folderDefaultState: "collapsed" | "open"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: "sort" | "filter" | "map"[]
}
type FolderState = {
path: string
collapsed: boolean
}
let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
const nearestExplorer = this.closest(".explorer") as HTMLElement
if (!nearestExplorer) return
const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
nearestExplorer.setAttribute(
"aria-expanded",
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
if (!explorerCollapsed) {
// Stop <html> from being scrollable when mobile explorer is open
document.documentElement.classList.add("mobile-no-scroll")
} else {
document.documentElement.classList.remove("mobile-no-scroll")
}
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
const target = evt.target as MaybeHTMLElement
if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const folderContainer = (
isSvg
? // svg -> div.folder-container
target.parentElement
: // button.folder-button -> div -> div.folder-container
target.parentElement?.parentElement
) as MaybeHTMLElement
if (!folderContainer) return
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
if (!childFolderContainer) return
childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = !childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, isCollapsed)
const currentFolderState = currentExplorerState.find(
(item) => item.path === folderContainer.dataset.folderpath,
)
if (currentFolderState) {
currentFolderState.collapsed = isCollapsed
} else {
currentExplorerState.push({
path: folderContainer.dataset.folderpath as FullSlug,
collapsed: isCollapsed,
})
}
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
const template = document.getElementById("template-file") as HTMLTemplateElement
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const a = li.querySelector("a") as HTMLAnchorElement
a.href = resolveRelative(currentSlug, node.slug)
a.dataset.for = node.slug
a.textContent = node.displayName
if (currentSlug === node.slug) {
a.classList.add("active")
}
return li
}
function createFolderNode(
currentSlug: FullSlug,
node: FileTrieNode,
opts: ParsedOptions,
): HTMLLIElement {
const template = document.getElementById("template-folder") as HTMLTemplateElement
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const folderContainer = li.querySelector(".folder-container") as HTMLElement
const titleContainer = folderContainer.querySelector("div") as HTMLElement
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
const ul = folderOuter.querySelector("ul") as HTMLUListElement
const folderPath = node.slug
folderContainer.dataset.folderpath = folderPath
if (currentSlug === folderPath) {
folderContainer.classList.add("active")
}
if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement
const a = document.createElement("a")
a.href = resolveRelative(currentSlug, folderPath)
a.dataset.for = folderPath
a.className = "folder-title"
a.textContent = node.displayName
button.replaceWith(a)
} else {
const span = titleContainer.querySelector(".folder-title") as HTMLElement
span.textContent = node.displayName
}
// if the saved state is collapsed or the default state is collapsed
const isCollapsed =
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
opts.folderDefaultState === "collapsed"
// if this folder is a prefix of the current path we
// want to open it anyways
const simpleFolderPath = simplifySlug(folderPath)
const folderIsPrefixOfCurrentSlug =
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
folderOuter.classList.add("open")
}
for (const child of node.children) {
const childNode = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
ul.appendChild(childNode)
}
return li
}
async function setupExplorer(currentSlug: FullSlug) {
const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
const opts: ParsedOptions = {
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
useSavedState: explorer.dataset.savestate === "true",
order: dataFns.order || ["filter", "map", "sort"],
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
const oldIndex = new Map<string, boolean>(
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
)
const data = await fetchData
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
const trie = FileTrieNode.fromEntries(entries)
// Apply functions in order
for (const fn of opts.order) {
switch (fn) {
case "filter":
if (opts.filterFn) trie.filter(opts.filterFn)
break
case "map":
if (opts.mapFn) trie.map(opts.mapFn)
break
case "sort":
if (opts.sortFn) trie.sort(opts.sortFn)
break
}
}
// Get folder paths for state management
const folderPaths = trie.getFolderPaths()
currentExplorerState = folderPaths.map((path) => {
const previousState = oldIndex.get(path)
return {
path,
collapsed:
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
}
})
const explorerUl = explorer.querySelector(".explorer-ul")
if (!explorerUl) continue
// Create and insert new content
const fragment = document.createDocumentFragment()
for (const child of trie.children) {
const node = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
fragment.appendChild(node)
}
explorerUl.insertBefore(fragment, explorerUl.firstChild)
// restore explorer scrollTop position if it exists
const scrollTop = sessionStorage.getItem("explorerScrollTop")
if (scrollTop) {
explorerUl.scrollTop = parseInt(scrollTop)
} else {
// try to scroll to the active element if it exists
const activeElement = explorerUl.querySelector(".active")
if (activeElement) {
activeElement.scrollIntoView({ behavior: "smooth" })
}
}
// Set up event handlers
const explorerButtons = explorer.getElementsByClassName(
"explorer-toggle",
) as HTMLCollectionOf<HTMLElement>
for (const button of explorerButtons) {
button.addEventListener("click", toggleExplorer)
window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
}
// Set up folder click handlers
if (opts.folderClickBehavior === "collapse") {
const folderButtons = explorer.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>
for (const button of folderButtons) {
button.addEventListener("click", toggleFolder)
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
}
}
const folderIcons = explorer.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>
for (const icon of folderIcons) {
icon.addEventListener("click", toggleFolder)
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
}
}
}
document.addEventListener("prenav", async () => {
// save explorer scrollTop position
const explorer = document.querySelector(".explorer-ul")
if (!explorer) return
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default
for (const explorer of document.getElementsByClassName("explorer")) {
const mobileExplorer = explorer.querySelector(".mobile-explorer")
if (!mobileExplorer) return
if (mobileExplorer.checkVisibility()) {
explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false")
// Allow <html> to be scrollable when mobile explorer is collapsed
document.documentElement.classList.remove("mobile-no-scroll")
}
mobileExplorer.classList.remove("hide-until-loaded")
}
})
window.addEventListener("resize", function () {
// Desktop explorer opens by default, and it stays open when the window is resized
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
const explorer = document.querySelector(".explorer")
if (explorer && !explorer.classList.contains("collapsed")) {
document.documentElement.classList.add("mobile-no-scroll")
return
}
})
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}

View File

@ -1,282 +0,0 @@
@use "../../styles/variables.scss" as *;
@media all and ($mobile) {
.page > #quartz-body {
// Shift page position when toggling Explorer on mobile.
& > :not(.sidebar.left:has(.explorer)) {
transition: transform 300ms ease-in-out;
}
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
transform: translateX(100dvw);
transition: transform 300ms ease-in-out;
}
// Sticky top bar (stays in place when scrolling down on mobile).
.sidebar.left:has(.explorer) {
box-sizing: border-box;
position: sticky;
background-color: var(--light);
padding: 1rem 0 1rem 0;
margin: 0;
}
.hide-until-loaded ~ .explorer-content {
display: none;
}
}
}
.explorer {
display: flex;
flex-direction: column;
overflow-y: hidden;
min-height: 1.2rem;
flex: 0 1 auto;
&.collapsed {
flex: 0 1 1.2rem;
& .fold {
transform: rotateZ(-90deg);
}
}
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
@media all and ($mobile) {
order: -1;
height: initial;
overflow: hidden;
flex-shrink: 0;
align-self: flex-start;
margin-top: auto;
margin-bottom: auto;
}
button.mobile-explorer {
display: none;
}
button.desktop-explorer {
display: flex;
}
@media all and ($mobile) {
button.mobile-explorer {
display: flex;
}
button.desktop-explorer {
display: none;
}
}
&.desktop-only {
@media all and not ($mobile) {
display: flex;
}
}
svg {
pointer-events: all;
transition: transform 0.35s ease;
& > polyline {
pointer-events: none;
}
}
}
button.mobile-explorer,
button.desktop-explorer {
background-color: transparent;
border: none;
text-align: left;
cursor: pointer;
padding: 0;
color: var(--dark);
display: flex;
align-items: center;
& h2 {
font-size: 1rem;
display: inline-block;
margin: 0;
}
}
.explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
margin-top: 0.5rem;
& ul {
list-style: none;
margin: 0;
padding: 0;
&.explorer-ul {
overscroll-behavior: contain;
}
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
&.active {
opacity: 1;
color: var(--tertiary);
}
}
}
.folder-outer {
visibility: collapse;
display: grid;
grid-template-rows: 0fr;
transition-property: grid-template-rows, visibility;
transition-duration: 0.3s;
transition-timing-function: ease-in-out;
}
.folder-outer.open {
visibility: visible;
grid-template-rows: 1fr;
}
.folder-outer > ul {
overflow: hidden;
margin-left: 6px;
padding-left: 0.8rem;
border-left: 1px solid var(--lightgray);
}
}
.folder-container {
flex-direction: row;
display: flex;
align-items: center;
user-select: none;
& div > a {
color: var(--secondary);
font-family: var(--headerFont);
font-size: 0.95rem;
font-weight: $semiBoldWeight;
line-height: 1.5rem;
display: inline-block;
}
& div > a:hover {
color: var(--tertiary);
}
& div > button {
color: var(--dark);
background-color: transparent;
border: none;
text-align: left;
cursor: pointer;
padding-left: 0;
padding-right: 0;
display: flex;
align-items: center;
font-family: var(--headerFont);
& span {
font-size: 0.95rem;
display: inline-block;
color: var(--secondary);
font-weight: $semiBoldWeight;
margin: 0;
line-height: 1.5rem;
pointer-events: none;
}
}
}
.folder-icon {
margin-right: 5px;
color: var(--secondary);
cursor: pointer;
transition: transform 0.3s ease;
backface-visibility: visible;
flex-shrink: 0;
}
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg);
}
.folder-icon:hover {
color: var(--tertiary);
}
.explorer {
@media all and ($mobile) {
&.collapsed {
flex: 0 0 34px;
& > .explorer-content {
transform: translateX(-100vw);
visibility: hidden;
}
}
&:not(.collapsed) {
flex: 0 0 34px;
& > .explorer-content {
transform: translateX(0);
visibility: visible;
}
}
.explorer-content {
box-sizing: border-box;
z-index: 100;
position: absolute;
top: 0;
left: 0;
margin-top: 0;
background-color: var(--light);
max-width: 100vw;
width: 100vw;
transform: translateX(-100vw);
transition:
transform 200ms ease,
visibility 200ms ease;
overflow: hidden;
padding: 4rem 0 2rem 0;
height: 100dvh;
max-height: 100dvh;
visibility: hidden;
}
.mobile-explorer {
margin: 0;
padding: 5px;
z-index: 101;
.lucide-menu {
stroke: var(--darkgray);
}
}
}
}
.mobile-no-scroll {
@media all and ($mobile) {
.explorer-content > .explorer-ul {
overscroll-behavior: contain;
}
}
}