fix(links): virtual page links

This commit is contained in:
saberzero1 2026-02-26 23:47:05 +01:00
parent da7e62a1d8
commit ed3ff89568
No known key found for this signature in database
7 changed files with 169 additions and 51 deletions

21
docs/features/Bases.md Normal file
View File

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

21
docs/features/Canvas.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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<string> {
const extensions = new Set<string>()
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<string>): 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())

View File

@ -107,25 +107,18 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
// 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<Partial<DispatcherOptions>>
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<Partial<DispatcherOptions>>
}
}
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<Partial<DispatcherOptions>>
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,
)
}
},
}
}