From 1175c7af870bff821e3f1ba34af3be9498605065 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sat, 1 Mar 2025 17:22:55 +0100 Subject: [PATCH] Canvas Rendering stub --- docs/features/Canvas.canvas | 10 +- docs/features/Canvas.md | 7 - quartz.config.ts | 1 + quartz.layout.ts | 7 +- quartz/build.ts | 2 +- quartz/cfg.ts | 1 + quartz/components/index.ts | 2 + quartz/components/pages/CanvasContent.tsx | 28 ++- quartz/components/renderCanvas.tsx | 266 ++++++++++++++++++++++ quartz/plugins/emitters/canvasPage.tsx | 141 ++++++++++++ quartz/plugins/emitters/index.ts | 1 + quartz/util/path.ts | 2 +- 12 files changed, 452 insertions(+), 16 deletions(-) delete mode 100644 docs/features/Canvas.md create mode 100644 quartz/components/renderCanvas.tsx create mode 100644 quartz/plugins/emitters/canvasPage.tsx diff --git a/docs/features/Canvas.canvas b/docs/features/Canvas.canvas index 0a316cf5f..ee948e9bd 100644 --- a/docs/features/Canvas.canvas +++ b/docs/features/Canvas.canvas @@ -1,13 +1,13 @@ { "nodes":[ {"id":"a85fff7a7493ef52","type":"group","x":-600,"y":-240,"width":1340,"height":980,"color":"4","label":"Canvas Group"}, - {"id":"f5769d77556ecdca","type":"file","file":"features/Canvas.md","x":-200,"y":-200,"width":400,"height":400,"color":"1"}, {"id":"05e0c45c85417543","type":"link","url":"https://jsoncanvas.org/spec/1.0/","x":300,"y":300,"width":401,"height":400,"color":"3"}, - {"id":"5997bc7558e18f0b","type":"text","text":"Plain text note","x":-560,"y":470,"width":250,"height":60,"color":"5"} + {"id":"5997bc7558e18f0b","type":"text","text":"Plain text note","x":-560,"y":470,"width":250,"height":60,"color":"5"}, + {"id":"f583da2f9411eca1","x":-200,"y":-200,"width":400,"height":400,"color":"1","type":"file","file":"features/popover previews.md"} ], "edges":[ - {"id":"d63d107e865d06ec","fromNode":"f5769d77556ecdca","fromSide":"right","toNode":"05e0c45c85417543","toSide":"top","color":"2","label":"Specification"}, - {"id":"4dcf493fa007b200","fromNode":"f5769d77556ecdca","fromSide":"bottom","toNode":"5997bc7558e18f0b","toSide":"top","toEnd":"none","color":"6"}, - {"id":"185431e9b6c045dc","fromNode":"5997bc7558e18f0b","fromSide":"right","toNode":"05e0c45c85417543","toSide":"left","fromEnd":"arrow"} + {"id":"185431e9b6c045dc","fromNode":"5997bc7558e18f0b","fromSide":"right","toNode":"05e0c45c85417543","toSide":"left","fromEnd":"arrow"}, + {"id":"be55c44ecde61a30","fromNode":"f583da2f9411eca1","fromSide":"right","toNode":"05e0c45c85417543","toSide":"top","color":"2","label":"Specifications"}, + {"id":"1ff7941319ca11b8","fromNode":"f583da2f9411eca1","fromSide":"left","toNode":"5997bc7558e18f0b","toSide":"top","color":"6"} ] } \ No newline at end of file diff --git a/docs/features/Canvas.md b/docs/features/Canvas.md deleted file mode 100644 index c050fec81..000000000 --- a/docs/features/Canvas.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "Canvas" -tags: - - component ---- - -Quartz supports Obsidian's [JSON Canvas](https://jsoncanvas.org/). diff --git a/quartz.config.ts b/quartz.config.ts index dc339d987..4a4ff37e7 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -81,6 +81,7 @@ const config: QuartzConfig = { Plugin.ContentPage(), Plugin.FolderPage(), Plugin.TagPage(), + Plugin.CanvasPage(), Plugin.ContentIndex({ enableSiteMap: true, enableRSS: true, diff --git a/quartz.layout.ts b/quartz.layout.ts index f45da0c92..b45007569 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -1,4 +1,4 @@ -import { PageLayout, SharedLayout } from "./quartz/cfg" +import { PageLayout, SharedLayout, CanvasLayout } from "./quartz/cfg" import * as Component from "./quartz/components" // components shared across all pages @@ -48,3 +48,8 @@ export const defaultListPageLayout: PageLayout = { ], right: [], } + +// components for canvas pages +export const defaultCanvasPageLayout: CanvasLayout = { + left: [Component.PageTitle(), Component.Search(), Component.Darkmode(), Component.Explorer()], +} diff --git a/quartz/build.ts b/quartz/build.ts index 64c462b14..a6d580c23 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -70,7 +70,7 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { perf.addEvent("glob") const allFiles = await glob("**/*.*", argv.directory, cfg.configuration.ignorePatterns) - const fps = allFiles.filter((fp) => fp.endsWith(".md")).sort() + const fps = allFiles.filter((fp) => fp.endsWith(".md") || fp.endsWith(".canvas")).sort() console.log( `Found ${fps.length} input files from \`${argv.directory}\` in ${perf.timeSince("glob")}`, ) diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 135f58499..0dcadc121 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -95,3 +95,4 @@ export interface FullPageLayout { export type PageLayout = Pick export type SharedLayout = Pick +export type CanvasLayout = Pick diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 5b197941c..2945fb7d4 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,6 +1,7 @@ import Content from "./pages/Content" import TagContent from "./pages/TagContent" import FolderContent from "./pages/FolderContent" +import CanvasContent from "./pages/CanvasContent" import NotFound from "./pages/404" import ArticleTitle from "./ArticleTitle" import Darkmode from "./Darkmode" @@ -26,6 +27,7 @@ export { Content, TagContent, FolderContent, + CanvasContent, Darkmode, Head, PageTitle, diff --git a/quartz/components/pages/CanvasContent.tsx b/quartz/components/pages/CanvasContent.tsx index ee6238c63..35b0ace6f 100644 --- a/quartz/components/pages/CanvasContent.tsx +++ b/quartz/components/pages/CanvasContent.tsx @@ -1 +1,27 @@ -import { QuartzCanvasComponent } from "../types" +import { ComponentChildren } from "preact" +import { htmlToJsx } from "../../util/jsx" +import { + QuartzComponent, + QuartzComponentConstructor, + QuartzComponentProps, + QuartzCanvasComponent, +} from "../types" +import { type FilePath } from "../../util/path" +import fs from "fs" + +function loadCanvas(file: FilePath): QuartzCanvasComponent { + const data = fs.readFileSync(file, "utf8") ?? "{}" + console.log(data) + return JSON.parse(data) as QuartzCanvasComponent +} + +const CanvasContent: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => { + //const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren + const content = loadCanvas(fileData.filePath!) + const classes: string[] = fileData.frontmatter?.cssclasses ?? [] + const classString = ["popover-hint", ...classes].join(" ") + //TODO: Implement canvas rendering + return
{content["nodes"]}
+} + +export default (() => CanvasContent) satisfies QuartzComponentConstructor diff --git a/quartz/components/renderCanvas.tsx b/quartz/components/renderCanvas.tsx new file mode 100644 index 000000000..3a04ba5df --- /dev/null +++ b/quartz/components/renderCanvas.tsx @@ -0,0 +1,266 @@ +import { render } from "preact-render-to-string" +import { QuartzComponent, QuartzComponentProps } from "./types" +import HeaderConstructor from "./Header" +import BodyConstructor from "./Body" +import { JSResourceToScriptElement, StaticResources } from "../util/resources" +import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" +import { visit } from "unist-util-visit" +import { Root, Element, ElementContent } from "hast" +import { GlobalConfiguration } from "../cfg" +import { i18n } from "../i18n" +// @ts-ignore +import mermaidScript from "./scripts/mermaid.inline" +import mermaidStyle from "./styles/mermaid.inline.scss" +import { QuartzPluginData } from "../plugins/vfile" + +interface RenderComponents { + head: QuartzComponent + header: QuartzComponent[] + beforeBody: QuartzComponent[] + pageBody: QuartzComponent + afterBody: QuartzComponent[] + left: QuartzComponent[] + right: QuartzComponent[] + footer: QuartzComponent +} + +const headerRegex = new RegExp(/h[1-6]/) +export function pageResources( + baseDir: FullSlug | RelativeURL, + fileData: QuartzPluginData, + staticResources: StaticResources, +): StaticResources { + const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") + const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` + + const resources: StaticResources = { + css: [ + { + content: joinSegments(baseDir, "index.css"), + }, + ...staticResources.css, + ], + js: [ + { + src: joinSegments(baseDir, "prescript.js"), + loadTime: "beforeDOMReady", + contentType: "external", + }, + { + loadTime: "beforeDOMReady", + contentType: "inline", + spaPreserve: true, + script: contentIndexScript, + }, + ...staticResources.js, + ], + } + + if (fileData.hasMermaidDiagram) { + resources.js.push({ + script: mermaidScript, + loadTime: "afterDOMReady", + moduleType: "module", + contentType: "inline", + }) + resources.css.push({ content: mermaidStyle, inline: true }) + } + + // NOTE: we have to put this last to make sure spa.inline.ts is the last item. + resources.js.push({ + src: joinSegments(baseDir, "postscript.js"), + loadTime: "afterDOMReady", + moduleType: "module", + contentType: "external", + }) + + return resources +} + +export function renderCanvas( + cfg: GlobalConfiguration, + slug: FullSlug, + componentData: QuartzComponentProps, + components: RenderComponents, + pageResources: StaticResources, +): string { + // make a deep copy of the tree so we don't remove the transclusion references + // for the file cached in contentMap in build.ts + const root = clone(componentData.tree) as Root + + console.log(root) + + // process transcludes in componentData + /*visit(root, "element", (node, _index, _parent) => { + if (node.tagName === "blockquote") { + const classNames = (node.properties?.className ?? []) as string[] + if (classNames.includes("transclude")) { + const inner = node.children[0] as Element + const transcludeTarget = inner.properties["data-slug"] as FullSlug + const page = componentData.allFiles.find((f) => f.slug === transcludeTarget) + if (!page) { + return + } + + let blockRef = node.properties.dataBlock as string | undefined + if (blockRef?.startsWith("#^")) { + // block transclude + blockRef = blockRef.slice("#^".length) + let blockNode = page.blocks?.[blockRef] + if (blockNode) { + if (blockNode.tagName === "li") { + blockNode = { + type: "element", + tagName: "ul", + properties: {}, + children: [blockNode], + } + } + + node.children = [ + normalizeHastElement(blockNode, slug, transcludeTarget), + { + type: "element", + tagName: "a", + properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } + } else if (blockRef?.startsWith("#") && page.htmlAst) { + // header transclude + blockRef = blockRef.slice(1) + let startIdx = undefined + let startDepth = undefined + let endIdx = undefined + for (const [i, el] of page.htmlAst.children.entries()) { + // skip non-headers + if (!(el.type === "element" && el.tagName.match(headerRegex))) continue + const depth = Number(el.tagName.substring(1)) + + // lookin for our blockref + if (startIdx === undefined || startDepth === undefined) { + // skip until we find the blockref that matches + if (el.properties?.id === blockRef) { + startIdx = i + startDepth = depth + } + } else if (depth <= startDepth) { + // looking for new header that is same level or higher + endIdx = i + break + } + } + + if (startIdx === undefined) { + return + } + + node.children = [ + ...(page.htmlAst.children.slice(startIdx, endIdx) as ElementContent[]).map((child) => + normalizeHastElement(child as Element, slug, transcludeTarget), + ), + { + type: "element", + tagName: "a", + properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } else if (page.htmlAst) { + // page transclude + node.children = [ + { + type: "element", + tagName: "h1", + properties: {}, + children: [ + { + type: "text", + value: + page.frontmatter?.title ?? + i18n(cfg.locale).components.transcludes.transcludeOf({ + targetSlug: page.slug!, + }), + }, + ], + }, + ...(page.htmlAst.children as ElementContent[]).map((child) => + normalizeHastElement(child as Element, slug, transcludeTarget), + ), + { + type: "element", + tagName: "a", + properties: { href: inner.properties?.href, class: ["internal", "transclude-src"] }, + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], + }, + ] + } + } + } + })*/ + + // set componentData.tree to the edited html that has transclusions rendered + componentData.tree = root + + const { + head: Head, + header, + beforeBody, + pageBody: CanvasContent, + afterBody, + left, + right, + footer: Footer, + } = components + const Header = HeaderConstructor() + const Body = BodyConstructor() + + const LeftComponent = ( + + ) + + const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en" + const doc = ( + + + +
+ + {LeftComponent} +
+ + +
+ +
+ + {pageResources.js + .filter((resource) => resource.loadTime === "afterDOMReady") + .map((res) => JSResourceToScriptElement(res))} + + ) + + return "\n" + render(doc) +} diff --git a/quartz/plugins/emitters/canvasPage.tsx b/quartz/plugins/emitters/canvasPage.tsx new file mode 100644 index 000000000..3159fae0c --- /dev/null +++ b/quartz/plugins/emitters/canvasPage.tsx @@ -0,0 +1,141 @@ +import path from "path" +import { visit } from "unist-util-visit" +import { Root } from "hast" +import { VFile } from "vfile" +import { QuartzEmitterPlugin } from "../types" +import { QuartzComponentProps } from "../../components/types" +import HeaderConstructor from "../../components/Header" +import BodyConstructor from "../../components/Body" +import { pageResources, renderCanvas } from "../../components/renderCanvas" +import { FullPageLayout } from "../../cfg" +import { Argv } from "../../util/ctx" +import { FilePath, isRelativeURL, joinSegments, pathToRoot } from "../../util/path" +import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" +import { CanvasContent } from "../../components" +import chalk from "chalk" +import { write } from "./helpers" +import DepGraph from "../../depgraph" + +// get all the dependencies for the markdown file +// eg. images, scripts, stylesheets, transclusions +const parseDependencies = (argv: Argv, hast: Root, file: VFile): string[] => { + const dependencies: string[] = [] + + visit(hast, "element", (elem): void => { + let ref: string | null = null + + if ( + ["script", "img", "audio", "video", "source", "iframe"].includes(elem.tagName) && + elem?.properties?.src + ) { + ref = elem.properties.src.toString() + } else if (["a", "link"].includes(elem.tagName) && elem?.properties?.href) { + // transclusions will create a tags with relative hrefs + ref = elem.properties.href.toString() + } + + // if it is a relative url, its a local file and we need to add + // it to the dependency graph. otherwise, ignore + if (ref === null || !isRelativeURL(ref)) { + return + } + + let fp = path.join(file.data.filePath!, path.relative(argv.directory, ref)).replace(/\\/g, "/") + // markdown files have the .md extension stripped in hrefs, add it back here + if (!fp.split("/").pop()?.includes(".")) { + fp += ".md" + } + dependencies.push(fp) + }) + + return dependencies +} + +export const CanvasPage: QuartzEmitterPlugin> = (userOpts) => { + const opts: FullPageLayout = { + ...sharedPageComponents, + ...defaultContentPageLayout, + pageBody: CanvasContent(), + ...userOpts, + } + + const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts + const Header = HeaderConstructor() + const Body = BodyConstructor() + + return { + name: "CanvasPage", + getQuartzComponents() { + return [ + Head, + Header, + Body, + ...header, + ...beforeBody, + pageBody, + ...afterBody, + ...left, + ...right, + Footer, + ] + }, + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph() + + for (const [tree, file] of content) { + const sourcePath = file.data.filePath! + const slug = file.data.slug! + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) + + parseDependencies(ctx.argv, tree as Root, file).forEach((dep) => { + graph.addEdge(dep as FilePath, sourcePath) + }) + } + + return graph + }, + async emit(ctx, content, resources): Promise { + const cfg = ctx.cfg.configuration + const fps: FilePath[] = [] + const allFiles = content.map((c) => c[1].data) + + let containsCanvas = false + for (const [tree, file] of content) { + if (!file.data.filePath?.endsWith(".canvas")) continue + const slug = file.data.slug! + containsCanvas = true + + const externalResources = pageResources(pathToRoot(slug), file.data, resources) + const componentData: QuartzComponentProps = { + ctx, + fileData: file.data, + externalResources, + cfg, + children: [], + tree, + allFiles, + } + + const content = renderCanvas(cfg, slug, componentData, opts, externalResources) + const fp = await write({ + ctx, + content, + slug, + ext: ".html", + }) + + fps.push(fp) + } + + if (!containsCanvas && !ctx.argv.fastRebuild) { + console.log( + chalk.yellow( + `\nWarning: No canvas files detected in \`${ctx.argv.directory}\`. Skipping.`, + ), + ) + } + + return fps + }, + } +} diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index bc378c47b..dd0a4bcaf 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -1,6 +1,7 @@ export { ContentPage } from "./contentPage" export { TagPage } from "./tagPage" export { FolderPage } from "./folderPage" +export { CanvasPage } from "./canvasPage" export { ContentIndex } from "./contentIndex" export { AliasRedirects } from "./aliases" export { Assets } from "./assets" diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 5835f15cc..a83f0b40c 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -66,7 +66,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { fp = stripSlashes(fp) as FilePath let ext = _getFileExtension(fp) const withoutFileExt = fp.replace(new RegExp(ext + "$"), "") - if (excludeExt || [".md", ".html", undefined].includes(ext)) { + if (excludeExt || [".md", ".html", ".canvas", undefined].includes(ext)) { ext = "" }