fix: use slugs instead of title as basis for explorer (#652)

* use slugs instead of title as basis for explorer

* fix folder persist state, better default behaviour

* use relative path instead of full path as full path is affected by -d

* dont use title in breadcrumb if it's just index lol
This commit is contained in:
Jacky Zhao 2023-12-27 16:44:14 -08:00 committed by GitHub
parent 63bf1e14b5
commit 504b447162
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 110 additions and 90 deletions

View file

@ -68,8 +68,9 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
// construct the index for the first time // construct the index for the first time
for (const file of allFiles) { for (const file of allFiles) {
if (file.slug?.endsWith("index")) { if (file.slug?.endsWith("index")) {
const folderParts = file.filePath?.split("/") const folderParts = file.slug?.split("/")
if (folderParts) { if (folderParts) {
// 2nd last to exclude the /index
const folderName = folderParts[folderParts?.length - 2] const folderName = folderParts[folderParts?.length - 2]
folderIndex.set(folderName, file) folderIndex.set(folderName, file)
} }
@ -88,7 +89,10 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
// Try to resolve frontmatter folder title // Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(curPathSegment) const currentFile = folderIndex?.get(curPathSegment)
if (currentFile) { if (currentFile) {
curPathSegment = currentFile.frontmatter!.title const title = currentFile.frontmatter!.title
if (title !== "index") {
curPathSegment = title
}
} }
// Add current slug to full path // Add current slug to full path

View file

@ -12,6 +12,9 @@ const defaultOptions = {
folderClickBehavior: "collapse", folderClickBehavior: "collapse",
folderDefaultState: "collapsed", folderDefaultState: "collapsed",
useSavedState: true, useSavedState: true,
mapFn: (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 alphabetically
if ((!a.file && !b.file) || (a.file && b.file)) { if ((!a.file && !b.file) || (a.file && b.file)) {
@ -22,6 +25,7 @@ const defaultOptions = {
sensitivity: "base", sensitivity: "base",
}) })
} }
if (a.file && !b.file) { if (a.file && !b.file) {
return 1 return 1
} else { } else {
@ -41,36 +45,25 @@ export default ((userOpts?: Partial<Options>) => {
let jsonTree: string let jsonTree: string
function constructFileTree(allFiles: QuartzPluginData[]) { function constructFileTree(allFiles: QuartzPluginData[]) {
if (!fileTree) { if (fileTree) {
return
}
// Construct tree from allFiles // Construct tree from allFiles
fileTree = new FileNode("") fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file, 1)) allFiles.forEach((file) => fileTree.add(file))
/**
* 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) // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) { if (opts.order) {
// Order is important, use loop with index instead of order.map() // Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) { for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i] const functionName = opts.order[i]
if (functions[functionName]) { if (functionName === "map") {
// for every entry in order, call matching function in FileNode and pass matching argument fileTree.map(opts.mapFn)
// e.g. i = 0; functionName = "filter" } else if (functionName === "sort") {
// converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn) fileTree.sort(opts.sortFn)
} else if (functionName === "filter") {
// @ts-ignore fileTree.filter(opts.filterFn)
// typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
fileTree[functionName].call(fileTree, functions[functionName])
} }
} }
} }
@ -81,7 +74,6 @@ export default ((userOpts?: Partial<Options>) => {
// Stringify to pass json tree as data attribute ([data-tree]) // Stringify to pass json tree as data attribute ([data-tree])
jsonTree = JSON.stringify(folders) jsonTree = JSON.stringify(folders)
} }
}
function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
constructFileTree(allFiles) constructFileTree(allFiles)
@ -120,6 +112,7 @@ export default ((userOpts?: Partial<Options>) => {
</div> </div>
) )
} }
Explorer.css = explorerStyle Explorer.css = explorerStyle
Explorer.afterDOMLoaded = script Explorer.afterDOMLoaded = script
return Explorer return Explorer

View file

@ -1,6 +1,13 @@
// @ts-ignore // @ts-ignore
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { resolveRelative } from "../util/path" import {
joinSegments,
resolveRelative,
clone,
simplifySlug,
SimpleSlug,
FilePath,
} from "../util/path"
type OrderEntries = "sort" | "filter" | "map" type OrderEntries = "sort" | "filter" | "map"
@ -10,9 +17,9 @@ export interface Options {
folderClickBehavior: "collapse" | "link" folderClickBehavior: "collapse" | "link"
useSavedState: boolean useSavedState: boolean
sortFn: (a: FileNode, b: FileNode) => number sortFn: (a: FileNode, b: FileNode) => number
filterFn?: (node: FileNode) => boolean filterFn: (node: FileNode) => boolean
mapFn?: (node: FileNode) => void mapFn: (node: FileNode) => void
order?: OrderEntries[] order: OrderEntries[]
} }
type DataWrapper = { type DataWrapper = {
@ -25,59 +32,74 @@ export type FolderState = {
collapsed: boolean 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 // Structure to add all files into a tree
export class FileNode { export class FileNode {
children: FileNode[] children: Array<FileNode>
name: string name: string // this is the slug segment
displayName: string displayName: string
file: QuartzPluginData | null file: QuartzPluginData | null
depth: number depth: number
constructor(name: string, file?: QuartzPluginData, depth?: number) { constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
this.children = [] this.children = []
this.name = name this.name = slugSegment
this.displayName = name this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
this.file = file ? structuredClone(file) : null this.file = file ? clone(file) : null
this.depth = depth ?? 0 this.depth = depth ?? 0
} }
private insert(file: DataWrapper) { private insert(fileData: DataWrapper) {
if (file.path.length === 1) { if (fileData.path.length === 0) {
if (file.path[0] !== "index.md") {
this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
} else {
const title = file.file.frontmatter?.title
if (title && title !== "index" && file.path[0] === "index.md") {
this.displayName = title
}
}
} else {
const next = file.path[0]
file.path = file.path.splice(1)
for (const child of this.children) {
if (child.name === next) {
child.insert(file)
return 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))
} }
const newChild = new FileNode(next, undefined, this.depth + 1) return
newChild.insert(file)
this.children.push(newChild)
} }
// 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 new file to tree
add(file: QuartzPluginData, splice: number = 0) { add(file: QuartzPluginData) {
this.insert({ file, path: file.filePath!.split("/").splice(splice) }) this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
}
// Print tree structure (for debugging)
print(depth: number = 0) {
let folderChar = ""
if (!this.file) folderChar = "|"
console.log("-".repeat(depth), folderChar, this.name, this.depth)
this.children.forEach((e) => e.print(depth + 1))
} }
/** /**
@ -95,7 +117,6 @@ export class FileNode {
*/ */
map(mapFn: (node: FileNode) => void) { map(mapFn: (node: FileNode) => void) {
mapFn(this) mapFn(this)
this.children.forEach((child) => child.map(mapFn)) this.children.forEach((child) => child.map(mapFn))
} }
@ -110,16 +131,16 @@ export class FileNode {
const traverse = (node: FileNode, currentPath: string) => { const traverse = (node: FileNode, currentPath: string) => {
if (!node.file) { if (!node.file) {
const folderPath = currentPath + (currentPath ? "/" : "") + node.name const folderPath = joinSegments(currentPath, node.name)
if (folderPath !== "") { if (folderPath !== "") {
folderPaths.push({ path: folderPath, collapsed }) folderPaths.push({ path: folderPath, collapsed })
} }
node.children.forEach((child) => traverse(child, folderPath)) node.children.forEach((child) => traverse(child, folderPath))
} }
} }
traverse(this, "") traverse(this, "")
return folderPaths return folderPaths
} }
@ -147,10 +168,9 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
const isDefaultOpen = opts.folderDefaultState === "open" const isDefaultOpen = opts.folderDefaultState === "open"
// Calculate current folderPath // Calculate current folderPath
let pathOld = fullPath ? fullPath : ""
let folderPath = "" let folderPath = ""
if (node.name !== "") { if (node.name !== "") {
folderPath = `${pathOld}/${node.name}` folderPath = joinSegments(fullPath ?? "", node.name)
} }
return ( return (
@ -185,7 +205,11 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
{/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */} {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
<div key={node.name} data-folderpath={folderPath}> <div key={node.name} data-folderpath={folderPath}>
{folderBehavior === "link" ? ( {folderBehavior === "link" ? (
<a href={`${folderPath}`} data-for={node.name} class="folder-title"> <a
href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
data-for={node.name}
class="folder-title"
>
{node.displayName} {node.displayName}
</a> </a>
) : ( ) : (

View file

@ -59,8 +59,7 @@ function toggleFolder(evt: MouseEvent) {
// Save folder state to localStorage // Save folder state to localStorage
const clickFolderPath = currentFolderParent.dataset.folderpath as string const clickFolderPath = currentFolderParent.dataset.folderpath as string
// Remove leading "/" const fullFolderPath = clickFolderPath
const fullFolderPath = clickFolderPath.substring(1)
toggleCollapsedByPath(explorerState, fullFolderPath) toggleCollapsedByPath(explorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(explorerState) const stringifiedFileTree = JSON.stringify(explorerState)
@ -108,9 +107,7 @@ function setupExplorer() {
explorerState = JSON.parse(storageTree) explorerState = JSON.parse(storageTree)
explorerState.map((folderUl) => { explorerState.map((folderUl) => {
// grab <li> element for matching folder path // grab <li> element for matching folder path
const folderLi = document.querySelector( const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
`[data-folderpath='/${folderUl.path}']`,
) as HTMLElement
// Get corresponding content <ul> tag and set state // Get corresponding content <ul> tag and set state
if (folderLi) { if (folderLi) {

View file

@ -30,5 +30,6 @@ declare module "vfile" {
interface DataMap { interface DataMap {
slug: FullSlug slug: FullSlug
filePath: FilePath filePath: FilePath
relativePath: FilePath
} }
} }

View file

@ -91,8 +91,9 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) {
} }
// base data properties that plugins may use // base data properties that plugins may use
file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath) file.data.filePath = file.path as FilePath
file.data.filePath = fp file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
file.data.slug = slugifyFilePath(file.data.relativePath)
const ast = processor.parse(file) const ast = processor.parse(file)
const newAst = await processor.run(ast, file) const newAst = await processor.run(ast, file)

View file

@ -2,7 +2,7 @@ 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 rfdc from "rfdc"
const clone = rfdc() 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)