diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..d11b3976d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/CNAME b/CNAME new file mode 100644 index 000000000..602b46608 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +systophia.ir diff --git a/docs/images/quartz layout.png b/docs/images/quartz layout.png new file mode 100644 index 000000000..71ef3ac71 Binary files /dev/null and b/docs/images/quartz layout.png differ diff --git a/quartz.config.ts b/quartz.config.ts index ebf1a7e6a..ac4945e4e 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins" */ const config: QuartzConfig = { configuration: { - pageTitle: "Quartz 4", + pageTitle: "سیستوفیا", pageTitleSuffix: "", enableSPA: true, enablePopovers: true, @@ -16,7 +16,7 @@ const config: QuartzConfig = { provider: "plausible", }, locale: "fa-IR", - baseUrl: "????", + baseUrl: "systophia.ir", ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "modified", theme: { diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx new file mode 100644 index 000000000..e57d67715 --- /dev/null +++ b/quartz/components/ExplorerNode.tsx @@ -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 + 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 +
  • + + {node.displayName} + +
  • + ) : ( +
  • + {node.name !== "" && ( + // Node with entire folder + // Render svg button + folder name, then children + + + )} + {/* Recursively render children of folder */} +
    +
      + {node.children.map((childNode, i) => ( + + ))} +
    +
    +
  • + )} + + ) +} diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts new file mode 100644 index 000000000..062f13e35 --- /dev/null +++ b/quartz/depgraph.test.ts @@ -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() + 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() + 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() + 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() + graph.addEdge("A.md", "A.html") + + const other = new DepGraph() + 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() + 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() + 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() + graph.addEdge("A.md", "B.md") + + // Add a new file C.md that transcludes B.md + // B.md -> C.md + const other = new DepGraph() + 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) + }) + }) +}) diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts new file mode 100644 index 000000000..3d048cd83 --- /dev/null +++ b/quartz/depgraph.ts @@ -0,0 +1,228 @@ +export default class DepGraph { + // node: incoming and outgoing edges + _graph = new Map; outgoing: Set }>() + + 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): 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, 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 { + let orphanNodes = new Set() + + 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 { + let stack: T[] = [node] + let visited = new Set() + let leafNodes = new Set() + + // 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 { + const leafNodes = this.getLeafNodes(node) + let visited = new Set() + let upstreamNodes = new Set() + + // 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 + } +} diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts new file mode 100644 index 000000000..c0fef86d2 --- /dev/null +++ b/quartz/plugins/emitters/contentIndex.ts @@ -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 +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 => ` + https://${joinSegments(base, encodeURI(slug))} + ${content.date && `${content.date.toISOString()}`} + ` + const urls = Array.from(idx) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .join("") + return `${urls}` +} + +function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { + const base = cfg.baseUrl ?? "" + + const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + ${escapeHTML(content.title)} + https://${joinSegments(base, encodeURI(slug))} + https://${joinSegments(base, encodeURI(slug))} + ${content.richContent ?? content.description} + ${content.date?.toUTCString()} + ` + + 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 ` + + + ${escapeHTML(cfg.pageTitle)} + https://${base} + ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( + cfg.pageTitle, + )} + Quartz -- quartz.jzhao.xyz + ${items} + + ` +} + +export const ContentIndex: QuartzEmitterPlugin> = (opts) => { + opts = { ...defaultOptions, ...opts } + return { + name: "ContentIndex", + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph() + + 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: () => [], + } +}