mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-26 06:14:06 -06:00
RTL
This commit is contained in:
parent
6b710f3b9b
commit
92679f79d7
45
.github/workflows/deploy.yml
vendored
Normal file
45
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Deploy Quartz site to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- v4
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history for git info
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build Quartz
|
||||||
|
run: npx quartz build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: public
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
BIN
docs/images/quartz layout.png
Normal file
BIN
docs/images/quartz layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins"
|
|||||||
*/
|
*/
|
||||||
const config: QuartzConfig = {
|
const config: QuartzConfig = {
|
||||||
configuration: {
|
configuration: {
|
||||||
pageTitle: "Quartz 4",
|
pageTitle: "سیستوفیا",
|
||||||
pageTitleSuffix: "",
|
pageTitleSuffix: "",
|
||||||
enableSPA: true,
|
enableSPA: true,
|
||||||
enablePopovers: true,
|
enablePopovers: true,
|
||||||
@ -16,7 +16,7 @@ const config: QuartzConfig = {
|
|||||||
provider: "plausible",
|
provider: "plausible",
|
||||||
},
|
},
|
||||||
locale: "fa-IR",
|
locale: "fa-IR",
|
||||||
baseUrl: "????",
|
baseUrl: "systophia.ir",
|
||||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||||
defaultDateType: "modified",
|
defaultDateType: "modified",
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
242
quartz/components/ExplorerNode.tsx
Normal file
242
quartz/components/ExplorerNode.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
// @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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
quartz/depgraph.test.ts
Normal file
118
quartz/depgraph.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import test, { describe } from "node:test"
|
||||||
|
import DepGraph from "./depgraph"
|
||||||
|
import assert from "node:assert"
|
||||||
|
|
||||||
|
describe("DepGraph", () => {
|
||||||
|
test("getLeafNodes", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("D", "C")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getLeafNodeAncestors", () => {
|
||||||
|
test("gets correct ancestors in a graph without cycles", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("D", "B")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gets correct ancestors in a graph with cycles", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A", "B")
|
||||||
|
graph.addEdge("B", "C")
|
||||||
|
graph.addEdge("C", "A")
|
||||||
|
graph.addEdge("C", "D")
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"]))
|
||||||
|
assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mergeGraph", () => {
|
||||||
|
test("merges two graphs", () => {
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "A.html")
|
||||||
|
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
graph.mergeGraph(other)
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "A.html", "B.md", "B.html"],
|
||||||
|
edges: [
|
||||||
|
["A.md", "A.html"],
|
||||||
|
["B.md", "B.html"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("updateIncomingEdgesForNode", () => {
|
||||||
|
test("merges when node exists", () => {
|
||||||
|
// A.md -> B.md -> B.html
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "B.md")
|
||||||
|
graph.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
// B.md is edited so it removes the A.md transclusion
|
||||||
|
// and adds C.md transclusion
|
||||||
|
// C.md -> B.md
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("C.md", "B.md")
|
||||||
|
other.addEdge("B.md", "B.html")
|
||||||
|
|
||||||
|
// A.md -> B.md removed, C.md -> B.md added
|
||||||
|
// C.md -> B.md -> B.html
|
||||||
|
graph.updateIncomingEdgesForNode(other, "B.md")
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "B.md", "B.html", "C.md"],
|
||||||
|
edges: [
|
||||||
|
["B.md", "B.html"],
|
||||||
|
["C.md", "B.md"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds node if it does not exist", () => {
|
||||||
|
// A.md -> B.md
|
||||||
|
const graph = new DepGraph<string>()
|
||||||
|
graph.addEdge("A.md", "B.md")
|
||||||
|
|
||||||
|
// Add a new file C.md that transcludes B.md
|
||||||
|
// B.md -> C.md
|
||||||
|
const other = new DepGraph<string>()
|
||||||
|
other.addEdge("B.md", "C.md")
|
||||||
|
|
||||||
|
// B.md -> C.md added
|
||||||
|
// A.md -> B.md -> C.md
|
||||||
|
graph.updateIncomingEdgesForNode(other, "C.md")
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
nodes: ["A.md", "B.md", "C.md"],
|
||||||
|
edges: [
|
||||||
|
["A.md", "B.md"],
|
||||||
|
["B.md", "C.md"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(graph.export(), expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
228
quartz/depgraph.ts
Normal file
228
quartz/depgraph.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
export default class DepGraph<T> {
|
||||||
|
// node: incoming and outgoing edges
|
||||||
|
_graph = new Map<T, { incoming: Set<T>; outgoing: Set<T> }>()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._graph = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
export(): Object {
|
||||||
|
return {
|
||||||
|
nodes: this.nodes,
|
||||||
|
edges: this.edges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return JSON.stringify(this.export(), null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASIC GRAPH OPERATIONS
|
||||||
|
|
||||||
|
get nodes(): T[] {
|
||||||
|
return Array.from(this._graph.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
get edges(): [T, T][] {
|
||||||
|
let edges: [T, T][] = []
|
||||||
|
this.forEachEdge((edge) => edges.push(edge))
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNode(node: T): boolean {
|
||||||
|
return this._graph.has(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(node: T): void {
|
||||||
|
if (!this._graph.has(node)) {
|
||||||
|
this._graph.set(node, { incoming: new Set(), outgoing: new Set() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove node and all edges connected to it
|
||||||
|
removeNode(node: T): void {
|
||||||
|
if (this._graph.has(node)) {
|
||||||
|
// first remove all edges so other nodes don't have references to this node
|
||||||
|
for (const target of this._graph.get(node)!.outgoing) {
|
||||||
|
this.removeEdge(node, target)
|
||||||
|
}
|
||||||
|
for (const source of this._graph.get(node)!.incoming) {
|
||||||
|
this.removeEdge(source, node)
|
||||||
|
}
|
||||||
|
this._graph.delete(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachNode(callback: (node: T) => void): void {
|
||||||
|
for (const node of this._graph.keys()) {
|
||||||
|
callback(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEdge(from: T, to: T): boolean {
|
||||||
|
return Boolean(this._graph.get(from)?.outgoing.has(to))
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdge(from: T, to: T): void {
|
||||||
|
this.addNode(from)
|
||||||
|
this.addNode(to)
|
||||||
|
|
||||||
|
this._graph.get(from)!.outgoing.add(to)
|
||||||
|
this._graph.get(to)!.incoming.add(from)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEdge(from: T, to: T): void {
|
||||||
|
if (this._graph.has(from) && this._graph.has(to)) {
|
||||||
|
this._graph.get(from)!.outgoing.delete(to)
|
||||||
|
this._graph.get(to)!.incoming.delete(from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns -1 if node does not exist
|
||||||
|
outDegree(node: T): number {
|
||||||
|
return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns -1 if node does not exist
|
||||||
|
inDegree(node: T): number {
|
||||||
|
return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void {
|
||||||
|
this._graph.get(node)?.outgoing.forEach(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachInNeighbor(node: T, callback: (neighbor: T) => void): void {
|
||||||
|
this._graph.get(node)?.incoming.forEach(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachEdge(callback: (edge: [T, T]) => void): void {
|
||||||
|
for (const [source, { outgoing }] of this._graph.entries()) {
|
||||||
|
for (const target of outgoing) {
|
||||||
|
callback([source, target])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPENDENCY ALGORITHMS
|
||||||
|
|
||||||
|
// Add all nodes and edges from other graph to this graph
|
||||||
|
mergeGraph(other: DepGraph<T>): void {
|
||||||
|
other.forEachEdge(([source, target]) => {
|
||||||
|
this.addNode(source)
|
||||||
|
this.addNode(target)
|
||||||
|
this.addEdge(source, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the node provided:
|
||||||
|
// If node does not exist, add it
|
||||||
|
// If an incoming edge was added in other, it is added in this graph
|
||||||
|
// If an incoming edge was deleted in other, it is deleted in this graph
|
||||||
|
updateIncomingEdgesForNode(other: DepGraph<T>, node: T): void {
|
||||||
|
this.addNode(node)
|
||||||
|
|
||||||
|
// Add edge if it is present in other
|
||||||
|
other.forEachInNeighbor(node, (neighbor) => {
|
||||||
|
this.addEdge(neighbor, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
// For node provided, remove incoming edge if it is absent in other
|
||||||
|
this.forEachEdge(([source, target]) => {
|
||||||
|
if (target === node && !other.hasEdge(source, target)) {
|
||||||
|
this.removeEdge(source, target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all nodes that do not have any incoming or outgoing edges
|
||||||
|
// A node may be orphaned if the only node pointing to it was removed
|
||||||
|
removeOrphanNodes(): Set<T> {
|
||||||
|
let orphanNodes = new Set<T>()
|
||||||
|
|
||||||
|
this.forEachNode((node) => {
|
||||||
|
if (this.inDegree(node) === 0 && this.outDegree(node) === 0) {
|
||||||
|
orphanNodes.add(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
orphanNodes.forEach((node) => {
|
||||||
|
this.removeNode(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
return orphanNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all leaf nodes (i.e. destination paths) reachable from the node provided
|
||||||
|
// Eg. if the graph is A -> B -> C
|
||||||
|
// D ---^
|
||||||
|
// and the node is B, this function returns [C]
|
||||||
|
getLeafNodes(node: T): Set<T> {
|
||||||
|
let stack: T[] = [node]
|
||||||
|
let visited = new Set<T>()
|
||||||
|
let leafNodes = new Set<T>()
|
||||||
|
|
||||||
|
// DFS
|
||||||
|
while (stack.length > 0) {
|
||||||
|
let node = stack.pop()!
|
||||||
|
|
||||||
|
// If the node is already visited, skip it
|
||||||
|
if (visited.has(node)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited.add(node)
|
||||||
|
|
||||||
|
// Check if the node is a leaf node (i.e. destination path)
|
||||||
|
if (this.outDegree(node) === 0) {
|
||||||
|
leafNodes.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all unvisited neighbors to the stack
|
||||||
|
this.forEachOutNeighbor(node, (neighbor) => {
|
||||||
|
if (!visited.has(neighbor)) {
|
||||||
|
stack.push(neighbor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return leafNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all ancestors of the leaf nodes reachable from the node provided
|
||||||
|
// Eg. if the graph is A -> B -> C
|
||||||
|
// D ---^
|
||||||
|
// and the node is B, this function returns [A, B, D]
|
||||||
|
getLeafNodeAncestors(node: T): Set<T> {
|
||||||
|
const leafNodes = this.getLeafNodes(node)
|
||||||
|
let visited = new Set<T>()
|
||||||
|
let upstreamNodes = new Set<T>()
|
||||||
|
|
||||||
|
// Backwards DFS for each leaf node
|
||||||
|
leafNodes.forEach((leafNode) => {
|
||||||
|
let stack: T[] = [leafNode]
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
let node = stack.pop()!
|
||||||
|
|
||||||
|
if (visited.has(node)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visited.add(node)
|
||||||
|
// Add node if it's not a leaf node (i.e. destination path)
|
||||||
|
// Assumes destination file cannot depend on another destination file
|
||||||
|
if (this.outDegree(node) !== 0) {
|
||||||
|
upstreamNodes.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all unvisited parents to the stack
|
||||||
|
this.forEachInNeighbor(node, (parentNode) => {
|
||||||
|
if (!visited.has(parentNode)) {
|
||||||
|
stack.push(parentNode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return upstreamNodes
|
||||||
|
}
|
||||||
|
}
|
||||||
185
quartz/plugins/emitters/contentIndex.ts
Normal file
185
quartz/plugins/emitters/contentIndex.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { Root } from "hast"
|
||||||
|
import { GlobalConfiguration } from "../../cfg"
|
||||||
|
import { getDate } from "../../components/Date"
|
||||||
|
import { escapeHTML } from "../../util/escape"
|
||||||
|
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
|
||||||
|
import { QuartzEmitterPlugin } from "../types"
|
||||||
|
import { toHtml } from "hast-util-to-html"
|
||||||
|
import { write } from "./helpers"
|
||||||
|
import { i18n } from "../../i18n"
|
||||||
|
import DepGraph from "../../depgraph"
|
||||||
|
|
||||||
|
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||||
|
export type ContentDetails = {
|
||||||
|
title: string
|
||||||
|
links: SimpleSlug[]
|
||||||
|
tags: string[]
|
||||||
|
content: string
|
||||||
|
richContent?: string
|
||||||
|
date?: Date
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
enableSiteMap: boolean
|
||||||
|
enableRSS: boolean
|
||||||
|
rssLimit?: number
|
||||||
|
rssFullHtml: boolean
|
||||||
|
includeEmptyFiles: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
enableSiteMap: true,
|
||||||
|
enableRSS: true,
|
||||||
|
rssLimit: 10,
|
||||||
|
rssFullHtml: false,
|
||||||
|
includeEmptyFiles: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||||
|
const base = cfg.baseUrl ?? ""
|
||||||
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||||
|
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||||
|
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
|
||||||
|
</url>`
|
||||||
|
const urls = Array.from(idx)
|
||||||
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
|
.join("")
|
||||||
|
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string {
|
||||||
|
const base = cfg.baseUrl ?? ""
|
||||||
|
|
||||||
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||||
|
<title>${escapeHTML(content.title)}</title>
|
||||||
|
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||||
|
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||||
|
<description>${content.richContent ?? content.description}</description>
|
||||||
|
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||||
|
</item>`
|
||||||
|
|
||||||
|
const items = Array.from(idx)
|
||||||
|
.sort(([_, f1], [__, f2]) => {
|
||||||
|
if (f1.date && f2.date) {
|
||||||
|
return f2.date.getTime() - f1.date.getTime()
|
||||||
|
} else if (f1.date && !f2.date) {
|
||||||
|
return -1
|
||||||
|
} else if (!f1.date && f2.date) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return f1.title.localeCompare(f2.title)
|
||||||
|
})
|
||||||
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
|
.slice(0, limit ?? idx.size)
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||||
|
<link>https://${base}</link>
|
||||||
|
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
|
||||||
|
cfg.pageTitle,
|
||||||
|
)}</description>
|
||||||
|
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||||
|
opts = { ...defaultOptions, ...opts }
|
||||||
|
return {
|
||||||
|
name: "ContentIndex",
|
||||||
|
async getDependencyGraph(ctx, content, _resources) {
|
||||||
|
const graph = new DepGraph<FilePath>()
|
||||||
|
|
||||||
|
for (const [_tree, file] of content) {
|
||||||
|
const sourcePath = file.data.filePath!
|
||||||
|
|
||||||
|
graph.addEdge(
|
||||||
|
sourcePath,
|
||||||
|
joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath,
|
||||||
|
)
|
||||||
|
if (opts?.enableSiteMap) {
|
||||||
|
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath)
|
||||||
|
}
|
||||||
|
if (opts?.enableRSS) {
|
||||||
|
graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph
|
||||||
|
},
|
||||||
|
async emit(ctx, content, _resources) {
|
||||||
|
const cfg = ctx.cfg.configuration
|
||||||
|
const emitted: FilePath[] = []
|
||||||
|
const linkIndex: ContentIndex = new Map()
|
||||||
|
for (const [tree, file] of content) {
|
||||||
|
const slug = file.data.slug!
|
||||||
|
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||||
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
|
linkIndex.set(slug, {
|
||||||
|
title: file.data.frontmatter?.title!,
|
||||||
|
links: file.data.links ?? [],
|
||||||
|
tags: file.data.frontmatter?.tags ?? [],
|
||||||
|
content: file.data.text ?? "",
|
||||||
|
richContent: opts?.rssFullHtml
|
||||||
|
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||||
|
: undefined,
|
||||||
|
date: date,
|
||||||
|
description: file.data.description ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.enableSiteMap) {
|
||||||
|
emitted.push(
|
||||||
|
await write({
|
||||||
|
ctx,
|
||||||
|
content: generateSiteMap(cfg, linkIndex),
|
||||||
|
slug: "sitemap" as FullSlug,
|
||||||
|
ext: ".xml",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.enableRSS) {
|
||||||
|
emitted.push(
|
||||||
|
await write({
|
||||||
|
ctx,
|
||||||
|
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||||
|
slug: "index" as FullSlug,
|
||||||
|
ext: ".xml",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = joinSegments("static", "contentIndex") as FullSlug
|
||||||
|
const simplifiedIndex = Object.fromEntries(
|
||||||
|
Array.from(linkIndex).map(([slug, content]) => {
|
||||||
|
// remove description and from content index as nothing downstream
|
||||||
|
// actually uses it. we only keep it in the index as we need it
|
||||||
|
// for the RSS feed
|
||||||
|
delete content.description
|
||||||
|
delete content.date
|
||||||
|
return [slug, content]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
emitted.push(
|
||||||
|
await write({
|
||||||
|
ctx,
|
||||||
|
content: JSON.stringify(simplifiedIndex),
|
||||||
|
slug: fp,
|
||||||
|
ext: ".json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return emitted
|
||||||
|
},
|
||||||
|
getQuartzComponents: () => [],
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user