Canvas Rendering stub

This commit is contained in:
saberzero1 2025-03-01 17:22:55 +01:00
parent 6af17125f0
commit 1175c7af87
No known key found for this signature in database
12 changed files with 452 additions and 16 deletions

View File

@ -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"}
]
}

View File

@ -1,7 +0,0 @@
---
title: "Canvas"
tags:
- component
---
Quartz supports Obsidian's [JSON Canvas](https://jsoncanvas.org/).

View File

@ -81,6 +81,7 @@ const config: QuartzConfig = {
Plugin.ContentPage(),
Plugin.FolderPage(),
Plugin.TagPage(),
Plugin.CanvasPage(),
Plugin.ContentIndex({
enableSiteMap: true,
enableRSS: true,

View File

@ -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()],
}

View File

@ -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")}`,
)

View File

@ -95,3 +95,4 @@ export interface FullPageLayout {
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
export type SharedLayout = Pick<FullPageLayout, "head" | "header" | "footer" | "afterBody">
export type CanvasLayout = Pick<FullPageLayout, "left">

View File

@ -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,

View File

@ -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 <article class={classString}>{content["nodes"]}</article>
}
export default (() => CanvasContent) satisfies QuartzComponentConstructor

View File

@ -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 = (
<div class="left sidebar">
{left.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
)
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
const doc = (
<html lang={lang}>
<Head {...componentData} />
<body data-slug={slug}>
<div id="quartz-root" class="page canvas">
<Body {...componentData}>
{LeftComponent}
<div class="center">
<div class="page-header">
<Header {...componentData}>
{header.map((HeaderComponent) => (
<HeaderComponent {...componentData} />
))}
</Header>
<div class="popover-hint">
{beforeBody.map((BodyComponent) => (
<BodyComponent {...componentData} />
))}
</div>
</div>
<CanvasContent {...componentData} />
</div>
</Body>
</div>
</body>
{pageResources.js
.filter((resource) => resource.loadTime === "afterDOMReady")
.map((res) => JSResourceToScriptElement(res))}
</html>
)
return "<!DOCTYPE html>\n" + render(doc)
}

View File

@ -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<Partial<FullPageLayout>> = (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<FilePath>()
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<FilePath[]> {
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
},
}
}

View File

@ -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"

View File

@ -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 = ""
}