This commit is contained in:
Emile Bangma 2025-03-01 20:34:26 +00:00 committed by GitHub
commit 6f9fac15d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 702 additions and 4 deletions

View File

@ -0,0 +1,13 @@
{
"nodes":[
{"id":"a85fff7a7493ef52","type":"group","x":-600,"y":-240,"width":1340,"height":980,"color":"4","label":"Canvas Group"},
{"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":"f583da2f9411eca1","x":-200,"y":-200,"width":400,"height":400,"color":"1","type":"file","file":"features/popover previews.md"}
],
"edges":[
{"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

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

@ -0,0 +1,105 @@
import { ComponentChildren } from "preact"
import { htmlToJsx } from "../../util/jsx"
import d3 from "d3"
import {
QuartzComponent,
QuartzComponentConstructor,
QuartzComponentProps,
QuartzCanvasComponent,
CanvasNode,
CanvasEdge,
CanvasTextNode,
CanvasFileNode,
CanvasLinkNode,
CanvasGroupNode,
} from "../types"
import { type FilePath, slugifyFilePath } 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
}
function renderTextNodes(nodes: CanvasTextNode[]): ComponentChildren {
return nodes.map((node) => (
<div
class="canvas canvas-node canvas-text-node"
style={{
position: "fixed",
width: node.width,
height: node.height,
left: node.x,
top: node.y,
}}
>
{node["text"]}
</div>
))
}
function renderFileNodes(nodes: CanvasFileNode[]): ComponentChildren {
return nodes.map((node) => (
<div
class="internal alias internal-canvas"
data-slug={slugifyFilePath(node.file as FilePath)}
data-x={node.x}
data-y={node.y}
style={{
position: "fixed",
width: node.width,
height: node.height,
left: node.x,
top: node.y,
}}
></div>
))
}
function renderLinkNodes(nodes: CanvasLinkNode[]): ComponentChildren {
return nodes.map((node) => (
<iframe
src={node["url"]}
style={{
position: "fixed",
width: node.width,
height: node.height,
left: node.x,
top: node.y,
}}
/>
))
}
const CanvasContent: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
//const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
const canvas = loadCanvas(fileData.filePath!)
const canvasNodes = (canvas["nodes"] ?? []) as CanvasNode[]
const canvasEdges = (canvas["edges"] ?? []) as CanvasEdge[]
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
const classString = ["popover-hint", ...classes].join(" ")
// Canvas parts
const textNodes = (canvasNodes.filter((node) => node["type"] === "text") ??
[]) as CanvasTextNode[]
const fileNodes = (canvasNodes.filter((node) => node["type"] === "file") ??
[]) as CanvasFileNode[]
const linkNodes = (canvasNodes.filter((node) => node["type"] === "link") ??
[]) as CanvasLinkNode[]
const groupNodes = (canvasNodes.filter((node) => node["type"] === "group") ??
[]) as CanvasGroupNode[]
const result = (
<article class={classString}>
{renderTextNodes(textNodes)}
{renderFileNodes(fileNodes)}
{renderLinkNodes(linkNodes)}
</article>
)
//TODO: Implement canvas rendering
return <article class={classString}>{result}</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

@ -100,10 +100,121 @@ async function mouseEnterHandler(
}
}
async function navigationHandler(this: HTMLAnchorElement) {
const link = this
if (link.dataset.noPopover === "true") {
return
}
const canvasX = Number(link.getAttribute("data-x") ?? "0")
const canvasY = Number(link.getAttribute("data-y") ?? "0")
async function setPosition(popoverElement: HTMLElement) {
const { x, y } = await computePosition(link, popoverElement, {
middleware: [inline({ x: canvasX, y: canvasY }), shift(), flip()],
})
Object.assign(popoverElement.style, {
left: `${x}px`,
top: `${y}px`,
width: "100%",
height: "100%",
})
}
const hasAlreadyBeenFetched = () =>
[...link.children].some((child) => child.classList.contains("popover"))
// dont refetch if there's already a popover
if (hasAlreadyBeenFetched()) {
return setPosition(link.lastChild as HTMLElement)
}
const thisUrl = new URL(document.location.href)
thisUrl.hash = ""
thisUrl.search = ""
const targetUrl = new URL(link.href)
const hash = decodeURIComponent(targetUrl.hash)
targetUrl.hash = ""
targetUrl.search = ""
const response = await fetchCanonical(targetUrl).catch((err) => {
console.error(err)
})
// bailout if another popover exists
if (hasAlreadyBeenFetched()) {
return
}
if (!response) return
const [contentType] = response.headers.get("Content-Type")!.split(";")
const [contentTypeCategory, typeInfo] = contentType.split("/")
const popoverElement = document.createElement("div")
popoverElement.classList.add("popover")
const popoverInner = document.createElement("div")
popoverInner.classList.add("popover-inner")
popoverElement.appendChild(popoverInner)
popoverInner.dataset.contentType = contentType ?? undefined
switch (contentTypeCategory) {
case "image":
const img = document.createElement("img")
img.src = targetUrl.toString()
img.alt = targetUrl.pathname
popoverInner.appendChild(img)
break
case "application":
switch (typeInfo) {
case "pdf":
const pdf = document.createElement("iframe")
pdf.src = targetUrl.toString()
popoverInner.appendChild(pdf)
break
default:
break
}
break
default:
const contents = await response.text()
const html = p.parseFromString(contents, "text/html")
normalizeRelativeURLs(html, targetUrl)
const elts = [...html.getElementsByClassName("popover-hint")]
if (elts.length === 0) return
elts.forEach((elt) => popoverInner.appendChild(elt))
}
setPosition(popoverElement)
link.appendChild(popoverElement)
if (hash !== "") {
const heading = popoverInner.querySelector(hash) as HTMLElement | null
if (heading) {
// leave ~12px of buffer when scrolling to a heading
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" })
}
}
}
document.addEventListener("nav", () => {
const links = [...document.getElementsByClassName("internal")] as HTMLAnchorElement[]
for (const link of links) {
console.log(links)
const pageLinks = links.filter((link) =>
link.classList.contains("internal-canvas"),
) as HTMLAnchorElement[]
const canvasLinks = links.filter(
(link) => !link.classList.contains("internal-canvas"),
) as HTMLAnchorElement[]
for (const link of pageLinks) {
link.addEventListener("mouseenter", mouseEnterHandler)
window.addCleanup(() => link.removeEventListener("mouseenter", mouseEnterHandler))
}
//TODO: Fix canvas loading of popovers
for (const link of canvasLinks) {
link.addEventListener("load", navigationHandler)
window.addCleanup(() => link.removeEventListener("load", navigationHandler))
}
})

View File

@ -27,3 +27,55 @@ export type QuartzComponent = ComponentType<QuartzComponentProps> & {
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
opts: Options,
) => QuartzComponent
export type QuartzCanvasComponent = {
nodes?: CanvasNode[]
edges?: CanvasEdge[]
}
export type CanvasNode = {
id: string
type: "text" | "file" | "link" | "group"
x: number
y: number
width: number
height: number
color?: CanvasColor
}
export type CanvasTextNode = CanvasNode & {
type: "text"
text: string
}
export type CanvasFileNode = CanvasNode & {
type: "file"
file: string
subpath?: string
}
export type CanvasLinkNode = CanvasNode & {
type: "link"
url: string
}
export type CanvasGroupNode = CanvasNode & {
type: "group"
label?: string
background?: string
backgroundStyle?: "cover" | "ratio" | "repeat"
}
export type CanvasEdge = {
id: string
fromNode: string
toNode: string
fromSide?: "top" | "bottom" | "left" | "right"
toSide?: "top" | "bottom" | "left" | "right"
fromEnd?: "none" | "arrow"
toEnd?: "none" | "arrow"
color?: CanvasColor
label?: string
}
export type CanvasColor = "1" | "2" | "3" | "4" | "5" | "6" | "#${string}"

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