Merge pull request #479 from benschlegel/explorer-config

feat(explorer): add config for custom sort/map/filter functions
This commit is contained in:
Ben Schlegel 2023-09-17 21:36:04 +02:00 committed by GitHub
commit e67f409ec1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 264 additions and 21 deletions

View file

@ -4,9 +4,9 @@ tags:
- component - component
--- ---
Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and has options for customization. Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and is highly customizable.
By default, it will show all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]]. By default, it shows all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]].
> [!info] > [!info]
> The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages. > The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages.
@ -25,6 +25,14 @@ Component.Explorer({
folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) 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") folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer
// Sort order: folders first, then files. Sort folders and files alphabetically
sortFn: (a, b) => {
... // default implementation shown later
},
filterFn: undefined,
mapFn: undefined,
// what order to apply functions in
order: ["filter", "map", "sort"],
}) })
``` ```
@ -33,9 +41,187 @@ When passing in your own options, you can omit any or all of these fields if you
Want to customize it even more? Want to customize it even more?
- Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts` - Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts`
- (optional): After removing the explorer component, you can move the [[table of contents]] component back to the `left` part of the layout - (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: - Component:
- Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx` - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx`
- Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx` - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx`
- Style: `quartz/components/styles/explorer.scss` - Style: `quartz/components/styles/explorer.scss`
- Script: `quartz/components/scripts/explorer.inline.ts` - 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 `FileNode` class, which has the following properties:
```ts title="quartz/components/ExplorerNode.tsx" {2-5}
export class FileNode {
children: FileNode[] // children of current node
name: string // name of node (only useful for folders)
file: QuartzPluginData | null // set if node is a file, see `QuartzPluginData` for more detail
depth: number // depth of current node
... // rest of implementation
}
```
Every function you can pass is optional. By default, only a `sort` function will be used:
```ts title="Default sort function"
// Sort order: folders first, then files. Sort folders and files alphabetically
Component.Explorer({
sortFn: (a, b) => {
if ((!a.file && !b.file) || (a.file && b.file)) {
return a.name.localeCompare(b.name)
}
if (a.file && !b.file) {
return 1
} else {
return -1
}
},
})
```
---
You can pass your own functions for `sortFn`, `filterFn` and `mapFn`. All functions will be executed in the order provided by the `order` option (see [[#Customization]]). These functions behave similarly to their `Array.prototype` counterpart, except they modify the entire `FileNode` tree in place instead of returning a new one.
For more information on how to use `sort`, `filter` and `map`, you can check [Array.prototype.sort()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort), [Array.prototype.filter()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) and [Array.prototype.map()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map).
Type definitions look like this:
```ts
sortFn: (a: FileNode, b: FileNode) => number
filterFn: (node: FileNode) => boolean
mapFn: (node: FileNode) => void
```
> [!tip]
> You can check if a `FileNode` is a folder or a file like this:
>
> ```ts
> if (node.file) {
> // node is a file
> } else {
> // node is a folder
> }
> ```
## Basic examples
These examples show the basic usage of `sort`, `map` and `filter`.
### Use `sort` to put files first
Using this example, the explorer will alphabetically sort everything, but put all **files** above all **folders**.
```ts title="quartz.layout.ts"
Component.Explorer({
sortFn: (a, b) => {
if ((!a.file && !b.file) || (a.file && b.file)) {
return a.name.localeCompare(b.name)
}
if (a.file && !b.file) {
return -1
} else {
return 1
}
},
})
```
### Change display names (`map`)
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({
mapFn: (node) => {
node.name = node.name.toUpperCase()
},
})
```
### Remove list of elements (`filter`)
Using this example, you can remove elements from your explorer by providing an array of folders/files using the `omit` set.
```ts title="quartz.layout.ts"
Component.Explorer({
filterFn: (node) => {
// set containing names of everything you want to filter out
const omit = new Set(["authoring content", "tags", "hosting"])
return omit.has(node.name.toLowerCase())
},
})
```
You can customize this by changing the entries of the `omit` set. Simply add all folder or file names you want to remove.
## Advanced examples
### Add emoji prefix
To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this:
```ts title="quartz.layout.ts"
Component.Explorer({
mapFn: (node) => {
// dont change name of root node
if (node.depth > 0) {
// set emoji for file/folder
if (node.file) {
node.name = "📄 " + node.name
} else {
node.name = "📁 " + node.name
}
}
},
}})
```
### Putting it all together
In this example, we're going to customize the explorer by using functions from examples above to [[#Add emoji prefix | add emoji prefixes]], [[#remove-list-of-elements-filter| filter out some folders]] and [[#use-sort-to-put-files-first | sort with files above folders]].
```ts title="quartz.layout.ts"
Component.Explorer({
filterFn: sampleFilterFn,
mapFn: sampleMapFn,
sortFn: sampleSortFn,
order: ["filter", "sort", "map"],
})
```
Notice how we customized the `order` array here. This is done because the default order applies the `sort` function last. While this normally works well, it would cause unintended behavior here, since we changed the first characters of all display names. In our example, `sort` would be applied based off the emoji prefix instead of the first _real_ character.
To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function.
> [!tip]
> When writing more complicated functions, the `layout` file can start to look very cramped.
> You can fix this by defining your functions in another file.
>
> ```ts title="functions.ts"
> import { Options } from "./quartz/components/ExplorerNode"
> export const mapFn: Options["mapFn"] = (node) => {
> // implement your function here
> }
> export const filterFn: Options["filterFn"] = (node) => {
> // implement your function here
> }
> export const sortFn: Options["sortFn"] = (a, b) => {
> // implement your function here
> }
> ```
>
> You can then import them like this:
>
> ```ts title="quartz.layout.ts"
> import { mapFn, filterFn, sortFn } from "./functions.ts"
> Component.Explorer({
> mapFn: mapFn,
> filterFn: filterFn,
> sortFn: sortFn,
> })
> ```

View file

@ -11,6 +11,18 @@ const defaultOptions = (): Options => ({
folderClickBehavior: "collapse", folderClickBehavior: "collapse",
folderDefaultState: "collapsed", folderDefaultState: "collapsed",
useSavedState: true, useSavedState: true,
// Sort order: folders first, then files. Sort folders and files alphabetically
sortFn: (a, b) => {
if ((!a.file && !b.file) || (a.file && b.file)) {
return a.name.localeCompare(b.name)
}
if (a.file && !b.file) {
return 1
} else {
return -1
}
},
order: ["filter", "map", "sort"],
}) })
export default ((userOpts?: Partial<Options>) => { export default ((userOpts?: Partial<Options>) => {
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
@ -21,8 +33,34 @@ export default ((userOpts?: Partial<Options>) => {
const fileTree = new FileNode("") const fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file, 1)) allFiles.forEach((file) => fileTree.add(file, 1))
// Sort tree (folders first, then files (alphabetic)) /**
fileTree.sort() * Keys of this object must match corresponding function name of `FileNode`,
* while values must be the argument that will be passed to the function.
*
* e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
*/
const functions = {
map: opts.mapFn,
sort: opts.sortFn,
filter: opts.filterFn,
}
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functions[functionName]) {
// for every entry in order, call matching function in FileNode and pass matching argument
// e.g. i = 0; functionName = "filter"
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
// @ts-ignore
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
fileTree[functionName].call(fileTree, functions[functionName])
}
}
}
// Get all folders of tree. Initialize with collapsed state // Get all folders of tree. Initialize with collapsed state
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")

View file

@ -1,12 +1,18 @@
// @ts-ignore // @ts-ignore
import { QuartzPluginData } from "vfile" import { QuartzPluginData } from "../plugins/vfile"
import { resolveRelative } from "../util/path" import { resolveRelative } from "../util/path"
type OrderEntries = "sort" | "filter" | "map"
export interface Options { export interface Options {
title: string title: string
folderDefaultState: "collapsed" | "open" folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link" folderClickBehavior: "collapse" | "link"
useSavedState: boolean useSavedState: boolean
sortFn: (a: FileNode, b: FileNode) => number
filterFn?: (node: FileNode) => boolean
mapFn?: (node: FileNode) => void
order?: OrderEntries[]
} }
type DataWrapper = { type DataWrapper = {
@ -29,7 +35,7 @@ export class FileNode {
constructor(name: string, file?: QuartzPluginData, depth?: number) { constructor(name: string, file?: QuartzPluginData, depth?: number) {
this.children = [] this.children = []
this.name = name this.name = name
this.file = file ?? null this.file = file ? structuredClone(file) : null
this.depth = depth ?? 0 this.depth = depth ?? 0
} }
@ -65,6 +71,25 @@ export class FileNode {
this.children.forEach((e) => e.print(depth + 1)) this.children.forEach((e) => e.print(depth + 1))
} }
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
* @param filterFn function to filter tree with
*/
filter(filterFn: (node: FileNode) => boolean) {
this.children = this.children.filter(filterFn)
this.children.forEach((child) => child.filter(filterFn))
}
/**
* Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
* @param mapFn function to use for mapping over tree
*/
map(mapFn: (node: FileNode) => void) {
mapFn(this)
this.children.forEach((child) => child.map(mapFn))
}
/** /**
* Get folder representation with state of tree. * Get folder representation with state of tree.
* Intended to only be called on root node before changes to the tree are made * Intended to only be called on root node before changes to the tree are made
@ -90,19 +115,13 @@ export class FileNode {
} }
// Sort order: folders first, then files. Sort folders and files alphabetically // Sort order: folders first, then files. Sort folders and files alphabetically
sort() { /**
this.children = this.children.sort((a, b) => { * Sorts tree according to sort/compare function
if ((!a.file && !b.file) || (a.file && b.file)) { * @param sortFn compare function used for `.sort()`, also used recursively for children
return a.name.localeCompare(b.name) */
} sort(sortFn: (a: FileNode, b: FileNode) => number) {
if (a.file && !b.file) { this.children = this.children.sort(sortFn)
return 1 this.children.forEach((e) => e.sort(sortFn))
} else {
return -1
}
})
this.children.forEach((e) => e.sort())
} }
} }
@ -131,7 +150,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
// Single file node // Single file node
<li key={node.file.slug}> <li key={node.file.slug}>
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}> <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
{node.file.frontmatter?.title} {node.name}
</a> </a>
</li> </li>
) : ( ) : (