perf(explorer): client side explorer (#1810)
* start work on client side explorer * fix tests * fmt * generic test flag * add prenav hook * add highlight class * make flex more consistent, remove transition * open folders that are prefixes of current path * make mobile look nice * more style fixes
This commit is contained in:
parent
a201105442
commit
5480269d38
24 changed files with 797 additions and 674 deletions
|
@ -161,6 +161,18 @@ document.addEventListener("nav", () => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
document.addEventListener("prenav", () => {
|
||||||
|
// executed after an SPA navigation is triggered but
|
||||||
|
// before the page is replaced
|
||||||
|
// one usage pattern is to store things in sessionStorage
|
||||||
|
// in the prenav and then conditionally load then in the consequent
|
||||||
|
// nav
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
It is best practice to track any event handlers via `window.addCleanup` 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.
|
This will get called on page navigation.
|
||||||
|
|
||||||
|
|
1
index.d.ts
vendored
1
index.d.ts
vendored
|
@ -5,6 +5,7 @@ declare module "*.scss" {
|
||||||
|
|
||||||
// dom custom event
|
// dom custom event
|
||||||
interface CustomEventMap {
|
interface CustomEventMap {
|
||||||
|
prenav: CustomEvent<{}>
|
||||||
nav: CustomEvent<{ url: FullSlug }>
|
nav: CustomEvent<{ url: FullSlug }>
|
||||||
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
themechange: CustomEvent<{ theme: "light" | "dark" }>
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
"docs": "npx quartz build --serve -d docs",
|
"docs": "npx quartz build --serve -d docs",
|
||||||
"check": "tsc --noEmit && npx prettier . --check",
|
"check": "tsc --noEmit && npx prettier . --check",
|
||||||
"format": "npx prettier . --write",
|
"format": "npx prettier . --write",
|
||||||
"test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
|
"test": "tsx --test",
|
||||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins"
|
||||||
*/
|
*/
|
||||||
const config: QuartzConfig = {
|
const config: QuartzConfig = {
|
||||||
configuration: {
|
configuration: {
|
||||||
pageTitle: "🪴 Quartz 4",
|
pageTitle: "Quartz 4",
|
||||||
pageTitleSuffix: "",
|
pageTitleSuffix: "",
|
||||||
enableSPA: true,
|
enableSPA: true,
|
||||||
enablePopovers: true,
|
enablePopovers: true,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss"
|
||||||
import { resolveRelative, simplifySlug } from "../util/path"
|
import { resolveRelative, simplifySlug } from "../util/path"
|
||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
|
import OverflowList from "./OverflowList"
|
||||||
|
|
||||||
interface BacklinksOptions {
|
interface BacklinksOptions {
|
||||||
hideWhenEmpty: boolean
|
hideWhenEmpty: boolean
|
||||||
|
@ -29,7 +30,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
|
||||||
return (
|
return (
|
||||||
<div class={classNames(displayClass, "backlinks")}>
|
<div class={classNames(displayClass, "backlinks")}>
|
||||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||||
<ul class="overflow">
|
<OverflowList id="backlinks-ul">
|
||||||
{backlinkFiles.length > 0 ? (
|
{backlinkFiles.length > 0 ? (
|
||||||
backlinkFiles.map((f) => (
|
backlinkFiles.map((f) => (
|
||||||
<li>
|
<li>
|
||||||
|
@ -41,12 +42,13 @@ export default ((opts?: Partial<BacklinksOptions>) => {
|
||||||
) : (
|
) : (
|
||||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</OverflowList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Backlinks.css = style
|
Backlinks.css = style
|
||||||
|
Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
|
||||||
|
|
||||||
return Backlinks
|
return Backlinks
|
||||||
}) satisfies QuartzComponentConstructor
|
}) satisfies QuartzComponentConstructor
|
||||||
|
|
|
@ -3,22 +3,34 @@ import style from "./styles/explorer.scss"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/explorer.inline"
|
import script from "./scripts/explorer.inline"
|
||||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
|
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
|
||||||
import { classNames } from "../util/lang"
|
import { classNames } from "../util/lang"
|
||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
|
import { FileTrieNode } from "../util/fileTrie"
|
||||||
|
import OverflowList from "./OverflowList"
|
||||||
|
|
||||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
|
type OrderEntries = "sort" | "filter" | "map"
|
||||||
const defaultOptions = {
|
|
||||||
folderClickBehavior: "collapse",
|
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",
|
folderDefaultState: "collapsed",
|
||||||
|
folderClickBehavior: "collapse",
|
||||||
useSavedState: true,
|
useSavedState: true,
|
||||||
mapFn: (node) => {
|
mapFn: (node) => {
|
||||||
return node
|
return node
|
||||||
},
|
},
|
||||||
sortFn: (a, b) => {
|
sortFn: (a, b) => {
|
||||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||||
if ((!a.file && !b.file) || (a.file && b.file)) {
|
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
// 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
|
// 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, {
|
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||||
|
@ -27,75 +39,44 @@ const defaultOptions = {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.file && !b.file) {
|
if (!a.isFolder && b.isFolder) {
|
||||||
return 1
|
return 1
|
||||||
} else {
|
} else {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filterFn: (node) => node.name !== "tags",
|
filterFn: (node) => node.slugSegment !== "tags",
|
||||||
order: ["filter", "map", "sort"],
|
order: ["filter", "map", "sort"],
|
||||||
} satisfies Options
|
}
|
||||||
|
|
||||||
|
export type FolderState = {
|
||||||
|
path: string
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default ((userOpts?: Partial<Options>) => {
|
export default ((userOpts?: Partial<Options>) => {
|
||||||
// Parse config
|
|
||||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||||
|
|
||||||
// memoized
|
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||||
let fileTree: FileNode
|
|
||||||
let jsonTree: string
|
|
||||||
let lastBuildId: string = ""
|
|
||||||
|
|
||||||
function constructFileTree(allFiles: QuartzPluginData[]) {
|
|
||||||
// Construct tree from allFiles
|
|
||||||
fileTree = new FileNode("")
|
|
||||||
allFiles.forEach((file) => fileTree.add(file))
|
|
||||||
|
|
||||||
// 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 (functionName === "map") {
|
|
||||||
fileTree.map(opts.mapFn)
|
|
||||||
} else if (functionName === "sort") {
|
|
||||||
fileTree.sort(opts.sortFn)
|
|
||||||
} else if (functionName === "filter") {
|
|
||||||
fileTree.filter(opts.filterFn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all folders of tree. Initialize with collapsed state
|
|
||||||
// Stringify to pass json tree as data attribute ([data-tree])
|
|
||||||
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
|
|
||||||
jsonTree = JSON.stringify(folders)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Explorer: QuartzComponent = ({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
allFiles,
|
|
||||||
displayClass,
|
|
||||||
fileData,
|
|
||||||
}: QuartzComponentProps) => {
|
|
||||||
if (ctx.buildId !== lastBuildId) {
|
|
||||||
lastBuildId = ctx.buildId
|
|
||||||
constructFileTree(allFiles)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div class={classNames(displayClass, "explorer")}>
|
<div
|
||||||
<button
|
class={classNames(displayClass, "explorer")}
|
||||||
type="button"
|
|
||||||
id="mobile-explorer"
|
|
||||||
class="collapsed hide-until-loaded"
|
|
||||||
data-behavior={opts.folderClickBehavior}
|
data-behavior={opts.folderClickBehavior}
|
||||||
data-collapsed={opts.folderDefaultState}
|
data-collapsed={opts.folderDefaultState}
|
||||||
data-savestate={opts.useSavedState}
|
data-savestate={opts.useSavedState}
|
||||||
data-tree={jsonTree}
|
data-data-fns={JSON.stringify({
|
||||||
|
order: opts.order,
|
||||||
|
sortFn: opts.sortFn.toString(),
|
||||||
|
filterFn: opts.filterFn.toString(),
|
||||||
|
mapFn: opts.mapFn.toString(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="mobile-explorer"
|
||||||
|
class="explorer-toggle hide-until-loaded"
|
||||||
data-mobile={true}
|
data-mobile={true}
|
||||||
aria-controls="explorer-content"
|
aria-controls="explorer-content"
|
||||||
aria-expanded={false}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -105,7 +86,7 @@ export default ((userOpts?: Partial<Options>) => {
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="lucide lucide-menu"
|
class="lucide-menu"
|
||||||
>
|
>
|
||||||
<line x1="4" x2="20" y1="12" y2="12" />
|
<line x1="4" x2="20" y1="12" y2="12" />
|
||||||
<line x1="4" x2="20" y1="6" y2="6" />
|
<line x1="4" x2="20" y1="6" y2="6" />
|
||||||
|
@ -115,13 +96,8 @@ export default ((userOpts?: Partial<Options>) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="desktop-explorer"
|
id="desktop-explorer"
|
||||||
class="title-button"
|
class="title-button explorer-toggle"
|
||||||
data-behavior={opts.folderClickBehavior}
|
|
||||||
data-collapsed={opts.folderDefaultState}
|
|
||||||
data-savestate={opts.useSavedState}
|
|
||||||
data-tree={jsonTree}
|
|
||||||
data-mobile={false}
|
data-mobile={false}
|
||||||
aria-controls="explorer-content"
|
|
||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
>
|
>
|
||||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||||
|
@ -140,17 +116,47 @@ export default ((userOpts?: Partial<Options>) => {
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="explorer-content">
|
<div id="explorer-content" aria-expanded={false}>
|
||||||
<ul class="overflow" id="explorer-ul">
|
<OverflowList id="explorer-ul" />
|
||||||
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
|
|
||||||
<li id="explorer-end" />
|
|
||||||
</ul>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Explorer.css = style
|
Explorer.css = style
|
||||||
Explorer.afterDOMLoaded = script
|
Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
|
||||||
return Explorer
|
return Explorer
|
||||||
}) satisfies QuartzComponentConstructor
|
}) satisfies QuartzComponentConstructor
|
||||||
|
|
|
@ -1,242 +0,0 @@
|
||||||
// @ts-ignore
|
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
|
||||||
import {
|
|
||||||
joinSegments,
|
|
||||||
resolveRelative,
|
|
||||||
clone,
|
|
||||||
simplifySlug,
|
|
||||||
SimpleSlug,
|
|
||||||
FilePath,
|
|
||||||
} from "../util/path"
|
|
||||||
|
|
||||||
type OrderEntries = "sort" | "filter" | "map"
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
title?: string
|
|
||||||
folderDefaultState: "collapsed" | "open"
|
|
||||||
folderClickBehavior: "collapse" | "link"
|
|
||||||
useSavedState: boolean
|
|
||||||
sortFn: (a: FileNode, b: FileNode) => number
|
|
||||||
filterFn: (node: FileNode) => boolean
|
|
||||||
mapFn: (node: FileNode) => void
|
|
||||||
order: OrderEntries[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataWrapper = {
|
|
||||||
file: QuartzPluginData
|
|
||||||
path: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FolderState = {
|
|
||||||
path: string
|
|
||||||
collapsed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
|
|
||||||
if (!fp) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return fp.split("/").at(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structure to add all files into a tree
|
|
||||||
export class FileNode {
|
|
||||||
children: Array<FileNode>
|
|
||||||
name: string // this is the slug segment
|
|
||||||
displayName: string
|
|
||||||
file: QuartzPluginData | null
|
|
||||||
depth: number
|
|
||||||
|
|
||||||
constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
|
|
||||||
this.children = []
|
|
||||||
this.name = slugSegment
|
|
||||||
this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
|
|
||||||
this.file = file ? clone(file) : null
|
|
||||||
this.depth = depth ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private insert(fileData: DataWrapper) {
|
|
||||||
if (fileData.path.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSegment = fileData.path[0]
|
|
||||||
|
|
||||||
// base case, insert here
|
|
||||||
if (fileData.path.length === 1) {
|
|
||||||
if (nextSegment === "") {
|
|
||||||
// index case (we are the root and we just found index.md), set our data appropriately
|
|
||||||
const title = fileData.file.frontmatter?.title
|
|
||||||
if (title && title !== "index") {
|
|
||||||
this.displayName = title
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// direct child
|
|
||||||
this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// find the right child to insert into
|
|
||||||
fileData.path = fileData.path.splice(1)
|
|
||||||
const child = this.children.find((c) => c.name === nextSegment)
|
|
||||||
if (child) {
|
|
||||||
child.insert(fileData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newChild = new FileNode(
|
|
||||||
nextSegment,
|
|
||||||
getPathSegment(fileData.file.relativePath, this.depth),
|
|
||||||
undefined,
|
|
||||||
this.depth + 1,
|
|
||||||
)
|
|
||||||
newChild.insert(fileData)
|
|
||||||
this.children.push(newChild)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new file to tree
|
|
||||||
add(file: QuartzPluginData) {
|
|
||||||
this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
* Intended to only be called on root node before changes to the tree are made
|
|
||||||
* @param collapsed default state of folders (collapsed by default or not)
|
|
||||||
* @returns array containing folder state for tree
|
|
||||||
*/
|
|
||||||
getFolderPaths(collapsed: boolean): FolderState[] {
|
|
||||||
const folderPaths: FolderState[] = []
|
|
||||||
|
|
||||||
const traverse = (node: FileNode, currentPath: string) => {
|
|
||||||
if (!node.file) {
|
|
||||||
const folderPath = joinSegments(currentPath, node.name)
|
|
||||||
if (folderPath !== "") {
|
|
||||||
folderPaths.push({ path: folderPath, collapsed })
|
|
||||||
}
|
|
||||||
|
|
||||||
node.children.forEach((child) => traverse(child, folderPath))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traverse(this, "")
|
|
||||||
return folderPaths
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort order: folders first, then files. Sort folders and files alphabetically
|
|
||||||
/**
|
|
||||||
* Sorts tree according to sort/compare function
|
|
||||||
* @param sortFn compare function used for `.sort()`, also used recursively for children
|
|
||||||
*/
|
|
||||||
sort(sortFn: (a: FileNode, b: FileNode) => number) {
|
|
||||||
this.children = this.children.sort(sortFn)
|
|
||||||
this.children.forEach((e) => e.sort(sortFn))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExplorerNodeProps = {
|
|
||||||
node: FileNode
|
|
||||||
opts: Options
|
|
||||||
fileData: QuartzPluginData
|
|
||||||
fullPath?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
|
|
||||||
// Get options
|
|
||||||
const folderBehavior = opts.folderClickBehavior
|
|
||||||
const isDefaultOpen = opts.folderDefaultState === "open"
|
|
||||||
|
|
||||||
// Calculate current folderPath
|
|
||||||
const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
|
|
||||||
const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{node.file ? (
|
|
||||||
// Single file node
|
|
||||||
<li key={node.file.slug}>
|
|
||||||
<a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
|
|
||||||
{node.displayName}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<li>
|
|
||||||
{node.name !== "" && (
|
|
||||||
// Node with entire folder
|
|
||||||
// Render svg button + folder name, then children
|
|
||||||
<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>
|
|
||||||
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
|
|
||||||
<div key={node.name} data-folderpath={folderPath}>
|
|
||||||
{folderBehavior === "link" ? (
|
|
||||||
<a href={href} data-for={node.name} class="folder-title">
|
|
||||||
{node.displayName}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button class="folder-button">
|
|
||||||
<span class="folder-title">{node.displayName}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Recursively render children of folder */}
|
|
||||||
<div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
|
|
||||||
<ul
|
|
||||||
// Inline style for left folder paddings
|
|
||||||
style={{
|
|
||||||
paddingLeft: node.name !== "" ? "1.4rem" : "0",
|
|
||||||
}}
|
|
||||||
class="content"
|
|
||||||
data-folderul={folderPath}
|
|
||||||
>
|
|
||||||
{node.children.map((childNode, i) => (
|
|
||||||
<ExplorerNode
|
|
||||||
node={childNode}
|
|
||||||
key={i}
|
|
||||||
opts={opts}
|
|
||||||
fullPath={folderPath}
|
|
||||||
fileData={fileData}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
39
quartz/components/OverflowList.tsx
Normal file
39
quartz/components/OverflowList.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { JSX } from "preact"
|
||||||
|
|
||||||
|
const OverflowList = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
||||||
|
return (
|
||||||
|
<ul class="overflow" {...props}>
|
||||||
|
{children}
|
||||||
|
<li class="overflow-end" />
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OverflowList.afterDOMLoaded = (id: string) => `
|
||||||
|
document.addEventListener("nav", (e) => {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const parentUl = entry.target.parentElement
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
parentUl.classList.remove("gradient-active")
|
||||||
|
} else {
|
||||||
|
parentUl.classList.add("gradient-active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ul = document.getElementById("${id}")
|
||||||
|
if (!ul) return
|
||||||
|
|
||||||
|
const end = ul.querySelector(".overflow-end")
|
||||||
|
if (!end) return
|
||||||
|
|
||||||
|
observer.observe(end)
|
||||||
|
window.addCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
`
|
||||||
|
|
||||||
|
export default OverflowList
|
|
@ -6,6 +6,7 @@ import { classNames } from "../util/lang"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import script from "./scripts/toc.inline"
|
import script from "./scripts/toc.inline"
|
||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
|
import OverflowList from "./OverflowList"
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
layout: "modern" | "legacy"
|
layout: "modern" | "legacy"
|
||||||
|
@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
|
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
|
||||||
<ul class="overflow">
|
<OverflowList id="toc-ul">
|
||||||
{fileData.toc.map((tocEntry) => (
|
{fileData.toc.map((tocEntry) => (
|
||||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||||
|
@ -58,13 +59,13 @@ const TableOfContents: QuartzComponent = ({
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</OverflowList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TableOfContents.css = modernStyle
|
TableOfContents.css = modernStyle
|
||||||
TableOfContents.afterDOMLoaded = script
|
TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
|
||||||
|
|
||||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||||
if (!fileData.toc) {
|
if (!fileData.toc) {
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
|
||||||
import HeaderConstructor from "./Header"
|
import HeaderConstructor from "./Header"
|
||||||
import BodyConstructor from "./Body"
|
import BodyConstructor from "./Body"
|
||||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
|
||||||
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||||
|
import { clone } from "../util/clone"
|
||||||
import { visit } from "unist-util-visit"
|
import { visit } from "unist-util-visit"
|
||||||
import { Root, Element, ElementContent } from "hast"
|
import { Root, Element, ElementContent } from "hast"
|
||||||
import { GlobalConfiguration } from "../cfg"
|
import { GlobalConfiguration } from "../cfg"
|
||||||
|
|
|
@ -1,53 +1,38 @@
|
||||||
import { FolderState } from "../ExplorerNode"
|
import { FileTrieNode } from "../../util/fileTrie"
|
||||||
|
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||||
|
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||||
|
|
||||||
// Current state of folders
|
|
||||||
type MaybeHTMLElement = HTMLElement | undefined
|
type MaybeHTMLElement = HTMLElement | undefined
|
||||||
let currentExplorerState: FolderState[]
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
interface ParsedOptions {
|
||||||
// If last element is observed, remove gradient of "overflow" class so element is visible
|
folderClickBehavior: "collapse" | "link"
|
||||||
const explorerUl = document.getElementById("explorer-ul")
|
folderDefaultState: "collapsed" | "open"
|
||||||
if (!explorerUl) return
|
useSavedState: boolean
|
||||||
for (const entry of entries) {
|
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||||
if (entry.isIntersecting) {
|
filterFn: (node: FileTrieNode) => boolean
|
||||||
explorerUl.classList.add("no-background")
|
mapFn: (node: FileTrieNode) => void
|
||||||
} else {
|
order: "sort" | "filter" | "map"[]
|
||||||
explorerUl.classList.remove("no-background")
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
type FolderState = {
|
||||||
|
path: string
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentExplorerState: Array<FolderState>
|
||||||
function toggleExplorer(this: HTMLElement) {
|
function toggleExplorer(this: HTMLElement) {
|
||||||
// Toggle collapsed state of entire explorer
|
const explorers = document.querySelectorAll(".explorer")
|
||||||
this.classList.toggle("collapsed")
|
for (const explorer of explorers) {
|
||||||
|
explorer.classList.toggle("collapsed")
|
||||||
// Toggle collapsed aria state of entire explorer
|
explorer.setAttribute(
|
||||||
this.setAttribute(
|
|
||||||
"aria-expanded",
|
"aria-expanded",
|
||||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||||
)
|
)
|
||||||
|
|
||||||
const content = (
|
|
||||||
this.nextElementSibling?.nextElementSibling
|
|
||||||
? this.nextElementSibling.nextElementSibling
|
|
||||||
: this.nextElementSibling
|
|
||||||
) as MaybeHTMLElement
|
|
||||||
if (!content) return
|
|
||||||
content.classList.toggle("collapsed")
|
|
||||||
content.classList.toggle("explorer-viewmode")
|
|
||||||
|
|
||||||
// Prevent scroll under
|
|
||||||
if (document.querySelector("#mobile-explorer")) {
|
|
||||||
// Disable scrolling on the page when the explorer is opened on mobile
|
|
||||||
const bodySelector = document.querySelector("#quartz-body")
|
|
||||||
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFolder(evt: MouseEvent) {
|
function toggleFolder(evt: MouseEvent) {
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
|
|
||||||
// Element that was clicked
|
|
||||||
const target = evt.target as MaybeHTMLElement
|
const target = evt.target as MaybeHTMLElement
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
@ -55,162 +40,240 @@ function toggleFolder(evt: MouseEvent) {
|
||||||
const isSvg = target.nodeName === "svg"
|
const isSvg = target.nodeName === "svg"
|
||||||
|
|
||||||
// corresponding <ul> element relative to clicked button/folder
|
// corresponding <ul> element relative to clicked button/folder
|
||||||
const childFolderContainer = (
|
const folderContainer = (
|
||||||
isSvg
|
isSvg
|
||||||
? target.parentElement?.nextSibling
|
? // svg -> div.folder-container
|
||||||
: target.parentElement?.parentElement?.nextElementSibling
|
target.parentElement
|
||||||
|
: // button.folder-button -> div -> div.folder-container
|
||||||
|
target.parentElement?.parentElement
|
||||||
) as MaybeHTMLElement
|
) as MaybeHTMLElement
|
||||||
const currentFolderParent = (
|
if (!folderContainer) return
|
||||||
isSvg ? target.nextElementSibling : target.parentElement
|
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
|
||||||
) as MaybeHTMLElement
|
if (!childFolderContainer) return
|
||||||
if (!(childFolderContainer && currentFolderParent)) return
|
|
||||||
// <li> element of folder (stores folder-path dataset)
|
|
||||||
childFolderContainer.classList.toggle("open")
|
childFolderContainer.classList.toggle("open")
|
||||||
|
|
||||||
// Collapse folder container
|
// Collapse folder container
|
||||||
const isCollapsed = childFolderContainer.classList.contains("open")
|
const isCollapsed = !childFolderContainer.classList.contains("open")
|
||||||
setFolderState(childFolderContainer, !isCollapsed)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Save folder state to localStorage
|
|
||||||
const fullFolderPath = currentFolderParent.dataset.folderpath as string
|
|
||||||
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
|
|
||||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupExplorer() {
|
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
|
||||||
// Set click handler for collapsing entire explorer
|
const template = document.getElementById("template-file") as HTMLTemplateElement
|
||||||
const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
|
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.data?.slug!)
|
||||||
|
a.dataset.for = node.data?.slug
|
||||||
|
a.textContent = node.displayName
|
||||||
|
|
||||||
|
if (currentSlug === node.data?.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.data?.slug!
|
||||||
|
folderContainer.dataset.folderpath = folderPath
|
||||||
|
|
||||||
|
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 = node.data?.slug
|
||||||
|
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.data
|
||||||
|
? createFileNode(currentSlug, child)
|
||||||
|
: createFolderNode(currentSlug, child, opts)
|
||||||
|
ul.appendChild(childNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupExplorer(currentSlug: FullSlug) {
|
||||||
|
const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
for (const explorer of allExplorers) {
|
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
|
// Get folder state from local storage
|
||||||
const storageTree = localStorage.getItem("fileTree")
|
const storageTree = localStorage.getItem("fileTree")
|
||||||
|
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||||
|
const oldIndex = new Map(
|
||||||
|
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||||
|
)
|
||||||
|
|
||||||
// Convert to bool
|
const data = await fetchData
|
||||||
const useSavedFolderState = explorer?.dataset.savestate === "true"
|
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||||
|
const trie = FileTrieNode.fromEntries(entries)
|
||||||
|
|
||||||
if (explorer) {
|
// Apply functions in order
|
||||||
// Get config
|
for (const fn of opts.order) {
|
||||||
const collapseBehavior = explorer.dataset.behavior
|
switch (fn) {
|
||||||
|
case "filter":
|
||||||
// Add click handlers for all folders (click handler on folder "label")
|
if (opts.filterFn) trie.filter(opts.filterFn)
|
||||||
if (collapseBehavior === "collapse") {
|
break
|
||||||
for (const item of document.getElementsByClassName(
|
case "map":
|
||||||
"folder-button",
|
if (opts.mapFn) trie.map(opts.mapFn)
|
||||||
) as HTMLCollectionOf<HTMLElement>) {
|
break
|
||||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
case "sort":
|
||||||
item.addEventListener("click", toggleFolder)
|
if (opts.sortFn) trie.sort(opts.sortFn)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler to main explorer
|
// Get folder paths for state management
|
||||||
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
|
const folderPaths = trie.getFolderPaths()
|
||||||
explorer.addEventListener("click", toggleExplorer)
|
currentExplorerState = folderPaths.map((path) => ({
|
||||||
}
|
|
||||||
|
|
||||||
// Set up click handlers for each folder (click handler on folder "icon")
|
|
||||||
for (const item of document.getElementsByClassName(
|
|
||||||
"folder-icon",
|
|
||||||
) as HTMLCollectionOf<HTMLElement>) {
|
|
||||||
item.addEventListener("click", toggleFolder)
|
|
||||||
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get folder state from local storage
|
|
||||||
const oldExplorerState: FolderState[] =
|
|
||||||
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
|
|
||||||
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
|
|
||||||
const newExplorerState: FolderState[] = explorer.dataset.tree
|
|
||||||
? JSON.parse(explorer.dataset.tree)
|
|
||||||
: []
|
|
||||||
currentExplorerState = []
|
|
||||||
|
|
||||||
for (const { path, collapsed } of newExplorerState) {
|
|
||||||
currentExplorerState.push({
|
|
||||||
path,
|
path,
|
||||||
collapsed: oldIndex.get(path) ?? collapsed,
|
collapsed: oldIndex.get(path) === true,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
const explorerUl = document.getElementById("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" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentExplorerState.map((folderState) => {
|
// Set up event handlers
|
||||||
const folderLi = document.querySelector(
|
const explorerButtons = explorer.querySelectorAll(
|
||||||
`[data-folderpath='${folderState.path.replace("'", "-")}']`,
|
"button.explorer-toggle",
|
||||||
) as MaybeHTMLElement
|
) as NodeListOf<HTMLElement>
|
||||||
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
|
if (explorerButtons) {
|
||||||
if (folderUl) {
|
window.addCleanup(() =>
|
||||||
setFolderState(folderUl, folderState.collapsed)
|
explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
|
||||||
|
)
|
||||||
|
explorerButtons.forEach((button) => button.addEventListener("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) {
|
||||||
|
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
|
||||||
|
button.addEventListener("click", toggleFolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIcons = explorer.getElementsByClassName(
|
||||||
|
"folder-icon",
|
||||||
|
) as HTMLCollectionOf<HTMLElement>
|
||||||
|
for (const icon of folderIcons) {
|
||||||
|
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
|
||||||
|
icon.addEventListener("click", toggleFolder)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExplorerFolders() {
|
document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
|
||||||
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
|
// save explorer scrollTop position
|
||||||
/\/index$/g,
|
const explorer = document.getElementById("explorer-ul")
|
||||||
"",
|
if (!explorer) return
|
||||||
)
|
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
|
||||||
const allFolders = document.querySelectorAll(".folder-outer")
|
})
|
||||||
|
|
||||||
allFolders.forEach((element) => {
|
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||||
const folderUl = Array.from(element.children).find((child) =>
|
const currentSlug = e.detail.url
|
||||||
child.matches("ul[data-folderul]"),
|
await setupExplorer(currentSlug)
|
||||||
)
|
|
||||||
if (folderUl) {
|
// if mobile hamburger is visible, collapse by default
|
||||||
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
|
const mobileExplorer = document.getElementById("mobile-explorer")
|
||||||
if (!element.classList.contains("open")) {
|
if (mobileExplorer && mobileExplorer.checkVisibility()) {
|
||||||
element.classList.add("open")
|
for (const explorer of document.querySelectorAll(".explorer")) {
|
||||||
}
|
explorer.classList.add("collapsed")
|
||||||
}
|
explorer.setAttribute("aria-expanded", "false")
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
|
||||||
window.addEventListener("resize", setupExplorer)
|
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
|
||||||
|
|
||||||
document.addEventListener("nav", () => {
|
|
||||||
const explorer = document.querySelector("#mobile-explorer")
|
|
||||||
if (explorer) {
|
|
||||||
explorer.classList.add("collapsed")
|
|
||||||
const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
|
|
||||||
if (content) {
|
|
||||||
content.classList.add("collapsed")
|
|
||||||
content.classList.toggle("explorer-viewmode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setupExplorer()
|
|
||||||
|
|
||||||
observer.disconnect()
|
|
||||||
|
|
||||||
// select pseudo element at end of list
|
|
||||||
const lastItem = document.getElementById("explorer-end")
|
|
||||||
if (lastItem) {
|
|
||||||
observer.observe(lastItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide explorer on mobile until it is requested
|
|
||||||
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
|
|
||||||
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
|
|
||||||
|
|
||||||
toggleExplorerFolders()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the state of a given folder
|
|
||||||
* @param folderElement <div class="folder-outer"> Element of folder (parent)
|
|
||||||
* @param collapsed if folder should be set to collapsed or not
|
|
||||||
*/
|
|
||||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles visibility of a folder
|
|
||||||
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
|
|
||||||
* @param path path to folder (e.g. 'advanced/more/more2')
|
|
||||||
*/
|
|
||||||
function toggleCollapsedByPath(array: FolderState[], path: string) {
|
|
||||||
const entry = array.find((item) => item.path === path)
|
|
||||||
if (entry) {
|
|
||||||
entry.collapsed = !entry.collapsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -75,6 +75,10 @@ async function navigate(url: URL, isBack: boolean = false) {
|
||||||
|
|
||||||
if (!contents) return
|
if (!contents) return
|
||||||
|
|
||||||
|
// notify about to nav
|
||||||
|
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
|
||||||
// cleanup old
|
// cleanup old
|
||||||
cleanupFns.forEach((fn) => fn())
|
cleanupFns.forEach((fn) => fn())
|
||||||
cleanupFns.clear()
|
cleanupFns.clear()
|
||||||
|
@ -108,7 +112,7 @@ async function navigate(url: URL, isBack: boolean = false) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// now, patch head
|
// now, patch head, re-executing scripts
|
||||||
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
|
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
|
||||||
elementsToRemove.forEach((el) => el.remove())
|
elementsToRemove.forEach((el) => el.remove())
|
||||||
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
|
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
const bufferPx = 150
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const slug = entry.target.id
|
const slug = entry.target.id
|
||||||
|
@ -28,7 +27,6 @@ function toggleToc(this: HTMLElement) {
|
||||||
function setupToc() {
|
function setupToc() {
|
||||||
const toc = document.getElementById("toc")
|
const toc = document.getElementById("toc")
|
||||||
if (toc) {
|
if (toc) {
|
||||||
const collapsed = toc.classList.contains("collapsed")
|
|
||||||
const content = toc.nextElementSibling as HTMLElement | undefined
|
const content = toc.nextElementSibling as HTMLElement | undefined
|
||||||
if (!content) return
|
if (!content) return
|
||||||
toc.addEventListener("click", toggleToc)
|
toc.addEventListener("click", toggleToc)
|
||||||
|
|
|
@ -37,6 +37,7 @@ export async function fetchCanonical(url: URL): Promise<Response> {
|
||||||
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
if (!res.headers.get("content-type")?.startsWith("text/html")) {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// reading the body can only be done once, so we need to clone the response
|
// reading the body can only be done once, so we need to clone the response
|
||||||
// to allow the caller to read it if it's was not a redirect
|
// to allow the caller to read it if it's was not a redirect
|
||||||
const text = await res.clone().text()
|
const text = await res.clone().text()
|
||||||
|
|
|
@ -2,18 +2,6 @@
|
||||||
|
|
||||||
.backlinks {
|
.backlinks {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/*&:after {
|
|
||||||
pointer-events: none;
|
|
||||||
content: "";
|
|
||||||
width: 100%;
|
|
||||||
height: 50px;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
background: linear-gradient(transparent 0px, var(--light));
|
|
||||||
}*/
|
|
||||||
|
|
||||||
& > h3 {
|
& > h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
@ -31,14 +19,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .overflow {
|
|
||||||
&:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
height: auto;
|
|
||||||
@media all and not ($desktop) {
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
& svg {
|
& svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -16,10 +16,10 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
background-color: var(--light);
|
background-color: var(--light);
|
||||||
|
padding: 1rem 0 1rem 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Explorer on mobile until done loading.
|
|
||||||
// Prevents ugly animation on page load.
|
|
||||||
.hide-until-loaded ~ #explorer-content {
|
.hide-until-loaded ~ #explorer-content {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -28,9 +28,21 @@
|
||||||
|
|
||||||
.explorer {
|
.explorer {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
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) {
|
@media all and ($mobile) {
|
||||||
order: -1;
|
order: -1;
|
||||||
|
@ -64,18 +76,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*&:after {
|
svg {
|
||||||
|
pointer-events: all;
|
||||||
|
transition: transform 0.35s ease;
|
||||||
|
|
||||||
|
& > polyline {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
content: "";
|
}
|
||||||
width: 100%;
|
}
|
||||||
height: 50px;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
background: linear-gradient(transparent 0px, var(--light));
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button#mobile-explorer,
|
button#mobile-explorer,
|
||||||
|
@ -94,77 +102,46 @@ button#desktop-explorer {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .fold {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.collapsed .fold {
|
|
||||||
transform: rotateZ(-90deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-outer {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 0fr;
|
|
||||||
transition: grid-template-rows 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-outer.open {
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-outer > ul {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#explorer-content {
|
#explorer-content {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 0px;
|
|
||||||
transition:
|
|
||||||
max-height 0.35s ease,
|
|
||||||
visibility 0s linear 0.35s;
|
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
max-height: 100%;
|
|
||||||
transition:
|
|
||||||
max-height 0.35s ease,
|
|
||||||
visibility 0s linear 0s;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
& ul {
|
& ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.08rem 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition:
|
|
||||||
max-height 0.35s ease,
|
|
||||||
transform 0.35s ease,
|
|
||||||
opacity 0.2s ease;
|
|
||||||
|
|
||||||
& li > a {
|
& li > a {
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> #explorer-ul {
|
.folder-outer {
|
||||||
max-height: none;
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
.folder-outer.open {
|
||||||
pointer-events: all;
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
& > polyline {
|
.folder-outer > ul {
|
||||||
pointer-events: none;
|
overflow: hidden;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
border-left: 1px solid var(--lightgray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,69 +204,54 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||||
color: var(--tertiary);
|
color: var(--tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-background::after {
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#explorer-end {
|
|
||||||
// needs height so IntersectionObserver gets triggered
|
|
||||||
height: 4px;
|
|
||||||
// remove default margin from li
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explorer {
|
.explorer {
|
||||||
@media all and ($mobile) {
|
@media all and ($mobile) {
|
||||||
#explorer-content {
|
&.collapsed {
|
||||||
box-sizing: border-box;
|
flex: 0 0 34px;
|
||||||
overscroll-behavior: none;
|
|
||||||
z-index: 100;
|
& > #explorer-content {
|
||||||
position: absolute;
|
transform: translateX(-100vw);
|
||||||
top: 0;
|
|
||||||
background-color: var(--light);
|
|
||||||
max-width: 100dvw;
|
|
||||||
left: -100dvw;
|
|
||||||
width: 100%;
|
|
||||||
transition: transform 300ms ease-in-out;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: $topSpacing 2rem 2rem;
|
|
||||||
height: 100dvh;
|
|
||||||
max-height: 100dvh;
|
|
||||||
margin-top: 0;
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:not(.collapsed) {
|
&:not(.collapsed) {
|
||||||
transform: translateX(100dvw);
|
flex: 0 0 34px;
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.overflow {
|
& > #explorer-content {
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#mobile-explorer {
|
#explorer-content {
|
||||||
margin: 5px;
|
box-sizing: border-box;
|
||||||
z-index: 101;
|
z-index: 100;
|
||||||
|
position: absolute;
|
||||||
&:not(.collapsed) .lucide-menu {
|
top: 0;
|
||||||
transform: rotate(-90deg);
|
left: 0;
|
||||||
transition: transform 200ms ease-in-out;
|
margin-top: 0;
|
||||||
|
background-color: var(--light);
|
||||||
|
max-width: 100vw;
|
||||||
|
width: 100%;
|
||||||
|
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 {
|
.lucide-menu {
|
||||||
stroke: var(--darkgray);
|
stroke: var(--darkgray);
|
||||||
transition: transform 200ms ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
stroke: var(--dark);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&.desktop-only {
|
overflow-y: hidden;
|
||||||
max-height: 40%;
|
flex: 0 1 auto;
|
||||||
|
&:has(button#toc.collapsed) {
|
||||||
|
flex: 0 1 1.2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,26 +46,7 @@ button#toc {
|
||||||
|
|
||||||
#toc-content {
|
#toc-content {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
overflow: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
transition:
|
|
||||||
max-height 0.35s ease,
|
|
||||||
visibility 0s linear 0s;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
visibility: visible;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
max-height: 0;
|
|
||||||
transition:
|
|
||||||
max-height 0.35s ease,
|
|
||||||
visibility 0s linear 0.35s;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.collapsed > .overflow::after {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& ul {
|
& ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -80,10 +63,6 @@ button#toc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
> ul.overflow {
|
|
||||||
max-height: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@for $i from 0 through 6 {
|
@for $i from 0 through 6 {
|
||||||
& .depth-#{$i} {
|
& .depth-#{$i} {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||||
export type ContentDetails = {
|
export type ContentDetails = {
|
||||||
|
slug: FullSlug
|
||||||
title: string
|
title: string
|
||||||
links: SimpleSlug[]
|
links: SimpleSlug[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
linkIndex.set(slug, {
|
linkIndex.set(slug, {
|
||||||
|
slug,
|
||||||
title: file.data.frontmatter?.title!,
|
title: file.data.frontmatter?.title!,
|
||||||
links: file.data.links ?? [],
|
links: file.data.links ?? [],
|
||||||
tags: file.data.frontmatter?.tags ?? [],
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
|
|
|
@ -543,7 +543,6 @@ video {
|
||||||
|
|
||||||
div:has(> .overflow) {
|
div:has(> .overflow) {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -551,6 +550,7 @@ ul.overflow,
|
||||||
ol.overflow {
|
ol.overflow {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
// clearfix
|
// clearfix
|
||||||
content: "";
|
content: "";
|
||||||
|
@ -559,18 +559,15 @@ ol.overflow {
|
||||||
& > li:last-of-type {
|
& > li:last-of-type {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
/*&:after {
|
|
||||||
pointer-events: none;
|
& > li.overflow-end {
|
||||||
content: "";
|
height: 4px;
|
||||||
width: 100%;
|
margin: 0;
|
||||||
height: 50px;
|
}
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
&.gradient-active {
|
||||||
bottom: 0;
|
mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
|
||||||
opacity: 1;
|
}
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
background: linear-gradient(transparent 0px, var(--light));
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transclude {
|
.transclude {
|
||||||
|
|
3
quartz/util/clone.ts
Normal file
3
quartz/util/clone.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import rfdc from "rfdc"
|
||||||
|
|
||||||
|
export const clone = rfdc()
|
190
quartz/util/fileTrie.test.ts
Normal file
190
quartz/util/fileTrie.test.ts
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
import test, { describe, beforeEach } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { FileTrieNode } from "./fileTrie"
|
||||||
|
|
||||||
|
interface TestData {
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FileTrie", () => {
|
||||||
|
let trie: FileTrieNode<TestData>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trie = new FileTrieNode<TestData>("")
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("should create an empty trie", () => {
|
||||||
|
assert.deepStrictEqual(trie.children, [])
|
||||||
|
assert.strictEqual(trie.slugSegment, "")
|
||||||
|
assert.strictEqual(trie.displayName, "")
|
||||||
|
assert.strictEqual(trie.data, null)
|
||||||
|
assert.strictEqual(trie.depth, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should set displayName from data title", () => {
|
||||||
|
const data = {
|
||||||
|
title: "Test Title",
|
||||||
|
slug: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
trie.add(data)
|
||||||
|
assert.strictEqual(trie.children[0].displayName, "Test Title")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("add", () => {
|
||||||
|
test("should add a file at root level", () => {
|
||||||
|
const data = {
|
||||||
|
title: "Test",
|
||||||
|
slug: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
trie.add(data)
|
||||||
|
assert.strictEqual(trie.children.length, 1)
|
||||||
|
assert.strictEqual(trie.children[0].slugSegment, "test")
|
||||||
|
assert.strictEqual(trie.children[0].data, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should handle index files", () => {
|
||||||
|
const data = {
|
||||||
|
title: "Index",
|
||||||
|
slug: "index",
|
||||||
|
}
|
||||||
|
|
||||||
|
trie.add(data)
|
||||||
|
assert.strictEqual(trie.data, data)
|
||||||
|
assert.strictEqual(trie.children.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should add nested files", () => {
|
||||||
|
const data1 = {
|
||||||
|
title: "Nested",
|
||||||
|
slug: "folder/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
const data2 = {
|
||||||
|
title: "Really nested index",
|
||||||
|
slug: "a/b/c/index",
|
||||||
|
}
|
||||||
|
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
assert.strictEqual(trie.children.length, 2)
|
||||||
|
assert.strictEqual(trie.children[0].slugSegment, "folder")
|
||||||
|
assert.strictEqual(trie.children[0].children.length, 1)
|
||||||
|
assert.strictEqual(trie.children[0].children[0].slugSegment, "test")
|
||||||
|
assert.strictEqual(trie.children[0].children[0].data, data1)
|
||||||
|
|
||||||
|
assert.strictEqual(trie.children[1].slugSegment, "a")
|
||||||
|
assert.strictEqual(trie.children[1].children.length, 1)
|
||||||
|
assert.strictEqual(trie.children[1].data, null)
|
||||||
|
|
||||||
|
assert.strictEqual(trie.children[1].children[0].slugSegment, "b")
|
||||||
|
assert.strictEqual(trie.children[1].children[0].children.length, 1)
|
||||||
|
assert.strictEqual(trie.children[1].children[0].data, null)
|
||||||
|
|
||||||
|
assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c")
|
||||||
|
assert.strictEqual(trie.children[1].children[0].children[0].data, data2)
|
||||||
|
assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("filter", () => {
|
||||||
|
test("should filter nodes based on condition", () => {
|
||||||
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
|
const data2 = { title: "Test2", slug: "test2" }
|
||||||
|
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
|
||||||
|
trie.filter((node) => node.slugSegment !== "test1")
|
||||||
|
assert.strictEqual(trie.children.length, 1)
|
||||||
|
assert.strictEqual(trie.children[0].slugSegment, "test2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("map", () => {
|
||||||
|
test("should apply function to all nodes", () => {
|
||||||
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
|
const data2 = { title: "Test2", slug: "test2" }
|
||||||
|
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
|
||||||
|
trie.map((node) => {
|
||||||
|
if (node.data) {
|
||||||
|
node.displayName = "Modified"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.strictEqual(trie.children[0].displayName, "Modified")
|
||||||
|
assert.strictEqual(trie.children[1].displayName, "Modified")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("entries", () => {
|
||||||
|
test("should return all entries", () => {
|
||||||
|
const data1 = { title: "Test1", slug: "test1" }
|
||||||
|
const data2 = { title: "Test2", slug: "a/b/test2" }
|
||||||
|
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
|
||||||
|
const entries = trie.entries()
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
entries.map(([path, node]) => [path, node.data]),
|
||||||
|
[
|
||||||
|
["", trie.data],
|
||||||
|
["test1", data1],
|
||||||
|
["a/index", null],
|
||||||
|
["a/b/index", null],
|
||||||
|
["a/b/test2", data2],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getFolderPaths", () => {
|
||||||
|
test("should return all folder paths", () => {
|
||||||
|
const data1 = {
|
||||||
|
title: "Root",
|
||||||
|
slug: "index",
|
||||||
|
}
|
||||||
|
const data2 = {
|
||||||
|
title: "Test",
|
||||||
|
slug: "folder/subfolder/test",
|
||||||
|
}
|
||||||
|
const data3 = {
|
||||||
|
title: "Folder Index",
|
||||||
|
slug: "abc/index",
|
||||||
|
}
|
||||||
|
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
trie.add(data3)
|
||||||
|
const paths = trie.getFolderPaths()
|
||||||
|
|
||||||
|
assert.deepStrictEqual(paths, ["folder/index", "folder/subfolder/index", "abc/index"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sort", () => {
|
||||||
|
test("should sort nodes according to sort function", () => {
|
||||||
|
const data1 = { title: "A", slug: "a" }
|
||||||
|
const data2 = { title: "B", slug: "b" }
|
||||||
|
const data3 = { title: "C", slug: "c" }
|
||||||
|
|
||||||
|
trie.add(data3)
|
||||||
|
trie.add(data1)
|
||||||
|
trie.add(data2)
|
||||||
|
|
||||||
|
trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment))
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
trie.children.map((n) => n.slugSegment),
|
||||||
|
["a", "b", "c"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
128
quartz/util/fileTrie.ts
Normal file
128
quartz/util/fileTrie.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import { ContentDetails } from "../plugins/emitters/contentIndex"
|
||||||
|
import { FullSlug, joinSegments } from "./path"
|
||||||
|
|
||||||
|
interface FileTrieData {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
|
||||||
|
children: Array<FileTrieNode<T>>
|
||||||
|
slugSegment: string
|
||||||
|
displayName: string
|
||||||
|
data: T | null
|
||||||
|
depth: number
|
||||||
|
isFolder: boolean
|
||||||
|
|
||||||
|
constructor(segment: string, data?: T, depth: number = 0) {
|
||||||
|
this.children = []
|
||||||
|
this.slugSegment = segment
|
||||||
|
this.displayName = data?.title ?? segment
|
||||||
|
this.data = data ?? null
|
||||||
|
this.depth = depth
|
||||||
|
this.isFolder = segment === "index"
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(path: string[], file: T) {
|
||||||
|
if (path.length === 0) return
|
||||||
|
|
||||||
|
const nextSegment = path[0]
|
||||||
|
|
||||||
|
// base case, insert here
|
||||||
|
if (path.length === 1) {
|
||||||
|
if (nextSegment === "index") {
|
||||||
|
// index case (we are the root and we just found index.md)
|
||||||
|
this.data ??= file
|
||||||
|
const title = file.title
|
||||||
|
if (title !== "index") {
|
||||||
|
this.displayName = title
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// direct child
|
||||||
|
this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1))
|
||||||
|
this.isFolder = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the right child to insert into, creating it if it doesn't exist
|
||||||
|
path = path.splice(1)
|
||||||
|
let child = this.children.find((c) => c.slugSegment === nextSegment)
|
||||||
|
if (!child) {
|
||||||
|
child = new FileTrieNode<T>(nextSegment, undefined, this.depth + 1)
|
||||||
|
this.children.push(child)
|
||||||
|
child.isFolder = true
|
||||||
|
}
|
||||||
|
|
||||||
|
child.insert(path, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new file to trie
|
||||||
|
add(file: T) {
|
||||||
|
this.insert(file.slug.split("/"), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
|
||||||
|
*/
|
||||||
|
filter(filterFn: (node: FileTrieNode<T>) => boolean) {
|
||||||
|
this.children = this.children.filter(filterFn)
|
||||||
|
this.children.forEach((child) => child.filter(filterFn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place
|
||||||
|
*/
|
||||||
|
map(mapFn: (node: FileTrieNode<T>) => void) {
|
||||||
|
mapFn(this)
|
||||||
|
this.children.forEach((child) => child.map(mapFn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort trie nodes according to sort/compare function
|
||||||
|
*/
|
||||||
|
sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) {
|
||||||
|
this.children = this.children.sort(sortFn)
|
||||||
|
this.children.forEach((e) => e.sort(sortFn))
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) {
|
||||||
|
const trie = new FileTrieNode<T>("")
|
||||||
|
entries.forEach(([, entry]) => trie.add(entry))
|
||||||
|
return trie
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries in the trie
|
||||||
|
* in the a flat array including the full path and the node
|
||||||
|
*/
|
||||||
|
entries(): [FullSlug, FileTrieNode<T>][] {
|
||||||
|
const traverse = (
|
||||||
|
node: FileTrieNode<T>,
|
||||||
|
currentPath: string,
|
||||||
|
): [FullSlug, FileTrieNode<T>][] => {
|
||||||
|
const segments = [currentPath, node.slugSegment]
|
||||||
|
const fullPath = joinSegments(...segments) as FullSlug
|
||||||
|
|
||||||
|
const indexQualifiedPath =
|
||||||
|
node.isFolder && node.depth > 0 ? (joinSegments(fullPath, "index") as FullSlug) : fullPath
|
||||||
|
|
||||||
|
const result: [FullSlug, FileTrieNode<T>][] = [[indexQualifiedPath, node]]
|
||||||
|
|
||||||
|
return result.concat(...node.children.map((child) => traverse(child, fullPath)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return traverse(this, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all folder paths in the trie
|
||||||
|
* @returns array containing folder state for trie
|
||||||
|
*/
|
||||||
|
getFolderPaths() {
|
||||||
|
return this.entries()
|
||||||
|
.filter(([_, node]) => node.isFolder)
|
||||||
|
.map(([path, _]) => path)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,6 @@
|
||||||
import { slug as slugAnchor } from "github-slugger"
|
import { slug as slugAnchor } from "github-slugger"
|
||||||
import type { Element as HastElement } from "hast"
|
import type { Element as HastElement } from "hast"
|
||||||
import rfdc from "rfdc"
|
import { clone } from "./clone"
|
||||||
|
|
||||||
export const clone = rfdc()
|
|
||||||
|
|
||||||
// this file must be isomorphic so it can't use node libs (e.g. path)
|
// this file must be isomorphic so it can't use node libs (e.g. path)
|
||||||
|
|
||||||
export const QUARTZ = "quartz"
|
export const QUARTZ = "quartz"
|
||||||
|
|
Loading…
Add table
Reference in a new issue