From ed3ff895688ae8a42c43c4448249154595ca1180 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Thu, 26 Feb 2026 23:47:05 +0100 Subject: [PATCH] fix(links): virtual page links --- docs/features/Bases.md | 21 +++++ docs/features/Canvas.md | 21 +++++ docs/features/index.md | 2 + docs/plugins/BasesPage.md | 4 - docs/plugins/CanvasPage.md | 4 - quartz/build.ts | 50 ++++++++++- quartz/plugins/pageTypes/dispatcher.ts | 118 ++++++++++++++++--------- 7 files changed, 169 insertions(+), 51 deletions(-) create mode 100644 docs/features/Bases.md create mode 100644 docs/features/Canvas.md diff --git a/docs/features/Bases.md b/docs/features/Bases.md new file mode 100644 index 000000000..2f0583930 --- /dev/null +++ b/docs/features/Bases.md @@ -0,0 +1,21 @@ +--- +title: Bases Support +tags: + - component +--- + +Quartz supports rendering [Obsidian Bases](https://obsidian.md/changelog/2025-04-15-desktop-v1.8.0/) (`.base` files) as interactive database-like views. Bases files define queries over your vault's notes and display the results in configurable views such as tables, lists, cards, galleries, and boards. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +Bases support is provided by the [[BasesPage]] plugin. See the plugin page for configuration options, built-in views, the expression engine, and how to extend with custom views. + +## Demo + +![[navigation.base]] + +## Customization + +- Install: `npx quartz plugin add github:quartz-community/bases-page` +- Source: [`quartz-community/bases-page`](https://github.com/quartz-community/bases-page) diff --git a/docs/features/Canvas.md b/docs/features/Canvas.md new file mode 100644 index 000000000..aa7ba3651 --- /dev/null +++ b/docs/features/Canvas.md @@ -0,0 +1,21 @@ +--- +title: Canvas Support +tags: + - component +--- + +Quartz supports rendering [JSON Canvas](https://jsoncanvas.org) (`.canvas`) files as interactive, pannable and zoomable canvas pages. This brings your Obsidian canvas files to the web, preserving text nodes, file references, link nodes, group nodes, and edges with full visual fidelity. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. + +Canvas support is provided by the [[CanvasPage]] plugin. See the plugin page for configuration options and a full list of supported features. + +## Demo + +![[CanvasPage.canvas]] + +## Customization + +- Install: `npx quartz plugin add github:quartz-community/canvas-page` +- Source: [`quartz-community/canvas-page`](https://github.com/quartz-community/canvas-page) diff --git a/docs/features/index.md b/docs/features/index.md index af97a9c3e..220204e36 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -15,6 +15,8 @@ Quartz comes with a wide variety of features out of the box. Most features are p - [[OxHugo compatibility]] — Support for ox-hugo Markdown - [[Roam Research compatibility]] — Support for Roam Research syntax - [[Citations]] — Academic citation support +- [[Canvas]] — Render Obsidian Canvas files as interactive pages +- [[Bases]] — Database-like views for your notes (tables, cards, galleries, and more) ## Navigation & Discovery diff --git a/docs/plugins/BasesPage.md b/docs/plugins/BasesPage.md index 16ba34c11..dc8493b7f 100644 --- a/docs/plugins/BasesPage.md +++ b/docs/plugins/BasesPage.md @@ -7,10 +7,6 @@ tags: This plugin provides support for [Obsidian Bases](https://obsidian.md/changelog/2025-04-15-desktop-v1.8.0/) (`.base` files) in Quartz. It reads `.base` files from your vault, resolves matching notes based on the query definition, and renders them as interactive database-like views with support for tables, lists, cards, and maps. -See [[navigation.base]] for a live demo showcasing all supported features. - -![[navigation.base]] - > [!note] > For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. diff --git a/docs/plugins/CanvasPage.md b/docs/plugins/CanvasPage.md index dfcd3ad3e..e393b0d5d 100644 --- a/docs/plugins/CanvasPage.md +++ b/docs/plugins/CanvasPage.md @@ -6,10 +6,6 @@ tags: This plugin is a page type plugin that renders [JSON Canvas](https://jsoncanvas.org) (`.canvas`) files as interactive, pannable and zoomable canvas pages. It supports the full [JSON Canvas 1.0 spec](https://jsoncanvas.org/spec/1.0/), including text nodes with Markdown rendering, file nodes that link to other pages in your vault, link nodes for external URLs, and group nodes for visual organization. Edges between nodes are rendered as SVG paths with optional labels, arrow markers, and colors. -See [[CanvasPage.canvas]] for a live demo showcasing all supported features. - -![[canvasPage.canvas]] - > [!note] > For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. diff --git a/quartz/build.ts b/quartz/build.ts index 4518fa1d8..bc0340e7d 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -9,7 +9,7 @@ import { parseMarkdown } from "./processors/parse" import { filterContent } from "./processors/filter" import { emitContent } from "./processors/emit" import cfg from "../quartz" -import { FilePath, joinSegments, slugifyFilePath } from "./util/path" +import { FilePath, FullSlug, joinSegments, slugifyFilePath } from "./util/path" import chokidar from "chokidar" import { ProcessedContent } from "./plugins/vfile" import { Argv, BuildCtx } from "./util/ctx" @@ -19,9 +19,40 @@ import { options } from "./util/sourcemap" import { Mutex } from "async-mutex" import { getStaticResourcesFromPlugins } from "./plugins" import { randomIdNonSecure } from "./util/random" -import { ChangeEvent } from "./plugins/types" +import { ChangeEvent, QuartzPageTypePluginInstance } from "./plugins/types" import { minimatch } from "minimatch" +function getPageTypeExtensions(ctx: BuildCtx): Set { + const extensions = new Set() + const pageTypes = (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[] + for (const pt of pageTypes) { + if (pt.fileExtensions) { + for (const ext of pt.fileExtensions) { + extensions.add(ext) + } + } + } + return extensions +} + +// For files whose extensions are handled by PageType plugins (e.g. .canvas, .base), +// add extension-stripped slug aliases so that wikilink resolution (CrawlLinks) maps +// `![[file.canvas]]` to the virtual-page slug `file` instead of the raw `file.canvas`. +function addVirtualPageSlugAliases(allSlugs: FullSlug[], extensions: Set): FullSlug[] { + const extra: FullSlug[] = [] + for (const slug of allSlugs) { + for (const ext of extensions) { + if (slug.endsWith(ext)) { + const stripped = slug.slice(0, -ext.length) as FullSlug + if (!allSlugs.includes(stripped) && !extra.includes(stripped)) { + extra.push(stripped) + } + } + } + } + return extra +} + type ContentMap = Map< FilePath, | { @@ -83,6 +114,14 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { ctx.allFiles = allFiles ctx.allSlugs = allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + // Add extension-stripped slug aliases for PageType-registered extensions + // so that wikilinks like ![[file.canvas]] resolve to virtual page slugs + const ptExtensions = getPageTypeExtensions(ctx) + if (ptExtensions.size > 0) { + const aliases = addVirtualPageSlugAliases(ctx.allSlugs, ptExtensions) + ctx.allSlugs.push(...aliases) + } + const parsedFiles = await parseMarkdown(ctx, filePaths) const filteredContent = filterContent(ctx, parsedFiles) @@ -257,6 +296,13 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD // update allFiles and then allSlugs with the consistent view of content map ctx.allFiles = Array.from(contentMap.keys()) ctx.allSlugs = ctx.allFiles.map((fp) => slugifyFilePath(fp as FilePath)) + + // Add extension-stripped slug aliases for PageType-registered extensions + const ptExtensions = getPageTypeExtensions(ctx) + if (ptExtensions.size > 0) { + const aliases = addVirtualPageSlugAliases(ctx.allSlugs, ptExtensions) + ctx.allSlugs.push(...aliases) + } let processedFiles = filterContent( ctx, Array.from(contentMap.values()) diff --git a/quartz/plugins/pageTypes/dispatcher.ts b/quartz/plugins/pageTypes/dispatcher.ts index 3e983a6f9..3aa5adbd7 100644 --- a/quartz/plugins/pageTypes/dispatcher.ts +++ b/quartz/plugins/pageTypes/dispatcher.ts @@ -107,25 +107,18 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> // Ensure trie is available for components that need folder hierarchy (e.g. FolderContent) ctx.trie ??= trieFromAllFiles(allFiles) - for (const [tree, file] of content) { - const slug = file.data.slug! - const fileData = file.data - - for (const pt of pageTypes) { - if (pt.match({ slug, fileData, cfg })) { - const layout = resolveLayout(pt, defaults, byPageType) - yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources) - break - } - } - } - + // Phase 1: Generate all virtual pages first so their data is available in allFiles + // for transclude resolution in renderPage (e.g. ![[file.canvas]], ![[file.base]]) + const virtualEntries: Array<{ + tree: ProcessedContent[0] + vfile: ProcessedContent[1] + layout: FullPageLayout + vpSlug: FullSlug + }> = [] for (const pt of pageTypes) { if (!pt.generate) continue - const virtualPages = pt.generate({ content, cfg, ctx }) const layout = resolveLayout(pt, defaults, byPageType) - for (const vp of virtualPages) { const vpSlug = vp.slug as FullSlug const vpRelativePath = (vpSlug + ".md") as FilePath @@ -135,16 +128,41 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> frontmatter: { title: vp.title, tags: [] }, ...vp.data, }) - - // Expose virtual pages on ctx so other emitters (e.g. ContentIndex) can include them. - // Skip special pages like 404 that shouldn't appear in content listings. if (vpSlug !== "404") { ctx.virtualPages.push([tree, vfile]) } - - yield emitPage(ctx, vpSlug, tree, vfile.data, allFiles, layout, resources) + virtualEntries.push({ tree, vfile, layout, vpSlug }) } } + + // Merge virtual page data into allFiles so renderPage can resolve transcludes + const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)] + + // Phase 2: Emit regular pages (with virtual page data available for transclusion) + for (const [tree, file] of content) { + const slug = file.data.slug! + const fileData = file.data + for (const pt of pageTypes) { + if (pt.match({ slug, fileData, cfg })) { + const layout = resolveLayout(pt, defaults, byPageType) + yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources) + break + } + } + } + + // Phase 3: Emit virtual pages + for (const ve of virtualEntries) { + yield emitPage( + ctx, + ve.vpSlug, + ve.tree, + ve.vfile.data, + allFilesWithVirtual, + ve.layout, + resources, + ) + } }, async *partialEmit(ctx, content, resources, changeEvents) { const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) @@ -162,26 +180,17 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> } } - for (const [tree, file] of content) { - const slug = file.data.slug! - if (!changedSlugs.has(slug)) continue - - const fileData = file.data - for (const pt of pageTypes) { - if (pt.match({ slug, fileData, cfg })) { - const layout = resolveLayout(pt, defaults, byPageType) - yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources) - break - } - } - } - + // Phase 1: Generate all virtual pages first so their data is available in allFiles + const virtualEntries: Array<{ + tree: ProcessedContent[0] + vfile: ProcessedContent[1] + layout: FullPageLayout + vpSlug: FullSlug + }> = [] for (const pt of pageTypes) { if (!pt.generate) continue - const virtualPages = pt.generate({ content, cfg, ctx }) const layout = resolveLayout(pt, defaults, byPageType) - for (const vp of virtualPages) { const vpSlug = vp.slug as FullSlug const vpRelativePath = (vpSlug + ".md") as FilePath @@ -191,16 +200,43 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> frontmatter: { title: vp.title, tags: [] }, ...vp.data, }) - - // Expose virtual pages on ctx so other emitters (e.g. ContentIndex) can include them. - // Skip special pages like 404 that shouldn't appear in content listings. if (vpSlug !== "404") { ctx.virtualPages.push([tree, vfile]) } - - yield emitPage(ctx, vpSlug, tree, vfile.data, allFiles, layout, resources) + virtualEntries.push({ tree, vfile, layout, vpSlug }) } } + + // Merge virtual page data into allFiles for transclude resolution + const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)] + + // Phase 2: Emit changed regular pages + for (const [tree, file] of content) { + const slug = file.data.slug! + if (!changedSlugs.has(slug)) continue + + const fileData = file.data + for (const pt of pageTypes) { + if (pt.match({ slug, fileData, cfg })) { + const layout = resolveLayout(pt, defaults, byPageType) + yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources) + break + } + } + } + + // Phase 3: Emit virtual pages + for (const ve of virtualEntries) { + yield emitPage( + ctx, + ve.vpSlug, + ve.tree, + ve.vfile.data, + allFilesWithVirtual, + ve.layout, + resources, + ) + } }, } }