feat(v5): add plugin system (#2295)
* feat(plugins): v5 plugin system * feat(plugins): explorer as community plugin * feat(plugins): graph as community plugin * chore: update package-lock.json * chore: update package-lock.json * docs: updated plugin-specific docs * chore: update package-lock.json * chore: update package-lock.json * chore: update package-lock.json * Implement Git-based plugin system with dogfooding for community plugins - Remove npm dependencies for @quartz-community/* plugins - Add gitLoader.ts for installing plugins from GitHub - Update quartz.layout.ts to import from .quartz/plugins/ - Add install-plugins.ts script for prebuild hook - Add .quartz/ to .gitignore * Add comprehensive Git-based plugin CLI with lockfile support - Create quartz.lock.json format for tracking exact plugin commits - Add 'npx quartz plugin' commands: install, add, remove, update, list, restore - Plugin state is fully reproducible via lockfile - No npm dependencies required for community plugins * Fix TypeScript errors in git-installed plugins - Install @quartz-community/types as devDependency - Fix plugin imports to define types locally - Fix search inline script fetchData bug - Format code with prettier * fix(types): install types from github * docs: updated plugin-specific docs * Update Dockerfile and add CI/CD documentation - Add plugin install step to Dockerfile - Create docs/ci-cd.md with pipeline configuration guide * Update GitHub Actions workflows for v5 branch and Git-based plugins - Change branch references from v4 to v5 - Add plugin caching to speed up builds - Use 'npx quartz plugin install' instead of 'restore' - Update Docker workflow branch trigger * Update quartz.lock.json with fixed plugin versions * fix(docker): install command * docs: add plugin migration analysis document Comprehensive analysis of which Quartz v4 components and plugins can be migrated to separate repositories, including: - Component analysis (25 components) - Plugin analysis (transformers, emitters, filters) - Migration strategies for different plugin types - Lessons learned from Explorer/Graph/Search migrations - Recommended migration order * chore: updated plugins * chore: updated plugins * chore: updated dependencies * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: tsconfig * feat: build installed plugins * chore: updated plugins * chore: updated plugins * chore: update explorer plugin with duplication fix * docs: Quartz v5 * chore: update graph plugin with navigation fix * fix: update explorer plugin with toggle fix * fix: update explorer plugin - ensure toggle buttons always work * fix: create plugin components once to prevent duplicate script registration * chore: updated plugins * chore: updated plugins * feat: migrate 7 feature components to community plugins (Phase B) Migrate ArticleTitle, TagList, PageTitle, Darkmode, ReaderMode, ContentMeta, and Footer from internal components to community plugins. Update layout to use Plugin.X() pattern, remove internal component files and their styles/scripts. Add MIGRATION_TASKS.md documenting the full migration roadmap. * chore: updated plugins * refactor: delete 6 internal component duplicates (Phase A) Remove Backlinks, Breadcrumbs, RecentNotes, Search, TableOfContents, Comments, and OverflowList — all replaced by community plugins. Delete associated styles (6) and scripts (3). Switch layout to use Plugin.Breadcrumbs() instead of Component.Breadcrumbs(). * refactor: unify QuartzComponent type to structural interface (Phase C) - Changed QuartzComponent from ComponentType<QuartzComponentProps> to callable type ((props: QuartzComponentProps) => any) - Added optional displayName property for better debugging - Removed ComponentType import from preact - Removed all 13 'as QuartzComponent' type casts from quartz.layout.ts - Community plugin components now directly assignable without casts * feat: add PageType plugin infrastructure (Phase D Step 4) * feat: add PageTypePluginEntry for cross-boundary type compatibility Introduce PageTypePluginEntry with never[] parameter types to accept both internal and community PageType plugins in config arrays without casts, working around branded FullSlug contravariance mismatch. * refactor: update dispatcher to cast PageTypePluginEntry at boundary Add getPageTypes() helper that casts config's PageTypePluginEntry[] to QuartzPageTypePluginInstance[] in one place. Cast VirtualPage.slug to FullSlug at emitPage/defaultProcessedContent call sites. * feat: integrate community PageType plugins (Phase D Step 6) Replace old page-rendering emitters with PageTypeDispatcher emitter and pageTypes array. Restructure quartz.layout.ts from three separate exports to unified layout object with defaults and byPageType record. Install content-page, folder-page, tag-page community plugins. * refactor: delete old page-rendering emitters Remove ContentPage, FolderPage, TagPage, and NotFoundPage emitters now replaced by community PageType plugins and the PageTypeDispatcher. * refactor: remove migrated page body components Delete Content, FolderContent, TagContent page components now provided by community PageType plugins. Update components barrel export. * fix: update lockfile to fixed folder-page and tag-page commits Points to commits that remove duplicate PageList/SortFn re-exports, fixing TS2300 duplicate identifier errors in generated plugin index. * chore: updated plugins * fix: populate ctx.trie in PageTypeDispatcher before rendering Components like FolderContent depend on ctx.trie for folder hierarchy. The dispatcher now lazily initializes it via trieFromAllFiles in emit and force-rebuilds it in partialEmit to reflect file changes. * chore: update lockfile to fixed folder-page commit * chore: updated plugins * chore: update explorer plugin to fix SPA folder navigation * feat: extract transformers to community plugins and fix type compatibility - Delete 12 internal transformer files (keep FrontMatter as internal) - Switch quartz.config.ts to use ExternalPlugin.* for all transformers - Align branded types with @quartz-community/types (_brand, FullSlug etc.) - Add vfile DataMap augmentations for fields from extracted transformers - Update all 29 plugins to @quartz-community/types v0.2.1 * Migrate filters to external plugins (remove-draft, explicit-publish) Delete internal RemoveDrafts and ExplicitPublish filter implementations, install them as community plugins, and update quartz.config.ts to use ExternalPlugin.RemoveDrafts(). * Migrate emitters to external plugins (alias-redirects, cname, favicon, content-index, og-image) * refactor: remove inline scripts/styles migrated to plugins Delete dead code: callout, checkbox, mermaid inline scripts and styles are now bundled by the obsidian-flavored-markdown plugin. Clipboard script and styles moved to the syntax-highlighting plugin. listPage.scss was unreferenced. Body.tsx simplified to a pure layout wrapper. * refactor: consolidate utils to re-export from @quartz-community/utils * fix: use dangerouslySetInnerHTML for inline CSS to prevent HTML-escaping Preact was escaping & characters in SCSS-compiled CSS (e.g. & nesting) into &, breaking CSS rules. Using dangerouslySetInnerHTML bypasses the escaping, matching how browsers expect style element content. * chore: update plugins with inline script transpilation fix * chore: updated plugins * docs: update plugin API sections for v5 community plugins * docs: rewrite documentation for v5 plugin system Update feature docs, hosting, CI/CD, getting started, configuration, layout, architecture, creating components, making plugins, and migration guide to reflect the v5 community plugin architecture. * docs: fix outdated v4 references in documentation * chore: remove completed migration planning docs * chore: updated plugins * chore: cleanup * chore: cleanup * chore: bump version to 5.0.0 * chore: updated dependencies * feat: integrate CanvasPage plugin with types, assets, config, layout, and documentation * chore: updated dependencies * chore: updated dependencies * chore: updated linter * chore: update canvas-page plugin to c942fcb * chore: updated plugins * chore: update canvas-page plugin to f88f1b9 * chore: updated plugins * chore: update canvas-page plugin to 079304c * chore: updated plugins * chore: canvas layout * chore: update canvas-page plugin to 38d49e1 * chore: updated plugins * chore: update canvas-page plugin to 505c099 * chore: updated plugins * chore: updated plugins * fix: Obsidian flavored markdown * fix: Obsidian flavored markdown * fix: Obsidian flavored markdown * chore: cleanup * chore: updated plugins * feat: configuration files * feat: Quartz TUI * feat(tui): YAML configuration * chore: tsup * chore: tsup * feat: support array categories in plugin manifests Plugins like note-properties export both transformer and component functionality. Allow PluginManifest.category to be a single value or an array, with config-loader resolving to the first processing category (transformer/filter/emitter/pageType) for dispatch. * refactor: remove built-in FrontMatter transformer Frontmatter processing is now handled by the note-properties plugin, which provides the same YAML/TOML parsing plus link extraction and a visual properties panel. The built-in transformer is no longer needed. * feat: add note-properties plugin to default configuration Register note-properties as the first plugin (order 5) in both the user config and the default config. Placed in beforeBody layout zone with priority 15 (between article-title at 10 and content-meta at 20). * docs: add plugin management strategy and syncer v5 notes Document the plugin management system design decisions and provide implementation guidance for the Quartz Syncer v5 integration. * feat: add bases-page plugin to default configuration Enable Obsidian Bases (.base) file support with bases page type and layout entry in both user and default config. * docs: update syncer notes with bases-page, note-properties, and spacer Add all three new plugins to the quick reference table (40 total). Add content, canvas, and bases page types to byPageType documentation. * chore: updated plugins * fix: update CI to Node 24 and regenerate lockfiles for clean install * fix: resolve type errors for CI checks * chore: updated plugins * chore: updated plugins * fix: plugin mapping from configuration * fix: CI * fix: CI * docs: rewrite Frontmatter documentation for note-properties plugin * chore: updated plugins * docs: Quartz v5 * chore: updated plugins * chore: updated plugins * refactor: extract TUI to standalone plugin repository * chore: linting * docs: Quartz v5 * feat: update and upgrade commands * chore: updated plugins * chore: updated plugins * chore: cleanup * chore: cleanup * chore: cleanup * chore: cleanup * chore: cleanup * fix: layout group priority * fix: view classes * fix: include virtual pages in content index for explorer visibility * docs: add board, gallery, and cards view examples to navigation page * chore: updated plugins * fix: include virtualPages in worker serializable build context * fix: set relativePath on virtual pages to prevent explorer crash * fix: exclude 404 * fix(links): virtual page links * fix(links): virtual page transclusion * docs: architecture overview * fix: only call scripts one per page * fix: type error in component registry instantiate method * fix: left layout order * fix(layout): remove tag-list by default * docs(plugins): updated plugin list defaults * fix(layout): priorities * feat: add PageFrame system for custom page layouts * feat: integrate PageFrame into rendering pipeline * feat: add frame resolution to page type dispatcher and config loader * style: add CSS grid overrides for full-width and minimal page frames * feat: set minimal frame for 404 and update canvas-page plugin * docs: add PageFrame system to architecture overview * fix: wrap frame.render() in array to satisfy Body children type * chore: format * fix: use absolute asset paths for 404 page so it works in subdirectories * fix(layout): priorities * docs: page frames * feat: add FrameRegistry for plugin-provided page frames Plugins can now register custom page frames via their manifest's 'frames' field. Frames are loaded alongside components during plugin initialization and resolved by name at render time with fallback to built-in frames. * feat(layout): page frames * fix(layout): linting * fix: inject frame CSS into page so plugin-provided frames render correctly * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * docs: canvas * chore: updated plugins * chore: updated plugins * chore: updated plugins * feat: add TreeTransform hook, fix multi-category plugins, and resolve cross-plugin dependencies - Add TreeTransform type and treeTransforms hook to pageType plugins, enabling render-time HAST tree mutations (e.g. bases-page inline codeblock resolution) - Fix config-loader to push multi-category plugins into ALL matching processing buckets instead of only the first match - Add side-effect import for component-only plugins so view registrations (e.g. leaflet-map via globalThis ViewRegistry) execute at load time - Add npm prune --omit=dev and cross-plugin peer dependency symlinking to buildPlugin() to prevent duplicate-singleton issues from nested node_modules * chore: format * chore: test docs * chore: updated plugins * fix: prevent HTML-escaping of inline style and script content in htmlToJsx Add dangerouslySetInnerHTML overrides for <style> and <script> elements so that CSS/JS injected by tree transforms is not HTML-escaped during preact-render-to-string serialization. * chore: update plugin lockfile for htmlToJsx migration * chore: update leaflet-map plugin (fix deferred L.Control) * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: updated plugins * chore: test npx quartz upgrade * feat(templates): add obsidian, ttrpg, blog templates * docs: move bases * docs: removed leaflet demo * feat(cli): configure baseUrl during create * docs: updated cli commands * docs: updated documentation for v5 * feat(cli): prune and resolve * chore: rebuild lockfile * docs: cli documentation * docs: plugin development and setup guide * chore: deleted redundant files * fix(build): fallback config * chore: updated lockfile * docs: removed outdated v3 setup * feat(cli): allow non-default branch plugins * docs: install branch commands * feat(cli): allow local plugins * docs: install local commands * feat: add render event type and listener for in-place DOM re-initialization * docs: add EncryptedPages plugin documentation * docs: add encrypted pages live demo page - New password-protected demo page (password: quartz) showing the plugin in action - Link to demo from EncryptedPages plugin page with password hint callout * feat: add encrypted-pages plugin to all templates - Enabled by default in default, obsidian, and ttrpg templates - Disabled by default in blog template * chore: updated plugins * chore: updated layouts * chore: updated plugins * feat: stacked pages * feat: added stacked page panes * docs: touch-ups
This commit is contained in:
@@ -5,12 +5,77 @@ import {
|
||||
handleBuild,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleUpgrade,
|
||||
handleRestore,
|
||||
handleSync,
|
||||
} from "./cli/handlers.js"
|
||||
import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js"
|
||||
import { handleMigrate } from "./cli/migrate-handler.js"
|
||||
import {
|
||||
handlePluginInstall as handleGitPluginInstall,
|
||||
handlePluginAdd,
|
||||
handlePluginRemove,
|
||||
handlePluginUpdate,
|
||||
handlePluginRestore,
|
||||
handlePluginList,
|
||||
handlePluginEnable,
|
||||
handlePluginDisable,
|
||||
handlePluginConfig,
|
||||
handlePluginCheck,
|
||||
handlePluginPrune,
|
||||
handlePluginResolve,
|
||||
} from "./cli/plugin-git-handlers.js"
|
||||
import {
|
||||
CommonArgv,
|
||||
BuildArgv,
|
||||
CreateArgv,
|
||||
SyncArgv,
|
||||
PluginInstallArgv,
|
||||
PluginUninstallArgv,
|
||||
PluginSearchArgv,
|
||||
} from "./cli/args.js"
|
||||
import { version } from "./cli/constants.js"
|
||||
|
||||
async function launchTui() {
|
||||
const { join } = await import("path")
|
||||
const { existsSync } = await import("fs")
|
||||
const { spawn } = await import("child_process")
|
||||
const tuiPath = join(process.cwd(), ".quartz", "plugins", "tui", "dist", "App.mjs")
|
||||
|
||||
if (!existsSync(tuiPath)) {
|
||||
console.error(
|
||||
"TUI plugin not installed. Install with:\n" +
|
||||
" npx quartz plugin add github:quartz-community/tui\n",
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// OpenTUI requires Bun runtime (uses bun:ffi for Zig renderer)
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("bun", ["run", tuiPath], {
|
||||
stdio: "inherit",
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (err.code === "ENOENT") {
|
||||
console.error(
|
||||
"Error: Bun runtime not found. The TUI requires Bun to run.\n" +
|
||||
"Install Bun: https://bun.sh/docs/installation",
|
||||
)
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`TUI exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("quartz")
|
||||
.version(version)
|
||||
@@ -18,8 +83,16 @@ yargs(hideBin(process.argv))
|
||||
.command("create", "Initialize Quartz", CreateArgv, async (argv) => {
|
||||
await handleCreate(argv)
|
||||
})
|
||||
.command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => {
|
||||
await handleUpdate(argv)
|
||||
.command(
|
||||
"update [names..]",
|
||||
"Update installed plugins to latest version",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handleUpdate(argv)
|
||||
},
|
||||
)
|
||||
.command("upgrade", "Upgrade Quartz to the latest version", CommonArgv, async (argv) => {
|
||||
await handleUpgrade(argv)
|
||||
})
|
||||
.command(
|
||||
"restore",
|
||||
@@ -35,6 +108,115 @@ yargs(hideBin(process.argv))
|
||||
.command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
|
||||
await handleBuild(argv)
|
||||
})
|
||||
.command("migrate", "Migrate old config to quartz.config.yaml", CommonArgv, async () => {
|
||||
await handleMigrate()
|
||||
})
|
||||
.command("tui", "Launch interactive plugin manager", CommonArgv, async () => {
|
||||
await launchTui()
|
||||
})
|
||||
.command(
|
||||
"plugin [subcommand]",
|
||||
"Manage Quartz plugins",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
.command("install", "Install plugins from quartz.lock.json", CommonArgv, async () => {
|
||||
await handleGitPluginInstall()
|
||||
})
|
||||
.command("add <repos..>", "Add plugins from Git repositories", CommonArgv, async (argv) => {
|
||||
await handlePluginAdd(argv.repos)
|
||||
})
|
||||
.command("remove <names..>", "Remove installed plugins", CommonArgv, async (argv) => {
|
||||
await handlePluginRemove(argv.names)
|
||||
})
|
||||
.command(
|
||||
"update [names..]",
|
||||
"Update installed plugins to latest version",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handlePluginUpdate(argv.names)
|
||||
},
|
||||
)
|
||||
.command("list", "List all installed plugins", CommonArgv, async () => {
|
||||
await handlePluginList()
|
||||
})
|
||||
.command(
|
||||
"restore",
|
||||
"Restore plugins from lockfile (exact versions)",
|
||||
CommonArgv,
|
||||
async () => {
|
||||
await handlePluginRestore()
|
||||
},
|
||||
)
|
||||
.command(
|
||||
"enable <names..>",
|
||||
"Enable plugins in quartz.config.yaml",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handlePluginEnable(argv.names)
|
||||
},
|
||||
)
|
||||
.command(
|
||||
"disable <names..>",
|
||||
"Disable plugins in quartz.config.yaml",
|
||||
CommonArgv,
|
||||
async (argv) => {
|
||||
await handlePluginDisable(argv.names)
|
||||
},
|
||||
)
|
||||
.command(
|
||||
"config <name>",
|
||||
"View or set plugin configuration",
|
||||
{
|
||||
...CommonArgv,
|
||||
set: {
|
||||
string: true,
|
||||
describe: "Set a config value (key=value)",
|
||||
},
|
||||
},
|
||||
async (argv) => {
|
||||
await handlePluginConfig(argv.name, { set: argv.set })
|
||||
},
|
||||
)
|
||||
.command("check", "Check for plugin updates", CommonArgv, async () => {
|
||||
await handlePluginCheck()
|
||||
})
|
||||
.command(
|
||||
"prune",
|
||||
"Remove installed plugins no longer referenced in config",
|
||||
{
|
||||
...CommonArgv,
|
||||
"dry-run": {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "show what would be pruned without making changes",
|
||||
},
|
||||
},
|
||||
async (argv) => {
|
||||
await handlePluginPrune({ dryRun: argv.dryRun })
|
||||
},
|
||||
)
|
||||
.command(
|
||||
"resolve",
|
||||
"Install plugins from config that are not yet in the lockfile",
|
||||
{
|
||||
...CommonArgv,
|
||||
"dry-run": {
|
||||
boolean: true,
|
||||
default: false,
|
||||
describe: "show what would be resolved without making changes",
|
||||
},
|
||||
},
|
||||
async (argv) => {
|
||||
await handlePluginResolve({ dryRun: argv.dryRun })
|
||||
},
|
||||
)
|
||||
.demandCommand(0, "")
|
||||
},
|
||||
async (argv) => {
|
||||
if (!argv._.includes("plugin") || argv._.length > 1) return
|
||||
await launchTui()
|
||||
},
|
||||
)
|
||||
.showHelpOnFail(false)
|
||||
.help()
|
||||
.strict()
|
||||
|
||||
@@ -8,8 +8,8 @@ import { styleText } from "util"
|
||||
import { parseMarkdown } from "./processors/parse"
|
||||
import { filterContent } from "./processors/filter"
|
||||
import { emitContent } from "./processors/emit"
|
||||
import cfg from "../quartz.config"
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "./util/path"
|
||||
import cfg from "../quartz"
|
||||
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,
|
||||
| {
|
||||
@@ -50,19 +81,21 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
||||
allSlugs: [],
|
||||
allFiles: [],
|
||||
incremental: false,
|
||||
virtualPages: [],
|
||||
}
|
||||
|
||||
const perf = new PerfTimer()
|
||||
const output = argv.output
|
||||
|
||||
const pluginCount = Object.values(cfg.plugins).flat().length
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
||||
cfg.plugins[key].map((plugin) => plugin.name)
|
||||
const pluginNames = (key: "transformers" | "filters" | "emitters" | "pageTypes") =>
|
||||
(cfg.plugins[key] ?? []).map((plugin) => plugin.name)
|
||||
if (argv.verbose) {
|
||||
console.log(`Loaded ${pluginCount} plugins`)
|
||||
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
|
||||
console.log(` Filters: ${pluginNames("filters").join(", ")}`)
|
||||
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
|
||||
console.log(` PageTypes: ${pluginNames("pageTypes").join(", ")}`)
|
||||
}
|
||||
|
||||
const release = await mut.acquire()
|
||||
@@ -81,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)
|
||||
|
||||
@@ -255,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())
|
||||
@@ -263,10 +311,40 @@ async function rebuild(changes: ChangeEvent[], clientRefresh: () => void, buildD
|
||||
)
|
||||
|
||||
let emittedFiles = 0
|
||||
|
||||
// Phase 1: Run PageTypeDispatcher first so it populates ctx.virtualPages
|
||||
const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher")
|
||||
if (dispatcher) {
|
||||
ctx.virtualPages = []
|
||||
const emitFn = dispatcher.partialEmit ?? dispatcher.emit
|
||||
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
|
||||
if (emitted !== null) {
|
||||
if (Symbol.asyncIterator in emitted) {
|
||||
for await (const file of emitted) {
|
||||
emittedFiles++
|
||||
if (ctx.argv.verbose) {
|
||||
console.log(`[emit:${dispatcher.name}] ${file}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emittedFiles += emitted.length
|
||||
if (ctx.argv.verbose) {
|
||||
for (const file of emitted) {
|
||||
console.log(`[emit:${dispatcher.name}] ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Run all other emitters with content extended by virtual pages
|
||||
const contentWithVirtual =
|
||||
ctx.virtualPages.length > 0 ? [...processedFiles, ...ctx.virtualPages] : processedFiles
|
||||
for (const emitter of cfg.plugins.emitters) {
|
||||
if (emitter.name === "PageTypeDispatcher") continue
|
||||
// Try to use partialEmit if available, otherwise assume the output is static
|
||||
const emitFn = emitter.partialEmit ?? emitter.emit
|
||||
const emitted = await emitFn(ctx, processedFiles, staticResources, changeEvents)
|
||||
const emitted = await emitFn(ctx, contentWithVirtual, staticResources, changeEvents)
|
||||
if (emitted === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ValidDateType } from "./components/Date"
|
||||
import { QuartzComponent } from "./components/types"
|
||||
import { ValidLocale } from "./i18n"
|
||||
import { PluginSpecifier } from "./plugins/loader/types"
|
||||
import { PluginTypes } from "./plugins/types"
|
||||
import { Theme } from "./util/theme"
|
||||
|
||||
@@ -88,6 +89,7 @@ export interface GlobalConfiguration {
|
||||
export interface QuartzConfig {
|
||||
configuration: GlobalConfiguration
|
||||
plugins: PluginTypes
|
||||
externalPlugins?: PluginSpecifier[]
|
||||
}
|
||||
|
||||
export interface FullPageLayout {
|
||||
@@ -99,6 +101,8 @@ export interface FullPageLayout {
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
/** Page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
||||
frame?: string
|
||||
}
|
||||
|
||||
export type PageLayout = Pick<FullPageLayout, "beforeBody" | "left" | "right">
|
||||
|
||||
@@ -15,6 +15,12 @@ export const CommonArgv = {
|
||||
|
||||
export const CreateArgv = {
|
||||
...CommonArgv,
|
||||
template: {
|
||||
string: true,
|
||||
alias: ["t"],
|
||||
choices: ["default", "obsidian", "ttrpg", "blog"],
|
||||
describe: "template to use for initial configuration",
|
||||
},
|
||||
source: {
|
||||
string: true,
|
||||
alias: ["s"],
|
||||
@@ -26,6 +32,11 @@ export const CreateArgv = {
|
||||
choices: ["new", "copy", "symlink"],
|
||||
describe: "strategy for content folder setup",
|
||||
},
|
||||
baseUrl: {
|
||||
string: true,
|
||||
alias: ["b"],
|
||||
describe: "base URL for your Quartz site (e.g. mysite.github.io/quartz)",
|
||||
},
|
||||
links: {
|
||||
string: true,
|
||||
alias: ["l"],
|
||||
@@ -106,3 +117,30 @@ export const BuildArgv = {
|
||||
describe: "how many threads to use to parse notes",
|
||||
},
|
||||
}
|
||||
|
||||
export const PluginInstallArgv = {
|
||||
...CommonArgv,
|
||||
_: {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "package names to install",
|
||||
},
|
||||
}
|
||||
|
||||
export const PluginUninstallArgv = {
|
||||
...CommonArgv,
|
||||
_: {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "package names to uninstall",
|
||||
},
|
||||
}
|
||||
|
||||
export const PluginSearchArgv = {
|
||||
...CommonArgv,
|
||||
query: {
|
||||
string: true,
|
||||
alias: ["q"],
|
||||
describe: "search query for plugins",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { readFileSync } from "fs"
|
||||
*/
|
||||
export const ORIGIN_NAME = "origin"
|
||||
export const UPSTREAM_NAME = "upstream"
|
||||
export const QUARTZ_SOURCE_BRANCH = "v4"
|
||||
export const QUARTZ_SOURCE_BRANCH = "v5"
|
||||
export const QUARTZ_SOURCE_REPO = "https://github.com/jackyzha0/quartz.git"
|
||||
export const cwd = process.cwd()
|
||||
export const cacheDir = path.join(cwd, ".quartz-cache")
|
||||
export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs"
|
||||
|
||||
@@ -23,9 +23,24 @@ import {
|
||||
popContentFolder,
|
||||
stashContentFolder,
|
||||
} from "./helpers.js"
|
||||
import {
|
||||
handlePluginRestore,
|
||||
handlePluginCheck,
|
||||
handlePluginUpdate,
|
||||
} from "./plugin-git-handlers.js"
|
||||
import {
|
||||
configExists,
|
||||
createConfigFromDefault,
|
||||
createConfigFromTemplate,
|
||||
readPluginsJson,
|
||||
writePluginsJson,
|
||||
extractPluginName,
|
||||
updateGlobalConfig,
|
||||
} from "./plugin-data.js"
|
||||
import {
|
||||
UPSTREAM_NAME,
|
||||
QUARTZ_SOURCE_BRANCH,
|
||||
QUARTZ_SOURCE_REPO,
|
||||
ORIGIN_NAME,
|
||||
version,
|
||||
fp,
|
||||
@@ -53,6 +68,8 @@ export async function handleCreate(argv) {
|
||||
let setupStrategy = argv.strategy?.toLowerCase()
|
||||
let linkResolutionStrategy = argv.links?.toLowerCase()
|
||||
const sourceDirectory = argv.source
|
||||
let template = argv.template?.toLowerCase()
|
||||
let baseUrl = argv.baseUrl
|
||||
|
||||
// If all cmd arguments were provided, check if they're valid
|
||||
if (setupStrategy && linkResolutionStrategy) {
|
||||
@@ -104,6 +121,32 @@ export async function handleCreate(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
// Template selection
|
||||
if (!template) {
|
||||
template = exitIfCancel(
|
||||
await select({
|
||||
message: "Choose a template for your Quartz configuration",
|
||||
options: [
|
||||
{ value: "default", label: "Default", hint: "clean Quartz setup with sensible defaults" },
|
||||
{
|
||||
value: "obsidian",
|
||||
label: "Obsidian",
|
||||
hint: "optimized for Obsidian vaults with full OFM support",
|
||||
},
|
||||
{
|
||||
value: "ttrpg",
|
||||
label: "TTRPG",
|
||||
hint: "Obsidian + map plugin + ITS Theme for D&D/TTRPG wikis",
|
||||
},
|
||||
{
|
||||
value: "blog",
|
||||
label: "Blog",
|
||||
hint: "recent notes and comments enabled for blogging",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Use cli process if cmd args werent provided
|
||||
if (!setupStrategy) {
|
||||
setupStrategy = exitIfCancel(
|
||||
@@ -181,12 +224,18 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
)
|
||||
}
|
||||
|
||||
// Obsidian and TTRPG templates auto-set link resolution to "shortest"
|
||||
const skipLinkPrompt = template === "obsidian" || template === "ttrpg"
|
||||
if (skipLinkPrompt) {
|
||||
linkResolutionStrategy = "shortest"
|
||||
}
|
||||
|
||||
// Use cli process if cmd args werent provided
|
||||
if (!linkResolutionStrategy) {
|
||||
// get a preferred link resolution strategy
|
||||
linkResolutionStrategy = exitIfCancel(
|
||||
await select({
|
||||
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.ts\`.`,
|
||||
message: `Choose how Quartz should resolve links in your content. This should match Obsidian's link format. You can change this later in \`quartz.config.yaml\`.`,
|
||||
options: [
|
||||
{
|
||||
value: "shortest",
|
||||
@@ -206,23 +255,60 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started.
|
||||
)
|
||||
}
|
||||
|
||||
// now, do config changes
|
||||
const configFilePath = path.join(cwd, "quartz.config.ts")
|
||||
let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" })
|
||||
configContent = configContent.replace(
|
||||
/markdownLinkResolution: '(.+)'/,
|
||||
`markdownLinkResolution: '${linkResolutionStrategy}'`,
|
||||
)
|
||||
await fs.promises.writeFile(configFilePath, configContent)
|
||||
// Base URL prompt
|
||||
if (!baseUrl) {
|
||||
baseUrl = exitIfCancel(
|
||||
await text({
|
||||
message: "Enter the base URL for your Quartz site (e.g. mysite.github.io/quartz)",
|
||||
placeholder: "mysite.github.io",
|
||||
validate(value) {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return "Base URL cannot be empty"
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Strip protocol prefix if user included it
|
||||
baseUrl = baseUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
|
||||
// Create config if it doesn't exist
|
||||
if (!configExists()) {
|
||||
if (template && template !== "default") {
|
||||
createConfigFromTemplate(template)
|
||||
console.log(styleText("green", `Created quartz.config.yaml from '${template}' template`))
|
||||
} else {
|
||||
createConfigFromTemplate("default")
|
||||
console.log(styleText("green", "Created quartz.config.yaml from defaults"))
|
||||
}
|
||||
}
|
||||
|
||||
// Update markdownLinkResolution in the crawl-links plugin options via YAML config
|
||||
const json = readPluginsJson()
|
||||
if (json?.plugins) {
|
||||
const crawlLinksIndex = json.plugins.findIndex(
|
||||
(p) => extractPluginName(p.source) === "crawl-links",
|
||||
)
|
||||
if (crawlLinksIndex !== -1) {
|
||||
json.plugins[crawlLinksIndex].options = {
|
||||
...json.plugins[crawlLinksIndex].options,
|
||||
markdownLinkResolution: linkResolutionStrategy,
|
||||
}
|
||||
writePluginsJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
// Update baseUrl in configuration
|
||||
updateGlobalConfig({ baseUrl })
|
||||
|
||||
// setup remote
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
{ stdio: "ignore" },
|
||||
)
|
||||
execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`, {
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
outro(`You're all set! Not sure what to do next? Try:
|
||||
• Customizing Quartz a bit more by editing \`quartz.config.ts\`
|
||||
• Customizing Quartz a bit more by editing \`quartz.config.yaml\`
|
||||
• Running \`npx quartz build --serve\` to preview your Quartz locally
|
||||
• Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting)
|
||||
`)
|
||||
@@ -487,16 +573,15 @@ export async function handleBuild(argv) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz update`
|
||||
* @param {*} argv arguments for `update`
|
||||
* Handles `npx quartz upgrade`
|
||||
* Upgrades the Quartz framework itself by pulling latest changes from upstream.
|
||||
* @param {*} argv arguments for `upgrade`
|
||||
*/
|
||||
export async function handleUpdate(argv) {
|
||||
export async function handleUpgrade(argv) {
|
||||
const contentFolder = resolveContentPath(argv.directory)
|
||||
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
|
||||
console.log("Backing up your content")
|
||||
execSync(
|
||||
`git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`,
|
||||
)
|
||||
execSync(`git remote show upstream || git remote add upstream ${QUARTZ_SOURCE_REPO}`)
|
||||
await stashContentFolder(contentFolder)
|
||||
console.log(
|
||||
"Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.",
|
||||
@@ -511,6 +596,16 @@ export async function handleUpdate(argv) {
|
||||
}
|
||||
|
||||
await popContentFolder(contentFolder)
|
||||
|
||||
// Read the new version after pulling
|
||||
const newPkg = JSON.parse(fs.readFileSync("./package.json").toString())
|
||||
const newVersion = newPkg.version
|
||||
if (newVersion !== version) {
|
||||
console.log(styleText("cyan", `Upgraded Quartz: v${version} → v${newVersion}`))
|
||||
} else {
|
||||
console.log(styleText("gray", `Quartz is already up to date (v${version})`))
|
||||
}
|
||||
|
||||
console.log("Ensuring dependencies are up to date")
|
||||
|
||||
/*
|
||||
@@ -518,7 +613,7 @@ export async function handleUpdate(argv) {
|
||||
as it will be unable to find `npm`. This is often the case on systems
|
||||
where `npm` is installed via a package manager.
|
||||
|
||||
This means `npx quartz update` will not actually update dependencies
|
||||
This means `npx quartz upgrade` will not actually update dependencies
|
||||
on Windows, without a manual `npm i` from the caller.
|
||||
|
||||
However, by spawning a shell, we are able to call `npm.cmd`.
|
||||
@@ -532,10 +627,28 @@ export async function handleUpdate(argv) {
|
||||
|
||||
const res = spawnSync("npm", ["i"], opts)
|
||||
if (res.status === 0) {
|
||||
console.log(styleText("green", "Done!"))
|
||||
console.log(styleText("green", "Dependencies updated!"))
|
||||
} else {
|
||||
console.log(styleText("red", "An error occurred above while installing dependencies."))
|
||||
}
|
||||
|
||||
console.log("Restoring plugins from lockfile...")
|
||||
await handlePluginRestore()
|
||||
|
||||
console.log("Checking plugin compatibility...")
|
||||
await handlePluginCheck()
|
||||
|
||||
console.log(styleText("green", "Done!"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles `npx quartz update`
|
||||
* Shortcut for `npx quartz plugin update` — updates all installed plugins.
|
||||
* @param {*} argv arguments for `update`
|
||||
*/
|
||||
export async function handleUpdate(argv) {
|
||||
console.log(`\n${styleText(["bgGreen", "black"], ` Quartz v${version} `)} \n`)
|
||||
await handlePluginUpdate(argv.names)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function stashContentFolder(contentFolder) {
|
||||
}
|
||||
|
||||
export function gitPull(origin, branch) {
|
||||
const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"]
|
||||
const flags = ["--no-rebase", "--autostash", "--no-edit"]
|
||||
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
|
||||
if (out.stderr) {
|
||||
throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`))
|
||||
|
||||
191
quartz/cli/migrate-handler.js
Normal file
191
quartz/cli/migrate-handler.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { styleText } from "util"
|
||||
import YAML from "yaml"
|
||||
|
||||
const CWD = process.cwd()
|
||||
const QUARTZ_TS_PATH = path.join(CWD, "quartz.ts")
|
||||
const CONFIG_YAML_PATH = path.join(CWD, "quartz.config.yaml")
|
||||
const DEFAULT_CONFIG_YAML_PATH = path.join(CWD, "quartz.config.default.yaml")
|
||||
const LEGACY_DEFAULT_JSON_PATH = path.join(CWD, "quartz.plugins.default.json")
|
||||
const LOCKFILE_PATH = path.join(CWD, "quartz.lock.json")
|
||||
const PLUGINS_DIR = path.join(CWD, ".quartz", "plugins")
|
||||
const PACKAGE_JSON_PATH = path.join(CWD, "package.json")
|
||||
|
||||
function readJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasTsx() {
|
||||
const pkg = readJson(PACKAGE_JSON_PATH)
|
||||
return Boolean(pkg?.devDependencies?.tsx || pkg?.dependencies?.tsx)
|
||||
}
|
||||
|
||||
function extractWithTsx() {
|
||||
const script = `
|
||||
const { default: config } = await import("./quartz.ts")
|
||||
const { layout } = await import("./quartz.ts")
|
||||
const result = {
|
||||
configuration: config?.configuration ?? null,
|
||||
layoutInfo: {
|
||||
defaults: {
|
||||
afterBody: Array.isArray(layout?.defaults?.afterBody) ? layout.defaults.afterBody.length : 0,
|
||||
hasFooter: Boolean(layout?.defaults?.footer),
|
||||
},
|
||||
pageTypes: layout?.byPageType ? Object.keys(layout.byPageType) : [],
|
||||
},
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
|
||||
const res = spawnSync("node", ["--import", "tsx/esm", "--input-type=module", "-e", script], {
|
||||
encoding: "utf-8",
|
||||
cwd: CWD,
|
||||
})
|
||||
|
||||
if (res.error || res.status !== 0) {
|
||||
return { ok: false, error: res.error ?? res.stderr }
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, data: JSON.parse(res.stdout.trim()) }
|
||||
} catch (error) {
|
||||
return { ok: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(pluginDir) {
|
||||
const pkgPath = path.join(pluginDir, "package.json")
|
||||
if (!fs.existsSync(pkgPath)) return null
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
||||
return pkg.quartz ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLayoutDefaults(layout) {
|
||||
if (!layout.groups) layout.groups = {}
|
||||
if (!layout.groups.toolbar) {
|
||||
layout.groups.toolbar = { direction: "row", gap: "0.5rem" }
|
||||
}
|
||||
if (!layout.byPageType) layout.byPageType = {}
|
||||
if (!layout.byPageType["404"]) {
|
||||
layout.byPageType["404"] = { positions: { beforeBody: [], left: [], right: [] } }
|
||||
} else if (!layout.byPageType["404"].positions) {
|
||||
layout.byPageType["404"].positions = { beforeBody: [], left: [], right: [] }
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
function buildPluginEntry(name, entry) {
|
||||
const pluginDir = path.join(PLUGINS_DIR, name)
|
||||
const manifest = readManifest(pluginDir)
|
||||
const source = entry?.source ?? `github:quartz-community/${name}`
|
||||
const pluginEntry = {
|
||||
source,
|
||||
enabled: manifest?.defaultEnabled ?? true,
|
||||
options: manifest?.defaultOptions ?? {},
|
||||
order: manifest?.defaultOrder ?? 50,
|
||||
}
|
||||
|
||||
if (manifest?.components) {
|
||||
const component = Object.values(manifest.components).find((comp) => comp?.defaultPosition)
|
||||
if (component?.defaultPosition) {
|
||||
pluginEntry.layout = {
|
||||
position: component.defaultPosition,
|
||||
priority: component.defaultPriority ?? 50,
|
||||
display: "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pluginEntry
|
||||
}
|
||||
|
||||
export async function handleMigrate() {
|
||||
console.log(styleText("cyan", "Migrating Quartz configuration..."))
|
||||
|
||||
if (!fs.existsSync(QUARTZ_TS_PATH)) {
|
||||
console.log(styleText("red", "✗ quartz.ts not found. Aborting migration."))
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(CONFIG_YAML_PATH)) {
|
||||
console.log(styleText("yellow", "⚠ quartz.config.yaml already exists. Overwriting."))
|
||||
}
|
||||
|
||||
const defaultJson = readJson(DEFAULT_CONFIG_YAML_PATH) ?? readJson(LEGACY_DEFAULT_JSON_PATH)
|
||||
let configuration = defaultJson?.configuration ?? {}
|
||||
let layout = ensureLayoutDefaults(defaultJson?.layout ?? {})
|
||||
let layoutInfo = null
|
||||
|
||||
console.log(styleText("gray", "→ Extracting configuration..."))
|
||||
if (hasTsx()) {
|
||||
const extracted = extractWithTsx()
|
||||
if (extracted.ok) {
|
||||
configuration = extracted.data?.configuration ?? configuration
|
||||
layoutInfo = extracted.data?.layoutInfo ?? null
|
||||
} else {
|
||||
console.log(styleText("yellow", "⚠ Failed to import TS config with tsx. Using defaults."))
|
||||
}
|
||||
} else {
|
||||
console.log(styleText("yellow", "⚠ tsx not found. Using defaults."))
|
||||
}
|
||||
|
||||
if (layoutInfo?.pageTypes?.length) {
|
||||
for (const pageType of layoutInfo.pageTypes) {
|
||||
if (!layout.byPageType[pageType]) {
|
||||
layout.byPageType[pageType] = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(styleText("gray", "→ Reading plugin lockfile..."))
|
||||
const lockfile = readJson(LOCKFILE_PATH)
|
||||
const plugins = []
|
||||
|
||||
if (lockfile?.plugins) {
|
||||
for (const [name, entry] of Object.entries(lockfile.plugins)) {
|
||||
plugins.push(buildPluginEntry(name, entry))
|
||||
}
|
||||
} else if (defaultJson?.plugins) {
|
||||
console.log(styleText("yellow", "⚠ quartz.lock.json not found. Using default plugins."))
|
||||
for (const plugin of defaultJson.plugins) {
|
||||
plugins.push(plugin)
|
||||
}
|
||||
} else {
|
||||
console.log(styleText("yellow", "⚠ No lockfile or default plugins found. Writing empty list."))
|
||||
}
|
||||
|
||||
const outputJson = {
|
||||
configuration,
|
||||
plugins,
|
||||
layout,
|
||||
}
|
||||
|
||||
const header = "# yaml-language-server: $schema=./quartz/plugins/quartz-plugins.schema.json\n"
|
||||
fs.writeFileSync(CONFIG_YAML_PATH, header + YAML.stringify(outputJson, { lineWidth: 120 }))
|
||||
|
||||
const quartzTsTemplate =
|
||||
'import { loadQuartzConfig, loadQuartzLayout } from "./quartz/plugins/loader/config-loader"\n' +
|
||||
"\n" +
|
||||
"const config = await loadQuartzConfig()\n" +
|
||||
"export default config\n" +
|
||||
"export const layout = await loadQuartzLayout()\n"
|
||||
|
||||
fs.writeFileSync(QUARTZ_TS_PATH, quartzTsTemplate)
|
||||
|
||||
console.log(styleText("green", "✓ Created quartz.config.yaml"))
|
||||
console.log(styleText("green", "✓ Replaced quartz.ts"))
|
||||
console.log()
|
||||
console.log(styleText("yellow", "⚠ Verify plugin options in quartz.config.yaml"))
|
||||
console.log(styleText("gray", `Plugins migrated: ${plugins.length}`))
|
||||
}
|
||||
358
quartz/cli/plugin-data.js
Normal file
358
quartz/cli/plugin-data.js
Normal file
@@ -0,0 +1,358 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { execSync } from "child_process"
|
||||
import YAML from "yaml"
|
||||
|
||||
const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json")
|
||||
const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins")
|
||||
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
|
||||
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
|
||||
const TEMPLATES_DIR = path.join(process.cwd(), "quartz", "cli", "templates")
|
||||
|
||||
const LEGACY_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
|
||||
const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json")
|
||||
|
||||
function resolveConfigPath() {
|
||||
if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH
|
||||
if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH
|
||||
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
|
||||
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
|
||||
return CONFIG_YAML_PATH
|
||||
}
|
||||
|
||||
function resolveDefaultConfigPath() {
|
||||
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
|
||||
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
|
||||
return DEFAULT_CONFIG_YAML_PATH
|
||||
}
|
||||
|
||||
function readFileAsData(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8")
|
||||
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
|
||||
return YAML.parse(raw)
|
||||
}
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeDataToFile(filePath, data) {
|
||||
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
|
||||
const header = "# yaml-language-server: $schema=./quartz/plugins/quartz-plugins.schema.json\n"
|
||||
fs.writeFileSync(filePath, header + YAML.stringify(data, { lineWidth: 120 }))
|
||||
} else {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginsJson() {
|
||||
const configPath = resolveConfigPath()
|
||||
return readFileAsData(configPath)
|
||||
}
|
||||
|
||||
export function writePluginsJson(data) {
|
||||
const { $schema, ...rest } = data
|
||||
writeDataToFile(CONFIG_YAML_PATH, rest)
|
||||
}
|
||||
|
||||
export function readDefaultPluginsJson() {
|
||||
const defaultPath = resolveDefaultConfigPath()
|
||||
return readFileAsData(defaultPath)
|
||||
}
|
||||
|
||||
export function readLockfile() {
|
||||
if (!fs.existsSync(LOCKFILE_PATH)) return null
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(LOCKFILE_PATH, "utf-8"))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLockfile(lockfile) {
|
||||
if (lockfile.plugins) {
|
||||
const sorted = {}
|
||||
for (const key of Object.keys(lockfile.plugins).sort()) {
|
||||
sorted[key] = lockfile.plugins[key]
|
||||
}
|
||||
lockfile = { ...lockfile, plugins: sorted }
|
||||
}
|
||||
fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n")
|
||||
}
|
||||
|
||||
export function isLocalSource(source) {
|
||||
if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) {
|
||||
return true
|
||||
}
|
||||
// Windows absolute paths (e.g. C:\ or D:/)
|
||||
if (/^[A-Za-z]:[\\/]/.test(source)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
export function extractPluginName(source) {
|
||||
if (isLocalSource(source)) {
|
||||
return path.basename(source.replace(/[\/]+$/, ""))
|
||||
}
|
||||
if (source.startsWith("github:")) {
|
||||
const withoutPrefix = source.replace("github:", "")
|
||||
const [repoPath] = withoutPrefix.split("#")
|
||||
const parts = repoPath.split("/")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
if (source.startsWith("git+") || source.startsWith("https://")) {
|
||||
const url = source.replace("git+", "")
|
||||
const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
|
||||
return match?.[1] ?? source
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
export function readManifestFromPackageJson(pluginDir) {
|
||||
const pkgPath = path.join(pluginDir, "package.json")
|
||||
if (!fs.existsSync(pkgPath)) return null
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
||||
return pkg.quartz ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitSource(source) {
|
||||
if (isLocalSource(source)) {
|
||||
const resolved = path.resolve(source)
|
||||
const name = path.basename(resolved)
|
||||
return { name, url: resolved, ref: undefined, local: true }
|
||||
}
|
||||
if (source.startsWith("github:")) {
|
||||
const [repoPath, ref] = source.replace("github:", "").split("#")
|
||||
const [owner, repo] = repoPath.split("/")
|
||||
return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref }
|
||||
}
|
||||
if (source.startsWith("git+")) {
|
||||
const raw = source.replace("git+", "")
|
||||
const [url, ref] = raw.split("#")
|
||||
const name = path.basename(url, ".git")
|
||||
return { name, url, ref }
|
||||
}
|
||||
if (source.startsWith("https://")) {
|
||||
const [url, ref] = source.split("#")
|
||||
const name = path.basename(url, ".git")
|
||||
return { name, url, ref }
|
||||
}
|
||||
throw new Error(`Cannot parse plugin source: ${source}`)
|
||||
}
|
||||
|
||||
export function getGitCommit(pluginDir) {
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim()
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginDir(name) {
|
||||
return path.join(PLUGINS_DIR, name)
|
||||
}
|
||||
|
||||
export function pluginDirExists(name) {
|
||||
return fs.existsSync(path.join(PLUGINS_DIR, name))
|
||||
}
|
||||
|
||||
export function ensurePluginsDir() {
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges quartz.config.yaml, quartz.lock.json, and on-disk manifest data
|
||||
* into enriched plugin entries with: name, displayName, source, enabled,
|
||||
* options, order, layout, category, installed, locked, manifest,
|
||||
* currentCommit, modified.
|
||||
*/
|
||||
export function getEnrichedPlugins() {
|
||||
const pluginsJson = readPluginsJson()
|
||||
const lockfile = readLockfile()
|
||||
|
||||
if (!pluginsJson?.plugins) return []
|
||||
|
||||
return pluginsJson.plugins.map((entry, index) => {
|
||||
const name = extractPluginName(entry.source)
|
||||
const pluginDir = path.join(PLUGINS_DIR, name)
|
||||
const installed = fs.existsSync(pluginDir)
|
||||
const locked = lockfile?.plugins?.[name] ?? null
|
||||
const manifest = installed ? readManifestFromPackageJson(pluginDir) : null
|
||||
const currentCommit = installed ? getGitCommit(pluginDir) : null
|
||||
const modified = locked && currentCommit ? currentCommit !== locked.commit : false
|
||||
|
||||
return {
|
||||
index,
|
||||
name,
|
||||
displayName: manifest?.displayName ?? name,
|
||||
source: entry.source,
|
||||
enabled: entry.enabled ?? true,
|
||||
options: entry.options ?? {},
|
||||
order: entry.order ?? 50,
|
||||
layout: entry.layout ?? null,
|
||||
category: manifest?.category ?? "unknown",
|
||||
installed,
|
||||
locked,
|
||||
manifest,
|
||||
currentCommit,
|
||||
modified,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getLayoutConfig() {
|
||||
const pluginsJson = readPluginsJson()
|
||||
return pluginsJson?.layout ?? null
|
||||
}
|
||||
|
||||
export function getGlobalConfig() {
|
||||
const pluginsJson = readPluginsJson()
|
||||
return pluginsJson?.configuration ?? null
|
||||
}
|
||||
|
||||
export function updatePluginEntry(index, updates) {
|
||||
const json = readPluginsJson()
|
||||
if (!json?.plugins?.[index]) return false
|
||||
Object.assign(json.plugins[index], updates)
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function updateGlobalConfig(updates) {
|
||||
const json = readPluginsJson()
|
||||
if (!json) return false
|
||||
json.configuration = { ...json.configuration, ...updates }
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function updateLayoutConfig(layout) {
|
||||
const json = readPluginsJson()
|
||||
if (!json) return false
|
||||
json.layout = layout
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function reorderPlugin(fromIndex, toIndex) {
|
||||
const json = readPluginsJson()
|
||||
if (!json?.plugins) return false
|
||||
const [moved] = json.plugins.splice(fromIndex, 1)
|
||||
json.plugins.splice(toIndex, 0, moved)
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function removePluginEntry(index) {
|
||||
const json = readPluginsJson()
|
||||
if (!json?.plugins?.[index]) return false
|
||||
json.plugins.splice(index, 1)
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function addPluginEntry(entry) {
|
||||
const json = readPluginsJson()
|
||||
if (!json) return false
|
||||
if (!json.plugins) json.plugins = []
|
||||
json.plugins.push(entry)
|
||||
writePluginsJson(json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function configExists() {
|
||||
return fs.existsSync(CONFIG_YAML_PATH) || fs.existsSync(LEGACY_PLUGINS_JSON_PATH)
|
||||
}
|
||||
|
||||
export function createConfigFromDefault() {
|
||||
const defaultData = readDefaultPluginsJson()
|
||||
if (!defaultData) {
|
||||
// No default available — create minimal config
|
||||
const minimal = {
|
||||
configuration: {
|
||||
pageTitle: "Quartz",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
analytics: { provider: "plausible" },
|
||||
locale: "en-US",
|
||||
baseUrl: "quartz.jzhao.xyz",
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
defaultDateType: "created",
|
||||
theme: {
|
||||
cdnCaching: true,
|
||||
typography: {
|
||||
header: "Schibsted Grotesk",
|
||||
body: "Source Sans Pro",
|
||||
code: "IBM Plex Mono",
|
||||
},
|
||||
colors: {
|
||||
lightMode: {
|
||||
light: "#faf8f8",
|
||||
lightgray: "#e5e5e5",
|
||||
gray: "#b8b8b8",
|
||||
darkgray: "#4e4e4e",
|
||||
dark: "#2b2b2b",
|
||||
secondary: "#284b63",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#fff23688",
|
||||
},
|
||||
darkMode: {
|
||||
light: "#161618",
|
||||
lightgray: "#393639",
|
||||
gray: "#646464",
|
||||
darkgray: "#d4d4d4",
|
||||
dark: "#ebebec",
|
||||
secondary: "#7b97aa",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
textHighlight: "#fff23688",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
layout: { groups: {}, byPageType: {} },
|
||||
}
|
||||
writePluginsJson(minimal)
|
||||
return minimal
|
||||
}
|
||||
|
||||
const { $schema, ...rest } = defaultData
|
||||
writePluginsJson(rest)
|
||||
return rest
|
||||
}
|
||||
|
||||
const VALID_TEMPLATES = ["default", "obsidian", "ttrpg", "blog"]
|
||||
|
||||
export function createConfigFromTemplate(templateName) {
|
||||
if (!VALID_TEMPLATES.includes(templateName)) {
|
||||
throw new Error(
|
||||
`Unknown template: ${templateName}. Valid templates: ${VALID_TEMPLATES.join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
const templatePath = path.join(TEMPLATES_DIR, `${templateName}.yaml`)
|
||||
const templateData = readFileAsData(templatePath)
|
||||
if (!templateData) {
|
||||
// Template file missing — fall back to default config creation
|
||||
return createConfigFromDefault()
|
||||
}
|
||||
|
||||
const { $schema, ...rest } = templateData
|
||||
writePluginsJson(rest)
|
||||
return rest
|
||||
}
|
||||
|
||||
export const PLUGINS_JSON_PATH = CONFIG_YAML_PATH
|
||||
export const DEFAULT_PLUGINS_JSON_PATH = DEFAULT_CONFIG_YAML_PATH
|
||||
export { LOCKFILE_PATH, PLUGINS_DIR }
|
||||
1029
quartz/cli/plugin-git-handlers.js
Normal file
1029
quartz/cli/plugin-git-handlers.js
Normal file
File diff suppressed because it is too large
Load Diff
130
quartz/cli/plugin-handlers.js
Normal file
130
quartz/cli/plugin-handlers.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { styleText } from "util"
|
||||
import { execSync, spawnSync } from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
export async function handlePluginInstall(packageNames) {
|
||||
console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`)
|
||||
|
||||
if (packageNames.length === 0) {
|
||||
console.log(styleText("red", "Error: No package names provided"))
|
||||
console.log("Usage: npx quartz plugin install <package-name> [package-name...]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Installing ${packageNames.length} plugin(s)...`)
|
||||
|
||||
const npmArgs = ["install", ...packageNames]
|
||||
const result = spawnSync("npm", npmArgs, { stdio: "inherit" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.log(styleText("red", "Failed to install plugins"))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(styleText("green", "✓ Plugins installed successfully"))
|
||||
console.log("\nAdd them to your quartz.config.yaml:")
|
||||
|
||||
for (const pkg of packageNames) {
|
||||
console.log(` import { Plugin } from "${pkg}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePluginList() {
|
||||
console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`)
|
||||
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json")
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
console.log(styleText("red", "No package.json found"))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
|
||||
const allDeps = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
}
|
||||
|
||||
const quartzPlugins = Object.entries(allDeps).filter(([name]) => {
|
||||
return (
|
||||
name.startsWith("@quartz/") ||
|
||||
name.startsWith("quartz-") ||
|
||||
name.startsWith("@quartz-community/")
|
||||
)
|
||||
})
|
||||
|
||||
if (quartzPlugins.length === 0) {
|
||||
console.log("No Quartz plugins found in this project.")
|
||||
console.log("Install plugins with: npx quartz plugin install <package-name>")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${quartzPlugins.length} Quartz plugin(s):\n`)
|
||||
|
||||
for (const [name, version] of quartzPlugins) {
|
||||
console.log(` ${styleText("cyan", name)}@${version}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePluginSearch(query) {
|
||||
console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`)
|
||||
|
||||
const searchQuery = query || "quartz-plugin"
|
||||
|
||||
console.log(`Searching npm for packages matching "${searchQuery}"...`)
|
||||
console.log(styleText("grey", "(This may take a moment)\n"))
|
||||
|
||||
try {
|
||||
const result = execSync(`npm search ${searchQuery} --json`, { encoding: "utf-8" })
|
||||
const packages = JSON.parse(result)
|
||||
|
||||
const quartzPlugins = packages.filter(
|
||||
(pkg) =>
|
||||
pkg.name.startsWith("@quartz/") ||
|
||||
pkg.name.startsWith("quartz-") ||
|
||||
pkg.name.startsWith("@quartz-community/"),
|
||||
)
|
||||
|
||||
if (quartzPlugins.length === 0) {
|
||||
console.log("No Quartz plugins found matching your query.")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${quartzPlugins.length} Quartz plugin(s):\n`)
|
||||
|
||||
for (const pkg of quartzPlugins.slice(0, 20)) {
|
||||
console.log(` ${styleText("cyan", pkg.name)}@${pkg.version}`)
|
||||
if (pkg.description) {
|
||||
console.log(` ${styleText("grey", pkg.description)}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
} catch {
|
||||
console.log(styleText("yellow", "Could not search npm. Try visiting:"))
|
||||
console.log(" https://www.npmjs.com/search?q=quartz-plugin")
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePluginUninstall(packageNames) {
|
||||
console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`)
|
||||
|
||||
if (packageNames.length === 0) {
|
||||
console.log(styleText("red", "Error: No package names provided"))
|
||||
console.log("Usage: npx quartz plugin uninstall <package-name> [package-name...]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Uninstalling ${packageNames.length} plugin(s)...`)
|
||||
|
||||
const npmArgs = ["uninstall", ...packageNames]
|
||||
const result = spawnSync("npm", npmArgs, { stdio: "inherit" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.log(styleText("red", "Failed to uninstall plugins"))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(styleText("green", "✓ Plugins uninstalled successfully"))
|
||||
console.log(styleText("yellow", "Don't forget to remove them from your quartz.config.yaml!"))
|
||||
}
|
||||
282
quartz/cli/templates/blog.yaml
Normal file
282
quartz/cli/templates/blog.yaml
Normal file
@@ -0,0 +1,282 @@
|
||||
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
|
||||
# Template: blog
|
||||
# A blog-focused setup with recent notes and comments enabled.
|
||||
configuration:
|
||||
pageTitle: Quartz 5
|
||||
pageTitleSuffix: ""
|
||||
enableSPA: true
|
||||
enablePopovers: true
|
||||
analytics:
|
||||
provider: plausible
|
||||
locale: en-US
|
||||
baseUrl: quartz.jzhao.xyz
|
||||
ignorePatterns:
|
||||
- private
|
||||
- templates
|
||||
- .obsidian
|
||||
defaultDateType: modified
|
||||
theme:
|
||||
fontOrigin: googleFonts
|
||||
cdnCaching: true
|
||||
typography:
|
||||
header: Schibsted Grotesk
|
||||
body: Source Sans Pro
|
||||
code: IBM Plex Mono
|
||||
colors:
|
||||
lightMode:
|
||||
light: "#faf8f8"
|
||||
lightgray: "#e5e5e5"
|
||||
gray: "#b8b8b8"
|
||||
darkgray: "#4e4e4e"
|
||||
dark: "#2b2b2b"
|
||||
secondary: "#284b63"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#fff23688"
|
||||
darkMode:
|
||||
light: "#161618"
|
||||
lightgray: "#393639"
|
||||
gray: "#646464"
|
||||
darkgray: "#d4d4d4"
|
||||
dark: "#ebebec"
|
||||
secondary: "#7b97aa"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#b3aa0288"
|
||||
plugins:
|
||||
- source: github:quartz-community/created-modified-date
|
||||
enabled: true
|
||||
options:
|
||||
priority:
|
||||
- frontmatter
|
||||
- git
|
||||
- filesystem
|
||||
order: 10
|
||||
- source: github:quartz-community/syntax-highlighting
|
||||
enabled: true
|
||||
options:
|
||||
theme:
|
||||
light: github-light
|
||||
dark: github-dark
|
||||
keepBackground: false
|
||||
order: 20
|
||||
- source: github:quartz-community/obsidian-flavored-markdown
|
||||
enabled: true
|
||||
options:
|
||||
enableInHtmlEmbed: false
|
||||
enableCheckbox: true
|
||||
order: 30
|
||||
- source: github:quartz-community/github-flavored-markdown
|
||||
enabled: true
|
||||
order: 40
|
||||
- source: github:quartz-community/table-of-contents
|
||||
enabled: true
|
||||
order: 50
|
||||
- source: github:quartz-community/crawl-links
|
||||
enabled: true
|
||||
options:
|
||||
markdownLinkResolution: shortest
|
||||
order: 60
|
||||
- source: github:quartz-community/description
|
||||
enabled: true
|
||||
order: 70
|
||||
- source: github:quartz-community/latex
|
||||
enabled: true
|
||||
options:
|
||||
renderEngine: katex
|
||||
order: 80
|
||||
- source: github:quartz-community/citations
|
||||
enabled: false
|
||||
order: 85
|
||||
- source: github:quartz-community/hard-line-breaks
|
||||
enabled: false
|
||||
order: 90
|
||||
- source: github:quartz-community/ox-hugo
|
||||
enabled: false
|
||||
order: 91
|
||||
- source: github:quartz-community/roam
|
||||
enabled: false
|
||||
order: 92
|
||||
- source: github:quartz-community/remove-draft
|
||||
enabled: true
|
||||
- source: github:quartz-community/explicit-publish
|
||||
enabled: false
|
||||
- source: github:quartz-community/encrypted-pages
|
||||
enabled: false
|
||||
- source: github:quartz-community/stacked-pages
|
||||
enabled: false
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 50
|
||||
display: all
|
||||
- source: github:quartz-community/alias-redirects
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-index
|
||||
enabled: true
|
||||
options:
|
||||
enableSiteMap: true
|
||||
enableRSS: true
|
||||
- source: github:quartz-community/favicon
|
||||
enabled: true
|
||||
- source: github:quartz-community/og-image
|
||||
enabled: true
|
||||
- source: github:quartz-community/cname
|
||||
enabled: true
|
||||
- source: github:quartz-community/canvas-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/folder-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/tag-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/explorer
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 50
|
||||
- source: github:quartz-community/graph
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 10
|
||||
- source: github:quartz-community/search
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 20
|
||||
group: toolbar
|
||||
groupOptions:
|
||||
grow: true
|
||||
- source: github:quartz-community/backlinks
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 30
|
||||
- source: github:quartz-community/article-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/content-meta
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 20
|
||||
- source: github:quartz-community/tag-list
|
||||
enabled: false
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 30
|
||||
- source: github:quartz-community/page-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 10
|
||||
- source: github:quartz-community/darkmode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 30
|
||||
group: toolbar
|
||||
- source: github:quartz-community/reader-mode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 35
|
||||
group: toolbar
|
||||
- source: github:quartz-community/breadcrumbs
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 5
|
||||
condition: not-index
|
||||
- source: github:quartz-community/comments
|
||||
enabled: true
|
||||
options:
|
||||
provider: giscus
|
||||
options:
|
||||
repo: "TODO:username/repo-name"
|
||||
repoId: "TODO:your-repo-id"
|
||||
category: Announcements
|
||||
categoryId: "TODO:your-category-id"
|
||||
mapping: url
|
||||
strict: true
|
||||
reactionsEnabled: true
|
||||
inputPosition: bottom
|
||||
lightTheme: light
|
||||
darkTheme: dark
|
||||
lang: en
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/footer
|
||||
enabled: true
|
||||
options:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
- source: github:quartz-community/recent-notes
|
||||
enabled: true
|
||||
options:
|
||||
title: Recent Notes
|
||||
limit: 5
|
||||
linkToMore: false
|
||||
showTags: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 25
|
||||
- source: github:quartz-community/spacer
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 25
|
||||
layout:
|
||||
position: left
|
||||
priority: 25
|
||||
display: mobile-only
|
||||
- source: github:quartz-community/bases-page
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 50
|
||||
- source: github:quartz-community/note-properties
|
||||
enabled: true
|
||||
options:
|
||||
includeAll: false
|
||||
includedProperties:
|
||||
- description
|
||||
- tags
|
||||
- aliases
|
||||
excludedProperties: []
|
||||
hidePropertiesView: false
|
||||
delimiters: "---"
|
||||
language: yaml
|
||||
order: 5
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 15
|
||||
display: all
|
||||
layout:
|
||||
groups:
|
||||
toolbar:
|
||||
priority: 35
|
||||
direction: row
|
||||
gap: 0.5rem
|
||||
byPageType:
|
||||
"404":
|
||||
positions:
|
||||
beforeBody: []
|
||||
left: []
|
||||
right: []
|
||||
content: {}
|
||||
folder:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
tag:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
canvas: {}
|
||||
bases: {}
|
||||
263
quartz/cli/templates/default.yaml
Normal file
263
quartz/cli/templates/default.yaml
Normal file
@@ -0,0 +1,263 @@
|
||||
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
|
||||
# Template: default
|
||||
# A clean Quartz setup with sensible defaults.
|
||||
configuration:
|
||||
pageTitle: Quartz 5
|
||||
pageTitleSuffix: ""
|
||||
enableSPA: true
|
||||
enablePopovers: true
|
||||
analytics:
|
||||
provider: plausible
|
||||
locale: en-US
|
||||
baseUrl: quartz.jzhao.xyz
|
||||
ignorePatterns:
|
||||
- private
|
||||
- templates
|
||||
- .obsidian
|
||||
defaultDateType: modified
|
||||
theme:
|
||||
fontOrigin: googleFonts
|
||||
cdnCaching: true
|
||||
typography:
|
||||
header: Schibsted Grotesk
|
||||
body: Source Sans Pro
|
||||
code: IBM Plex Mono
|
||||
colors:
|
||||
lightMode:
|
||||
light: "#faf8f8"
|
||||
lightgray: "#e5e5e5"
|
||||
gray: "#b8b8b8"
|
||||
darkgray: "#4e4e4e"
|
||||
dark: "#2b2b2b"
|
||||
secondary: "#284b63"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#fff23688"
|
||||
darkMode:
|
||||
light: "#161618"
|
||||
lightgray: "#393639"
|
||||
gray: "#646464"
|
||||
darkgray: "#d4d4d4"
|
||||
dark: "#ebebec"
|
||||
secondary: "#7b97aa"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#b3aa0288"
|
||||
plugins:
|
||||
- source: github:quartz-community/created-modified-date
|
||||
enabled: true
|
||||
options:
|
||||
priority:
|
||||
- frontmatter
|
||||
- git
|
||||
- filesystem
|
||||
order: 10
|
||||
- source: github:quartz-community/syntax-highlighting
|
||||
enabled: true
|
||||
options:
|
||||
theme:
|
||||
light: github-light
|
||||
dark: github-dark
|
||||
keepBackground: false
|
||||
order: 20
|
||||
- source: github:quartz-community/obsidian-flavored-markdown
|
||||
enabled: true
|
||||
options:
|
||||
enableInHtmlEmbed: false
|
||||
enableCheckbox: true
|
||||
order: 30
|
||||
- source: github:quartz-community/github-flavored-markdown
|
||||
enabled: true
|
||||
order: 40
|
||||
- source: github:quartz-community/table-of-contents
|
||||
enabled: true
|
||||
order: 50
|
||||
- source: github:quartz-community/crawl-links
|
||||
enabled: true
|
||||
options:
|
||||
markdownLinkResolution: shortest
|
||||
order: 60
|
||||
- source: github:quartz-community/description
|
||||
enabled: true
|
||||
order: 70
|
||||
- source: github:quartz-community/latex
|
||||
enabled: true
|
||||
options:
|
||||
renderEngine: katex
|
||||
order: 80
|
||||
- source: github:quartz-community/citations
|
||||
enabled: false
|
||||
order: 85
|
||||
- source: github:quartz-community/hard-line-breaks
|
||||
enabled: false
|
||||
order: 90
|
||||
- source: github:quartz-community/ox-hugo
|
||||
enabled: false
|
||||
order: 91
|
||||
- source: github:quartz-community/roam
|
||||
enabled: false
|
||||
order: 92
|
||||
- source: github:quartz-community/remove-draft
|
||||
enabled: true
|
||||
- source: github:quartz-community/explicit-publish
|
||||
enabled: false
|
||||
- source: github:quartz-community/encrypted-pages
|
||||
enabled: true
|
||||
- source: github:quartz-community/stacked-pages
|
||||
enabled: false
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 50
|
||||
display: all
|
||||
- source: github:quartz-community/alias-redirects
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-index
|
||||
enabled: true
|
||||
options:
|
||||
enableSiteMap: true
|
||||
enableRSS: true
|
||||
- source: github:quartz-community/favicon
|
||||
enabled: true
|
||||
- source: github:quartz-community/og-image
|
||||
enabled: true
|
||||
- source: github:quartz-community/cname
|
||||
enabled: true
|
||||
- source: github:quartz-community/canvas-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/folder-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/tag-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/explorer
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 50
|
||||
- source: github:quartz-community/graph
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 10
|
||||
- source: github:quartz-community/search
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 20
|
||||
group: toolbar
|
||||
groupOptions:
|
||||
grow: true
|
||||
- source: github:quartz-community/backlinks
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 30
|
||||
- source: github:quartz-community/article-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/content-meta
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 20
|
||||
- source: github:quartz-community/tag-list
|
||||
enabled: false
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 30
|
||||
- source: github:quartz-community/page-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 10
|
||||
- source: github:quartz-community/darkmode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 30
|
||||
group: toolbar
|
||||
- source: github:quartz-community/reader-mode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 35
|
||||
group: toolbar
|
||||
- source: github:quartz-community/breadcrumbs
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 5
|
||||
condition: not-index
|
||||
- source: github:quartz-community/comments
|
||||
enabled: false
|
||||
options:
|
||||
provider: giscus
|
||||
options: {}
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/footer
|
||||
enabled: true
|
||||
options:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
- source: github:quartz-community/recent-notes
|
||||
enabled: false
|
||||
- source: github:quartz-community/spacer
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 25
|
||||
layout:
|
||||
position: left
|
||||
priority: 25
|
||||
display: mobile-only
|
||||
- source: github:quartz-community/bases-page
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 50
|
||||
- source: github:quartz-community/note-properties
|
||||
enabled: true
|
||||
options:
|
||||
includeAll: false
|
||||
includedProperties:
|
||||
- description
|
||||
- tags
|
||||
- aliases
|
||||
excludedProperties: []
|
||||
hidePropertiesView: false
|
||||
delimiters: "---"
|
||||
language: yaml
|
||||
order: 5
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 15
|
||||
display: all
|
||||
layout:
|
||||
groups:
|
||||
toolbar:
|
||||
priority: 35
|
||||
direction: row
|
||||
gap: 0.5rem
|
||||
byPageType:
|
||||
"404":
|
||||
positions:
|
||||
beforeBody: []
|
||||
left: []
|
||||
right: []
|
||||
content: {}
|
||||
folder:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
tag:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
canvas: {}
|
||||
bases: {}
|
||||
277
quartz/cli/templates/obsidian.yaml
Normal file
277
quartz/cli/templates/obsidian.yaml
Normal file
@@ -0,0 +1,277 @@
|
||||
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
|
||||
# Template: obsidian
|
||||
# Optimized for Obsidian vaults with full OFM support and shortest link resolution.
|
||||
configuration:
|
||||
pageTitle: Quartz 5
|
||||
pageTitleSuffix: ""
|
||||
enableSPA: true
|
||||
enablePopovers: true
|
||||
analytics:
|
||||
provider: plausible
|
||||
locale: en-US
|
||||
baseUrl: quartz.jzhao.xyz
|
||||
ignorePatterns:
|
||||
- private
|
||||
- templates
|
||||
- .obsidian
|
||||
defaultDateType: modified
|
||||
theme:
|
||||
fontOrigin: googleFonts
|
||||
cdnCaching: true
|
||||
typography:
|
||||
header: Schibsted Grotesk
|
||||
body: Source Sans Pro
|
||||
code: IBM Plex Mono
|
||||
colors:
|
||||
lightMode:
|
||||
light: "#faf8f8"
|
||||
lightgray: "#e5e5e5"
|
||||
gray: "#b8b8b8"
|
||||
darkgray: "#4e4e4e"
|
||||
dark: "#2b2b2b"
|
||||
secondary: "#284b63"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#fff23688"
|
||||
darkMode:
|
||||
light: "#161618"
|
||||
lightgray: "#393639"
|
||||
gray: "#646464"
|
||||
darkgray: "#d4d4d4"
|
||||
dark: "#ebebec"
|
||||
secondary: "#7b97aa"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#b3aa0288"
|
||||
plugins:
|
||||
- source: github:quartz-community/created-modified-date
|
||||
enabled: true
|
||||
options:
|
||||
priority:
|
||||
- frontmatter
|
||||
- git
|
||||
- filesystem
|
||||
order: 10
|
||||
- source: github:quartz-community/syntax-highlighting
|
||||
enabled: true
|
||||
options:
|
||||
theme:
|
||||
light: github-light
|
||||
dark: github-dark
|
||||
keepBackground: false
|
||||
order: 20
|
||||
- source: github:quartz-community/obsidian-flavored-markdown
|
||||
enabled: true
|
||||
options:
|
||||
comments: true
|
||||
highlight: true
|
||||
wikilinks: true
|
||||
callouts: true
|
||||
mermaid: true
|
||||
parseTags: true
|
||||
parseArrows: true
|
||||
parseBlockReferences: true
|
||||
enableInHtmlEmbed: false
|
||||
enableYouTubeEmbed: true
|
||||
enableVideoEmbed: true
|
||||
enableCheckbox: true
|
||||
order: 30
|
||||
- source: github:quartz-community/github-flavored-markdown
|
||||
enabled: true
|
||||
order: 40
|
||||
- source: github:quartz-community/table-of-contents
|
||||
enabled: true
|
||||
order: 50
|
||||
- source: github:quartz-community/crawl-links
|
||||
enabled: true
|
||||
options:
|
||||
markdownLinkResolution: shortest
|
||||
order: 60
|
||||
- source: github:quartz-community/description
|
||||
enabled: true
|
||||
order: 70
|
||||
- source: github:quartz-community/latex
|
||||
enabled: true
|
||||
options:
|
||||
renderEngine: katex
|
||||
order: 80
|
||||
- source: github:quartz-community/citations
|
||||
enabled: false
|
||||
order: 85
|
||||
- source: github:quartz-community/hard-line-breaks
|
||||
enabled: true
|
||||
order: 90
|
||||
- source: github:quartz-community/ox-hugo
|
||||
enabled: false
|
||||
order: 91
|
||||
- source: github:quartz-community/roam
|
||||
enabled: false
|
||||
order: 92
|
||||
- source: github:quartz-community/remove-draft
|
||||
enabled: true
|
||||
- source: github:quartz-community/explicit-publish
|
||||
enabled: false
|
||||
- source: github:quartz-community/encrypted-pages
|
||||
enabled: true
|
||||
- source: github:quartz-community/stacked-pages
|
||||
enabled: false
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 50
|
||||
display: all
|
||||
- source: github:quartz-community/alias-redirects
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-index
|
||||
enabled: true
|
||||
options:
|
||||
enableSiteMap: true
|
||||
enableRSS: true
|
||||
- source: github:quartz-community/favicon
|
||||
enabled: true
|
||||
- source: github:quartz-community/og-image
|
||||
enabled: true
|
||||
- source: github:quartz-community/cname
|
||||
enabled: true
|
||||
- source: github:quartz-community/canvas-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/folder-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/tag-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/explorer
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 50
|
||||
- source: github:quartz-community/graph
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 10
|
||||
- source: github:quartz-community/search
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 20
|
||||
group: toolbar
|
||||
groupOptions:
|
||||
grow: true
|
||||
- source: github:quartz-community/backlinks
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 30
|
||||
- source: github:quartz-community/article-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/content-meta
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 20
|
||||
- source: github:quartz-community/tag-list
|
||||
enabled: false
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 30
|
||||
- source: github:quartz-community/page-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 10
|
||||
- source: github:quartz-community/darkmode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 30
|
||||
group: toolbar
|
||||
- source: github:quartz-community/reader-mode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 35
|
||||
group: toolbar
|
||||
- source: github:quartz-community/breadcrumbs
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 5
|
||||
condition: not-index
|
||||
- source: github:quartz-community/comments
|
||||
enabled: false
|
||||
options:
|
||||
provider: giscus
|
||||
options: {}
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/footer
|
||||
enabled: true
|
||||
options:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
- source: github:quartz-community/recent-notes
|
||||
enabled: false
|
||||
- source: github:quartz-community/spacer
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 25
|
||||
layout:
|
||||
position: left
|
||||
priority: 25
|
||||
display: mobile-only
|
||||
- source: github:quartz-community/bases-page
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 50
|
||||
- source: github:quartz-community/note-properties
|
||||
enabled: true
|
||||
options:
|
||||
includeAll: false
|
||||
includedProperties:
|
||||
- description
|
||||
- tags
|
||||
- aliases
|
||||
excludedProperties: []
|
||||
hidePropertiesView: false
|
||||
delimiters: "---"
|
||||
language: yaml
|
||||
order: 5
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 15
|
||||
display: all
|
||||
- source: github:saberzero1/quartz-themes
|
||||
enabled: true
|
||||
options:
|
||||
theme: "default"
|
||||
layout:
|
||||
groups:
|
||||
toolbar:
|
||||
priority: 35
|
||||
direction: row
|
||||
gap: 0.5rem
|
||||
byPageType:
|
||||
"404":
|
||||
positions:
|
||||
beforeBody: []
|
||||
left: []
|
||||
right: []
|
||||
content: {}
|
||||
folder:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
tag:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
canvas: {}
|
||||
bases: {}
|
||||
281
quartz/cli/templates/ttrpg.yaml
Normal file
281
quartz/cli/templates/ttrpg.yaml
Normal file
@@ -0,0 +1,281 @@
|
||||
# yaml-language-server: $schema=../../plugins/quartz-plugins.schema.json
|
||||
# Template: ttrpg
|
||||
# Obsidian-based setup with map plugin and ITS Theme for TTRPG/D&D wikis.
|
||||
configuration:
|
||||
pageTitle: Quartz 5
|
||||
pageTitleSuffix: ""
|
||||
enableSPA: true
|
||||
enablePopovers: true
|
||||
analytics:
|
||||
provider: plausible
|
||||
locale: en-US
|
||||
baseUrl: quartz.jzhao.xyz
|
||||
ignorePatterns:
|
||||
- private
|
||||
- templates
|
||||
- .obsidian
|
||||
defaultDateType: modified
|
||||
theme:
|
||||
fontOrigin: googleFonts
|
||||
cdnCaching: true
|
||||
typography:
|
||||
header: Schibsted Grotesk
|
||||
body: Source Sans Pro
|
||||
code: IBM Plex Mono
|
||||
colors:
|
||||
lightMode:
|
||||
light: "#faf8f8"
|
||||
lightgray: "#e5e5e5"
|
||||
gray: "#b8b8b8"
|
||||
darkgray: "#4e4e4e"
|
||||
dark: "#2b2b2b"
|
||||
secondary: "#284b63"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#fff23688"
|
||||
darkMode:
|
||||
light: "#161618"
|
||||
lightgray: "#393639"
|
||||
gray: "#646464"
|
||||
darkgray: "#d4d4d4"
|
||||
dark: "#ebebec"
|
||||
secondary: "#7b97aa"
|
||||
tertiary: "#84a59d"
|
||||
highlight: rgba(143, 159, 169, 0.15)
|
||||
textHighlight: "#b3aa0288"
|
||||
plugins:
|
||||
- source: github:quartz-community/created-modified-date
|
||||
enabled: true
|
||||
options:
|
||||
priority:
|
||||
- frontmatter
|
||||
- git
|
||||
- filesystem
|
||||
order: 10
|
||||
- source: github:quartz-community/syntax-highlighting
|
||||
enabled: true
|
||||
options:
|
||||
theme:
|
||||
light: github-light
|
||||
dark: github-dark
|
||||
keepBackground: false
|
||||
order: 20
|
||||
- source: github:quartz-community/obsidian-flavored-markdown
|
||||
enabled: true
|
||||
options:
|
||||
comments: true
|
||||
highlight: true
|
||||
wikilinks: true
|
||||
callouts: true
|
||||
mermaid: true
|
||||
parseTags: true
|
||||
parseArrows: true
|
||||
parseBlockReferences: true
|
||||
enableInHtmlEmbed: false
|
||||
enableYouTubeEmbed: true
|
||||
enableVideoEmbed: true
|
||||
enableCheckbox: true
|
||||
order: 30
|
||||
- source: github:quartz-community/github-flavored-markdown
|
||||
enabled: true
|
||||
order: 40
|
||||
- source: github:quartz-community/table-of-contents
|
||||
enabled: true
|
||||
order: 50
|
||||
- source: github:quartz-community/crawl-links
|
||||
enabled: true
|
||||
options:
|
||||
markdownLinkResolution: shortest
|
||||
order: 60
|
||||
- source: github:quartz-community/description
|
||||
enabled: true
|
||||
order: 70
|
||||
- source: github:quartz-community/latex
|
||||
enabled: true
|
||||
options:
|
||||
renderEngine: katex
|
||||
order: 80
|
||||
- source: github:quartz-community/citations
|
||||
enabled: false
|
||||
order: 85
|
||||
- source: github:quartz-community/hard-line-breaks
|
||||
enabled: true
|
||||
order: 90
|
||||
- source: github:quartz-community/ox-hugo
|
||||
enabled: false
|
||||
order: 91
|
||||
- source: github:quartz-community/roam
|
||||
enabled: false
|
||||
order: 92
|
||||
- source: github:quartz-community/remove-draft
|
||||
enabled: true
|
||||
- source: github:quartz-community/explicit-publish
|
||||
enabled: false
|
||||
- source: github:quartz-community/encrypted-pages
|
||||
enabled: true
|
||||
- source: github:quartz-community/stacked-pages
|
||||
enabled: false
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 50
|
||||
display: all
|
||||
- source: github:quartz-community/alias-redirects
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-index
|
||||
enabled: true
|
||||
options:
|
||||
enableSiteMap: true
|
||||
enableRSS: true
|
||||
- source: github:quartz-community/favicon
|
||||
enabled: true
|
||||
- source: github:quartz-community/og-image
|
||||
enabled: true
|
||||
- source: github:quartz-community/cname
|
||||
enabled: true
|
||||
- source: github:quartz-community/canvas-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/content-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/folder-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/tag-page
|
||||
enabled: true
|
||||
- source: github:quartz-community/explorer
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 50
|
||||
- source: github:quartz-community/graph
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 10
|
||||
- source: github:quartz-community/search
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 20
|
||||
group: toolbar
|
||||
groupOptions:
|
||||
grow: true
|
||||
- source: github:quartz-community/backlinks
|
||||
enabled: true
|
||||
layout:
|
||||
position: right
|
||||
priority: 30
|
||||
- source: github:quartz-community/article-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/content-meta
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 20
|
||||
- source: github:quartz-community/tag-list
|
||||
enabled: false
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 30
|
||||
- source: github:quartz-community/page-title
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 10
|
||||
- source: github:quartz-community/darkmode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 30
|
||||
group: toolbar
|
||||
- source: github:quartz-community/reader-mode
|
||||
enabled: true
|
||||
layout:
|
||||
position: left
|
||||
priority: 35
|
||||
group: toolbar
|
||||
- source: github:quartz-community/breadcrumbs
|
||||
enabled: true
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 5
|
||||
condition: not-index
|
||||
- source: github:quartz-community/comments
|
||||
enabled: false
|
||||
options:
|
||||
provider: giscus
|
||||
options: {}
|
||||
layout:
|
||||
position: afterBody
|
||||
priority: 10
|
||||
- source: github:quartz-community/footer
|
||||
enabled: true
|
||||
options:
|
||||
links:
|
||||
GitHub: https://github.com/jackyzha0/quartz
|
||||
Discord Community: https://discord.gg/cRFFHYye7t
|
||||
- source: github:quartz-community/recent-notes
|
||||
enabled: false
|
||||
- source: github:quartz-community/spacer
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 25
|
||||
layout:
|
||||
position: left
|
||||
priority: 25
|
||||
display: mobile-only
|
||||
- source: github:quartz-community/bases-page
|
||||
enabled: true
|
||||
options: {}
|
||||
order: 50
|
||||
- source: github:quartz-community/note-properties
|
||||
enabled: true
|
||||
options:
|
||||
includeAll: false
|
||||
includedProperties:
|
||||
- description
|
||||
- tags
|
||||
- aliases
|
||||
excludedProperties: []
|
||||
hidePropertiesView: false
|
||||
delimiters: "---"
|
||||
language: yaml
|
||||
order: 5
|
||||
layout:
|
||||
position: beforeBody
|
||||
priority: 15
|
||||
display: all
|
||||
# TTRPG-specific plugins
|
||||
- source: github:quartz-community/external-quartz-leaflet-map-plugin
|
||||
enabled: true
|
||||
- source: github:saberzero1/quartz-themes
|
||||
enabled: true
|
||||
options:
|
||||
theme: "its-theme"
|
||||
variation: "ttrpg-dnd"
|
||||
layout:
|
||||
groups:
|
||||
toolbar:
|
||||
priority: 35
|
||||
direction: row
|
||||
gap: 0.5rem
|
||||
byPageType:
|
||||
"404":
|
||||
positions:
|
||||
beforeBody: []
|
||||
left: []
|
||||
right: []
|
||||
content: {}
|
||||
folder:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
tag:
|
||||
exclude:
|
||||
- reader-mode
|
||||
positions:
|
||||
right: []
|
||||
canvas: {}
|
||||
bases: {}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const title = fileData.frontmatter?.title
|
||||
if (title) {
|
||||
return <h1 class={classNames(displayClass, "article-title")}>{title}</h1>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
||||
@@ -1,55 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/backlinks.scss"
|
||||
import { resolveRelative, simplifySlug } from "../util/path"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
|
||||
interface BacklinksOptions {
|
||||
hideWhenEmpty: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BacklinksOptions = {
|
||||
hideWhenEmpty: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BacklinksOptions>) => {
|
||||
const options: BacklinksOptions = { ...defaultOptions, ...opts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Backlinks: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const slug = simplifySlug(fileData.slug!)
|
||||
const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
|
||||
if (options.hideWhenEmpty && backlinkFiles.length == 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={classNames(displayClass, "backlinks")}>
|
||||
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
|
||||
<OverflowList>
|
||||
{backlinkFiles.length > 0 ? (
|
||||
backlinkFiles.map((f) => (
|
||||
<li>
|
||||
<a href={resolveRelative(fileData.slug!, f.slug!)} class="internal">
|
||||
{f.frontmatter?.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
|
||||
)}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Backlinks.css = style
|
||||
Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
|
||||
|
||||
return Backlinks
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,13 +1,7 @@
|
||||
// @ts-ignore
|
||||
import clipboardScript from "./scripts/clipboard.inline"
|
||||
import clipboardStyle from "./styles/clipboard.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
const Body: QuartzComponent = ({ children }: QuartzComponentProps) => {
|
||||
return <div id="quartz-body">{children}</div>
|
||||
}
|
||||
|
||||
Body.afterDOMLoaded = clipboardScript
|
||||
Body.css = clipboardStyle
|
||||
|
||||
export default (() => Body) satisfies QuartzComponentConstructor
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import breadcrumbsStyle from "./styles/breadcrumbs.scss"
|
||||
import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
|
||||
import { classNames } from "../util/lang"
|
||||
import { trieFromAllFiles } from "../util/ctx"
|
||||
|
||||
type CrumbData = {
|
||||
displayName: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface BreadcrumbOptions {
|
||||
/**
|
||||
* Symbol between crumbs
|
||||
*/
|
||||
spacerSymbol: string
|
||||
/**
|
||||
* Name of first crumb
|
||||
*/
|
||||
rootName: string
|
||||
/**
|
||||
* Whether to look up frontmatter title for folders (could cause performance problems with big vaults)
|
||||
*/
|
||||
resolveFrontmatterTitle: boolean
|
||||
/**
|
||||
* Whether to display the current page in the breadcrumbs.
|
||||
*/
|
||||
showCurrentPage: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: BreadcrumbOptions = {
|
||||
spacerSymbol: "❯",
|
||||
rootName: "Home",
|
||||
resolveFrontmatterTitle: true,
|
||||
showCurrentPage: true,
|
||||
}
|
||||
|
||||
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
|
||||
return {
|
||||
displayName: displayName.replaceAll("-", " "),
|
||||
path: resolveRelative(baseSlug, currentSlug),
|
||||
}
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<BreadcrumbOptions>) => {
|
||||
const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
|
||||
const Breadcrumbs: QuartzComponent = ({
|
||||
fileData,
|
||||
allFiles,
|
||||
displayClass,
|
||||
ctx,
|
||||
}: QuartzComponentProps) => {
|
||||
const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const slugParts = fileData.slug!.split("/")
|
||||
const pathNodes = trie.ancestryChain(slugParts)
|
||||
|
||||
if (!pathNodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
|
||||
const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
|
||||
if (idx === 0) {
|
||||
crumb.displayName = options.rootName
|
||||
}
|
||||
|
||||
// For last node (current page), set empty path
|
||||
if (idx === pathNodes.length - 1) {
|
||||
crumb.path = ""
|
||||
}
|
||||
|
||||
return crumb
|
||||
})
|
||||
|
||||
if (!options.showCurrentPage) {
|
||||
crumbs.pop()
|
||||
}
|
||||
|
||||
return (
|
||||
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<div class="breadcrumb-element">
|
||||
<a href={crumb.path}>{crumb.displayName}</a>
|
||||
{index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Breadcrumbs.css = breadcrumbsStyle
|
||||
|
||||
return Breadcrumbs
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,62 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/comments.inline"
|
||||
|
||||
type Options = {
|
||||
provider: "giscus"
|
||||
options: {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl?: string
|
||||
lightTheme?: string
|
||||
darkTheme?: string
|
||||
mapping?: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict?: boolean
|
||||
reactionsEnabled?: boolean
|
||||
inputPosition?: "top" | "bottom"
|
||||
lang?: string
|
||||
}
|
||||
}
|
||||
|
||||
function boolToStringBool(b: boolean): string {
|
||||
return b ? "1" : "0"
|
||||
}
|
||||
|
||||
export default ((opts: Options) => {
|
||||
const Comments: QuartzComponent = ({ displayClass, fileData, cfg }: QuartzComponentProps) => {
|
||||
// check if comments should be displayed according to frontmatter
|
||||
const disableComment: boolean =
|
||||
typeof fileData.frontmatter?.comments !== "undefined" &&
|
||||
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
|
||||
if (disableComment) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "giscus")}
|
||||
data-repo={opts.options.repo}
|
||||
data-repo-id={opts.options.repoId}
|
||||
data-category={opts.options.category}
|
||||
data-category-id={opts.options.categoryId}
|
||||
data-mapping={opts.options.mapping ?? "url"}
|
||||
data-strict={boolToStringBool(opts.options.strict ?? true)}
|
||||
data-reactions-enabled={boolToStringBool(opts.options.reactionsEnabled ?? true)}
|
||||
data-input-position={opts.options.inputPosition ?? "bottom"}
|
||||
data-light-theme={opts.options.lightTheme ?? "light"}
|
||||
data-dark-theme={opts.options.darkTheme ?? "dark"}
|
||||
data-theme-url={
|
||||
opts.options.themeUrl ?? `https://${cfg.baseUrl ?? "example.com"}/static/giscus`
|
||||
}
|
||||
data-lang={opts.options.lang ?? "en"}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
Comments.afterDOMLoaded = script
|
||||
|
||||
return Comments
|
||||
}) satisfies QuartzComponentConstructor<Options>
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Date, getDate } from "./Date"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import readingTime from "reading-time"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { JSX } from "preact"
|
||||
import style from "./styles/contentMeta.scss"
|
||||
|
||||
interface ContentMetaOptions {
|
||||
/**
|
||||
* Whether to display reading time
|
||||
*/
|
||||
showReadingTime: boolean
|
||||
showComma: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: ContentMetaOptions = {
|
||||
showReadingTime: true,
|
||||
showComma: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<ContentMetaOptions>) => {
|
||||
// Merge options with defaults
|
||||
const options: ContentMetaOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) {
|
||||
const text = fileData.text
|
||||
|
||||
if (text) {
|
||||
const segments: (string | JSX.Element)[] = []
|
||||
|
||||
if (fileData.dates) {
|
||||
segments.push(<Date date={getDate(cfg, fileData)!} locale={cfg.locale} />)
|
||||
}
|
||||
|
||||
// Display reading time if enabled
|
||||
if (options.showReadingTime) {
|
||||
const { minutes, words: _words } = readingTime(text)
|
||||
const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({
|
||||
minutes: Math.ceil(minutes),
|
||||
})
|
||||
segments.push(<span>{displayedTime}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p show-comma={options.showComma} class={classNames(displayClass, "content-meta")}>
|
||||
{segments}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
ContentMetadata.css = style
|
||||
|
||||
return ContentMetadata
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,48 +0,0 @@
|
||||
// @ts-ignore
|
||||
import darkmodeScript from "./scripts/darkmode.inline"
|
||||
import styles from "./styles/darkmode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "darkmode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="dayIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 35 35"
|
||||
style="enable-background:new 0 0 35 35"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.darkMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.darkMode}</title>
|
||||
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="nightIcon"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 100 100"
|
||||
style="enable-background:new 0 0 100 100"
|
||||
xmlSpace="preserve"
|
||||
aria-label={i18n(cfg.locale).components.themeToggle.lightMode}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.themeToggle.lightMode}</title>
|
||||
<path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Darkmode.beforeDOMLoaded = darkmodeScript
|
||||
Darkmode.css = styles
|
||||
|
||||
export default (() => Darkmode) satisfies QuartzComponentConstructor
|
||||
@@ -12,7 +12,7 @@ export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
||||
if (!cfg.defaultDateType) {
|
||||
throw new Error(
|
||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.yaml. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||
)
|
||||
}
|
||||
return data.dates?.[cfg.defaultDateType]
|
||||
|
||||
@@ -3,7 +3,11 @@ import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } fro
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const DesktopOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="desktop-only" {...props} />
|
||||
return (
|
||||
<div class="desktop-only">
|
||||
<Component {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DesktopOnly.displayName = component.displayName
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/explorer.scss"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/explorer.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
import { FileTrieNode } from "../util/fileTrie"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
type OrderEntries = "sort" | "filter" | "map"
|
||||
|
||||
export interface Options {
|
||||
title?: string
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: OrderEntries[]
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
folderDefaultState: "collapsed",
|
||||
folderClickBehavior: "link",
|
||||
useSavedState: true,
|
||||
mapFn: (node) => {
|
||||
return node
|
||||
},
|
||||
sortFn: (a, b) => {
|
||||
// Sort order: folders first, then files. Sort folders and files alphabeticall
|
||||
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
|
||||
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
|
||||
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
|
||||
return a.displayName.localeCompare(b.displayName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
})
|
||||
}
|
||||
|
||||
if (!a.isFolder && b.isFolder) {
|
||||
return 1
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
},
|
||||
filterFn: (node) => node.slugSegment !== "tags",
|
||||
order: ["filter", "map", "sort"],
|
||||
}
|
||||
|
||||
export type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let numExplorers = 0
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const opts: Options = { ...defaultOptions, ...userOpts }
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
|
||||
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
|
||||
const id = `explorer-${numExplorers++}`
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames(displayClass, "explorer")}
|
||||
data-behavior={opts.folderClickBehavior}
|
||||
data-collapsed={opts.folderDefaultState}
|
||||
data-savestate={opts.useSavedState}
|
||||
data-data-fns={JSON.stringify({
|
||||
order: opts.order,
|
||||
sortFn: opts.sortFn.toString(),
|
||||
filterFn: opts.filterFn.toString(),
|
||||
mapFn: opts.mapFn.toString(),
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="explorer-toggle mobile-explorer hide-until-loaded"
|
||||
data-mobile={true}
|
||||
aria-controls={id}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="title-button explorer-toggle desktop-explorer"
|
||||
data-mobile={false}
|
||||
aria-expanded={true}
|
||||
>
|
||||
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="fold"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div id={id} class="explorer-content" aria-expanded={false} role="group">
|
||||
<OverflowList class="explorer-ul" />
|
||||
</div>
|
||||
<template id="template-file">
|
||||
<li>
|
||||
<a href="#"></a>
|
||||
</li>
|
||||
</template>
|
||||
<template id="template-folder">
|
||||
<li>
|
||||
<div class="folder-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="5 8 14 8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="folder-icon"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<div>
|
||||
<button class="folder-button">
|
||||
<span class="folder-title"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-outer">
|
||||
<ul class="content"></ul>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Explorer.css = style
|
||||
Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
return Explorer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,33 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/footer.scss"
|
||||
import { version } from "../../package.json"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
interface Options {
|
||||
links: Record<string, string>
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const year = new Date().getFullYear()
|
||||
const links = opts?.links ?? []
|
||||
return (
|
||||
<footer class={`${displayClass ?? ""}`}>
|
||||
<p>
|
||||
{i18n(cfg.locale).components.footer.createdWith}{" "}
|
||||
<a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
|
||||
</p>
|
||||
<ul>
|
||||
{Object.entries(links).map(([text, link]) => (
|
||||
<li>
|
||||
<a href={link}>{text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.css = style
|
||||
return Footer
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,109 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean
|
||||
zoom: boolean
|
||||
depth: number
|
||||
scale: number
|
||||
repelForce: number
|
||||
centerForce: number
|
||||
linkDistance: number
|
||||
fontSize: number
|
||||
opacityScale: number
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
enableRadial?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config> | undefined
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.1,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.3,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
enableRadial: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 0.9,
|
||||
repelForce: 0.5,
|
||||
centerForce: 0.2,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 1,
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
enableRadial: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<GraphOptions>) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
return (
|
||||
<div class={classNames(displayClass, "graph")}>
|
||||
<h3>{i18n(cfg.locale).components.graph.title}</h3>
|
||||
<div class="graph-outer">
|
||||
<div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
||||
<button class="global-graph-icon" aria-label="Global Graph">
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 55 55"
|
||||
fill="currentColor"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path
|
||||
d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17
|
||||
s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4
|
||||
c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562
|
||||
C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829
|
||||
c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91
|
||||
v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4
|
||||
s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665
|
||||
C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2
|
||||
S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4
|
||||
s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2
|
||||
s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-graph-outer">
|
||||
<div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -4,7 +4,7 @@ import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/re
|
||||
import { googleFontHref, googleFontSubsetHref } from "../util/theme"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { unescapeHTML } from "../util/escape"
|
||||
import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
|
||||
import { CustomOgImagesEmitterName } from "../../.quartz/plugins"
|
||||
export default (() => {
|
||||
const Head: QuartzComponent = ({
|
||||
cfg,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } fro
|
||||
export default ((component: QuartzComponent) => {
|
||||
const Component = component
|
||||
const MobileOnly: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
return <Component displayClass="mobile-only" {...props} />
|
||||
return (
|
||||
<div class="mobile-only">
|
||||
<Component {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
MobileOnly.displayName = component.displayName
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { JSX } from "preact"
|
||||
|
||||
const OverflowList = ({
|
||||
children,
|
||||
...props
|
||||
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
|
||||
return (
|
||||
<ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
|
||||
{children}
|
||||
<li class="overflow-end" />
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
let numLists = 0
|
||||
export default () => {
|
||||
const id = `list-${numLists++}`
|
||||
|
||||
return {
|
||||
OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
|
||||
<OverflowList {...props} id={id} />
|
||||
),
|
||||
overflowListAfterDOMLoaded: `
|
||||
document.addEventListener("nav", (e) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const parentUl = entry.target.parentElement
|
||||
if (!parentUl) return
|
||||
if (entry.isIntersecting) {
|
||||
parentUl.classList.remove("gradient-active")
|
||||
} else {
|
||||
parentUl.classList.add("gradient-active")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ul = document.getElementById("${id}")
|
||||
if (!ul) return
|
||||
|
||||
const end = ul.querySelector(".overflow-end")
|
||||
if (!end) return
|
||||
|
||||
observer.observe(end)
|
||||
window.addCleanup(() => observer.disconnect())
|
||||
})
|
||||
`,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { pathToRoot } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
|
||||
const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const baseDir = pathToRoot(fileData.slug!)
|
||||
return (
|
||||
<h2 class={classNames(displayClass, "page-title")}>
|
||||
<a href={baseDir}>{title}</a>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
PageTitle.css = `
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
font-family: var(--titleFont);
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
||||
@@ -1,38 +0,0 @@
|
||||
// @ts-ignore
|
||||
import readerModeScript from "./scripts/readermode.inline"
|
||||
import styles from "./styles/readermode.scss"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
return (
|
||||
<button class={classNames(displayClass, "readermode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
class="readerIcon"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
width="64px"
|
||||
height="64px"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={i18n(cfg.locale).components.readerMode.title}
|
||||
>
|
||||
<title>{i18n(cfg.locale).components.readerMode.title}</title>
|
||||
<g transform="translate(-1.8, -1.8) scale(1.15, 1.2)">
|
||||
<path d="M8.9891247,2.5 C10.1384702,2.5 11.2209868,2.96705384 12.0049645,3.76669482 C12.7883914,2.96705384 13.8709081,2.5 15.0202536,2.5 L18.7549359,2.5 C19.1691495,2.5 19.5049359,2.83578644 19.5049359,3.25 L19.5046891,4.004 L21.2546891,4.00457396 C21.6343849,4.00457396 21.9481801,4.28672784 21.9978425,4.6528034 L22.0046891,4.75457396 L22.0046891,20.25 C22.0046891,20.6296958 21.7225353,20.943491 21.3564597,20.9931534 L21.2546891,21 L2.75468914,21 C2.37499337,21 2.06119817,20.7178461 2.01153575,20.3517706 L2.00468914,20.25 L2.00468914,4.75457396 C2.00468914,4.37487819 2.28684302,4.061083 2.65291858,4.01142057 L2.75468914,4.00457396 L4.50368914,4.004 L4.50444233,3.25 C4.50444233,2.87030423 4.78659621,2.55650904 5.15267177,2.50684662 L5.25444233,2.5 L8.9891247,2.5 Z M4.50368914,5.504 L3.50468914,5.504 L3.50468914,19.5 L10.9478955,19.4998273 C10.4513189,18.9207296 9.73864328,18.5588115 8.96709342,18.5065584 L8.77307039,18.5 L5.25444233,18.5 C4.87474657,18.5 4.56095137,18.2178461 4.51128895,17.8517706 L4.50444233,17.75 L4.50368914,5.504 Z M19.5049359,17.75 C19.5049359,18.1642136 19.1691495,18.5 18.7549359,18.5 L15.2363079,18.5 C14.3910149,18.5 13.5994408,18.8724714 13.0614828,19.4998273 L20.5046891,19.5 L20.5046891,5.504 L19.5046891,5.504 L19.5049359,17.75 Z M18.0059359,3.999 L15.0202536,4 L14.8259077,4.00692283 C13.9889509,4.06666544 13.2254227,4.50975805 12.7549359,5.212 L12.7549359,17.777 L12.7782651,17.7601316 C13.4923805,17.2719483 14.3447024,17 15.2363079,17 L18.0059359,16.999 L18.0056891,4.798 L18.0033792,4.75457396 L18.0056891,4.71 L18.0059359,3.999 Z M8.9891247,4 L6.00368914,3.999 L6.00599909,4.75457396 L6.00599909,4.75457396 L6.00368914,4.783 L6.00368914,16.999 L8.77307039,17 C9.57551536,17 10.3461406,17.2202781 11.0128313,17.6202194 L11.2536891,17.776 L11.2536891,5.211 C10.8200889,4.56369974 10.1361548,4.13636104 9.37521067,4.02745763 L9.18347055,4.00692283 L8.9891247,4 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
ReaderMode.beforeDOMLoaded = readerModeScript
|
||||
ReaderMode.css = styles
|
||||
|
||||
export default (() => ReaderMode) satisfies QuartzComponentConstructor
|
||||
@@ -1,93 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { FullSlug, SimpleSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { byDateAndAlphabetical } from "./PageList"
|
||||
import style from "./styles/recentNotes.scss"
|
||||
import { Date, getDate } from "./Date"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
interface Options {
|
||||
title?: string
|
||||
limit: number
|
||||
linkToMore: SimpleSlug | false
|
||||
showTags: boolean
|
||||
filter: (f: QuartzPluginData) => boolean
|
||||
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
}
|
||||
|
||||
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
|
||||
limit: 3,
|
||||
linkToMore: false,
|
||||
showTags: true,
|
||||
filter: () => true,
|
||||
sort: byDateAndAlphabetical(cfg),
|
||||
})
|
||||
|
||||
export default ((userOpts?: Partial<Options>) => {
|
||||
const RecentNotes: QuartzComponent = ({
|
||||
allFiles,
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions(cfg), ...userOpts }
|
||||
const pages = allFiles.filter(opts.filter).sort(opts.sort)
|
||||
const remaining = Math.max(0, pages.length - opts.limit)
|
||||
return (
|
||||
<div class={classNames(displayClass, "recent-notes")}>
|
||||
<h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
|
||||
<ul class="recent-ul">
|
||||
{pages.slice(0, opts.limit).map((page) => {
|
||||
const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
|
||||
const tags = page.frontmatter?.tags ?? []
|
||||
|
||||
return (
|
||||
<li class="recent-li">
|
||||
<div class="section">
|
||||
<div class="desc">
|
||||
<h3>
|
||||
<a href={resolveRelative(fileData.slug!, page.slug!)} class="internal">
|
||||
{title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
{page.dates && (
|
||||
<p class="meta">
|
||||
<Date date={getDate(cfg, page)!} locale={cfg.locale} />
|
||||
</p>
|
||||
)}
|
||||
{opts.showTags && (
|
||||
<ul class="tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="internal tag-link"
|
||||
href={resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{opts.linkToMore && remaining > 0 && (
|
||||
<p>
|
||||
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
|
||||
{i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
RecentNotes.css = style
|
||||
return RecentNotes
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,53 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import style from "./styles/search.scss"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/search.inline"
|
||||
import { classNames } from "../util/lang"
|
||||
import { i18n } from "../i18n"
|
||||
|
||||
export interface SearchOptions {
|
||||
enablePreview: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: SearchOptions = {
|
||||
enablePreview: true,
|
||||
}
|
||||
|
||||
export default ((userOpts?: Partial<SearchOptions>) => {
|
||||
const Search: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const opts = { ...defaultOptions, ...userOpts }
|
||||
const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
|
||||
return (
|
||||
<div class={classNames(displayClass, "search")}>
|
||||
<button class="search-button">
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7">
|
||||
<title>Search</title>
|
||||
<g class="search-path" fill="none">
|
||||
<path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4" />
|
||||
<circle cx="8" cy="8" r="7" />
|
||||
</g>
|
||||
</svg>
|
||||
<p>{i18n(cfg.locale).components.search.title}</p>
|
||||
</button>
|
||||
<div class="search-container">
|
||||
<div class="search-space">
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="search-bar"
|
||||
name="search"
|
||||
type="text"
|
||||
aria-label={searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
<div class="search-layout" data-preview={opts.enablePreview}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Search.afterDOMLoaded = script
|
||||
Search.css = style
|
||||
|
||||
return Search
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,101 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import legacyStyle from "./styles/legacyToc.scss"
|
||||
import modernStyle from "./styles/toc.scss"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
// @ts-ignore
|
||||
import script from "./scripts/toc.inline"
|
||||
import { i18n } from "../i18n"
|
||||
import OverflowListFactory from "./OverflowList"
|
||||
import { concatenateResources } from "../util/resources"
|
||||
|
||||
interface Options {
|
||||
layout: "modern" | "legacy"
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
layout: "modern",
|
||||
}
|
||||
|
||||
let numTocs = 0
|
||||
export default ((opts?: Partial<Options>) => {
|
||||
const layout = opts?.layout ?? defaultOptions.layout
|
||||
const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
|
||||
const TableOfContents: QuartzComponent = ({
|
||||
fileData,
|
||||
displayClass,
|
||||
cfg,
|
||||
}: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = `toc-${numTocs++}`
|
||||
return (
|
||||
<div class={classNames(displayClass, "toc")}>
|
||||
<button
|
||||
type="button"
|
||||
class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
|
||||
aria-controls={id}
|
||||
aria-expanded={!fileData.collapseToc}
|
||||
>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="fold"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<OverflowList
|
||||
id={id}
|
||||
class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}
|
||||
>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</OverflowList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
|
||||
|
||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
|
||||
if (!fileData.toc) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<details class="toc" open={!fileData.collapseToc}>
|
||||
<summary>
|
||||
<h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
|
||||
</summary>
|
||||
<ul>
|
||||
{fileData.toc.map((tocEntry) => (
|
||||
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
|
||||
{tocEntry.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
LegacyTableOfContents.css = legacyStyle
|
||||
|
||||
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FullSlug, resolveRelative } from "../util/path"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
import { classNames } from "../util/lang"
|
||||
|
||||
const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
|
||||
const tags = fileData.frontmatter?.tags
|
||||
if (tags && tags.length > 0) {
|
||||
return (
|
||||
<ul class={classNames(displayClass, "tags")}>
|
||||
{tags.map((tag) => {
|
||||
const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
|
||||
return (
|
||||
<li>
|
||||
<a href={linkDest} class="internal tag-link">
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
TagList.css = `
|
||||
.tags {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding-left: 0;
|
||||
gap: 0.4rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-li > .section > .tags {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tags > li {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
a.internal.tag-link {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => TagList) satisfies QuartzComponentConstructor
|
||||
23
quartz/components/external.ts
Normal file
23
quartz/components/external.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { componentRegistry } from "./registry"
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export function External<Options extends object | undefined>(
|
||||
name: string,
|
||||
options?: Options,
|
||||
): QuartzComponent {
|
||||
const registered = componentRegistry.get(name)
|
||||
if (!registered) {
|
||||
throw new Error(
|
||||
`External component "${name}" not found. ` +
|
||||
`Make sure the plugin is installed and components are loaded before layouts are evaluated.`,
|
||||
)
|
||||
}
|
||||
|
||||
const { component } = registered
|
||||
|
||||
if (typeof component === "function") {
|
||||
return (component as QuartzComponentConstructor<Options>)(options as Options)
|
||||
}
|
||||
|
||||
return component as QuartzComponent
|
||||
}
|
||||
61
quartz/components/frames/DefaultFrame.tsx
Normal file
61
quartz/components/frames/DefaultFrame.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
import HeaderConstructor from "../Header"
|
||||
|
||||
const Header = HeaderConstructor()
|
||||
|
||||
/**
|
||||
* The default page frame — three-column layout with left sidebar, center
|
||||
* content (header + body + afterBody), and right sidebar, followed by a footer.
|
||||
*
|
||||
* This is the original Quartz layout, extracted from renderPage.tsx.
|
||||
*/
|
||||
export const DefaultFrame: PageFrame = {
|
||||
name: "default",
|
||||
render({
|
||||
componentData,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
51
quartz/components/frames/FullWidthFrame.tsx
Normal file
51
quartz/components/frames/FullWidthFrame.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
import HeaderConstructor from "../Header"
|
||||
|
||||
const Header = HeaderConstructor()
|
||||
|
||||
/**
|
||||
* Full-width page frame — no sidebars. The center content area spans the
|
||||
* full width of the page. Header, beforeBody, body, afterBody, and footer
|
||||
* are all rendered in a single column.
|
||||
*
|
||||
* Useful for page types like Canvas, presentations, or dashboards that
|
||||
* need maximum horizontal space.
|
||||
*/
|
||||
export const FullWidthFrame: PageFrame = {
|
||||
name: "full-width",
|
||||
render({
|
||||
componentData,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
footer: Footer,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="center full-width">
|
||||
<div class="page-header">
|
||||
<Header {...componentData}>
|
||||
{header.map((HeaderComponent) => (
|
||||
<HeaderComponent {...componentData} />
|
||||
))}
|
||||
</Header>
|
||||
<div class="popover-hint">
|
||||
{beforeBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
23
quartz/components/frames/MinimalFrame.tsx
Normal file
23
quartz/components/frames/MinimalFrame.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { PageFrame, PageFrameProps } from "./types"
|
||||
|
||||
/**
|
||||
* Minimal page frame — no sidebars, no header/footer chrome. Only the
|
||||
* page body is rendered with a thin wrapper, plus the footer for legal/link
|
||||
* obligations.
|
||||
*
|
||||
* Useful for immersive page types like full-screen canvases, kiosks,
|
||||
* or custom landing pages that want complete control of the viewport.
|
||||
*/
|
||||
export const MinimalFrame: PageFrame = {
|
||||
name: "minimal",
|
||||
render({ componentData, pageBody: Content, footer: Footer }: PageFrameProps) {
|
||||
return (
|
||||
<>
|
||||
<div class="center minimal">
|
||||
<Content {...componentData} />
|
||||
</div>
|
||||
<Footer {...componentData} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
52
quartz/components/frames/index.ts
Normal file
52
quartz/components/frames/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { PageFrame } from "./types"
|
||||
import { DefaultFrame } from "./DefaultFrame"
|
||||
import { FullWidthFrame } from "./FullWidthFrame"
|
||||
import { MinimalFrame } from "./MinimalFrame"
|
||||
import { frameRegistry } from "./registry"
|
||||
|
||||
export type { PageFrame, PageFrameProps } from "./types"
|
||||
export { DefaultFrame } from "./DefaultFrame"
|
||||
export { FullWidthFrame } from "./FullWidthFrame"
|
||||
export { MinimalFrame } from "./MinimalFrame"
|
||||
export { frameRegistry } from "./registry"
|
||||
export type { RegisteredFrame } from "./registry"
|
||||
|
||||
/**
|
||||
* Registry of built-in page frames. Page types can reference these by name
|
||||
* via their `frame` property, and YAML config can override via
|
||||
* `layout.byPageType.<name>.template`.
|
||||
*
|
||||
* The "default" frame reproduces the original three-column Quartz layout.
|
||||
*/
|
||||
const builtinFrames: Record<string, PageFrame> = {
|
||||
default: DefaultFrame,
|
||||
"full-width": FullWidthFrame,
|
||||
minimal: MinimalFrame,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a frame by name. Checks plugin-registered frames first,
|
||||
* then built-in frames, then falls back to DefaultFrame.
|
||||
*/
|
||||
export function resolveFrame(name: string | undefined): PageFrame {
|
||||
if (!name || name === "default") {
|
||||
return DefaultFrame
|
||||
}
|
||||
|
||||
// Check plugin-registered frames first
|
||||
const registered = frameRegistry.get(name)
|
||||
if (registered) {
|
||||
return registered.frame
|
||||
}
|
||||
|
||||
// Fall back to built-in frames
|
||||
const frame = builtinFrames[name]
|
||||
if (!frame) {
|
||||
const allFrameNames = [...Object.keys(builtinFrames), ...[...frameRegistry.getAll().keys()]]
|
||||
console.warn(
|
||||
`Unknown page frame "${name}", falling back to "default". Available frames: ${allFrameNames.join(", ")}`,
|
||||
)
|
||||
return DefaultFrame
|
||||
}
|
||||
return frame
|
||||
}
|
||||
34
quartz/components/frames/registry.ts
Normal file
34
quartz/components/frames/registry.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PageFrame } from "./types"
|
||||
|
||||
export interface RegisteredFrame {
|
||||
frame: PageFrame
|
||||
source: string
|
||||
}
|
||||
|
||||
class FrameRegistry {
|
||||
private frames = new Map<string, RegisteredFrame>()
|
||||
|
||||
register(name: string, frame: PageFrame, source: string): void {
|
||||
const existing = this.frames.get(name)
|
||||
if (existing && existing.source !== source) {
|
||||
console.warn(
|
||||
`Page frame "${name}" from ${source} is overwriting frame from ${existing.source}`,
|
||||
)
|
||||
}
|
||||
this.frames.set(name, { frame, source })
|
||||
}
|
||||
|
||||
get(name: string): RegisteredFrame | undefined {
|
||||
return this.frames.get(name)
|
||||
}
|
||||
|
||||
getAll(): Map<string, RegisteredFrame> {
|
||||
return new Map(this.frames)
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.frames.has(name)
|
||||
}
|
||||
}
|
||||
|
||||
export const frameRegistry = new FrameRegistry()
|
||||
43
quartz/components/frames/types.ts
Normal file
43
quartz/components/frames/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { JSX } from "preact"
|
||||
import { QuartzComponent, QuartzComponentProps } from "../types"
|
||||
|
||||
/**
|
||||
* Props passed to a PageFrame's render function.
|
||||
* Contains the resolved layout components and the shared component data.
|
||||
*/
|
||||
export interface PageFrameProps {
|
||||
/** Component data shared across all components on the page */
|
||||
componentData: QuartzComponentProps
|
||||
/** The Head component (rendered in <head>) — NOT used by frames, included for completeness */
|
||||
head: QuartzComponent
|
||||
/** Header slot components (rendered inside <header>) */
|
||||
header: QuartzComponent[]
|
||||
/** Components rendered before the page body */
|
||||
beforeBody: QuartzComponent[]
|
||||
/** The page body component (Content) */
|
||||
pageBody: QuartzComponent
|
||||
/** Components rendered after the page body */
|
||||
afterBody: QuartzComponent[]
|
||||
/** Left sidebar components */
|
||||
left: QuartzComponent[]
|
||||
/** Right sidebar components */
|
||||
right: QuartzComponent[]
|
||||
/** Footer component */
|
||||
footer: QuartzComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* A PageFrame defines the inner HTML structure of a page inside the
|
||||
* `<div id="quartz-root">` shell. Different frames can produce completely
|
||||
* different layouts (e.g. with/without sidebars, horizontal scroll, etc.)
|
||||
* while the outer shell (html, head, body, quartz-root) remains stable
|
||||
* for SPA navigation.
|
||||
*/
|
||||
export interface PageFrame {
|
||||
/** Unique name for this frame (e.g. "default", "full-width", "minimal") */
|
||||
name: string
|
||||
/** Render the inner page structure. Returns a JSX tree to be placed inside Body > #quartz-body. */
|
||||
render: (props: PageFrameProps) => JSX.Element
|
||||
/** Optional CSS string to include when this frame is active */
|
||||
css?: string
|
||||
}
|
||||
@@ -1,53 +1,14 @@
|
||||
import Content from "./pages/Content"
|
||||
import TagContent from "./pages/TagContent"
|
||||
import FolderContent from "./pages/FolderContent"
|
||||
import NotFound from "./pages/404"
|
||||
import ArticleTitle from "./ArticleTitle"
|
||||
import Darkmode from "./Darkmode"
|
||||
import ReaderMode from "./ReaderMode"
|
||||
import Head from "./Head"
|
||||
import PageTitle from "./PageTitle"
|
||||
import ContentMeta from "./ContentMeta"
|
||||
import Spacer from "./Spacer"
|
||||
import TableOfContents from "./TableOfContents"
|
||||
import Explorer from "./Explorer"
|
||||
import TagList from "./TagList"
|
||||
import Graph from "./Graph"
|
||||
import Backlinks from "./Backlinks"
|
||||
import Search from "./Search"
|
||||
import Footer from "./Footer"
|
||||
import DesktopOnly from "./DesktopOnly"
|
||||
import MobileOnly from "./MobileOnly"
|
||||
import RecentNotes from "./RecentNotes"
|
||||
import Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
Content,
|
||||
TagContent,
|
||||
FolderContent,
|
||||
Darkmode,
|
||||
ReaderMode,
|
||||
Head,
|
||||
PageTitle,
|
||||
ContentMeta,
|
||||
Spacer,
|
||||
TableOfContents,
|
||||
Explorer,
|
||||
TagList,
|
||||
Graph,
|
||||
Backlinks,
|
||||
Search,
|
||||
Footer,
|
||||
DesktopOnly,
|
||||
MobileOnly,
|
||||
RecentNotes,
|
||||
NotFound,
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Flex,
|
||||
ConditionalRender,
|
||||
}
|
||||
export { componentRegistry, defineComponent } from "./registry"
|
||||
export { External } from "./external"
|
||||
export type { ComponentManifest, RegisteredComponent } from "./registry"
|
||||
export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
export { Head, Spacer, DesktopOnly, MobileOnly, NotFound, Flex, ConditionalRender }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ComponentChildren } from "preact"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
const Content: QuartzComponent = ({ fileData, tree }: QuartzComponentProps) => {
|
||||
const content = htmlToJsx(fileData.filePath!, tree) as ComponentChildren
|
||||
const classes: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classString = ["popover-hint", ...classes].join(" ")
|
||||
return <article class={classString}>{content}</article>
|
||||
}
|
||||
|
||||
export default (() => Content) satisfies QuartzComponentConstructor
|
||||
@@ -1,126 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
import { trieFromAllFiles } from "../../util/ctx"
|
||||
|
||||
interface FolderContentOptions {
|
||||
/**
|
||||
* Whether to display number of folders
|
||||
*/
|
||||
showFolderCount: boolean
|
||||
showSubfolders: boolean
|
||||
sort?: SortFn
|
||||
}
|
||||
|
||||
const defaultOptions: FolderContentOptions = {
|
||||
showFolderCount: true,
|
||||
showSubfolders: true,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<FolderContentOptions>) => {
|
||||
const options: FolderContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
|
||||
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
|
||||
const folder = trie.findNode(fileData.slug!.split("/"))
|
||||
if (!folder) {
|
||||
return null
|
||||
}
|
||||
|
||||
const allPagesInFolder: QuartzPluginData[] =
|
||||
folder.children
|
||||
.map((node) => {
|
||||
// regular file, proceed
|
||||
if (node.data) {
|
||||
return node.data
|
||||
}
|
||||
|
||||
if (node.isFolder && options.showSubfolders) {
|
||||
// folders that dont have data need synthetic files
|
||||
const getMostRecentDates = (): QuartzPluginData["dates"] => {
|
||||
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
|
||||
for (const child of node.children) {
|
||||
if (child.data?.dates) {
|
||||
// compare all dates and assign to maybeDates if its more recent or its not set
|
||||
if (!maybeDates) {
|
||||
maybeDates = { ...child.data.dates }
|
||||
} else {
|
||||
if (child.data.dates.created > maybeDates.created) {
|
||||
maybeDates.created = child.data.dates.created
|
||||
}
|
||||
|
||||
if (child.data.dates.modified > maybeDates.modified) {
|
||||
maybeDates.modified = child.data.dates.modified
|
||||
}
|
||||
|
||||
if (child.data.dates.published > maybeDates.published) {
|
||||
maybeDates.published = child.data.dates.published
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
maybeDates ?? {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
published: new Date(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
slug: node.slug,
|
||||
dates: getMostRecentDates(),
|
||||
frontmatter: {
|
||||
title: node.displayName,
|
||||
tags: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((page) => page !== undefined) ?? []
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
const listProps = {
|
||||
...props,
|
||||
sort: options.sort,
|
||||
allFiles: allPagesInFolder,
|
||||
}
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
{options.showFolderCount && (
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
|
||||
count: allPagesInFolder.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<PageList {...listProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FolderContent.css = concatenateResources(style, PageList.css)
|
||||
return FolderContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
@@ -1,133 +0,0 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
|
||||
import style from "../styles/listPage.scss"
|
||||
import { PageList, SortFn } from "../PageList"
|
||||
import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzPluginData } from "../../plugins/vfile"
|
||||
import { Root } from "hast"
|
||||
import { htmlToJsx } from "../../util/jsx"
|
||||
import { i18n } from "../../i18n"
|
||||
import { ComponentChildren } from "preact"
|
||||
import { concatenateResources } from "../../util/resources"
|
||||
|
||||
interface TagContentOptions {
|
||||
sort?: SortFn
|
||||
numPages: number
|
||||
}
|
||||
|
||||
const defaultOptions: TagContentOptions = {
|
||||
numPages: 10,
|
||||
}
|
||||
|
||||
export default ((opts?: Partial<TagContentOptions>) => {
|
||||
const options: TagContentOptions = { ...defaultOptions, ...opts }
|
||||
|
||||
const TagContent: QuartzComponent = (props: QuartzComponentProps) => {
|
||||
const { tree, fileData, allFiles, cfg } = props
|
||||
const slug = fileData.slug
|
||||
|
||||
if (!(slug?.startsWith("tags/") || slug === "tags")) {
|
||||
throw new Error(`Component "TagContent" tried to render a non-tag page: ${slug}`)
|
||||
}
|
||||
|
||||
const tag = simplifySlug(slug.slice("tags/".length) as FullSlug)
|
||||
const allPagesWithTag = (tag: string) =>
|
||||
allFiles.filter((file) =>
|
||||
(file.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes).includes(tag),
|
||||
)
|
||||
|
||||
const content = (
|
||||
(tree as Root).children.length === 0
|
||||
? fileData.description
|
||||
: htmlToJsx(fileData.filePath!, tree)
|
||||
) as ComponentChildren
|
||||
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
|
||||
const classes = cssClasses.join(" ")
|
||||
if (tag === "/") {
|
||||
const tags = [
|
||||
...new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
),
|
||||
].sort((a, b) => a.localeCompare(b))
|
||||
const tagItemMap: Map<string, QuartzPluginData[]> = new Map()
|
||||
for (const tag of tags) {
|
||||
tagItemMap.set(tag, allPagesWithTag(tag))
|
||||
}
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>
|
||||
<p>{content}</p>
|
||||
</article>
|
||||
<p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
const pages = tagItemMap.get(tag)!
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
const contentPage = allFiles.filter((file) => file.slug === `tags/${tag}`).at(0)
|
||||
|
||||
const root = contentPage?.htmlAst
|
||||
const content =
|
||||
!root || root?.children.length === 0
|
||||
? contentPage?.description
|
||||
: htmlToJsx(contentPage.filePath!, root)
|
||||
|
||||
const tagListingPage = `/tags/${tag}` as FullSlug
|
||||
const href = resolveRelative(fileData.slug!, tagListingPage)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
<a class="internal tag-link" href={href}>
|
||||
{tag}
|
||||
</a>
|
||||
</h2>
|
||||
{content && <p>{content}</p>}
|
||||
<div class="page-listing">
|
||||
<p>
|
||||
{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
|
||||
{pages.length > options.numPages && (
|
||||
<>
|
||||
{" "}
|
||||
<span>
|
||||
{i18n(cfg.locale).pages.tagContent.showingFirst({
|
||||
count: options.numPages,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PageList limit={options.numPages} {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const pages = allPagesWithTag(tag)
|
||||
const listProps = {
|
||||
...props,
|
||||
allFiles: pages,
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="popover-hint">
|
||||
<article class={classes}>{content}</article>
|
||||
<div class="page-listing">
|
||||
<p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
|
||||
<div>
|
||||
<PageList {...listProps} sort={options?.sort} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TagContent.css = concatenateResources(style, PageList.css)
|
||||
return TagContent
|
||||
}) satisfies QuartzComponentConstructor
|
||||
104
quartz/components/registry.ts
Normal file
104
quartz/components/registry.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export interface ComponentManifest {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
version: string
|
||||
quartzVersion?: string
|
||||
author?: string
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
export interface RegisteredComponent {
|
||||
component: QuartzComponent | QuartzComponentConstructor
|
||||
source: string
|
||||
manifest?: ComponentManifest
|
||||
}
|
||||
|
||||
class ComponentRegistry {
|
||||
private components = new Map<string, RegisteredComponent>()
|
||||
private instanceCache = new Map<string, QuartzComponent>()
|
||||
|
||||
register(
|
||||
name: string,
|
||||
component: QuartzComponent | QuartzComponentConstructor,
|
||||
source: string,
|
||||
manifest?: ComponentManifest,
|
||||
): void {
|
||||
const existing = this.components.get(name)
|
||||
if (existing && existing.source !== source) {
|
||||
console.warn(`Component "${name}" is being overwritten by ${source}`)
|
||||
}
|
||||
this.components.set(name, { component, source, manifest })
|
||||
}
|
||||
|
||||
get(name: string): RegisteredComponent | undefined {
|
||||
return this.components.get(name)
|
||||
}
|
||||
|
||||
getAll(): Map<string, RegisteredComponent> {
|
||||
return new Map(this.components)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a component constructor with options, returning a cached instance
|
||||
* if the same constructor was already called with equivalent options.
|
||||
* This prevents duplicate afterDOMLoaded scripts when the same component
|
||||
* appears in multiple page-type layouts.
|
||||
*/
|
||||
instantiate(
|
||||
constructor: QuartzComponentConstructor<any>,
|
||||
options?: Record<string, unknown>,
|
||||
): QuartzComponent {
|
||||
const optsKey = options !== undefined ? JSON.stringify(options) : ""
|
||||
// Use constructor identity + serialized options as cache key
|
||||
// We store constructor name as a hint but rely on a unique id for identity
|
||||
const ctorId =
|
||||
(constructor as unknown as { __cacheId?: string }).__cacheId ??
|
||||
((constructor as unknown as { __cacheId: string }).__cacheId =
|
||||
`ctor_${this.instanceCache.size}`)
|
||||
const cacheKey = `${ctorId}:${optsKey}`
|
||||
|
||||
const cached = this.instanceCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const instance = constructor(options)
|
||||
this.instanceCache.set(cacheKey, instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
getAllComponents(): QuartzComponent[] {
|
||||
// Deduplicate by component reference (same constructor may be registered under multiple keys)
|
||||
const seen = new Set<QuartzComponent | QuartzComponentConstructor>()
|
||||
const results: QuartzComponent[] = []
|
||||
for (const r of this.components.values()) {
|
||||
if (seen.has(r.component)) continue
|
||||
seen.add(r.component)
|
||||
try {
|
||||
let instance: QuartzComponent
|
||||
if (typeof r.component === "function") {
|
||||
instance = this.instantiate(r.component as QuartzComponentConstructor, undefined)
|
||||
} else {
|
||||
instance = r.component as QuartzComponent
|
||||
}
|
||||
if (instance) {
|
||||
results.push(instance)
|
||||
}
|
||||
} catch {
|
||||
// Skip components that fail to instantiate
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
export const componentRegistry = new ComponentRegistry()
|
||||
|
||||
export function defineComponent<Options extends object | undefined = undefined>(
|
||||
factory: QuartzComponentConstructor<Options>,
|
||||
manifest: ComponentManifest,
|
||||
): QuartzComponentConstructor<Options> {
|
||||
;(factory as any).__quartzComponent = { manifest }
|
||||
return factory
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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 { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
|
||||
@@ -10,6 +9,8 @@ import { Root, Element, ElementContent } from "hast"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { i18n } from "../i18n"
|
||||
import { styleText } from "util"
|
||||
import { resolveFrame } from "./frames"
|
||||
import type { TreeTransform } from "../plugins/types"
|
||||
|
||||
interface RenderComponents {
|
||||
head: QuartzComponent
|
||||
@@ -20,6 +21,7 @@ interface RenderComponents {
|
||||
left: QuartzComponent[]
|
||||
right: QuartzComponent[]
|
||||
footer: QuartzComponent
|
||||
frame?: string
|
||||
}
|
||||
|
||||
const headerRegex = new RegExp(/h[1-6]/)
|
||||
@@ -102,7 +104,19 @@ function renderTranscludes(
|
||||
}
|
||||
visited.add(transcludeTarget)
|
||||
|
||||
const page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
let page = componentData.allFiles.find((f) => f.slug === transcludeTarget)
|
||||
if (!page) {
|
||||
// Virtual pages from PageType plugins have slugs without extensions
|
||||
// (e.g. "plugins/CanvasPage") but CrawlLinks resolves wikilinks like
|
||||
// ![[CanvasPage.canvas]] to "plugins/CanvasPage.canvas". Fall back to
|
||||
// stripping the extension from the transclude target.
|
||||
const dotIdx = transcludeTarget.lastIndexOf(".")
|
||||
const slashIdx = transcludeTarget.lastIndexOf("/")
|
||||
if (dotIdx > slashIdx + 1) {
|
||||
const stripped = transcludeTarget.slice(0, dotIdx) as FullSlug
|
||||
page = componentData.allFiles.findLast((f) => f.slug === stripped)
|
||||
}
|
||||
}
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
@@ -218,6 +232,7 @@ export function renderPage(
|
||||
componentData: QuartzComponentProps,
|
||||
components: RenderComponents,
|
||||
pageResources: StaticResources,
|
||||
treeTransforms?: TreeTransform[],
|
||||
): 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
|
||||
@@ -225,6 +240,13 @@ export function renderPage(
|
||||
const visited = new Set<FullSlug>([slug])
|
||||
renderTranscludes(root, cfg, slug, componentData, visited)
|
||||
|
||||
// Run plugin-provided tree transforms (e.g. resolving inline bases codeblocks)
|
||||
if (treeTransforms) {
|
||||
for (const transform of treeTransforms) {
|
||||
transform(root, slug, componentData)
|
||||
}
|
||||
}
|
||||
|
||||
// set componentData.tree to the edited html that has transclusions rendered
|
||||
componentData.tree = root
|
||||
|
||||
@@ -237,25 +259,10 @@ export function renderPage(
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
frame: frameName,
|
||||
} = components
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
const LeftComponent = (
|
||||
<div class="left sidebar">
|
||||
{left.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const RightComponent = (
|
||||
<div class="right sidebar">
|
||||
{right.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const frame = resolveFrame(frameName)
|
||||
|
||||
const lang = componentData.fileData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en"
|
||||
const direction = i18n(cfg.locale).direction ?? "ltr"
|
||||
@@ -263,32 +270,22 @@ export function renderPage(
|
||||
<html lang={lang} dir={direction}>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={slug}>
|
||||
<div id="quartz-root" class="page">
|
||||
{frame.css && <style dangerouslySetInnerHTML={{ __html: frame.css }} />}
|
||||
<div id="quartz-root" class="page" data-frame={frame.name}>
|
||||
<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>
|
||||
<Content {...componentData} />
|
||||
<hr />
|
||||
<div class="page-footer">
|
||||
{afterBody.map((BodyComponent) => (
|
||||
<BodyComponent {...componentData} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{RightComponent}
|
||||
<Footer {...componentData} />
|
||||
{[
|
||||
frame.render({
|
||||
componentData,
|
||||
head: Head,
|
||||
header,
|
||||
beforeBody,
|
||||
pageBody: Content,
|
||||
afterBody,
|
||||
left,
|
||||
right,
|
||||
footer: Footer,
|
||||
}),
|
||||
]}
|
||||
</Body>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
function toggleCallout(this: HTMLElement) {
|
||||
const outerBlock = this.parentElement!
|
||||
outerBlock.classList.toggle("is-collapsed")
|
||||
const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!content) return
|
||||
const collapsed = outerBlock.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
|
||||
function setupCallout() {
|
||||
const collapsible = document.getElementsByClassName(
|
||||
`callout is-collapsible`,
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const div of collapsible) {
|
||||
const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
|
||||
const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
|
||||
if (!title || !content) continue
|
||||
|
||||
title.addEventListener("click", toggleCallout)
|
||||
window.addCleanup(() => title.removeEventListener("click", toggleCallout))
|
||||
|
||||
const collapsed = div.classList.contains("is-collapsed")
|
||||
content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupCallout)
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getFullSlug } from "../../util/path"
|
||||
|
||||
const checkboxId = (index: number) => `${getFullSlug(window)}-checkbox-${index}`
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const checkboxes = document.querySelectorAll(
|
||||
"input.checkbox-toggle",
|
||||
) as NodeListOf<HTMLInputElement>
|
||||
checkboxes.forEach((el, index) => {
|
||||
const elId = checkboxId(index)
|
||||
|
||||
const switchState = (e: Event) => {
|
||||
const newCheckboxState = (e.target as HTMLInputElement)?.checked ? "true" : "false"
|
||||
localStorage.setItem(elId, newCheckboxState)
|
||||
}
|
||||
|
||||
el.addEventListener("change", switchState)
|
||||
window.addCleanup(() => el.removeEventListener("change", switchState))
|
||||
if (localStorage.getItem(elId) === "true") {
|
||||
el.checked = true
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
const svgCopy =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>'
|
||||
const svgCheck =
|
||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const els = document.getElementsByTagName("pre")
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||
if (codeBlock) {
|
||||
const source = (
|
||||
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
|
||||
).replace(/\n\n/g, "\n")
|
||||
const button = document.createElement("button")
|
||||
button.className = "clipboard-button"
|
||||
button.type = "button"
|
||||
button.innerHTML = svgCopy
|
||||
button.ariaLabel = "Copy source"
|
||||
function onClick() {
|
||||
navigator.clipboard.writeText(source).then(
|
||||
() => {
|
||||
button.blur()
|
||||
button.innerHTML = svgCheck
|
||||
setTimeout(() => {
|
||||
button.innerHTML = svgCopy
|
||||
button.style.borderColor = ""
|
||||
}, 2000)
|
||||
},
|
||||
(error) => console.error(error),
|
||||
)
|
||||
}
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
els[i].prepend(button)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
const changeTheme = (e: CustomEventMap["themechange"]) => {
|
||||
const theme = e.detail.theme
|
||||
const iframe = document.querySelector("iframe.giscus-frame") as HTMLIFrameElement
|
||||
if (!iframe) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!iframe.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
giscus: {
|
||||
setConfig: {
|
||||
theme: getThemeUrl(getThemeName(theme)),
|
||||
},
|
||||
},
|
||||
},
|
||||
"https://giscus.app",
|
||||
)
|
||||
}
|
||||
|
||||
const getThemeName = (theme: string) => {
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
return theme
|
||||
}
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return theme
|
||||
}
|
||||
const darkGiscus = giscusContainer.dataset.darkTheme ?? "dark"
|
||||
const lightGiscus = giscusContainer.dataset.lightTheme ?? "light"
|
||||
return theme === "dark" ? darkGiscus : lightGiscus
|
||||
}
|
||||
|
||||
const getThemeUrl = (theme: string) => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return `https://giscus.app/themes/${theme}.css`
|
||||
}
|
||||
return `${giscusContainer.dataset.themeUrl ?? "https://giscus.app/themes"}/${theme}.css`
|
||||
}
|
||||
|
||||
type GiscusElement = Omit<HTMLElement, "dataset"> & {
|
||||
dataset: DOMStringMap & {
|
||||
repo: `${string}/${string}`
|
||||
repoId: string
|
||||
category: string
|
||||
categoryId: string
|
||||
themeUrl: string
|
||||
lightTheme: string
|
||||
darkTheme: string
|
||||
mapping: "url" | "title" | "og:title" | "specific" | "number" | "pathname"
|
||||
strict: string
|
||||
reactionsEnabled: string
|
||||
inputPosition: "top" | "bottom"
|
||||
lang: string
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const giscusContainer = document.querySelector(".giscus") as GiscusElement
|
||||
if (!giscusContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const giscusScript = document.createElement("script")
|
||||
giscusScript.src = "https://giscus.app/client.js"
|
||||
giscusScript.async = true
|
||||
giscusScript.crossOrigin = "anonymous"
|
||||
giscusScript.setAttribute("data-loading", "lazy")
|
||||
giscusScript.setAttribute("data-emit-metadata", "0")
|
||||
giscusScript.setAttribute("data-repo", giscusContainer.dataset.repo)
|
||||
giscusScript.setAttribute("data-repo-id", giscusContainer.dataset.repoId)
|
||||
giscusScript.setAttribute("data-category", giscusContainer.dataset.category)
|
||||
giscusScript.setAttribute("data-category-id", giscusContainer.dataset.categoryId)
|
||||
giscusScript.setAttribute("data-mapping", giscusContainer.dataset.mapping)
|
||||
giscusScript.setAttribute("data-strict", giscusContainer.dataset.strict)
|
||||
giscusScript.setAttribute("data-reactions-enabled", giscusContainer.dataset.reactionsEnabled)
|
||||
giscusScript.setAttribute("data-input-position", giscusContainer.dataset.inputPosition)
|
||||
giscusScript.setAttribute("data-lang", giscusContainer.dataset.lang)
|
||||
const theme = document.documentElement.getAttribute("saved-theme")
|
||||
if (theme) {
|
||||
giscusScript.setAttribute("data-theme", getThemeUrl(getThemeName(theme)))
|
||||
}
|
||||
|
||||
giscusContainer.appendChild(giscusScript)
|
||||
|
||||
document.addEventListener("themechange", changeTheme)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", changeTheme))
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"
|
||||
const currentTheme = localStorage.getItem("theme") ?? userPref
|
||||
document.documentElement.setAttribute("saved-theme", currentTheme)
|
||||
|
||||
const emitThemeChangeEvent = (theme: "light" | "dark") => {
|
||||
const event: CustomEventMap["themechange"] = new CustomEvent("themechange", {
|
||||
detail: { theme },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchTheme = () => {
|
||||
const newTheme =
|
||||
document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
const themeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light"
|
||||
document.documentElement.setAttribute("saved-theme", newTheme)
|
||||
localStorage.setItem("theme", newTheme)
|
||||
emitThemeChangeEvent(newTheme)
|
||||
}
|
||||
|
||||
for (const darkmodeButton of document.getElementsByClassName("darkmode")) {
|
||||
darkmodeButton.addEventListener("click", switchTheme)
|
||||
window.addCleanup(() => darkmodeButton.removeEventListener("click", switchTheme))
|
||||
}
|
||||
|
||||
// Listen for changes in prefers-color-scheme
|
||||
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
colorSchemeMediaQuery.addEventListener("change", themeChange)
|
||||
window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange))
|
||||
})
|
||||
@@ -1,305 +0,0 @@
|
||||
import { FileTrieNode } from "../../util/fileTrie"
|
||||
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
|
||||
type MaybeHTMLElement = HTMLElement | undefined
|
||||
|
||||
interface ParsedOptions {
|
||||
folderClickBehavior: "collapse" | "link"
|
||||
folderDefaultState: "collapsed" | "open"
|
||||
useSavedState: boolean
|
||||
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
|
||||
filterFn: (node: FileTrieNode) => boolean
|
||||
mapFn: (node: FileTrieNode) => void
|
||||
order: "sort" | "filter" | "map"[]
|
||||
}
|
||||
|
||||
type FolderState = {
|
||||
path: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
let currentExplorerState: Array<FolderState>
|
||||
function toggleExplorer(this: HTMLElement) {
|
||||
const nearestExplorer = this.closest(".explorer") as HTMLElement
|
||||
if (!nearestExplorer) return
|
||||
const explorerCollapsed = nearestExplorer.classList.toggle("collapsed")
|
||||
nearestExplorer.setAttribute(
|
||||
"aria-expanded",
|
||||
nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
|
||||
if (!explorerCollapsed) {
|
||||
// Stop <html> from being scrollable when mobile explorer is open
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
} else {
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(evt: MouseEvent) {
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as MaybeHTMLElement
|
||||
if (!target) return
|
||||
|
||||
// Check if target was svg icon or button
|
||||
const isSvg = target.nodeName === "svg"
|
||||
|
||||
// corresponding <ul> element relative to clicked button/folder
|
||||
const folderContainer = (
|
||||
isSvg
|
||||
? // svg -> div.folder-container
|
||||
target.parentElement
|
||||
: // button.folder-button -> div -> div.folder-container
|
||||
target.parentElement?.parentElement
|
||||
) as MaybeHTMLElement
|
||||
if (!folderContainer) return
|
||||
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
|
||||
if (!childFolderContainer) return
|
||||
|
||||
childFolderContainer.classList.toggle("open")
|
||||
|
||||
// Collapse folder container
|
||||
const isCollapsed = !childFolderContainer.classList.contains("open")
|
||||
setFolderState(childFolderContainer, isCollapsed)
|
||||
|
||||
const currentFolderState = currentExplorerState.find(
|
||||
(item) => item.path === folderContainer.dataset.folderpath,
|
||||
)
|
||||
if (currentFolderState) {
|
||||
currentFolderState.collapsed = isCollapsed
|
||||
} else {
|
||||
currentExplorerState.push({
|
||||
path: folderContainer.dataset.folderpath as FullSlug,
|
||||
collapsed: isCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
const stringifiedFileTree = JSON.stringify(currentExplorerState)
|
||||
localStorage.setItem("fileTree", stringifiedFileTree)
|
||||
}
|
||||
|
||||
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
|
||||
const template = document.getElementById("template-file") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const a = li.querySelector("a") as HTMLAnchorElement
|
||||
a.href = resolveRelative(currentSlug, node.slug)
|
||||
a.dataset.for = node.slug
|
||||
a.textContent = node.displayName
|
||||
|
||||
if (currentSlug === node.slug) {
|
||||
a.classList.add("active")
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
function createFolderNode(
|
||||
currentSlug: FullSlug,
|
||||
node: FileTrieNode,
|
||||
opts: ParsedOptions,
|
||||
): HTMLLIElement {
|
||||
const template = document.getElementById("template-folder") as HTMLTemplateElement
|
||||
const clone = template.content.cloneNode(true) as DocumentFragment
|
||||
const li = clone.querySelector("li") as HTMLLIElement
|
||||
const folderContainer = li.querySelector(".folder-container") as HTMLElement
|
||||
const titleContainer = folderContainer.querySelector("div") as HTMLElement
|
||||
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
|
||||
const ul = folderOuter.querySelector("ul") as HTMLUListElement
|
||||
|
||||
const folderPath = node.slug
|
||||
folderContainer.dataset.folderpath = folderPath
|
||||
|
||||
if (currentSlug === folderPath) {
|
||||
folderContainer.classList.add("active")
|
||||
}
|
||||
|
||||
if (opts.folderClickBehavior === "link") {
|
||||
// Replace button with link for link behavior
|
||||
const button = titleContainer.querySelector(".folder-button") as HTMLElement
|
||||
const a = document.createElement("a")
|
||||
a.href = resolveRelative(currentSlug, folderPath)
|
||||
a.dataset.for = folderPath
|
||||
a.className = "folder-title"
|
||||
a.textContent = node.displayName
|
||||
button.replaceWith(a)
|
||||
} else {
|
||||
const span = titleContainer.querySelector(".folder-title") as HTMLElement
|
||||
span.textContent = node.displayName
|
||||
}
|
||||
|
||||
// if the saved state is collapsed or the default state is collapsed
|
||||
const isCollapsed =
|
||||
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
|
||||
opts.folderDefaultState === "collapsed"
|
||||
|
||||
// if this folder is a prefix of the current path we
|
||||
// want to open it anyways
|
||||
const simpleFolderPath = simplifySlug(folderPath)
|
||||
const folderIsPrefixOfCurrentSlug =
|
||||
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
|
||||
|
||||
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
|
||||
folderOuter.classList.add("open")
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const childNode = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
ul.appendChild(childNode)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
async function setupExplorer(currentSlug: FullSlug) {
|
||||
const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
|
||||
|
||||
for (const explorer of allExplorers) {
|
||||
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
|
||||
const opts: ParsedOptions = {
|
||||
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
|
||||
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
|
||||
useSavedState: explorer.dataset.savestate === "true",
|
||||
order: dataFns.order || ["filter", "map", "sort"],
|
||||
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
|
||||
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
|
||||
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
|
||||
}
|
||||
|
||||
// Get folder state from local storage
|
||||
const storageTree = localStorage.getItem("fileTree")
|
||||
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
|
||||
const oldIndex = new Map<string, boolean>(
|
||||
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
|
||||
)
|
||||
|
||||
const data = await fetchData
|
||||
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
|
||||
const trie = FileTrieNode.fromEntries(entries)
|
||||
|
||||
// Apply functions in order
|
||||
for (const fn of opts.order) {
|
||||
switch (fn) {
|
||||
case "filter":
|
||||
if (opts.filterFn) trie.filter(opts.filterFn)
|
||||
break
|
||||
case "map":
|
||||
if (opts.mapFn) trie.map(opts.mapFn)
|
||||
break
|
||||
case "sort":
|
||||
if (opts.sortFn) trie.sort(opts.sortFn)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get folder paths for state management
|
||||
const folderPaths = trie.getFolderPaths()
|
||||
currentExplorerState = folderPaths.map((path) => {
|
||||
const previousState = oldIndex.get(path)
|
||||
return {
|
||||
path,
|
||||
collapsed:
|
||||
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState,
|
||||
}
|
||||
})
|
||||
|
||||
const explorerUl = explorer.querySelector(".explorer-ul")
|
||||
if (!explorerUl) continue
|
||||
|
||||
// Create and insert new content
|
||||
const fragment = document.createDocumentFragment()
|
||||
for (const child of trie.children) {
|
||||
const node = child.isFolder
|
||||
? createFolderNode(currentSlug, child, opts)
|
||||
: createFileNode(currentSlug, child)
|
||||
|
||||
fragment.appendChild(node)
|
||||
}
|
||||
explorerUl.insertBefore(fragment, explorerUl.firstChild)
|
||||
|
||||
// restore explorer scrollTop position if it exists
|
||||
const scrollTop = sessionStorage.getItem("explorerScrollTop")
|
||||
if (scrollTop) {
|
||||
explorerUl.scrollTop = parseInt(scrollTop)
|
||||
} else {
|
||||
// try to scroll to the active element if it exists
|
||||
const activeElement = explorerUl.querySelector(".active")
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
const explorerButtons = explorer.getElementsByClassName(
|
||||
"explorer-toggle",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of explorerButtons) {
|
||||
button.addEventListener("click", toggleExplorer)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
|
||||
}
|
||||
|
||||
// Set up folder click handlers
|
||||
if (opts.folderClickBehavior === "collapse") {
|
||||
const folderButtons = explorer.getElementsByClassName(
|
||||
"folder-button",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const button of folderButtons) {
|
||||
button.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
|
||||
const folderIcons = explorer.getElementsByClassName(
|
||||
"folder-icon",
|
||||
) as HTMLCollectionOf<HTMLElement>
|
||||
for (const icon of folderIcons) {
|
||||
icon.addEventListener("click", toggleFolder)
|
||||
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("prenav", async () => {
|
||||
// save explorer scrollTop position
|
||||
const explorer = document.querySelector(".explorer-ul")
|
||||
if (!explorer) return
|
||||
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
|
||||
})
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
await setupExplorer(currentSlug)
|
||||
|
||||
// if mobile hamburger is visible, collapse by default
|
||||
for (const explorer of document.getElementsByClassName("explorer")) {
|
||||
const mobileExplorer = explorer.querySelector(".mobile-explorer")
|
||||
if (!mobileExplorer) return
|
||||
|
||||
if (mobileExplorer.checkVisibility()) {
|
||||
explorer.classList.add("collapsed")
|
||||
explorer.setAttribute("aria-expanded", "false")
|
||||
|
||||
// Allow <html> to be scrollable when mobile explorer is collapsed
|
||||
document.documentElement.classList.remove("mobile-no-scroll")
|
||||
}
|
||||
|
||||
mobileExplorer.classList.remove("hide-until-loaded")
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
// Desktop explorer opens by default, and it stays open when the window is resized
|
||||
// to mobile screen size. Applies `no-scroll` to <html> in this edge case.
|
||||
const explorer = document.querySelector(".explorer")
|
||||
if (explorer && !explorer.classList.contains("collapsed")) {
|
||||
document.documentElement.classList.add("mobile-no-scroll")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
|
||||
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import {
|
||||
SimulationNodeDatum,
|
||||
SimulationLinkDatum,
|
||||
Simulation,
|
||||
forceSimulation,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
forceRadial,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
zoom,
|
||||
} from "d3"
|
||||
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
|
||||
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { D3Config } from "../Graph"
|
||||
|
||||
type GraphicsInfo = {
|
||||
color: string
|
||||
gfx: Graphics
|
||||
alpha: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type NodeData = {
|
||||
id: SimpleSlug
|
||||
text: string
|
||||
tags: string[]
|
||||
} & SimulationNodeDatum
|
||||
|
||||
type SimpleLinkData = {
|
||||
source: SimpleSlug
|
||||
target: SimpleSlug
|
||||
}
|
||||
|
||||
type LinkData = {
|
||||
source: NodeData
|
||||
target: NodeData
|
||||
} & SimulationLinkDatum<NodeData>
|
||||
|
||||
type LinkRenderData = GraphicsInfo & {
|
||||
simulationData: LinkData
|
||||
}
|
||||
|
||||
type NodeRenderData = GraphicsInfo & {
|
||||
simulationData: NodeData
|
||||
label: Text
|
||||
}
|
||||
|
||||
const localStorageKey = "graph-visited"
|
||||
function getVisited(): Set<SimpleSlug> {
|
||||
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
|
||||
}
|
||||
|
||||
function addToVisited(slug: SimpleSlug) {
|
||||
const visited = getVisited()
|
||||
visited.add(slug)
|
||||
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
||||
}
|
||||
|
||||
type TweenNode = {
|
||||
update: (time: number) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
||||
const slug = simplifySlug(fullSlug)
|
||||
const visited = getVisited()
|
||||
removeAllChildren(graph)
|
||||
|
||||
let {
|
||||
drag: enableDrag,
|
||||
zoom: enableZoom,
|
||||
depth,
|
||||
scale,
|
||||
repelForce,
|
||||
centerForce,
|
||||
linkDistance,
|
||||
fontSize,
|
||||
opacityScale,
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
enableRadial,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
|
||||
simplifySlug(k as FullSlug),
|
||||
v,
|
||||
]),
|
||||
)
|
||||
const links: SimpleLinkData[] = []
|
||||
const tags: SimpleSlug[] = []
|
||||
const validLinks = new Set(data.keys())
|
||||
|
||||
const tweens = new Map<string, TweenNode>()
|
||||
for (const [source, details] of data.entries()) {
|
||||
const outgoing = details.links ?? []
|
||||
|
||||
for (const dest of outgoing) {
|
||||
if (validLinks.has(dest)) {
|
||||
links.push({ source: source, target: dest })
|
||||
}
|
||||
}
|
||||
|
||||
if (showTags) {
|
||||
const localTags = details.tags
|
||||
.filter((tag) => !removeTags.includes(tag))
|
||||
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
||||
|
||||
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
||||
|
||||
for (const tag of localTags) {
|
||||
links.push({ source: source, target: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbourhood = new Set<SimpleSlug>()
|
||||
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
||||
if (depth >= 0) {
|
||||
while (depth >= 0 && wl.length > 0) {
|
||||
// compute neighbours
|
||||
const cur = wl.shift()!
|
||||
if (cur === "__SENTINEL") {
|
||||
depth--
|
||||
wl.push("__SENTINEL")
|
||||
} else {
|
||||
neighbourhood.add(cur)
|
||||
const outgoing = links.filter((l) => l.source === cur)
|
||||
const incoming = links.filter((l) => l.target === cur)
|
||||
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validLinks.forEach((id) => neighbourhood.add(id))
|
||||
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
||||
}
|
||||
|
||||
const nodes = [...neighbourhood].map((url) => {
|
||||
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
|
||||
return {
|
||||
id: url,
|
||||
text,
|
||||
tags: data.get(url)?.tags ?? [],
|
||||
}
|
||||
})
|
||||
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
||||
nodes,
|
||||
links: links
|
||||
.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
|
||||
.map((l) => ({
|
||||
source: nodes.find((n) => n.id === l.source)!,
|
||||
target: nodes.find((n) => n.id === l.target)!,
|
||||
})),
|
||||
}
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const radius = (Math.min(width, height) / 2) * 0.8
|
||||
if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--bodyFont",
|
||||
] as const
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
// calculate color
|
||||
const color = (d: NodeData) => {
|
||||
const isCurrent = d.id === slug
|
||||
if (isCurrent) {
|
||||
return computedStyleMap["--secondary"]
|
||||
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
||||
return computedStyleMap["--tertiary"]
|
||||
} else {
|
||||
return computedStyleMap["--gray"]
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(d: NodeData) {
|
||||
const numLinks = graphData.links.filter(
|
||||
(l) => l.source.id === d.id || l.target.id === d.id,
|
||||
).length
|
||||
return 2 + Math.sqrt(numLinks)
|
||||
}
|
||||
|
||||
let hoveredNodeId: string | null = null
|
||||
let hoveredNeighbours: Set<string> = new Set()
|
||||
const linkRenderData: LinkRenderData[] = []
|
||||
const nodeRenderData: NodeRenderData[] = []
|
||||
function updateHoverInfo(newHoveredId: string | null) {
|
||||
hoveredNodeId = newHoveredId
|
||||
|
||||
if (newHoveredId === null) {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = false
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
l.active = false
|
||||
}
|
||||
} else {
|
||||
hoveredNeighbours = new Set()
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
|
||||
hoveredNeighbours.add(linkData.source.id)
|
||||
hoveredNeighbours.add(linkData.target.id)
|
||||
}
|
||||
|
||||
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
||||
}
|
||||
|
||||
for (const n of nodeRenderData) {
|
||||
n.active = hoveredNeighbours.has(n.simulationData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dragStartTime = 0
|
||||
let dragging = false
|
||||
|
||||
function renderLinks() {
|
||||
tweens.get("link")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
// with full alpha and the rest with default alpha
|
||||
if (hoveredNodeId) {
|
||||
alpha = l.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
|
||||
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("link", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderLabels() {
|
||||
tweens.get("label")?.stop()
|
||||
const tweenGroup = new TweenGroup()
|
||||
|
||||
const defaultScale = 1 / scale
|
||||
const activeScale = defaultScale * 1.1
|
||||
for (const n of nodeRenderData) {
|
||||
const nodeId = n.simulationData.id
|
||||
|
||||
if (hoveredNodeId === nodeId) {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: 1,
|
||||
scale: { x: activeScale, y: activeScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tweenGroup.add(
|
||||
new Tweened<Text>(n.label).to(
|
||||
{
|
||||
alpha: n.label.alpha,
|
||||
scale: { x: defaultScale, y: defaultScale },
|
||||
},
|
||||
100,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("label", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
tweens.get("hover")?.stop()
|
||||
|
||||
const tweenGroup = new TweenGroup()
|
||||
for (const n of nodeRenderData) {
|
||||
let alpha = 1
|
||||
|
||||
// if we are hovering over a node, we want to highlight the immediate neighbours
|
||||
if (hoveredNodeId !== null && focusOnHover) {
|
||||
alpha = n.active ? 1 : 0.2
|
||||
}
|
||||
|
||||
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
|
||||
}
|
||||
|
||||
tweenGroup.getAll().forEach((tw) => tw.start())
|
||||
tweens.set("hover", {
|
||||
update: tweenGroup.update.bind(tweenGroup),
|
||||
stop() {
|
||||
tweenGroup.getAll().forEach((tw) => tw.stop())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderPixiFromD3() {
|
||||
renderNodes()
|
||||
renderLinks()
|
||||
renderLabels()
|
||||
}
|
||||
|
||||
tweens.forEach((tween) => tween.stop())
|
||||
tweens.clear()
|
||||
|
||||
const app = new Application()
|
||||
await app.init({
|
||||
width,
|
||||
height,
|
||||
antialias: true,
|
||||
autoStart: false,
|
||||
autoDensity: true,
|
||||
backgroundAlpha: 0,
|
||||
preference: "webgpu",
|
||||
resolution: window.devicePixelRatio,
|
||||
eventMode: "static",
|
||||
})
|
||||
graph.appendChild(app.canvas)
|
||||
|
||||
const stage = app.stage
|
||||
stage.interactive = false
|
||||
|
||||
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
|
||||
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
||||
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
||||
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
||||
|
||||
for (const n of graphData.nodes) {
|
||||
const nodeId = n.id
|
||||
|
||||
const label = new Text({
|
||||
interactive: false,
|
||||
eventMode: "none",
|
||||
text: n.text,
|
||||
alpha: 0,
|
||||
anchor: { x: 0.5, y: 1.2 },
|
||||
style: {
|
||||
fontSize: fontSize * 15,
|
||||
fill: computedStyleMap["--dark"],
|
||||
fontFamily: computedStyleMap["--bodyFont"],
|
||||
},
|
||||
resolution: window.devicePixelRatio * 4,
|
||||
})
|
||||
label.scale.set(1 / scale)
|
||||
|
||||
let oldLabelOpacity = 0
|
||||
const isTagNode = nodeId.startsWith("tags/")
|
||||
const gfx = new Graphics({
|
||||
interactive: true,
|
||||
label: nodeId,
|
||||
eventMode: "static",
|
||||
hitArea: new Circle(0, 0, nodeRadius(n)),
|
||||
cursor: "pointer",
|
||||
})
|
||||
.circle(0, 0, nodeRadius(n))
|
||||
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
||||
.on("pointerover", (e) => {
|
||||
updateHoverInfo(e.target.label)
|
||||
oldLabelOpacity = label.alpha
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
.on("pointerleave", () => {
|
||||
updateHoverInfo(null)
|
||||
label.alpha = oldLabelOpacity
|
||||
if (!dragging) {
|
||||
renderPixiFromD3()
|
||||
}
|
||||
})
|
||||
|
||||
if (isTagNode) {
|
||||
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
||||
}
|
||||
|
||||
nodesContainer.addChild(gfx)
|
||||
labelsContainer.addChild(label)
|
||||
|
||||
const nodeRenderDatum: NodeRenderData = {
|
||||
simulationData: n,
|
||||
gfx,
|
||||
label,
|
||||
color: color(n),
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
nodeRenderData.push(nodeRenderDatum)
|
||||
}
|
||||
|
||||
for (const l of graphData.links) {
|
||||
const gfx = new Graphics({ interactive: false, eventMode: "none" })
|
||||
linkContainer.addChild(gfx)
|
||||
|
||||
const linkRenderDatum: LinkRenderData = {
|
||||
simulationData: l,
|
||||
gfx,
|
||||
color: computedStyleMap["--lightgray"],
|
||||
alpha: 1,
|
||||
active: false,
|
||||
}
|
||||
|
||||
linkRenderData.push(linkRenderDatum)
|
||||
}
|
||||
|
||||
let currentTransform = zoomIdentity
|
||||
if (enableDrag) {
|
||||
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
|
||||
drag<HTMLCanvasElement, NodeData | undefined>()
|
||||
.container(() => app.canvas)
|
||||
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
|
||||
.on("start", function dragstarted(event) {
|
||||
if (!event.active) simulation.alphaTarget(1).restart()
|
||||
event.subject.fx = event.subject.x
|
||||
event.subject.fy = event.subject.y
|
||||
event.subject.__initialDragPos = {
|
||||
x: event.subject.x,
|
||||
y: event.subject.y,
|
||||
fx: event.subject.fx,
|
||||
fy: event.subject.fy,
|
||||
}
|
||||
dragStartTime = Date.now()
|
||||
dragging = true
|
||||
})
|
||||
.on("drag", function dragged(event) {
|
||||
const initPos = event.subject.__initialDragPos
|
||||
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
|
||||
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
|
||||
})
|
||||
.on("end", function dragended(event) {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
event.subject.fx = null
|
||||
event.subject.fy = null
|
||||
dragging = false
|
||||
|
||||
// if the time between mousedown and mouseup is short, we consider it a click
|
||||
if (Date.now() - dragStartTime < 500) {
|
||||
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
|
||||
const targ = resolveRelative(fullSlug, node.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
for (const node of nodeRenderData) {
|
||||
node.gfx.on("click", () => {
|
||||
const targ = resolveRelative(fullSlug, node.simulationData.id)
|
||||
window.spaNavigate(new URL(targ, window.location.toString()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (enableZoom) {
|
||||
select<HTMLCanvasElement, NodeData>(app.canvas).call(
|
||||
zoom<HTMLCanvasElement, NodeData>()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[width, height],
|
||||
])
|
||||
.scaleExtent([0.25, 4])
|
||||
.on("zoom", ({ transform }) => {
|
||||
currentTransform = transform
|
||||
stage.scale.set(transform.k, transform.k)
|
||||
stage.position.set(transform.x, transform.y)
|
||||
|
||||
// zoom adjusts opacity of labels too
|
||||
const scale = transform.k * opacityScale
|
||||
let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
|
||||
const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
|
||||
|
||||
for (const label of labelsContainer.children) {
|
||||
if (!activeNodes.includes(label)) {
|
||||
label.alpha = scaleOpacity
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let stopAnimation = false
|
||||
function animate(time: number) {
|
||||
if (stopAnimation) return
|
||||
for (const n of nodeRenderData) {
|
||||
const { x, y } = n.simulationData
|
||||
if (!x || !y) continue
|
||||
n.gfx.position.set(x + width / 2, y + height / 2)
|
||||
if (n.label) {
|
||||
n.label.position.set(x + width / 2, y + height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of linkRenderData) {
|
||||
const linkData = l.simulationData
|
||||
l.gfx.clear()
|
||||
l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
|
||||
l.gfx
|
||||
.lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
|
||||
.stroke({ alpha: l.alpha, width: 1, color: l.color })
|
||||
}
|
||||
|
||||
tweens.forEach((t) => t.update(time))
|
||||
app.renderer.render(stage)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
return () => {
|
||||
stopAnimation = true
|
||||
app.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
let localGraphCleanups: (() => void)[] = []
|
||||
let globalGraphCleanups: (() => void)[] = []
|
||||
|
||||
function cleanupLocalGraphs() {
|
||||
for (const cleanup of localGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
localGraphCleanups = []
|
||||
}
|
||||
|
||||
function cleanupGlobalGraphs() {
|
||||
for (const cleanup of globalGraphCleanups) {
|
||||
cleanup()
|
||||
}
|
||||
globalGraphCleanups = []
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const slug = e.detail.url
|
||||
addToVisited(simplifySlug(slug))
|
||||
|
||||
async function renderLocalGraph() {
|
||||
cleanupLocalGraphs()
|
||||
const localGraphContainers = document.getElementsByClassName("graph-container")
|
||||
for (const container of localGraphContainers) {
|
||||
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
|
||||
}
|
||||
}
|
||||
|
||||
await renderLocalGraph()
|
||||
const handleThemeChange = () => {
|
||||
void renderLocalGraph()
|
||||
}
|
||||
|
||||
document.addEventListener("themechange", handleThemeChange)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("themechange", handleThemeChange)
|
||||
})
|
||||
|
||||
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
|
||||
async function renderGlobalGraph() {
|
||||
const slug = getFullSlug(window)
|
||||
for (const container of containers) {
|
||||
container.classList.add("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = "1"
|
||||
}
|
||||
|
||||
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
|
||||
registerEscapeHandler(container, hideGlobalGraph)
|
||||
if (graphContainer) {
|
||||
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideGlobalGraph() {
|
||||
cleanupGlobalGraphs()
|
||||
for (const container of containers) {
|
||||
container.classList.remove("active")
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
||||
if (sidebar) {
|
||||
sidebar.style.zIndex = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const anyGlobalGraphOpen = containers.some((container) =>
|
||||
container.classList.contains("active"),
|
||||
)
|
||||
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
|
||||
}
|
||||
}
|
||||
|
||||
const containerIcons = document.getElementsByClassName("global-graph-icon")
|
||||
Array.from(containerIcons).forEach((icon) => {
|
||||
icon.addEventListener("click", renderGlobalGraph)
|
||||
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
|
||||
})
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("keydown", shortcutHandler)
|
||||
cleanupLocalGraphs()
|
||||
cleanupGlobalGraphs()
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
class DiagramPanZoom {
|
||||
private isDragging = false
|
||||
private startPan: Position = { x: 0, y: 0 }
|
||||
private currentPan: Position = { x: 0, y: 0 }
|
||||
private scale = 1
|
||||
private readonly MIN_SCALE = 0.5
|
||||
private readonly MAX_SCALE = 3
|
||||
|
||||
cleanups: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private content: HTMLElement,
|
||||
) {
|
||||
this.setupEventListeners()
|
||||
this.setupNavigationControls()
|
||||
this.resetTransform()
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
// Mouse drag events
|
||||
const mouseDownHandler = this.onMouseDown.bind(this)
|
||||
const mouseMoveHandler = this.onMouseMove.bind(this)
|
||||
const mouseUpHandler = this.onMouseUp.bind(this)
|
||||
|
||||
// Touch drag events
|
||||
const touchStartHandler = this.onTouchStart.bind(this)
|
||||
const touchMoveHandler = this.onTouchMove.bind(this)
|
||||
const touchEndHandler = this.onTouchEnd.bind(this)
|
||||
|
||||
const resizeHandler = this.resetTransform.bind(this)
|
||||
|
||||
this.container.addEventListener("mousedown", mouseDownHandler)
|
||||
document.addEventListener("mousemove", mouseMoveHandler)
|
||||
document.addEventListener("mouseup", mouseUpHandler)
|
||||
|
||||
this.container.addEventListener("touchstart", touchStartHandler, { passive: false })
|
||||
document.addEventListener("touchmove", touchMoveHandler, { passive: false })
|
||||
document.addEventListener("touchend", touchEndHandler)
|
||||
|
||||
window.addEventListener("resize", resizeHandler)
|
||||
|
||||
this.cleanups.push(
|
||||
() => this.container.removeEventListener("mousedown", mouseDownHandler),
|
||||
() => document.removeEventListener("mousemove", mouseMoveHandler),
|
||||
() => document.removeEventListener("mouseup", mouseUpHandler),
|
||||
() => this.container.removeEventListener("touchstart", touchStartHandler),
|
||||
() => document.removeEventListener("touchmove", touchMoveHandler),
|
||||
() => document.removeEventListener("touchend", touchEndHandler),
|
||||
() => window.removeEventListener("resize", resizeHandler),
|
||||
)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const cleanup of this.cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private setupNavigationControls() {
|
||||
const controls = document.createElement("div")
|
||||
controls.className = "mermaid-controls"
|
||||
|
||||
// Zoom controls
|
||||
const zoomIn = this.createButton("+", () => this.zoom(0.1))
|
||||
const zoomOut = this.createButton("-", () => this.zoom(-0.1))
|
||||
const resetBtn = this.createButton("Reset", () => this.resetTransform())
|
||||
|
||||
controls.appendChild(zoomOut)
|
||||
controls.appendChild(resetBtn)
|
||||
controls.appendChild(zoomIn)
|
||||
|
||||
this.container.appendChild(controls)
|
||||
}
|
||||
|
||||
private createButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button")
|
||||
button.textContent = text
|
||||
button.className = "mermaid-control-button"
|
||||
button.addEventListener("click", onClick)
|
||||
window.addCleanup(() => button.removeEventListener("click", onClick))
|
||||
return button
|
||||
}
|
||||
|
||||
private onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return // Only handle left click
|
||||
this.isDragging = true
|
||||
this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
|
||||
this.container.style.cursor = "grabbing"
|
||||
}
|
||||
|
||||
private onMouseMove(e: MouseEvent) {
|
||||
if (!this.isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
this.currentPan = {
|
||||
x: e.clientX - this.startPan.x,
|
||||
y: e.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onMouseUp() {
|
||||
this.isDragging = false
|
||||
this.container.style.cursor = "grab"
|
||||
}
|
||||
|
||||
private onTouchStart(e: TouchEvent) {
|
||||
if (e.touches.length !== 1) return
|
||||
this.isDragging = true
|
||||
const touch = e.touches[0]
|
||||
this.startPan = { x: touch.clientX - this.currentPan.x, y: touch.clientY - this.currentPan.y }
|
||||
}
|
||||
|
||||
private onTouchMove(e: TouchEvent) {
|
||||
if (!this.isDragging || e.touches.length !== 1) return
|
||||
e.preventDefault() // Prevent scrolling
|
||||
|
||||
const touch = e.touches[0]
|
||||
this.currentPan = {
|
||||
x: touch.clientX - this.startPan.x,
|
||||
y: touch.clientY - this.startPan.y,
|
||||
}
|
||||
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private onTouchEnd() {
|
||||
this.isDragging = false
|
||||
}
|
||||
|
||||
private zoom(delta: number) {
|
||||
const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
|
||||
|
||||
// Zoom around center
|
||||
const rect = this.content.getBoundingClientRect()
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
|
||||
const scaleDiff = newScale - this.scale
|
||||
this.currentPan.x -= centerX * scaleDiff
|
||||
this.currentPan.y -= centerY * scaleDiff
|
||||
|
||||
this.scale = newScale
|
||||
this.updateTransform()
|
||||
}
|
||||
|
||||
private updateTransform() {
|
||||
this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
|
||||
}
|
||||
|
||||
private resetTransform() {
|
||||
const svg = this.content.querySelector("svg")!
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const width = rect.width / this.scale
|
||||
const height = rect.height / this.scale
|
||||
|
||||
this.scale = 1
|
||||
this.currentPan = {
|
||||
x: (this.container.clientWidth - width) / 2,
|
||||
y: (this.container.clientHeight - height) / 2,
|
||||
}
|
||||
this.updateTransform()
|
||||
}
|
||||
}
|
||||
|
||||
const cssVars = [
|
||||
"--secondary",
|
||||
"--tertiary",
|
||||
"--gray",
|
||||
"--light",
|
||||
"--lightgray",
|
||||
"--highlight",
|
||||
"--dark",
|
||||
"--darkgray",
|
||||
"--codeFont",
|
||||
] as const
|
||||
|
||||
let mermaidImport = undefined
|
||||
document.addEventListener("nav", async () => {
|
||||
const center = document.querySelector(".center") as HTMLElement
|
||||
const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement>
|
||||
if (nodes.length === 0) return
|
||||
|
||||
mermaidImport ||= await import(
|
||||
// @ts-ignore
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/mermaid/11.4.0/mermaid.esm.min.mjs"
|
||||
)
|
||||
const mermaid = mermaidImport.default
|
||||
|
||||
const textMapping: WeakMap<HTMLElement, string> = new WeakMap()
|
||||
for (const node of nodes) {
|
||||
textMapping.set(node, node.innerText)
|
||||
}
|
||||
|
||||
async function renderMermaid() {
|
||||
// de-init any other diagrams
|
||||
for (const node of nodes) {
|
||||
node.removeAttribute("data-processed")
|
||||
const oldText = textMapping.get(node)
|
||||
if (oldText) {
|
||||
node.innerHTML = oldText
|
||||
}
|
||||
}
|
||||
|
||||
const computedStyleMap = cssVars.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key)
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof cssVars)[number], string>,
|
||||
)
|
||||
|
||||
const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "loose",
|
||||
theme: darkMode ? "dark" : "base",
|
||||
themeVariables: {
|
||||
fontFamily: computedStyleMap["--codeFont"],
|
||||
primaryColor: computedStyleMap["--light"],
|
||||
primaryTextColor: computedStyleMap["--darkgray"],
|
||||
primaryBorderColor: computedStyleMap["--tertiary"],
|
||||
lineColor: computedStyleMap["--darkgray"],
|
||||
secondaryColor: computedStyleMap["--secondary"],
|
||||
tertiaryColor: computedStyleMap["--tertiary"],
|
||||
clusterBkg: computedStyleMap["--light"],
|
||||
edgeLabelBackground: computedStyleMap["--highlight"],
|
||||
},
|
||||
})
|
||||
|
||||
await mermaid.run({ nodes })
|
||||
}
|
||||
|
||||
await renderMermaid()
|
||||
document.addEventListener("themechange", renderMermaid)
|
||||
window.addCleanup(() => document.removeEventListener("themechange", renderMermaid))
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const codeBlock = nodes[i] as HTMLElement
|
||||
const pre = codeBlock.parentElement as HTMLPreElement
|
||||
const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
|
||||
const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
|
||||
|
||||
const clipboardStyle = window.getComputedStyle(clipboardBtn)
|
||||
const clipboardWidth =
|
||||
clipboardBtn.offsetWidth +
|
||||
parseFloat(clipboardStyle.marginLeft || "0") +
|
||||
parseFloat(clipboardStyle.marginRight || "0")
|
||||
|
||||
// Set expand button position
|
||||
expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
|
||||
pre.prepend(expandBtn)
|
||||
|
||||
// query popup container
|
||||
const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
|
||||
if (!popupContainer) return
|
||||
|
||||
let panZoom: DiagramPanZoom | null = null
|
||||
function showMermaid() {
|
||||
const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
|
||||
const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
|
||||
if (!content) return
|
||||
removeAllChildren(content)
|
||||
|
||||
// Clone the mermaid content
|
||||
const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
|
||||
content.appendChild(mermaidContent)
|
||||
|
||||
// Show container
|
||||
popupContainer.classList.add("active")
|
||||
container.style.cursor = "grab"
|
||||
|
||||
// Initialize pan-zoom after showing the popup
|
||||
panZoom = new DiagramPanZoom(container, content)
|
||||
}
|
||||
|
||||
function hideMermaid() {
|
||||
popupContainer.classList.remove("active")
|
||||
panZoom?.cleanup()
|
||||
panZoom = null
|
||||
}
|
||||
|
||||
expandBtn.addEventListener("click", showMermaid)
|
||||
registerEscapeHandler(popupContainer, hideMermaid)
|
||||
|
||||
window.addCleanup(() => {
|
||||
panZoom?.cleanup()
|
||||
expandBtn.removeEventListener("click", showMermaid)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -120,7 +120,7 @@ function clearActivePopover() {
|
||||
allPopoverElements.forEach((popoverElement) => popoverElement.classList.remove("active-popover"))
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
function setupPopovers() {
|
||||
const links = [...document.querySelectorAll("a.internal")] as HTMLAnchorElement[]
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", mouseEnterHandler)
|
||||
@@ -130,4 +130,7 @@ document.addEventListener("nav", () => {
|
||||
link.removeEventListener("mouseleave", clearActivePopover)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener("nav", setupPopovers)
|
||||
document.addEventListener("render", setupPopovers)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
let isReaderMode = false
|
||||
|
||||
const emitReaderModeChangeEvent = (mode: "on" | "off") => {
|
||||
const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", {
|
||||
detail: { mode },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const switchReaderMode = () => {
|
||||
isReaderMode = !isReaderMode
|
||||
const newMode = isReaderMode ? "on" : "off"
|
||||
document.documentElement.setAttribute("reader-mode", newMode)
|
||||
emitReaderModeChangeEvent(newMode)
|
||||
}
|
||||
|
||||
for (const readerModeButton of document.getElementsByClassName("readermode")) {
|
||||
readerModeButton.addEventListener("click", switchReaderMode)
|
||||
window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode))
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off")
|
||||
})
|
||||
@@ -1,540 +0,0 @@
|
||||
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
|
||||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import { registerEscapeHandler, removeAllChildren } from "./util"
|
||||
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
slug: FullSlug
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Can be expanded with things like "term" in the future
|
||||
type SearchType = "basic" | "tags"
|
||||
let searchType: SearchType = "basic"
|
||||
let currentSearchTerm: string = ""
|
||||
const encoder = (str: string): string[] => {
|
||||
const tokens: string[] = []
|
||||
let bufferStart = -1
|
||||
let bufferEnd = -1
|
||||
const lower = str.toLowerCase()
|
||||
|
||||
let i = 0
|
||||
for (const char of lower) {
|
||||
const code = char.codePointAt(0)!
|
||||
|
||||
const isCJK =
|
||||
(code >= 0x3040 && code <= 0x309f) ||
|
||||
(code >= 0x30a0 && code <= 0x30ff) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0x20000 && code <= 0x2a6df)
|
||||
|
||||
const isWhitespace = code === 32 || code === 9 || code === 10 || code === 13
|
||||
|
||||
if (isCJK) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
tokens.push(char)
|
||||
} else if (isWhitespace) {
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart, bufferEnd))
|
||||
bufferStart = -1
|
||||
}
|
||||
} else {
|
||||
if (bufferStart === -1) bufferStart = i
|
||||
bufferEnd = i + char.length
|
||||
}
|
||||
|
||||
i += char.length
|
||||
}
|
||||
|
||||
if (bufferStart !== -1) {
|
||||
tokens.push(lower.slice(bufferStart))
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
let index = new FlexSearch.Document<Item>({
|
||||
encode: encoder,
|
||||
document: {
|
||||
id: "id",
|
||||
tag: "tags",
|
||||
index: [
|
||||
{
|
||||
field: "title",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "content",
|
||||
tokenize: "forward",
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
tokenize: "forward",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const p = new DOMParser()
|
||||
const fetchContentCache: Map<FullSlug, Element[]> = new Map()
|
||||
const contextWindowWords = 30
|
||||
const numSearchResults = 8
|
||||
const numTagResults = 5
|
||||
|
||||
const tokenizeTerm = (term: string) => {
|
||||
const tokens = term.split(/\s+/).filter((t) => t.trim() !== "")
|
||||
const tokenLen = tokens.length
|
||||
if (tokenLen > 1) {
|
||||
for (let i = 1; i < tokenLen; i++) {
|
||||
tokens.push(tokens.slice(0, i + 1).join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.sort((a, b) => b.length - a.length) // always highlight longest terms first
|
||||
}
|
||||
|
||||
function highlight(searchTerm: string, text: string, trim?: boolean) {
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
let tokenizedText = text.split(/\s+/).filter((t) => t !== "")
|
||||
|
||||
let startIndex = 0
|
||||
let endIndex = tokenizedText.length - 1
|
||||
if (trim) {
|
||||
const includesCheck = (tok: string) =>
|
||||
tokenizedTerms.some((term) => tok.toLowerCase().startsWith(term.toLowerCase()))
|
||||
const occurrencesIndices = tokenizedText.map(includesCheck)
|
||||
|
||||
let bestSum = 0
|
||||
let bestIndex = 0
|
||||
for (let i = 0; i < Math.max(tokenizedText.length - contextWindowWords, 0); i++) {
|
||||
const window = occurrencesIndices.slice(i, i + contextWindowWords)
|
||||
const windowSum = window.reduce((total, cur) => total + (cur ? 1 : 0), 0)
|
||||
if (windowSum >= bestSum) {
|
||||
bestSum = windowSum
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
startIndex = Math.max(bestIndex - contextWindowWords, 0)
|
||||
endIndex = Math.min(startIndex + 2 * contextWindowWords, tokenizedText.length - 1)
|
||||
tokenizedText = tokenizedText.slice(startIndex, endIndex)
|
||||
}
|
||||
|
||||
const slice = tokenizedText
|
||||
.map((tok) => {
|
||||
// see if this tok is prefixed by any search terms
|
||||
for (const searchTok of tokenizedTerms) {
|
||||
if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
|
||||
const regex = new RegExp(searchTok.toLowerCase(), "gi")
|
||||
return tok.replace(regex, `<span class="highlight">$&</span>`)
|
||||
}
|
||||
}
|
||||
return tok
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return `${startIndex === 0 ? "" : "..."}${slice}${
|
||||
endIndex === tokenizedText.length - 1 ? "" : "..."
|
||||
}`
|
||||
}
|
||||
|
||||
function highlightHTML(searchTerm: string, el: HTMLElement) {
|
||||
const p = new DOMParser()
|
||||
const tokenizedTerms = tokenizeTerm(searchTerm)
|
||||
const html = p.parseFromString(el.innerHTML, "text/html")
|
||||
|
||||
const createHighlightSpan = (text: string) => {
|
||||
const span = document.createElement("span")
|
||||
span.className = "highlight"
|
||||
span.textContent = text
|
||||
return span
|
||||
}
|
||||
|
||||
const highlightTextNodes = (node: Node, term: string) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const nodeText = node.nodeValue ?? ""
|
||||
const regex = new RegExp(term.toLowerCase(), "gi")
|
||||
const matches = nodeText.match(regex)
|
||||
if (!matches || matches.length === 0) return
|
||||
const spanContainer = document.createElement("span")
|
||||
let lastIndex = 0
|
||||
for (const match of matches) {
|
||||
const matchIndex = nodeText.indexOf(match, lastIndex)
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex, matchIndex)))
|
||||
spanContainer.appendChild(createHighlightSpan(match))
|
||||
lastIndex = matchIndex + match.length
|
||||
}
|
||||
spanContainer.appendChild(document.createTextNode(nodeText.slice(lastIndex)))
|
||||
node.parentNode?.replaceChild(spanContainer, node)
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if ((node as HTMLElement).classList.contains("highlight")) return
|
||||
Array.from(node.childNodes).forEach((child) => highlightTextNodes(child, term))
|
||||
}
|
||||
}
|
||||
|
||||
for (const term of tokenizedTerms) {
|
||||
highlightTextNodes(html.body, term)
|
||||
}
|
||||
|
||||
return html.body
|
||||
}
|
||||
|
||||
async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: ContentIndex) {
|
||||
const container = searchElement.querySelector(".search-container") as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const sidebar = container.closest(".sidebar") as HTMLElement | null
|
||||
|
||||
const searchButton = searchElement.querySelector(".search-button") as HTMLButtonElement
|
||||
if (!searchButton) return
|
||||
|
||||
const searchBar = searchElement.querySelector(".search-bar") as HTMLInputElement
|
||||
if (!searchBar) return
|
||||
|
||||
const searchLayout = searchElement.querySelector(".search-layout") as HTMLElement
|
||||
if (!searchLayout) return
|
||||
|
||||
const idDataMap = Object.keys(data) as FullSlug[]
|
||||
const appendLayout = (el: HTMLElement) => {
|
||||
searchLayout.appendChild(el)
|
||||
}
|
||||
|
||||
const enablePreview = searchLayout.dataset.preview === "true"
|
||||
let preview: HTMLDivElement | undefined = undefined
|
||||
let previewInner: HTMLDivElement | undefined = undefined
|
||||
const results = document.createElement("div")
|
||||
results.className = "results-container"
|
||||
appendLayout(results)
|
||||
|
||||
if (enablePreview) {
|
||||
preview = document.createElement("div")
|
||||
preview.className = "preview-container"
|
||||
appendLayout(preview)
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
container.classList.remove("active")
|
||||
searchBar.value = "" // clear the input when we dismiss the search
|
||||
if (sidebar) sidebar.style.zIndex = ""
|
||||
removeAllChildren(results)
|
||||
if (preview) {
|
||||
removeAllChildren(preview)
|
||||
}
|
||||
searchLayout.classList.remove("display-results")
|
||||
searchType = "basic" // reset search type after closing
|
||||
searchButton.focus()
|
||||
}
|
||||
|
||||
function showSearch(searchTypeNew: SearchType) {
|
||||
searchType = searchTypeNew
|
||||
if (sidebar) sidebar.style.zIndex = "1"
|
||||
container.classList.add("active")
|
||||
searchBar.focus()
|
||||
}
|
||||
|
||||
let currentHover: HTMLInputElement | null = null
|
||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
||||
if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("basic")
|
||||
return
|
||||
} else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
// Hotkey to open tag search
|
||||
e.preventDefault()
|
||||
const searchBarOpen = container.classList.contains("active")
|
||||
searchBarOpen ? hideSearch() : showSearch("tags")
|
||||
|
||||
// add "#" prefix for tag search
|
||||
searchBar.value = "#"
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHover) {
|
||||
currentHover.classList.remove("focus")
|
||||
}
|
||||
|
||||
// If search is active, then we will render the first result and display accordingly
|
||||
if (!container.classList.contains("active")) return
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
// If result has focus, navigate to that one, otherwise pick first result
|
||||
if (results.contains(document.activeElement)) {
|
||||
const active = document.activeElement as HTMLInputElement
|
||||
if (active.classList.contains("no-match")) return
|
||||
await displayPreview(active)
|
||||
active.click()
|
||||
} else {
|
||||
const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null
|
||||
if (!anchor || anchor.classList.contains("no-match")) return
|
||||
await displayPreview(anchor)
|
||||
anchor.click()
|
||||
}
|
||||
} else if (e.key === "ArrowUp" || (e.shiftKey && e.key === "Tab")) {
|
||||
e.preventDefault()
|
||||
if (results.contains(document.activeElement)) {
|
||||
// If an element in results-container already has focus, focus previous one
|
||||
const currentResult = currentHover
|
||||
? currentHover
|
||||
: (document.activeElement as HTMLInputElement | null)
|
||||
const prevResult = currentResult?.previousElementSibling as HTMLInputElement | null
|
||||
currentResult?.classList.remove("focus")
|
||||
prevResult?.focus()
|
||||
if (prevResult) currentHover = prevResult
|
||||
await displayPreview(prevResult)
|
||||
}
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
// The results should already been focused, so we need to find the next one.
|
||||
// The activeElement is the search bar, so we need to find the first result and focus it.
|
||||
if (document.activeElement === searchBar || currentHover !== null) {
|
||||
const firstResult = currentHover
|
||||
? currentHover
|
||||
: (document.getElementsByClassName("result-card")[0] as HTMLInputElement | null)
|
||||
const secondResult = firstResult?.nextElementSibling as HTMLInputElement | null
|
||||
firstResult?.classList.remove("focus")
|
||||
secondResult?.focus()
|
||||
if (secondResult) currentHover = secondResult
|
||||
await displayPreview(secondResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatForDisplay = (term: string, id: number) => {
|
||||
const slug = idDataMap[id]
|
||||
return {
|
||||
id,
|
||||
slug,
|
||||
title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""),
|
||||
content: highlight(term, data[slug].content ?? "", true),
|
||||
tags: highlightTags(term.substring(1), data[slug].tags),
|
||||
}
|
||||
}
|
||||
|
||||
function highlightTags(term: string, tags: string[]) {
|
||||
if (!tags || searchType !== "tags") {
|
||||
return []
|
||||
}
|
||||
|
||||
return tags
|
||||
.map((tag) => {
|
||||
if (tag.toLowerCase().includes(term.toLowerCase())) {
|
||||
return `<li><p class="match-tag">#${tag}</p></li>`
|
||||
} else {
|
||||
return `<li><p>#${tag}</p></li>`
|
||||
}
|
||||
})
|
||||
.slice(0, numTagResults)
|
||||
}
|
||||
|
||||
function resolveUrl(slug: FullSlug): URL {
|
||||
return new URL(resolveRelative(currentSlug, slug), location.toString())
|
||||
}
|
||||
|
||||
const resultToHTML = ({ slug, title, content, tags }: Item) => {
|
||||
const htmlTags = tags.length > 0 ? `<ul class="tags">${tags.join("")}</ul>` : ``
|
||||
const itemTile = document.createElement("a")
|
||||
itemTile.classList.add("result-card")
|
||||
itemTile.id = slug
|
||||
itemTile.href = resolveUrl(slug).toString()
|
||||
itemTile.innerHTML = `
|
||||
<h3 class="card-title">${title}</h3>
|
||||
${htmlTags}
|
||||
<p class="card-description">${content}</p>
|
||||
`
|
||||
itemTile.addEventListener("click", (event) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
})
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
hideSearch()
|
||||
}
|
||||
|
||||
async function onMouseEnter(ev: MouseEvent) {
|
||||
if (!ev.target) return
|
||||
const target = ev.target as HTMLInputElement
|
||||
await displayPreview(target)
|
||||
}
|
||||
|
||||
itemTile.addEventListener("mouseenter", onMouseEnter)
|
||||
window.addCleanup(() => itemTile.removeEventListener("mouseenter", onMouseEnter))
|
||||
itemTile.addEventListener("click", handler)
|
||||
window.addCleanup(() => itemTile.removeEventListener("click", handler))
|
||||
|
||||
return itemTile
|
||||
}
|
||||
|
||||
async function displayResults(finalResults: Item[]) {
|
||||
removeAllChildren(results)
|
||||
if (finalResults.length === 0) {
|
||||
results.innerHTML = `<a class="result-card no-match">
|
||||
<h3>No results.</h3>
|
||||
<p>Try another search term?</p>
|
||||
</a>`
|
||||
} else {
|
||||
results.append(...finalResults.map(resultToHTML))
|
||||
}
|
||||
|
||||
if (finalResults.length === 0 && preview) {
|
||||
// no results, clear previous preview
|
||||
removeAllChildren(preview)
|
||||
} else {
|
||||
// focus on first result, then also dispatch preview immediately
|
||||
const firstChild = results.firstElementChild as HTMLElement
|
||||
firstChild.classList.add("focus")
|
||||
currentHover = firstChild as HTMLInputElement
|
||||
await displayPreview(firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContent(slug: FullSlug): Promise<Element[]> {
|
||||
if (fetchContentCache.has(slug)) {
|
||||
return fetchContentCache.get(slug) as Element[]
|
||||
}
|
||||
|
||||
const targetUrl = resolveUrl(slug).toString()
|
||||
const contents = await fetch(targetUrl)
|
||||
.then((res) => res.text())
|
||||
.then((contents) => {
|
||||
if (contents === undefined) {
|
||||
throw new Error(`Could not fetch ${targetUrl}`)
|
||||
}
|
||||
const html = p.parseFromString(contents ?? "", "text/html")
|
||||
normalizeRelativeURLs(html, targetUrl)
|
||||
return [...html.getElementsByClassName("popover-hint")]
|
||||
})
|
||||
|
||||
fetchContentCache.set(slug, contents)
|
||||
return contents
|
||||
}
|
||||
|
||||
async function displayPreview(el: HTMLElement | null) {
|
||||
if (!searchLayout || !enablePreview || !el || !preview) return
|
||||
const slug = el.id as FullSlug
|
||||
const innerDiv = await fetchContent(slug).then((contents) =>
|
||||
contents.flatMap((el) => [...highlightHTML(currentSearchTerm, el as HTMLElement).children]),
|
||||
)
|
||||
previewInner = document.createElement("div")
|
||||
previewInner.classList.add("preview-inner")
|
||||
previewInner.append(...innerDiv)
|
||||
preview.replaceChildren(previewInner)
|
||||
|
||||
// scroll to longest
|
||||
const highlights = [...preview.getElementsByClassName("highlight")].sort(
|
||||
(a, b) => b.innerHTML.length - a.innerHTML.length,
|
||||
)
|
||||
highlights[0]?.scrollIntoView({ block: "start" })
|
||||
}
|
||||
|
||||
async function onType(e: HTMLElementEventMap["input"]) {
|
||||
if (!searchLayout || !index) return
|
||||
currentSearchTerm = (e.target as HTMLInputElement).value
|
||||
searchLayout.classList.toggle("display-results", currentSearchTerm !== "")
|
||||
searchType = currentSearchTerm.startsWith("#") ? "tags" : "basic"
|
||||
|
||||
let searchResults: DefaultDocumentSearchResults<Item>
|
||||
if (searchType === "tags") {
|
||||
currentSearchTerm = currentSearchTerm.substring(1).trim()
|
||||
const separatorIndex = currentSearchTerm.indexOf(" ")
|
||||
if (separatorIndex != -1) {
|
||||
// search by title and content index and then filter by tag (implemented in flexsearch)
|
||||
const tag = currentSearchTerm.substring(0, separatorIndex)
|
||||
const query = currentSearchTerm.substring(separatorIndex + 1).trim()
|
||||
searchResults = await index.searchAsync({
|
||||
query: query,
|
||||
// return at least 10000 documents, so it is enough to filter them by tag (implemented in flexsearch)
|
||||
limit: Math.max(numSearchResults, 10000),
|
||||
index: ["title", "content"],
|
||||
tag: { tags: tag },
|
||||
})
|
||||
for (let searchResult of searchResults) {
|
||||
searchResult.result = searchResult.result.slice(0, numSearchResults)
|
||||
}
|
||||
// set search type to basic and remove tag from term for proper highlightning and scroll
|
||||
searchType = "basic"
|
||||
currentSearchTerm = query
|
||||
} else {
|
||||
// default search by tags index
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["tags"],
|
||||
})
|
||||
}
|
||||
} else if (searchType === "basic") {
|
||||
searchResults = await index.searchAsync({
|
||||
query: currentSearchTerm,
|
||||
limit: numSearchResults,
|
||||
index: ["title", "content"],
|
||||
})
|
||||
}
|
||||
|
||||
const getByField = (field: string): number[] => {
|
||||
const results = searchResults.filter((x) => x.field === field)
|
||||
return results.length === 0 ? [] : ([...results[0].result] as number[])
|
||||
}
|
||||
|
||||
// order titles ahead of content
|
||||
const allIds: Set<number> = new Set([
|
||||
...getByField("title"),
|
||||
...getByField("content"),
|
||||
...getByField("tags"),
|
||||
])
|
||||
const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
|
||||
await displayResults(finalResults)
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", shortcutHandler)
|
||||
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
|
||||
searchButton.addEventListener("click", () => showSearch("basic"))
|
||||
window.addCleanup(() => searchButton.removeEventListener("click", () => showSearch("basic")))
|
||||
searchBar.addEventListener("input", onType)
|
||||
window.addCleanup(() => searchBar.removeEventListener("input", onType))
|
||||
|
||||
registerEscapeHandler(container, hideSearch)
|
||||
await fillDocument(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills flexsearch document with data
|
||||
* @param index index to fill
|
||||
* @param data data to fill index with
|
||||
*/
|
||||
let indexPopulated = false
|
||||
async function fillDocument(data: ContentIndex) {
|
||||
if (indexPopulated) return
|
||||
let id = 0
|
||||
const promises: Array<Promise<unknown>> = []
|
||||
for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
|
||||
promises.push(
|
||||
index.addAsync(id++, {
|
||||
id,
|
||||
slug: slug as FullSlug,
|
||||
title: fileData.title,
|
||||
content: fileData.content,
|
||||
tags: fileData.tags,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
indexPopulated = true
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
||||
const currentSlug = e.detail.url
|
||||
const data = await fetchData
|
||||
const searchElement = document.getElementsByClassName("search")
|
||||
for (const element of searchElement) {
|
||||
await setupSearch(element, currentSlug, data)
|
||||
}
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const slug = entry.target.id
|
||||
const tocEntryElements = document.querySelectorAll(`a[data-for="${slug}"]`)
|
||||
const windowHeight = entry.rootBounds?.height
|
||||
if (windowHeight && tocEntryElements.length > 0) {
|
||||
if (entry.boundingClientRect.y < windowHeight) {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.add("in-view"))
|
||||
} else {
|
||||
tocEntryElements.forEach((tocEntryElement) => tocEntryElement.classList.remove("in-view"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleToc(this: HTMLElement) {
|
||||
this.classList.toggle("collapsed")
|
||||
this.setAttribute(
|
||||
"aria-expanded",
|
||||
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
|
||||
)
|
||||
const content = this.nextElementSibling as HTMLElement | undefined
|
||||
if (!content) return
|
||||
content.classList.toggle("collapsed")
|
||||
}
|
||||
|
||||
function setupToc() {
|
||||
for (const toc of document.getElementsByClassName("toc")) {
|
||||
const button = toc.querySelector(".toc-header")
|
||||
const content = toc.querySelector(".toc-content")
|
||||
if (!button || !content) return
|
||||
button.addEventListener("click", toggleToc)
|
||||
window.addCleanup(() => button.removeEventListener("click", toggleToc))
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
setupToc()
|
||||
|
||||
// update toc entry highlighting
|
||||
observer.disconnect()
|
||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||
headers.forEach((header) => observer.observe(header))
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.backlinks {
|
||||
flex-direction: column;
|
||||
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > ul.overflow {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
|
||||
& > li {
|
||||
& > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.breadcrumb-container {
|
||||
margin: 0;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb-element {
|
||||
p {
|
||||
margin: 0;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0;
|
||||
line-height: normal;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
.clipboard-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .clipboard-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.content-meta {
|
||||
margin-top: 0;
|
||||
color: var(--darkgray);
|
||||
|
||||
&[show-comma="true"] {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
.darkmode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[saved-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[saved-theme="dark"] .darkmode {
|
||||
& > .dayIcon {
|
||||
display: none;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
:root .darkmode {
|
||||
& > .dayIcon {
|
||||
display: inline;
|
||||
}
|
||||
& > .nightIcon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
@media all and ($mobile) {
|
||||
.page > #quartz-body {
|
||||
// Shift page position when toggling Explorer on mobile.
|
||||
& > :not(.sidebar.left:has(.explorer)) {
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
|
||||
transform: translateX(100dvw);
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
// Sticky top bar (stays in place when scrolling down on mobile).
|
||||
.sidebar.left:has(.explorer) {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
background-color: var(--light);
|
||||
padding: 1rem 0 1rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hide-until-loaded ~ .explorer-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
|
||||
min-height: 1.2rem;
|
||||
flex: 0 1 auto;
|
||||
|
||||
&.collapsed {
|
||||
flex: 0 1 1.2rem;
|
||||
|
||||
& .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
order: -1;
|
||||
height: initial;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
button.mobile-explorer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
button.mobile-explorer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
button.desktop-explorer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.desktop-only {
|
||||
@media all and not ($mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
pointer-events: all;
|
||||
transition: transform 0.35s ease;
|
||||
|
||||
& > polyline {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.mobile-explorer,
|
||||
button.desktop-explorer {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h2 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&.explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
& li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.75;
|
||||
pointer-events: all;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-outer {
|
||||
visibility: collapse;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition-property: grid-template-rows, visibility;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.folder-outer.open {
|
||||
visibility: visible;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.folder-outer > ul {
|
||||
overflow: hidden;
|
||||
margin-left: 6px;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 1px solid var(--lightgray);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-container {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
& div > a {
|
||||
color: var(--secondary);
|
||||
font-family: var(--headerFont);
|
||||
font-size: 0.95rem;
|
||||
font-weight: $semiBoldWeight;
|
||||
line-height: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& div > a:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
& div > button {
|
||||
color: var(--dark);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--headerFont);
|
||||
|
||||
& span {
|
||||
font-size: 0.95rem;
|
||||
display: inline-block;
|
||||
color: var(--secondary);
|
||||
font-weight: $semiBoldWeight;
|
||||
margin: 0;
|
||||
line-height: 1.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
margin-right: 5px;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
backface-visibility: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
li:has(> .folder-outer:not(.open)) > .folder-container > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.folder-icon:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.explorer {
|
||||
@media all and ($mobile) {
|
||||
&.collapsed {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(-100vw);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.collapsed) {
|
||||
flex: 0 0 34px;
|
||||
|
||||
& > .explorer-content {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer-content {
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-top: 0;
|
||||
background-color: var(--light);
|
||||
max-width: 100vw;
|
||||
width: 100vw;
|
||||
transform: translateX(-100vw);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
visibility 200ms ease;
|
||||
overflow: hidden;
|
||||
padding: 4rem 0 2rem 0;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mobile-explorer {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
z-index: 101;
|
||||
|
||||
.lucide-menu {
|
||||
stroke: var(--darkgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-no-scroll {
|
||||
@media all and ($mobile) {
|
||||
.explorer-content > .explorer-ul {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
footer {
|
||||
text-align: left;
|
||||
margin-bottom: 4rem;
|
||||
opacity: 0.7;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.graph {
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > .graph-outer {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--lightgray);
|
||||
box-sizing: border-box;
|
||||
height: 250px;
|
||||
margin: 0.5em 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
& > .global-graph-icon {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dark);
|
||||
opacity: 0.5;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
padding: 0.2rem;
|
||||
margin: 0.3rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.5s ease;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--lightgray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .global-graph-outer {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .global-graph-container {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
details.toc {
|
||||
& summary {
|
||||
cursor: pointer;
|
||||
|
||||
&::marker {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
& > * {
|
||||
padding-left: 0.25rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
margin: 0.5rem 1.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@for $i from 1 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
ul.section-ul {
|
||||
list-style: none;
|
||||
margin-top: 2em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li.section-li {
|
||||
margin-bottom: 1em;
|
||||
|
||||
& > .section {
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(8em) 3fr 1fr;
|
||||
|
||||
@media all and ($mobile) {
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
& .meta {
|
||||
margin: 0 1em 0 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modifications in popover context
|
||||
.popover .section {
|
||||
grid-template-columns: fit-content(8em) 1fr !important;
|
||||
|
||||
& > .tags {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
.expand-button {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
float: right;
|
||||
padding: 0.4rem;
|
||||
margin: 0.3rem;
|
||||
right: 0; // NOTE: right will be set in mermaid.inline.ts
|
||||
color: var(--gray);
|
||||
border-color: var(--dark);
|
||||
background-color: var(--light);
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
|
||||
& > svg {
|
||||
fill: var(--light);
|
||||
filter: contrast(0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
&:hover > .expand-button {
|
||||
opacity: 1;
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
#mermaid-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > #mermaid-space {
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
overflow: hidden;
|
||||
|
||||
& > .mermaid-content {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 0.1s ease;
|
||||
overflow: visible;
|
||||
min-height: 200px;
|
||||
min-width: 200px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > .mermaid-controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--light);
|
||||
border: 1px solid var(--lightgray);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2;
|
||||
|
||||
.mermaid-control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--lightgray);
|
||||
background: var(--light);
|
||||
color: var(--dark);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-family: var(--bodyFont);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
// Style the reset button differently
|
||||
&:nth-child(2) {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
.readermode {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-align: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
fill: var(--darkgray);
|
||||
stroke: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
:root[reader-mode="on"] {
|
||||
& .sidebar.left,
|
||||
& .sidebar.right {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.recent-notes {
|
||||
& > h3 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
& > ul.recent-ul {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
padding-left: 0;
|
||||
|
||||
& > li {
|
||||
margin: 1rem 0;
|
||||
.section > .desc > h3 > a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.section > .meta {
|
||||
margin: 0 0 0.5rem 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.search {
|
||||
min-width: fit-content;
|
||||
max-width: 14rem;
|
||||
@media all and ($mobile) {
|
||||
flex-grow: 0.3;
|
||||
}
|
||||
|
||||
& > .search-button {
|
||||
background-color: transparent;
|
||||
border: 1px var(--lightgray) solid;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
height: 2rem;
|
||||
padding: 0 1rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
||||
& > p {
|
||||
display: inline;
|
||||
color: var(--gray);
|
||||
text-wrap: unset;
|
||||
}
|
||||
|
||||
& svg {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0 0.5rem;
|
||||
|
||||
.search-path {
|
||||
stroke: var(--darkgray);
|
||||
stroke-width: 1.5px;
|
||||
transition: stroke 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-container {
|
||||
position: fixed;
|
||||
contain: layout;
|
||||
z-index: 999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
&.active {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& > .search-space {
|
||||
width: 65%;
|
||||
margin-top: 12vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media all and not ($desktop) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--light);
|
||||
box-shadow:
|
||||
0 14px 50px rgba(27, 33, 48, 0.12),
|
||||
0 10px 30px rgba(27, 33, 48, 0.16);
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
& > input {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em 1em;
|
||||
font-family: var(--bodyFont);
|
||||
color: var(--dark);
|
||||
font-size: 1.1em;
|
||||
border: 1px solid var(--lightgray);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .search-layout {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--lightgray);
|
||||
flex: 0 0 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.display-results {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
flex: 0 0 min(30%, 450px);
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
&[data-preview] {
|
||||
& .result-card > p.preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > div {
|
||||
&:first-child {
|
||||
border-right: 1px solid var(--lightgray);
|
||||
border-top-right-radius: unset;
|
||||
border-bottom-right-radius: unset;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-left-radius: unset;
|
||||
border-bottom-left-radius: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
height: calc(75vh - 12vh);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media all and ($mobile) {
|
||||
flex-direction: column;
|
||||
|
||||
& > .preview-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[data-preview] > .results-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .highlight {
|
||||
background: color-mix(in srgb, var(--tertiary) 60%, rgba(255, 255, 255, 0));
|
||||
border-radius: 5px;
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
& > .preview-container {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
color: var(--dark);
|
||||
line-height: 1.5em;
|
||||
font-weight: $normalWeight;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
|
||||
& .preview-inner {
|
||||
margin: 0 auto;
|
||||
width: min($pageWidth, 100%);
|
||||
}
|
||||
|
||||
a[role="anchor"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
& > .results-container {
|
||||
overflow-y: auto;
|
||||
|
||||
& .result-card {
|
||||
overflow: hidden;
|
||||
padding: 1em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
border-bottom: 1px solid var(--lightgray);
|
||||
width: 100%;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
// normalize card props
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-weight: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus {
|
||||
background: var(--lightgray);
|
||||
}
|
||||
|
||||
& > h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media all and not ($mobile) {
|
||||
& > p.card-description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul.tags {
|
||||
margin-top: 0.45rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& > ul > li > p {
|
||||
border-radius: 8px;
|
||||
background-color: var(--highlight);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
font-weight: $boldWeight;
|
||||
color: var(--secondary);
|
||||
|
||||
&.match-tag {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@use "../../styles/variables.scss" as *;
|
||||
|
||||
.toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
min-height: 1.4rem;
|
||||
flex: 0 0.5 auto;
|
||||
&:has(button.toc-header.collapsed) {
|
||||
flex: 0 1 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
button.toc-header {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& h3 {
|
||||
font-size: 1rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .fold {
|
||||
margin-left: 0.5rem;
|
||||
transition: transform 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.collapsed .fold {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
ul.toc-content.overflow {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
max-height: calc(100% - 2rem);
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
|
||||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
0.5s ease opacity,
|
||||
0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 6 {
|
||||
& .depth-#{$i} {
|
||||
padding-left: calc(1rem * #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentType, JSX } from "preact"
|
||||
import { JSX } from "preact"
|
||||
import { StaticResources, StringResource } from "../util/resources"
|
||||
import { QuartzPluginData } from "../plugins/vfile"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
@@ -18,7 +18,8 @@ export type QuartzComponentProps = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
|
||||
export type QuartzComponent = ((props: QuartzComponentProps) => any) & {
|
||||
displayName?: string
|
||||
css?: StringResource
|
||||
beforeDOMLoaded?: StringResource
|
||||
afterDOMLoaded?: StringResource
|
||||
|
||||
51
quartz/plugins/config.ts
Normal file
51
quartz/plugins/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
QuartzTransformerPluginInstance,
|
||||
QuartzFilterPluginInstance,
|
||||
QuartzEmitterPluginInstance,
|
||||
PageTypePluginEntry,
|
||||
} from "./types"
|
||||
import { LoadedPlugin } from "./loader/types"
|
||||
|
||||
export interface PluginConfiguration {
|
||||
transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[]
|
||||
filters: (QuartzFilterPluginInstance | LoadedPlugin)[]
|
||||
emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[]
|
||||
pageTypes?: (PageTypePluginEntry | LoadedPlugin)[]
|
||||
}
|
||||
|
||||
export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin {
|
||||
return (
|
||||
typeof plugin === "object" &&
|
||||
plugin !== null &&
|
||||
"plugin" in plugin &&
|
||||
"manifest" in plugin &&
|
||||
"type" in plugin &&
|
||||
typeof (plugin as LoadedPlugin).plugin === "function"
|
||||
)
|
||||
}
|
||||
|
||||
export function getPluginInstance<T extends object | undefined>(
|
||||
plugin:
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| PageTypePluginEntry
|
||||
| LoadedPlugin,
|
||||
options?: T,
|
||||
):
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| PageTypePluginEntry {
|
||||
if (isLoadedPlugin(plugin)) {
|
||||
const factory = plugin.plugin as (
|
||||
opts?: T,
|
||||
) =>
|
||||
| QuartzTransformerPluginInstance
|
||||
| QuartzFilterPluginInstance
|
||||
| QuartzEmitterPluginInstance
|
||||
| PageTypePluginEntry
|
||||
return factory(options)
|
||||
}
|
||||
return plugin
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FullSlug } from "../../util/path"
|
||||
import { sharedPageComponents } from "../../../quartz.layout"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export const NotFoundPage: QuartzEmitterPlugin = () => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
pageBody: NotFound(),
|
||||
beforeBody: [],
|
||||
left: [],
|
||||
right: [],
|
||||
}
|
||||
|
||||
const { head: Head, pageBody, footer: Footer } = opts
|
||||
const Body = BodyConstructor()
|
||||
|
||||
return {
|
||||
name: "404Page",
|
||||
getQuartzComponents() {
|
||||
return [Head, Body, pageBody, Footer]
|
||||
},
|
||||
async *emit(ctx, _content, resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const slug = "404" as FullSlug
|
||||
|
||||
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
|
||||
const path = url.pathname as FullSlug
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: notFound,
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
const externalResources = pageResources(path, resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: vfile.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles: [],
|
||||
}
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, opts, externalResources),
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
},
|
||||
async *partialEmit() {},
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { VFile } from "vfile"
|
||||
import path from "path"
|
||||
|
||||
async function* processFile(ctx: BuildCtx, file: VFile) {
|
||||
const ogSlug = simplifySlug(file.data.slug!)
|
||||
|
||||
for (const aliasTarget of file.data.aliases ?? []) {
|
||||
const aliasTargetSlug = (
|
||||
isRelativeURL(aliasTarget)
|
||||
? path.normalize(path.join(ogSlug, "..", aliasTarget))
|
||||
: aliasTarget
|
||||
) as FullSlug
|
||||
|
||||
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug)
|
||||
yield write({
|
||||
ctx,
|
||||
content: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>${ogSlug}</title>
|
||||
<link rel="canonical" href="${redirUrl}">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="0; url=${redirUrl}">
|
||||
</head>
|
||||
</html>
|
||||
`,
|
||||
slug: aliasTargetSlug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const AliasRedirects: QuartzEmitterPlugin = () => ({
|
||||
name: "AliasRedirects",
|
||||
async *emit(ctx, content) {
|
||||
for (const [_tree, file] of content) {
|
||||
yield* processFile(ctx, file)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
// add new ones if this file still exists
|
||||
yield* processFile(ctx, changeEvent.file)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,14 +1,30 @@
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { glob } from "../../util/glob"
|
||||
import { Argv } from "../../util/ctx"
|
||||
import { Argv, BuildCtx } from "../../util/ctx"
|
||||
import { QuartzConfig } from "../../cfg"
|
||||
|
||||
const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
|
||||
// glob all non MD files in content folder and copy it over
|
||||
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
|
||||
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
|
||||
}
|
||||
|
||||
const filesToCopy = async (argv: Argv, cfg: QuartzConfig, excludeExtensions: Set<string>) => {
|
||||
const excludePatterns = ["**/*.md", ...cfg.configuration.ignorePatterns]
|
||||
for (const ext of excludeExtensions) {
|
||||
excludePatterns.push(`**/*${ext}`)
|
||||
}
|
||||
return await glob("**", argv.directory, excludePatterns)
|
||||
}
|
||||
|
||||
const copyFile = async (argv: Argv, fp: FilePath) => {
|
||||
@@ -17,7 +33,6 @@ const copyFile = async (argv: Argv, fp: FilePath) => {
|
||||
const name = slugifyFilePath(fp)
|
||||
const dest = joinSegments(argv.output, name) as FilePath
|
||||
|
||||
// ensure dir exists
|
||||
const dir = path.dirname(dest) as FilePath
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
@@ -28,16 +43,18 @@ const copyFile = async (argv: Argv, fp: FilePath) => {
|
||||
export const Assets: QuartzEmitterPlugin = () => {
|
||||
return {
|
||||
name: "Assets",
|
||||
async *emit({ argv, cfg }) {
|
||||
const fps = await filesToCopy(argv, cfg)
|
||||
async *emit(ctx) {
|
||||
const excludeExtensions = getPageTypeExtensions(ctx)
|
||||
const fps = await filesToCopy(ctx.argv, ctx.cfg, excludeExtensions)
|
||||
for (const fp of fps) {
|
||||
yield copyFile(argv, fp)
|
||||
yield copyFile(ctx.argv, fp)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
||||
const excludeExtensions = getPageTypeExtensions(ctx)
|
||||
for (const changeEvent of changeEvents) {
|
||||
const ext = path.extname(changeEvent.path)
|
||||
if (ext === ".md") continue
|
||||
if (ext === ".md" || excludeExtensions.has(ext)) continue
|
||||
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
yield copyFile(ctx.argv, changeEvent.path)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { write } from "./helpers"
|
||||
import { styleText } from "util"
|
||||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export function extractDomainFromBaseUrl(baseUrl: string) {
|
||||
const url = new URL(`https://${baseUrl}`)
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
export const CNAME: QuartzEmitterPlugin = () => ({
|
||||
name: "CNAME",
|
||||
async emit(ctx) {
|
||||
if (!ctx.cfg.configuration.baseUrl) {
|
||||
console.warn(
|
||||
styleText("yellow", "CNAME emitter requires `baseUrl` to be set in your configuration"),
|
||||
)
|
||||
return []
|
||||
}
|
||||
const content = extractDomainFromBaseUrl(ctx.cfg.configuration.baseUrl)
|
||||
if (!content) {
|
||||
return []
|
||||
}
|
||||
|
||||
const path = await write({
|
||||
ctx,
|
||||
content,
|
||||
slug: "CNAME" as FullSlug,
|
||||
ext: "",
|
||||
})
|
||||
return [path]
|
||||
},
|
||||
async *partialEmit() {},
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import styles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { componentRegistry } from "../../components/registry"
|
||||
import {
|
||||
googleFontHref,
|
||||
googleFontSubsetHref,
|
||||
@@ -27,6 +28,7 @@ type ComponentResources = {
|
||||
|
||||
function getComponentResources(ctx: BuildCtx): ComponentResources {
|
||||
const allComponents: Set<QuartzComponent> = new Set()
|
||||
|
||||
for (const emitter of ctx.cfg.plugins.emitters) {
|
||||
const components = emitter.getQuartzComponents?.(ctx) ?? []
|
||||
for (const component of components) {
|
||||
@@ -34,6 +36,10 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of componentRegistry.getAllComponents()) {
|
||||
allComponents.add(component)
|
||||
}
|
||||
|
||||
const componentResources = {
|
||||
css: new Set<string>(),
|
||||
beforeDOMLoaded: new Set<string>(),
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Root } from "hast"
|
||||
import { GlobalConfiguration } from "../../cfg"
|
||||
import { getDate } from "../../components/Date"
|
||||
import { escapeHTML } from "../../util/escape"
|
||||
import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
|
||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
|
||||
export type ContentDetails = {
|
||||
slug: FullSlug
|
||||
filePath: FilePath
|
||||
title: string
|
||||
links: SimpleSlug[]
|
||||
tags: string[]
|
||||
content: string
|
||||
richContent?: string
|
||||
date?: Date
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Options {
|
||||
enableSiteMap: boolean
|
||||
enableRSS: boolean
|
||||
rssLimit?: number
|
||||
rssFullHtml: boolean
|
||||
rssSlug: string
|
||||
includeEmptyFiles: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
enableSiteMap: true,
|
||||
enableRSS: true,
|
||||
rssLimit: 10,
|
||||
rssFullHtml: false,
|
||||
rssSlug: "index",
|
||||
includeEmptyFiles: true,
|
||||
}
|
||||
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
|
||||
</url>`
|
||||
const urls = Array.from(idx)
|
||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||
.join("")
|
||||
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
|
||||
}
|
||||
|
||||
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
|
||||
<title>${escapeHTML(content.title)}</title>
|
||||
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||
<description><![CDATA[ ${content.richContent ?? content.description} ]]></description>
|
||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
||||
</item>`
|
||||
|
||||
const items = Array.from(idx)
|
||||
.sort(([_, f1], [__, f2]) => {
|
||||
if (f1.date && f2.date) {
|
||||
return f2.date.getTime() - f1.date.getTime()
|
||||
} else if (f1.date && !f2.date) {
|
||||
return -1
|
||||
} else if (!f1.date && f2.date) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return f1.title.localeCompare(f2.title)
|
||||
})
|
||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||
.slice(0, limit ?? idx.size)
|
||||
.join("")
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<link>https://${base}</link>
|
||||
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
|
||||
cfg.pageTitle,
|
||||
)}</description>
|
||||
<generator>Quartz -- quartz.jzhao.xyz</generator>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`
|
||||
}
|
||||
|
||||
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
opts = { ...defaultOptions, ...opts }
|
||||
return {
|
||||
name: "ContentIndex",
|
||||
async *emit(ctx, content) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const linkIndex: ContentIndexMap = new Map()
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||
linkIndex.set(slug, {
|
||||
slug,
|
||||
filePath: file.data.relativePath!,
|
||||
title: file.data.frontmatter?.title!,
|
||||
links: file.data.links ?? [],
|
||||
tags: file.data.frontmatter?.tags ?? [],
|
||||
content: file.data.text ?? "",
|
||||
richContent: opts?.rssFullHtml
|
||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||
: undefined,
|
||||
date: date,
|
||||
description: file.data.description ?? "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.enableSiteMap) {
|
||||
yield write({
|
||||
ctx,
|
||||
content: generateSiteMap(cfg, linkIndex),
|
||||
slug: "sitemap" as FullSlug,
|
||||
ext: ".xml",
|
||||
})
|
||||
}
|
||||
|
||||
if (opts?.enableRSS) {
|
||||
yield write({
|
||||
ctx,
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
slug: (opts?.rssSlug ?? "index") as FullSlug,
|
||||
ext: ".xml",
|
||||
})
|
||||
}
|
||||
|
||||
const fp = joinSegments("static", "contentIndex") as FullSlug
|
||||
const simplifiedIndex = Object.fromEntries(
|
||||
Array.from(linkIndex).map(([slug, content]) => {
|
||||
// remove description and from content index as nothing downstream
|
||||
// actually uses it. we only keep it in the index as we need it
|
||||
// for the RSS feed
|
||||
delete content.description
|
||||
delete content.date
|
||||
return [slug, content]
|
||||
}),
|
||||
)
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
content: JSON.stringify(simplifiedIndex),
|
||||
slug: fp,
|
||||
ext: ".json",
|
||||
})
|
||||
},
|
||||
externalResources: (ctx) => {
|
||||
if (opts?.enableRSS) {
|
||||
return {
|
||||
additionalHead: [
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="RSS Feed"
|
||||
href={`https://${ctx.cfg.configuration.baseUrl}/index.xml`}
|
||||
/>,
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import path from "path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import HeaderConstructor from "../../components/Header"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { pathToRoot } from "../../util/path"
|
||||
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { Content } from "../../components"
|
||||
import { styleText } from "util"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { Node } from "unist"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
|
||||
async function processContent(
|
||||
ctx: BuildCtx,
|
||||
tree: Node,
|
||||
fileData: QuartzPluginData,
|
||||
allFiles: QuartzPluginData[],
|
||||
opts: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
) {
|
||||
const slug = fileData.slug!
|
||||
const cfg = ctx.cfg.configuration
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
return write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
||||
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
...defaultContentPageLayout,
|
||||
pageBody: Content(),
|
||||
...userOpts,
|
||||
}
|
||||
|
||||
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
return {
|
||||
name: "ContentPage",
|
||||
getQuartzComponents() {
|
||||
return [
|
||||
Head,
|
||||
Header,
|
||||
Body,
|
||||
...header,
|
||||
...beforeBody,
|
||||
pageBody,
|
||||
...afterBody,
|
||||
...left,
|
||||
...right,
|
||||
Footer,
|
||||
]
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
let containsIndex = false
|
||||
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (slug === "index") {
|
||||
containsIndex = true
|
||||
}
|
||||
|
||||
// only process home page, non-tag pages, and non-index pages
|
||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
||||
}
|
||||
|
||||
if (!containsIndex) {
|
||||
console.log(
|
||||
styleText(
|
||||
"yellow",
|
||||
`\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder (\`${path.join(ctx.argv.directory, "index.md")} does not exist\`). This may cause errors when deploying.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
// find all slugs that changed or were added
|
||||
const changedSlugs = new Set<string>()
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
changedSlugs.add(changeEvent.file.data.slug!)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (!changedSlugs.has(slug)) continue
|
||||
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
|
||||
|
||||
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import sharp from "sharp"
|
||||
import { joinSegments, QUARTZ, FullSlug } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
|
||||
export const Favicon: QuartzEmitterPlugin = () => ({
|
||||
name: "Favicon",
|
||||
async *emit({ argv }) {
|
||||
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
||||
|
||||
const faviconContent = sharp(iconPath).resize(48, 48).toFormat("png")
|
||||
|
||||
yield write({
|
||||
ctx: { argv } as BuildCtx,
|
||||
slug: "favicon" as FullSlug,
|
||||
ext: ".ico",
|
||||
content: faviconContent,
|
||||
})
|
||||
},
|
||||
async *partialEmit() {},
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import HeaderConstructor from "../../components/Header"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import path from "path"
|
||||
import {
|
||||
FullSlug,
|
||||
SimpleSlug,
|
||||
stripSlashes,
|
||||
joinSegments,
|
||||
pathToRoot,
|
||||
simplifySlug,
|
||||
} from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { FolderContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
import { i18n, TRANSLATIONS } from "../../i18n"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
interface FolderPageOptions extends FullPageLayout {
|
||||
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
}
|
||||
|
||||
async function* processFolderInfo(
|
||||
ctx: BuildCtx,
|
||||
folderInfo: Record<SimpleSlug, ProcessedContent>,
|
||||
allFiles: QuartzPluginData[],
|
||||
opts: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
) {
|
||||
for (const [folder, folderContent] of Object.entries(folderInfo) as [
|
||||
SimpleSlug,
|
||||
ProcessedContent,
|
||||
][]) {
|
||||
const slug = joinSegments(folder, "index") as FullSlug
|
||||
const [tree, file] = folderContent
|
||||
const cfg = ctx.cfg.configuration
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: file.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
yield write({
|
||||
ctx,
|
||||
content,
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function computeFolderInfo(
|
||||
folders: Set<SimpleSlug>,
|
||||
content: ProcessedContent[],
|
||||
locale: keyof typeof TRANSLATIONS,
|
||||
): Record<SimpleSlug, ProcessedContent> {
|
||||
// Create default folder descriptions
|
||||
const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries(
|
||||
[...folders].map((folder) => [
|
||||
folder,
|
||||
defaultProcessedContent({
|
||||
slug: joinSegments(folder, "index") as FullSlug,
|
||||
frontmatter: {
|
||||
title: `${i18n(locale).pages.folderContent.folder}: ${folder}`,
|
||||
tags: [],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
// Update with actual content if available
|
||||
for (const [tree, file] of content) {
|
||||
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
|
||||
if (folders.has(slug)) {
|
||||
folderInfo[slug] = [tree, file]
|
||||
}
|
||||
}
|
||||
|
||||
return folderInfo
|
||||
}
|
||||
|
||||
function _getFolders(slug: FullSlug): SimpleSlug[] {
|
||||
var folderName = path.dirname(slug ?? "") as SimpleSlug
|
||||
const parentFolderNames = [folderName]
|
||||
|
||||
while (folderName !== ".") {
|
||||
folderName = path.dirname(folderName ?? "") as SimpleSlug
|
||||
parentFolderNames.push(folderName)
|
||||
}
|
||||
return parentFolderNames
|
||||
}
|
||||
|
||||
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
...defaultListPageLayout,
|
||||
pageBody: FolderContent({ sort: userOpts?.sort }),
|
||||
...userOpts,
|
||||
}
|
||||
|
||||
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
return {
|
||||
name: "FolderPage",
|
||||
getQuartzComponents() {
|
||||
return [
|
||||
Head,
|
||||
Header,
|
||||
Body,
|
||||
...header,
|
||||
...beforeBody,
|
||||
pageBody,
|
||||
...afterBody,
|
||||
...left,
|
||||
...right,
|
||||
Footer,
|
||||
]
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
|
||||
const folders: Set<SimpleSlug> = new Set(
|
||||
allFiles.flatMap((data) => {
|
||||
return data.slug
|
||||
? _getFolders(data.slug).filter(
|
||||
(folderName) => folderName !== "." && folderName !== "tags",
|
||||
)
|
||||
: []
|
||||
}),
|
||||
)
|
||||
|
||||
const folderInfo = computeFolderInfo(folders, content, cfg.locale)
|
||||
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
|
||||
// Find all folders that need to be updated based on changed files
|
||||
const affectedFolders: Set<SimpleSlug> = new Set()
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
const slug = changeEvent.file.data.slug!
|
||||
const folders = _getFolders(slug).filter(
|
||||
(folderName) => folderName !== "." && folderName !== "tags",
|
||||
)
|
||||
folders.forEach((folder) => affectedFolders.add(folder))
|
||||
}
|
||||
|
||||
// If there are affected folders, rebuild their pages
|
||||
if (affectedFolders.size > 0) {
|
||||
const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale)
|
||||
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
export { ContentPage } from "./contentPage"
|
||||
export { TagPage } from "./tagPage"
|
||||
export { FolderPage } from "./folderPage"
|
||||
export { ContentIndex as ContentIndex } from "./contentIndex"
|
||||
export { AliasRedirects } from "./aliases"
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
export { Favicon } from "./favicon"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
export { NotFoundPage } from "./404"
|
||||
export { CNAME } from "./cname"
|
||||
export { CustomOgImages } from "./ogImage"
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { i18n } from "../../i18n"
|
||||
import { unescapeHTML } from "../../util/escape"
|
||||
import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
|
||||
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
|
||||
import sharp from "sharp"
|
||||
import satori, { SatoriOptions } from "satori"
|
||||
import { loadEmoji, getIconCode } from "../../util/emoji"
|
||||
import { Readable } from "stream"
|
||||
import { write } from "./helpers"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzPluginData } from "../vfile"
|
||||
import fs from "node:fs/promises"
|
||||
import { styleText } from "util"
|
||||
|
||||
const defaultOptions: SocialImageOptions = {
|
||||
colorScheme: "lightMode",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
imageStructure: defaultImage,
|
||||
excludeRoot: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
|
||||
* @param opts options for generating image
|
||||
*/
|
||||
async function generateSocialImage(
|
||||
{ cfg, description, fonts, title, fileData }: ImageOptions,
|
||||
userOpts: SocialImageOptions,
|
||||
): Promise<Readable> {
|
||||
const { width, height } = userOpts
|
||||
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
||||
let iconBase64: string | undefined = undefined
|
||||
try {
|
||||
const iconData = await fs.readFile(iconPath)
|
||||
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`
|
||||
} catch (err) {
|
||||
console.warn(styleText("yellow", `Warning: Could not find icon at ${iconPath}`))
|
||||
}
|
||||
|
||||
const imageComponent = userOpts.imageStructure({
|
||||
cfg,
|
||||
userOpts,
|
||||
title,
|
||||
description,
|
||||
fonts,
|
||||
fileData,
|
||||
iconBase64,
|
||||
})
|
||||
|
||||
const svg = await satori(imageComponent, {
|
||||
width,
|
||||
height,
|
||||
fonts,
|
||||
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
||||
if (languageCode === "emoji") {
|
||||
return await loadEmoji(getIconCode(segment))
|
||||
}
|
||||
|
||||
return languageCode
|
||||
},
|
||||
})
|
||||
|
||||
return sharp(Buffer.from(svg)).webp({ quality: 40 })
|
||||
}
|
||||
|
||||
async function processOgImage(
|
||||
ctx: BuildCtx,
|
||||
fileData: QuartzPluginData,
|
||||
fonts: SatoriOptions["fonts"],
|
||||
fullOptions: SocialImageOptions,
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const slug = fileData.slug!
|
||||
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
||||
const title =
|
||||
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
||||
const description =
|
||||
fileData.frontmatter?.socialDescription ??
|
||||
fileData.frontmatter?.description ??
|
||||
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
||||
|
||||
const stream = await generateSocialImage(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
fonts,
|
||||
cfg,
|
||||
fileData,
|
||||
},
|
||||
fullOptions,
|
||||
)
|
||||
|
||||
return write({
|
||||
ctx,
|
||||
content: stream,
|
||||
slug: `${slug}-og-image` as FullSlug,
|
||||
ext: ".webp",
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomOgImagesEmitterName = "CustomOgImages"
|
||||
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
|
||||
const fullOptions = { ...defaultOptions, ...userOpts }
|
||||
|
||||
return {
|
||||
name: CustomOgImagesEmitterName,
|
||||
getQuartzComponents() {
|
||||
return []
|
||||
},
|
||||
async *emit(ctx, content, _resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const headerFont = cfg.theme.typography.header
|
||||
const bodyFont = cfg.theme.typography.body
|
||||
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
||||
|
||||
for (const [_tree, vfile] of content) {
|
||||
if (vfile.data.frontmatter?.socialImage !== undefined) continue
|
||||
yield processOgImage(ctx, vfile.data, fonts, fullOptions)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
const headerFont = cfg.theme.typography.header
|
||||
const bodyFont = cfg.theme.typography.body
|
||||
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
||||
|
||||
// find all slugs that changed or were added
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions)
|
||||
}
|
||||
}
|
||||
},
|
||||
externalResources: (ctx) => {
|
||||
if (!ctx.cfg.configuration.baseUrl) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const baseUrl = ctx.cfg.configuration.baseUrl
|
||||
return {
|
||||
additionalHead: [
|
||||
(pageData) => {
|
||||
const isRealFile = pageData.filePath !== undefined
|
||||
let userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
||||
|
||||
if (userDefinedOgImagePath) {
|
||||
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
|
||||
? userDefinedOgImagePath
|
||||
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
|
||||
}
|
||||
|
||||
const generatedOgImagePath = isRealFile
|
||||
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
|
||||
: undefined
|
||||
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
|
||||
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
|
||||
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
|
||||
return (
|
||||
<>
|
||||
{!userDefinedOgImagePath && (
|
||||
<>
|
||||
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
||||
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<meta property="og:image" content={ogImagePath} />
|
||||
<meta property="og:image:url" content={ogImagePath} />
|
||||
<meta name="twitter:image" content={ogImagePath} />
|
||||
<meta property="og:image:type" content={ogImageMimeType} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import HeaderConstructor from "../../components/Header"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path"
|
||||
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
|
||||
import { TagContent } from "../../components"
|
||||
import { write } from "./helpers"
|
||||
import { i18n, TRANSLATIONS } from "../../i18n"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
|
||||
interface TagPageOptions extends FullPageLayout {
|
||||
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
|
||||
}
|
||||
|
||||
function computeTagInfo(
|
||||
allFiles: QuartzPluginData[],
|
||||
content: ProcessedContent[],
|
||||
locale: keyof typeof TRANSLATIONS,
|
||||
): [Set<string>, Record<string, ProcessedContent>] {
|
||||
const tags: Set<string> = new Set(
|
||||
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
|
||||
)
|
||||
|
||||
// add base tag
|
||||
tags.add("index")
|
||||
|
||||
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
|
||||
[...tags].map((tag) => {
|
||||
const title =
|
||||
tag === "index"
|
||||
? i18n(locale).pages.tagContent.tagIndex
|
||||
: `${i18n(locale).pages.tagContent.tag}: ${tag}`
|
||||
return [
|
||||
tag,
|
||||
defaultProcessedContent({
|
||||
slug: joinSegments("tags", tag) as FullSlug,
|
||||
frontmatter: { title, tags: [] },
|
||||
}),
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
// Update with actual content if available
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (slug.startsWith("tags/")) {
|
||||
const tag = slug.slice("tags/".length)
|
||||
if (tags.has(tag)) {
|
||||
tagDescriptions[tag] = [tree, file]
|
||||
if (file.data.frontmatter?.title === tag) {
|
||||
file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [tags, tagDescriptions]
|
||||
}
|
||||
|
||||
async function processTagPage(
|
||||
ctx: BuildCtx,
|
||||
tag: string,
|
||||
tagContent: ProcessedContent,
|
||||
allFiles: QuartzPluginData[],
|
||||
opts: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
) {
|
||||
const slug = joinSegments("tags", tag) as FullSlug
|
||||
const [tree, file] = tagContent
|
||||
const cfg = ctx.cfg.configuration
|
||||
const externalResources = pageResources(pathToRoot(slug), resources)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: file.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
const content = renderPage(cfg, slug, componentData, opts, externalResources)
|
||||
return write({
|
||||
ctx,
|
||||
content,
|
||||
slug: file.data.slug!,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
||||
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
|
||||
const opts: FullPageLayout = {
|
||||
...sharedPageComponents,
|
||||
...defaultListPageLayout,
|
||||
pageBody: TagContent({ sort: userOpts?.sort }),
|
||||
...userOpts,
|
||||
}
|
||||
|
||||
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
|
||||
const Header = HeaderConstructor()
|
||||
const Body = BodyConstructor()
|
||||
|
||||
return {
|
||||
name: "TagPage",
|
||||
getQuartzComponents() {
|
||||
return [
|
||||
Head,
|
||||
Header,
|
||||
Body,
|
||||
...header,
|
||||
...beforeBody,
|
||||
pageBody,
|
||||
...afterBody,
|
||||
...left,
|
||||
...right,
|
||||
Footer,
|
||||
]
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
|
||||
|
||||
for (const tag of tags) {
|
||||
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
const cfg = ctx.cfg.configuration
|
||||
|
||||
// Find all tags that need to be updated based on changed files
|
||||
const affectedTags: Set<string> = new Set()
|
||||
for (const changeEvent of changeEvents) {
|
||||
if (!changeEvent.file) continue
|
||||
const slug = changeEvent.file.data.slug!
|
||||
|
||||
// If it's a tag page itself that changed
|
||||
if (slug.startsWith("tags/")) {
|
||||
const tag = slug.slice("tags/".length)
|
||||
affectedTags.add(tag)
|
||||
}
|
||||
|
||||
// If a file with tags changed, we need to update those tag pages
|
||||
const fileTags = changeEvent.file.data.frontmatter?.tags ?? []
|
||||
fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag))
|
||||
|
||||
// Always update the index tag page if any file changes
|
||||
affectedTags.add("index")
|
||||
}
|
||||
|
||||
// If there are affected tags, rebuild their pages
|
||||
if (affectedTags.size > 0) {
|
||||
// We still need to compute all tags because tag pages show all tags
|
||||
const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
|
||||
|
||||
for (const tag of affectedTags) {
|
||||
if (tagDescriptions[tag]) {
|
||||
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { QuartzFilterPlugin } from "../types"
|
||||
|
||||
export const RemoveDrafts: QuartzFilterPlugin<{}> = () => ({
|
||||
name: "RemoveDrafts",
|
||||
shouldPublish(_ctx, [_tree, vfile]) {
|
||||
const draftFlag: boolean =
|
||||
vfile.data?.frontmatter?.draft === true || vfile.data?.frontmatter?.draft === "true"
|
||||
return !draftFlag
|
||||
},
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
import { QuartzFilterPlugin } from "../types"
|
||||
|
||||
export const ExplicitPublish: QuartzFilterPlugin = () => ({
|
||||
name: "ExplicitPublish",
|
||||
shouldPublish(_ctx, [_tree, vfile]) {
|
||||
return vfile.data?.frontmatter?.publish === true || vfile.data?.frontmatter?.publish === "true"
|
||||
},
|
||||
})
|
||||
@@ -1,2 +1 @@
|
||||
export { RemoveDrafts } from "./draft"
|
||||
export { ExplicitPublish } from "./explicit"
|
||||
export {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { FilePath, FullSlug } from "../util/path"
|
||||
import { FilePath, FullSlug, SimpleSlug } from "../util/path"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
import { Root as HtmlRoot } from "hast"
|
||||
import { Element } from "hast"
|
||||
|
||||
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||
const staticResources: StaticResources = {
|
||||
@@ -45,6 +47,10 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||
export * from "./transformers"
|
||||
export * from "./filters"
|
||||
export * from "./emitters"
|
||||
export * from "./types"
|
||||
export * from "./config"
|
||||
export * as PageTypes from "./pageTypes"
|
||||
export * as PluginLoader from "./loader"
|
||||
|
||||
declare module "vfile" {
|
||||
// inserted in processors.ts
|
||||
@@ -52,5 +58,32 @@ declare module "vfile" {
|
||||
slug: FullSlug
|
||||
filePath: FilePath
|
||||
relativePath: FilePath
|
||||
// from description transformer
|
||||
description: string
|
||||
text: string
|
||||
// from crawl-links transformer
|
||||
links: SimpleSlug[]
|
||||
// from table-of-contents transformer
|
||||
toc: { depth: number; text: string; slug: string }[]
|
||||
collapseToc: boolean
|
||||
// from obsidian-flavored-markdown transformer
|
||||
blocks: Record<string, Element>
|
||||
htmlAst: HtmlRoot
|
||||
hasMermaidDiagram: boolean | undefined
|
||||
// from frontmatter transformer (e.g. note-properties)
|
||||
frontmatter: {
|
||||
title: string
|
||||
tags: string[]
|
||||
description?: string
|
||||
socialDescription?: string
|
||||
lang?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
// from created-modified-date transformer
|
||||
dates: {
|
||||
created: Date
|
||||
modified: Date
|
||||
published: Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
quartz/plugins/loader/componentLoader.ts
Normal file
72
quartz/plugins/loader/componentLoader.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { componentRegistry } from "../../components/registry"
|
||||
import { ComponentManifest, PluginManifest } from "./types"
|
||||
import { QuartzComponentConstructor } from "../../components/types"
|
||||
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
|
||||
|
||||
export async function loadComponentsFromPackage(
|
||||
pluginName: string,
|
||||
manifest: PluginManifest | null,
|
||||
subdir?: string,
|
||||
): Promise<void> {
|
||||
if (!manifest?.components) return
|
||||
|
||||
try {
|
||||
const componentsPath = getPluginSubpathEntry(pluginName, "./components", subdir)
|
||||
|
||||
let componentsModule: Record<string, unknown>
|
||||
if (componentsPath) {
|
||||
componentsModule = await import(toFileUrl(componentsPath))
|
||||
} else {
|
||||
componentsModule = await import(`${pluginName}/components`)
|
||||
}
|
||||
|
||||
const componentEntries = Object.entries(manifest.components)
|
||||
for (const [exportName, componentManifest] of componentEntries) {
|
||||
const component = componentsModule[exportName]
|
||||
if (!component) {
|
||||
console.warn(
|
||||
`Component "${exportName}" declared in manifest but not found in ${pluginName}/components`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Register under the fully-qualified key (pluginName/exportName)
|
||||
componentRegistry.register(
|
||||
`${pluginName}/${exportName}`,
|
||||
component as QuartzComponentConstructor,
|
||||
pluginName,
|
||||
componentManifest as ComponentManifest,
|
||||
)
|
||||
|
||||
// Also register under just the export name (e.g. "Footer", "NotePropertiesComponent")
|
||||
// so buildLayoutForEntries can find it via PascalCase conversion of plugin name
|
||||
if (!componentRegistry.get(exportName)) {
|
||||
componentRegistry.register(
|
||||
exportName,
|
||||
component as QuartzComponentConstructor,
|
||||
pluginName,
|
||||
componentManifest as ComponentManifest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If plugin has exactly one component, also register under just the plugin name
|
||||
// (e.g. "footer", "note-properties") for direct kebab-case lookup
|
||||
if (componentEntries.length === 1) {
|
||||
const [exportName] = componentEntries[0]
|
||||
const component = componentsModule[exportName]
|
||||
if (component && !componentRegistry.get(pluginName)) {
|
||||
componentRegistry.register(
|
||||
pluginName,
|
||||
component as QuartzComponentConstructor,
|
||||
pluginName,
|
||||
componentEntries[0][1] as ComponentManifest,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (manifest.components && Object.keys(manifest.components).length > 0) {
|
||||
console.warn(`Plugin "${pluginName}" declares components but failed to load them`)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
quartz/plugins/loader/conditions.ts
Normal file
33
quartz/plugins/loader/conditions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
|
||||
export type ConditionPredicate = (props: QuartzComponentProps) => boolean
|
||||
|
||||
const builtinConditions: Record<string, ConditionPredicate> = {
|
||||
"not-index": (props) => props.fileData.slug !== "index",
|
||||
"has-tags": (props) => {
|
||||
const tags = props.fileData.frontmatter?.tags
|
||||
return Array.isArray(tags) && tags.length > 0
|
||||
},
|
||||
"has-backlinks": (props) => {
|
||||
const backlinks = (props.fileData as Record<string, unknown>).backlinks
|
||||
return Array.isArray(backlinks) && backlinks.length > 0
|
||||
},
|
||||
"has-toc": (props) => {
|
||||
const toc = (props.fileData as Record<string, unknown>).toc
|
||||
return Array.isArray(toc) && toc.length > 0
|
||||
},
|
||||
}
|
||||
|
||||
const customConditions = new Map<string, ConditionPredicate>()
|
||||
|
||||
export function registerCondition(name: string, predicate: ConditionPredicate): void {
|
||||
customConditions.set(name, predicate)
|
||||
}
|
||||
|
||||
export function getCondition(name: string): ConditionPredicate | undefined {
|
||||
return customConditions.get(name) ?? builtinConditions[name]
|
||||
}
|
||||
|
||||
export function getAllConditionNames(): string[] {
|
||||
return [...Object.keys(builtinConditions), ...customConditions.keys()]
|
||||
}
|
||||
840
quartz/plugins/loader/config-loader.ts
Normal file
840
quartz/plugins/loader/config-loader.ts
Normal file
@@ -0,0 +1,840 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import YAML from "yaml"
|
||||
import { styleText } from "util"
|
||||
import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg"
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
|
||||
import { PluginTypes } from "../types"
|
||||
import {
|
||||
PluginManifest,
|
||||
PluginJsonEntry,
|
||||
QuartzPluginsJson,
|
||||
LayoutConfig,
|
||||
PluginLayoutDeclaration,
|
||||
FlexGroupConfig,
|
||||
} from "./types"
|
||||
import {
|
||||
parsePluginSource,
|
||||
installPlugin,
|
||||
getPluginEntryPoint,
|
||||
toFileUrl,
|
||||
isLocalSource,
|
||||
} from "./gitLoader"
|
||||
import { loadComponentsFromPackage } from "./componentLoader"
|
||||
import { loadFramesFromPackage } from "./frameLoader"
|
||||
import { componentRegistry } from "../../components/registry"
|
||||
import { getCondition } from "./conditions"
|
||||
|
||||
const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml")
|
||||
const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml")
|
||||
const LEGACY_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
|
||||
const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json")
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH
|
||||
if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH
|
||||
if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH
|
||||
if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH
|
||||
return CONFIG_YAML_PATH
|
||||
}
|
||||
function readPluginsJson(): QuartzPluginsJson | null {
|
||||
const configPath = resolveConfigPath()
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, "utf-8")
|
||||
if (configPath.endsWith(".yaml") || configPath.endsWith(".yml")) {
|
||||
return YAML.parse(raw) as QuartzPluginsJson
|
||||
}
|
||||
return JSON.parse(raw) as QuartzPluginsJson
|
||||
}
|
||||
|
||||
function extractPluginName(source: string): string {
|
||||
// Local file paths: use directory basename
|
||||
if (isLocalSource(source)) {
|
||||
return path.basename(source.replace(/[\/]+$/, ""))
|
||||
}
|
||||
if (source.startsWith("github:")) {
|
||||
const withoutPrefix = source.replace("github:", "")
|
||||
const [repoPath] = withoutPrefix.split("#")
|
||||
const parts = repoPath.split("/")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
if (source.startsWith("git+") || source.startsWith("https://")) {
|
||||
const url = source.replace("git+", "")
|
||||
const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
|
||||
return match?.[1] ?? source
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
interface DependencyValidationResult {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
function validateDependencies(
|
||||
entries: PluginJsonEntry[],
|
||||
manifests: Map<string, PluginManifest>,
|
||||
): DependencyValidationResult {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const sourceToEntry = new Map<string, PluginJsonEntry>()
|
||||
const nameToSource = new Map<string, string>()
|
||||
for (const entry of entries) {
|
||||
sourceToEntry.set(entry.source, entry)
|
||||
nameToSource.set(extractPluginName(entry.source), entry.source)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.enabled) continue
|
||||
const manifest = manifests.get(entry.source)
|
||||
if (!manifest?.dependencies?.length) continue
|
||||
|
||||
const pluginName = manifest.displayName || extractPluginName(entry.source)
|
||||
const pluginOrder = entry.order ?? manifest.defaultOrder ?? 50
|
||||
|
||||
for (const dep of manifest.dependencies) {
|
||||
const depEntry = sourceToEntry.get(dep)
|
||||
const depName = extractPluginName(dep)
|
||||
|
||||
if (!depEntry) {
|
||||
errors.push(
|
||||
`Plugin "${pluginName}" requires "${depName}". Run: npx quartz plugin add ${dep}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!depEntry.enabled) {
|
||||
warnings.push(
|
||||
`Plugin "${pluginName}" depends on "${depName}" which is disabled. "${pluginName}" may not function correctly.`,
|
||||
)
|
||||
}
|
||||
|
||||
const depManifest = manifests.get(dep)
|
||||
const depOrder = depEntry.order ?? depManifest?.defaultOrder ?? 50
|
||||
|
||||
if (pluginOrder < depOrder) {
|
||||
errors.push(
|
||||
`Plugin "${pluginName}" (order: ${pluginOrder}) depends on "${depName}" (order: ${depOrder}), ` +
|
||||
`but "${pluginName}" is configured to run first. Either increase "${pluginName}"'s order above ${depOrder} ` +
|
||||
`or decrease "${depName}"'s order below ${pluginOrder}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circular dependency detection
|
||||
const graph = new Map<string, string[]>()
|
||||
for (const entry of entries) {
|
||||
const manifest = manifests.get(entry.source)
|
||||
if (manifest?.dependencies?.length) {
|
||||
graph.set(entry.source, manifest.dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const inStack = new Set<string>()
|
||||
|
||||
function detectCycle(node: string, pathSoFar: string[]): string[] | null {
|
||||
if (inStack.has(node)) {
|
||||
const cycleStart = pathSoFar.indexOf(node)
|
||||
return pathSoFar.slice(cycleStart).concat(node)
|
||||
}
|
||||
if (visited.has(node)) return null
|
||||
|
||||
visited.add(node)
|
||||
inStack.add(node)
|
||||
|
||||
for (const dep of graph.get(node) ?? []) {
|
||||
const cycle = detectCycle(dep, [...pathSoFar, node])
|
||||
if (cycle) return cycle
|
||||
}
|
||||
|
||||
inStack.delete(node)
|
||||
return null
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
const cycle = detectCycle(node, [])
|
||||
if (cycle) {
|
||||
const names = cycle.map(extractPluginName)
|
||||
errors.push(`Circular dependency detected: ${names.join(" → ")}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
async function resolvePluginManifest(source: string): Promise<PluginManifest | null> {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(source)
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
return module.manifest ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifestFromPackageJson(source: string): Promise<PluginManifest | null> {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(source)
|
||||
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
|
||||
const pkgPath = path.join(pluginDir, "package.json")
|
||||
if (!fs.existsSync(pkgPath)) return null
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
||||
if (!pkg.quartz) return null
|
||||
|
||||
const q = pkg.quartz
|
||||
return {
|
||||
name: q.name ?? gitSpec.name,
|
||||
displayName: q.displayName ?? q.name ?? gitSpec.name,
|
||||
description: q.description ?? pkg.description ?? "No description",
|
||||
version: q.version ?? pkg.version ?? "1.0.0",
|
||||
author: q.author ?? pkg.author,
|
||||
homepage: q.homepage ?? pkg.homepage,
|
||||
category: q.category,
|
||||
quartzVersion: q.quartzVersion,
|
||||
dependencies: q.dependencies,
|
||||
defaultOrder: q.defaultOrder,
|
||||
defaultEnabled: q.defaultEnabled,
|
||||
defaultOptions: q.defaultOptions,
|
||||
configSchema: q.configSchema,
|
||||
components: q.components,
|
||||
frames: q.frames,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getManifest(source: string): Promise<PluginManifest | null> {
|
||||
// Try package.json quartz field first (preferred), then fall back to manifest.ts export
|
||||
return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source))
|
||||
}
|
||||
|
||||
export async function loadQuartzConfig(
|
||||
configOverrides?: Partial<GlobalConfiguration>,
|
||||
): Promise<QuartzConfig> {
|
||||
const json = readPluginsJson()
|
||||
|
||||
if (!json) {
|
||||
// Fallback: import old-style config directly
|
||||
const oldConfig = await import("../../../quartz")
|
||||
return oldConfig.default
|
||||
}
|
||||
|
||||
const configuration = {
|
||||
...(json.configuration as unknown as GlobalConfiguration),
|
||||
...configOverrides,
|
||||
}
|
||||
|
||||
const enabledEntries = json.plugins.filter((e) => e.enabled)
|
||||
const manifests = new Map<string, PluginManifest>()
|
||||
|
||||
// Ensure all plugins are installed and collect manifests
|
||||
for (const entry of enabledEntries) {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(entry.source)
|
||||
await installPlugin(gitSpec, { verbose: false })
|
||||
|
||||
const manifest = await getManifest(entry.source)
|
||||
if (manifest) {
|
||||
manifests.set(entry.source, manifest)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
styleText("red", `✗`) +
|
||||
` Failed to install plugin: ${styleText("yellow", entry.source)}\n` +
|
||||
` ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate dependencies
|
||||
const validation = validateDependencies(enabledEntries, manifests)
|
||||
for (const warning of validation.warnings) {
|
||||
console.warn(styleText("yellow", `⚠`) + ` ${warning}`)
|
||||
}
|
||||
if (validation.errors.length > 0) {
|
||||
for (const error of validation.errors) {
|
||||
console.error(styleText("red", `✗`) + ` ${error}`)
|
||||
}
|
||||
throw new Error(
|
||||
`Plugin dependency validation failed with ${validation.errors.length} error(s). See above for details.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Categorize and sort plugins
|
||||
const transformers: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
|
||||
const filters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
|
||||
const emitters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
|
||||
const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
|
||||
|
||||
for (const entry of enabledEntries) {
|
||||
const manifest = manifests.get(entry.source)
|
||||
const category = manifest?.category
|
||||
// Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]),
|
||||
// push the plugin into ALL matching processing category buckets.
|
||||
// "component" is handled separately via loadComponentsFromPackage during instantiation.
|
||||
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
|
||||
const categoryMap: Record<string, typeof transformers> = {
|
||||
transformer: transformers,
|
||||
filter: filters,
|
||||
emitter: emitters,
|
||||
pageType: pageTypes,
|
||||
}
|
||||
|
||||
const categories = Array.isArray(category) ? category : category ? [category] : []
|
||||
const matchedProcessing = categories.filter((c) =>
|
||||
(processingCategories as readonly string[]).includes(c),
|
||||
)
|
||||
|
||||
if (matchedProcessing.length > 0) {
|
||||
for (const cat of matchedProcessing) {
|
||||
categoryMap[cat].push({ entry, manifest })
|
||||
}
|
||||
} else {
|
||||
const gitSpec = parsePluginSource(entry.source)
|
||||
const isComponentOnly = categories.length > 0 && categories.every((c) => c === "component")
|
||||
|
||||
if (isComponentOnly) {
|
||||
// Always import the main entry point for component-only plugins.
|
||||
// Some plugins (e.g. Bases view registrations) rely on side effects
|
||||
// in their index module to register functionality.
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
|
||||
try {
|
||||
await import(toFileUrl(entryPoint))
|
||||
} catch (e) {
|
||||
// Side-effect import failed — continue with manifest-based loading
|
||||
}
|
||||
if (manifest?.components && Object.keys(manifest.components).length > 0) {
|
||||
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
} else {
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
|
||||
try {
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
const detected = detectCategoryFromModule(module)
|
||||
if (detected) {
|
||||
categoryMap[detected].push({ entry, manifest })
|
||||
} else if (manifest?.components && Object.keys(manifest.components).length > 0) {
|
||||
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Could not determine category for plugin "${extractPluginName(entry.source)}". Skipping.`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
const hasComponents = manifest?.components && Object.keys(manifest.components).length > 0
|
||||
const hasFrames = manifest?.frames && Object.keys(manifest.frames).length > 0
|
||||
if (hasComponents) {
|
||||
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
if (hasFrames) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
if (!hasComponents && !hasFrames) {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by order within each category
|
||||
const sortByOrder = (
|
||||
a: { entry: PluginJsonEntry; manifest: PluginManifest | undefined },
|
||||
b: { entry: PluginJsonEntry; manifest: PluginManifest | undefined },
|
||||
) => {
|
||||
const orderA = a.entry.order ?? a.manifest?.defaultOrder ?? 50
|
||||
const orderB = b.entry.order ?? b.manifest?.defaultOrder ?? 50
|
||||
return orderA - orderB
|
||||
}
|
||||
|
||||
transformers.sort(sortByOrder)
|
||||
filters.sort(sortByOrder)
|
||||
emitters.sort(sortByOrder)
|
||||
pageTypes.sort(sortByOrder)
|
||||
|
||||
// Instantiate plugins
|
||||
const instantiate = async (
|
||||
items: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[],
|
||||
expectedCategory: ProcessingCategory,
|
||||
) => {
|
||||
const instances = []
|
||||
for (const { entry, manifest } of items) {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(entry.source)
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
if (manifest?.components && Object.keys(manifest.components).length > 0) {
|
||||
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
|
||||
}
|
||||
|
||||
const factory = findFactory(module, expectedCategory)
|
||||
if (!factory) {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". Skipping.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const options = { ...manifest?.defaultOptions, ...entry.options }
|
||||
instances.push(factory(Object.keys(options).length > 0 ? options : undefined))
|
||||
} catch (err) {
|
||||
console.error(
|
||||
styleText("red", `✗`) +
|
||||
` Failed to instantiate plugin "${extractPluginName(entry.source)}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return instances
|
||||
}
|
||||
|
||||
// Import built-in plugins
|
||||
const builtinPlugins = await import("../index")
|
||||
const builtinTransformers: unknown[] = []
|
||||
const builtinEmitters = [
|
||||
builtinPlugins.ComponentResources(),
|
||||
builtinPlugins.Assets(),
|
||||
builtinPlugins.Static(),
|
||||
]
|
||||
const builtinPageTypes = [builtinPlugins.PageTypes.NotFoundPageType()]
|
||||
|
||||
const plugins: PluginTypes = {
|
||||
transformers: [...builtinTransformers, ...(await instantiate(transformers, "transformer"))],
|
||||
filters: await instantiate(filters, "filter"),
|
||||
emitters: [...builtinEmitters, ...(await instantiate(emitters, "emitter"))],
|
||||
pageTypes: [...(await instantiate(pageTypes, "pageType")), ...builtinPageTypes],
|
||||
}
|
||||
|
||||
// Load layout and add PageTypeDispatcher to emitters.
|
||||
// This must happen after plugin instantiation so the component registry is populated.
|
||||
const layout = await loadQuartzLayout()
|
||||
plugins.emitters.push(
|
||||
builtinPlugins.PageTypes.PageTypeDispatcher({
|
||||
defaults: layout.defaults,
|
||||
byPageType: layout.byPageType,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
configuration,
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessingCategory = "transformer" | "filter" | "emitter" | "pageType"
|
||||
|
||||
function matchesCategory(factory: Function, expected: ProcessingCategory): boolean {
|
||||
try {
|
||||
const instance = factory()
|
||||
if (!instance || typeof instance !== "object") return false
|
||||
switch (expected) {
|
||||
case "pageType":
|
||||
return "match" in instance && "body" in instance && "layout" in instance
|
||||
case "emitter":
|
||||
return "emit" in instance
|
||||
case "filter":
|
||||
return "shouldPublish" in instance
|
||||
case "transformer":
|
||||
return (
|
||||
"textTransform" in instance || "markdownPlugins" in instance || "htmlPlugins" in instance
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function findFactory(
|
||||
module: Record<string, unknown>,
|
||||
expectedCategory: ProcessingCategory,
|
||||
): Function | null {
|
||||
if (
|
||||
typeof module.default === "function" &&
|
||||
matchesCategory(module.default as Function, expectedCategory)
|
||||
) {
|
||||
return module.default as Function
|
||||
}
|
||||
if (
|
||||
typeof module.plugin === "function" &&
|
||||
matchesCategory(module.plugin as Function, expectedCategory)
|
||||
) {
|
||||
return module.plugin as Function
|
||||
}
|
||||
|
||||
for (const [, value] of Object.entries(module)) {
|
||||
if (typeof value === "function" && matchesCategory(value as Function, expectedCategory)) {
|
||||
return value as Function
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function detectCategoryFromModule(module: unknown): ProcessingCategory | null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
const mod = module as Record<string, unknown>
|
||||
|
||||
if (typeof mod.default === "function") {
|
||||
// Try to instantiate and inspect
|
||||
try {
|
||||
const instance = (mod.default as Function)()
|
||||
if (instance && typeof instance === "object") {
|
||||
if ("match" in instance && "body" in instance && "layout" in instance) return "pageType"
|
||||
if ("emit" in instance) return "emitter"
|
||||
if ("shouldPublish" in instance) return "filter"
|
||||
if (
|
||||
"textTransform" in instance ||
|
||||
"markdownPlugins" in instance ||
|
||||
"htmlPlugins" in instance
|
||||
)
|
||||
return "transformer"
|
||||
}
|
||||
} catch {
|
||||
// Couldn't instantiate, skip detection
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function loadQuartzLayout(layoutOverrides?: {
|
||||
defaults?: Partial<FullPageLayout>
|
||||
byPageType?: Record<string, Partial<FullPageLayout>>
|
||||
}): Promise<{
|
||||
defaults: Partial<FullPageLayout>
|
||||
byPageType: Record<string, Partial<FullPageLayout>>
|
||||
}> {
|
||||
const json = readPluginsJson()
|
||||
|
||||
if (!json) {
|
||||
// Fallback: import old-style layout directly
|
||||
const oldLayout = await import("../../../quartz")
|
||||
return oldLayout.layout
|
||||
}
|
||||
|
||||
const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout)
|
||||
const layoutConfig = json.layout ?? {}
|
||||
|
||||
// Build default layout for all page types
|
||||
const defaultLayout = buildLayoutForEntries(enabledWithLayout, layoutConfig)
|
||||
|
||||
// Build per-page-type overrides
|
||||
const byPageType: Record<string, Partial<FullPageLayout>> = {}
|
||||
if (layoutConfig.byPageType) {
|
||||
for (const [pageType, override] of Object.entries(layoutConfig.byPageType)) {
|
||||
let filteredEntries = enabledWithLayout
|
||||
|
||||
// Apply exclusions
|
||||
if (override.exclude?.length) {
|
||||
filteredEntries = filteredEntries.filter((e) => {
|
||||
const name = extractPluginName(e.source)
|
||||
return !override.exclude!.includes(name)
|
||||
})
|
||||
}
|
||||
|
||||
const ptLayout = buildLayoutForEntries(filteredEntries, layoutConfig)
|
||||
|
||||
// Apply position overrides (empty array = clear position)
|
||||
if (override.positions) {
|
||||
for (const [pos, components] of Object.entries(override.positions)) {
|
||||
if (Array.isArray(components) && components.length === 0) {
|
||||
const key = pos as keyof Pick<
|
||||
FullPageLayout,
|
||||
"left" | "right" | "beforeBody" | "afterBody"
|
||||
>
|
||||
if (key in ptLayout) {
|
||||
;(ptLayout as Record<string, unknown>)[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply frame template override
|
||||
if (override.template) {
|
||||
ptLayout.frame = override.template
|
||||
}
|
||||
|
||||
byPageType[pageType] = ptLayout
|
||||
}
|
||||
}
|
||||
|
||||
// Add Head (built-in) and Footer (plugin)
|
||||
const HeadModule = await import("../../components/Head")
|
||||
const head = HeadModule.default()
|
||||
|
||||
// Find footer from component registry (loaded during plugin instantiation)
|
||||
const footerEntry = json.plugins.find(
|
||||
(e) => e.enabled && extractPluginName(e.source) === "footer",
|
||||
)
|
||||
let footer: QuartzComponent | undefined
|
||||
if (footerEntry) {
|
||||
// Try registry lookup: plugin name ("footer") or export name ("Footer")
|
||||
const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer")
|
||||
if (footerReg) {
|
||||
if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) {
|
||||
// It's a constructor — use registry cache for consistent instances
|
||||
const opts = { ...footerEntry.options }
|
||||
footer = componentRegistry.instantiate(
|
||||
footerReg.component as QuartzComponentConstructor,
|
||||
Object.keys(opts).length > 0 ? opts : undefined,
|
||||
)
|
||||
} else {
|
||||
footer = footerReg.component as QuartzComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply structural defaults
|
||||
defaultLayout.head = head
|
||||
defaultLayout.header = defaultLayout.header ?? []
|
||||
if (footer) {
|
||||
defaultLayout.footer = footer
|
||||
}
|
||||
|
||||
// Ensure all byPageType entries inherit structural slots
|
||||
for (const pageType of Object.keys(byPageType)) {
|
||||
const pt = byPageType[pageType]
|
||||
if (!pt.head) pt.head = head
|
||||
if (!pt.header) pt.header = []
|
||||
if (footer && !pt.footer) pt.footer = footer
|
||||
}
|
||||
|
||||
const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults }
|
||||
const mergedByPageType = { ...byPageType }
|
||||
if (layoutOverrides?.byPageType) {
|
||||
for (const [pageType, overrideLayout] of Object.entries(layoutOverrides.byPageType)) {
|
||||
mergedByPageType[pageType] = { ...mergedByPageType[pageType], ...overrideLayout }
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults: mergedDefaults, byPageType: mergedByPageType }
|
||||
}
|
||||
|
||||
function buildLayoutForEntries(
|
||||
entries: PluginJsonEntry[],
|
||||
layoutConfig: LayoutConfig,
|
||||
): Partial<FullPageLayout> {
|
||||
const positions: Record<
|
||||
string,
|
||||
{
|
||||
component: QuartzComponent
|
||||
priority: number
|
||||
group?: string
|
||||
groupOptions?: PluginLayoutDeclaration["groupOptions"]
|
||||
}[]
|
||||
> = {
|
||||
left: [],
|
||||
right: [],
|
||||
beforeBody: [],
|
||||
afterBody: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.layout) continue
|
||||
|
||||
const layout = entry.layout
|
||||
const name = extractPluginName(entry.source)
|
||||
|
||||
// Look up component from registry
|
||||
const registered =
|
||||
componentRegistry.get(name) ?? componentRegistry.get(`${entry.source}/${name}`)
|
||||
if (!registered) {
|
||||
// Try common naming patterns
|
||||
const pascalName = name
|
||||
.split("-")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join("")
|
||||
const altRegistered = componentRegistry.get(pascalName)
|
||||
if (!altRegistered) continue
|
||||
}
|
||||
|
||||
const reg =
|
||||
registered ??
|
||||
componentRegistry.get(
|
||||
name
|
||||
.split("-")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(""),
|
||||
)
|
||||
if (!reg) continue
|
||||
|
||||
let component: QuartzComponent
|
||||
if (typeof reg.component === "function" && !("displayName" in reg.component)) {
|
||||
// It's a constructor — use registry cache to avoid duplicate instances
|
||||
// (and duplicate afterDOMLoaded scripts) across page-type layouts
|
||||
const opts = { ...entry.options }
|
||||
const optsArg = Object.keys(opts).length > 0 ? opts : undefined
|
||||
component = componentRegistry.instantiate(
|
||||
reg.component as QuartzComponentConstructor,
|
||||
optsArg,
|
||||
)
|
||||
} else {
|
||||
component = reg.component as QuartzComponent
|
||||
}
|
||||
|
||||
// Apply display modifier
|
||||
if (layout.display && layout.display !== "all") {
|
||||
component = applyDisplayWrapper(component, layout.display)
|
||||
}
|
||||
|
||||
// Apply condition
|
||||
if (layout.condition) {
|
||||
component = applyConditionWrapper(component, layout.condition)
|
||||
}
|
||||
|
||||
const posArray = positions[layout.position]
|
||||
if (posArray) {
|
||||
posArray.push({
|
||||
component,
|
||||
priority: layout.priority,
|
||||
group: layout.group,
|
||||
groupOptions: layout.groupOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority and resolve groups
|
||||
const result: Partial<FullPageLayout> = {}
|
||||
|
||||
for (const [position, items] of Object.entries(positions)) {
|
||||
items.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
const resolved = resolveGroups(items, layoutConfig.groups ?? {})
|
||||
const key = position as keyof Pick<
|
||||
FullPageLayout,
|
||||
"left" | "right" | "beforeBody" | "afterBody"
|
||||
>
|
||||
;(result as Record<string, QuartzComponent[]>)[key] = resolved
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function resolveGroups(
|
||||
items: {
|
||||
component: QuartzComponent
|
||||
priority: number
|
||||
group?: string
|
||||
groupOptions?: PluginLayoutDeclaration["groupOptions"]
|
||||
}[],
|
||||
groups: Record<string, FlexGroupConfig>,
|
||||
): QuartzComponent[] {
|
||||
// Collect grouped components and track the effective priority for each group.
|
||||
// Effective priority = explicit group config priority ?? first member's priority.
|
||||
const groupedComponents = new Map<
|
||||
string,
|
||||
{ component: QuartzComponent; groupOptions?: PluginLayoutDeclaration["groupOptions"] }[]
|
||||
>()
|
||||
const groupPriority = new Map<string, number>()
|
||||
|
||||
for (const item of items) {
|
||||
if (item.group) {
|
||||
if (!groupedComponents.has(item.group)) {
|
||||
groupedComponents.set(item.group, [])
|
||||
// Use explicit group priority from config if set, otherwise fall back to first member's priority
|
||||
const groupConfig = groups[item.group]
|
||||
groupPriority.set(item.group, groupConfig?.priority ?? item.priority)
|
||||
}
|
||||
groupedComponents.get(item.group)!.push({
|
||||
component: item.component,
|
||||
groupOptions: item.groupOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build a unified list of renderable entries (ungrouped components + flex groups),
|
||||
// each with a priority, so we can sort them together.
|
||||
type RenderEntry = { priority: number; component: QuartzComponent }
|
||||
const entries: RenderEntry[] = []
|
||||
const processedGroups = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (item.group) {
|
||||
// Only emit the flex group once (on first encounter)
|
||||
if (processedGroups.has(item.group)) continue
|
||||
processedGroups.add(item.group)
|
||||
|
||||
const members = groupedComponents.get(item.group)!
|
||||
const groupConfig = groups[item.group] ?? {}
|
||||
|
||||
const flexComponents = members.map((m) => ({
|
||||
Component: m.component,
|
||||
grow: m.groupOptions?.grow,
|
||||
shrink: m.groupOptions?.shrink,
|
||||
basis: m.groupOptions?.basis,
|
||||
order: m.groupOptions?.order,
|
||||
align: m.groupOptions?.align,
|
||||
justify: m.groupOptions?.justify,
|
||||
}))
|
||||
|
||||
// Dynamically import Flex to avoid circular dependencies
|
||||
const FlexModule = require("../../components/Flex")
|
||||
const Flex = FlexModule.default as Function
|
||||
const flexComponent = Flex({
|
||||
components: flexComponents,
|
||||
direction: groupConfig.direction ?? "row",
|
||||
wrap: groupConfig.wrap,
|
||||
gap: groupConfig.gap ?? "1rem",
|
||||
}) as QuartzComponent
|
||||
|
||||
entries.push({ priority: groupPriority.get(item.group)!, component: flexComponent })
|
||||
} else {
|
||||
entries.push({ priority: item.priority, component: item.component })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable sort by priority (items already arrive sorted, so equal priorities preserve order)
|
||||
entries.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
return entries.map((e) => e.component)
|
||||
}
|
||||
|
||||
function applyDisplayWrapper(
|
||||
component: QuartzComponent,
|
||||
display: "mobile-only" | "desktop-only",
|
||||
): QuartzComponent {
|
||||
if (display === "mobile-only") {
|
||||
const MobileOnly = require("../../components/MobileOnly").default as Function
|
||||
return MobileOnly(component) as QuartzComponent
|
||||
} else {
|
||||
const DesktopOnly = require("../../components/DesktopOnly").default as Function
|
||||
return DesktopOnly(component) as QuartzComponent
|
||||
}
|
||||
}
|
||||
|
||||
function applyConditionWrapper(component: QuartzComponent, conditionName: string): QuartzComponent {
|
||||
const predicate = getCondition(conditionName)
|
||||
if (!predicate) {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Unknown condition "${conditionName}". Component will always render.`,
|
||||
)
|
||||
return component
|
||||
}
|
||||
|
||||
const ConditionalRender = require("../../components/ConditionalRender").default as Function
|
||||
return ConditionalRender({
|
||||
component,
|
||||
condition: predicate,
|
||||
}) as QuartzComponent
|
||||
}
|
||||
48
quartz/plugins/loader/frameLoader.ts
Normal file
48
quartz/plugins/loader/frameLoader.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { frameRegistry } from "../../components/frames/registry"
|
||||
import { PluginManifest } from "./types"
|
||||
import { PageFrame } from "../../components/frames/types"
|
||||
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
|
||||
|
||||
export async function loadFramesFromPackage(
|
||||
pluginName: string,
|
||||
manifest: PluginManifest | null,
|
||||
subdir?: string,
|
||||
): Promise<void> {
|
||||
if (!manifest?.frames) return
|
||||
|
||||
try {
|
||||
const framesPath = getPluginSubpathEntry(pluginName, "./frames", subdir)
|
||||
|
||||
let framesModule: Record<string, unknown>
|
||||
if (framesPath) {
|
||||
framesModule = await import(toFileUrl(framesPath))
|
||||
} else {
|
||||
framesModule = await import(`${pluginName}/frames`)
|
||||
}
|
||||
|
||||
for (const [exportName, _frameMeta] of Object.entries(manifest.frames)) {
|
||||
const frame = framesModule[exportName]
|
||||
if (!frame) {
|
||||
console.warn(
|
||||
`Frame "${exportName}" declared in manifest but not found in ${pluginName}/frames`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const pageFrame = frame as PageFrame
|
||||
if (!pageFrame.name || typeof pageFrame.render !== "function") {
|
||||
console.warn(
|
||||
`Frame "${exportName}" from ${pluginName} is not a valid PageFrame (missing name or render)`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Register under the frame's declared name
|
||||
frameRegistry.register(pageFrame.name, pageFrame, pluginName)
|
||||
}
|
||||
} catch {
|
||||
if (manifest.frames && Object.keys(manifest.frames).length > 0) {
|
||||
console.warn(`Plugin "${pluginName}" declares frames but failed to load them`)
|
||||
}
|
||||
}
|
||||
}
|
||||
497
quartz/plugins/loader/gitLoader.ts
Normal file
497
quartz/plugins/loader/gitLoader.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import git from "isomorphic-git"
|
||||
import http from "isomorphic-git/http/node"
|
||||
import { styleText } from "util"
|
||||
import { pathToFileURL } from "url"
|
||||
|
||||
/**
|
||||
* Convert an absolute filesystem path to a file:// URL string for use with dynamic import().
|
||||
* On Windows, absolute paths like D:\path\file.js have "D:" interpreted as a URL protocol
|
||||
* by Node ESM, so they must be converted to file:// URLs.
|
||||
* Non-absolute paths (e.g. npm package names) are returned as-is.
|
||||
*/
|
||||
export function toFileUrl(filePath: string): string {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return pathToFileURL(filePath).href
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
export interface GitPluginSpec {
|
||||
/** Plugin name (used for directory) */
|
||||
name: string
|
||||
/** Git repository URL or absolute local path */
|
||||
repo: string
|
||||
/** Git ref (branch, tag, or commit hash). Defaults to 'main' */
|
||||
ref?: string
|
||||
/** Optional subdirectory within the repo if plugin is not at root */
|
||||
subdir?: string
|
||||
/** Whether this is a local path source */
|
||||
local?: boolean
|
||||
}
|
||||
|
||||
export type PluginInstallSource = string | GitPluginSpec
|
||||
|
||||
const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins")
|
||||
|
||||
/**
|
||||
* Check if a source string refers to a local file path.
|
||||
* Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\).
|
||||
*/
|
||||
export function isLocalSource(source: string): boolean {
|
||||
if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) {
|
||||
return true
|
||||
}
|
||||
// Windows absolute paths (e.g. C:\ or D:/)
|
||||
if (/^[A-Za-z]:[\\/]/.test(source)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a plugin source string into a GitPluginSpec
|
||||
* Supports:
|
||||
* - "./path/to/plugin" or "/absolute/path" -> local path
|
||||
* - "github:user/repo" -> https://github.com/user/repo.git
|
||||
* - "github:user/repo#ref" -> https://github.com/user/repo.git with specific ref
|
||||
* - "git+https://..." -> direct git URL
|
||||
* - "https://github.com/..." -> direct https URL
|
||||
*/
|
||||
export function parsePluginSource(source: string): GitPluginSpec {
|
||||
// Handle local paths
|
||||
if (isLocalSource(source)) {
|
||||
const resolved = path.resolve(source)
|
||||
const name = path.basename(resolved)
|
||||
return { name, repo: resolved, local: true }
|
||||
}
|
||||
|
||||
// Handle github shorthand: github:user/repo or github:user/repo#ref
|
||||
if (source.startsWith("github:")) {
|
||||
const withoutPrefix = source.replace("github:", "")
|
||||
const [repoPath, ref] = withoutPrefix.split("#")
|
||||
const [owner, repo] = repoPath.split("/")
|
||||
|
||||
if (!owner || !repo) {
|
||||
throw new Error(`Invalid GitHub source: ${source}. Expected format: github:user/repo`)
|
||||
}
|
||||
|
||||
return {
|
||||
name: repo,
|
||||
repo: `https://github.com/${owner}/${repo}.git`,
|
||||
ref: ref || "main",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle git+https:// protocol
|
||||
if (source.startsWith("git+")) {
|
||||
const raw = source.replace("git+", "")
|
||||
const [url, ref] = raw.split("#")
|
||||
const name = extractRepoName(url)
|
||||
return { name, repo: url, ref: ref || "main" }
|
||||
}
|
||||
|
||||
// Handle direct HTTPS URL (GitHub, GitLab, etc.)
|
||||
if (source.startsWith("https://")) {
|
||||
const [url, ref] = source.split("#")
|
||||
const name = extractRepoName(url)
|
||||
return { name, repo: url, ref: ref || "main" }
|
||||
}
|
||||
|
||||
// Assume it's a plain repo name and try github
|
||||
const parts = source.split("/")
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
name: parts[1],
|
||||
repo: `https://github.com/${source}.git`,
|
||||
ref: "main",
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Cannot parse plugin source: ${source}`)
|
||||
}
|
||||
|
||||
function extractRepoName(url: string): string {
|
||||
// Extract repo name from URL like https://github.com/user/repo.git
|
||||
const match = url.match(/\/([^\/]+?)(?:\.git)?$/)
|
||||
return match ? match[1] : "unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from a Git repository, or symlink a local plugin.
|
||||
*/
|
||||
export async function installPlugin(
|
||||
spec: GitPluginSpec,
|
||||
options: { verbose?: boolean; force?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name)
|
||||
|
||||
// Local source: symlink instead of clone
|
||||
if (spec.local) {
|
||||
if (!fs.existsSync(spec.repo)) {
|
||||
throw new Error(`Local plugin path does not exist: ${spec.repo}`)
|
||||
}
|
||||
|
||||
if (!options.force && fs.existsSync(pluginDir)) {
|
||||
// Check if existing entry is already a symlink to the right place
|
||||
try {
|
||||
const stat = fs.lstatSync(pluginDir)
|
||||
if (stat.isSymbolicLink() && fs.realpathSync(pluginDir) === fs.realpathSync(spec.repo)) {
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `→`), `Plugin ${spec.name} already linked`)
|
||||
}
|
||||
return pluginDir
|
||||
}
|
||||
} catch {
|
||||
// stat failed, recreate
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up if force reinstall or existing non-symlink entry
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
const stat = fs.lstatSync(pluginDir)
|
||||
if (stat.isSymbolicLink()) {
|
||||
fs.unlinkSync(pluginDir)
|
||||
} else {
|
||||
fs.rmSync(pluginDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(pluginDir)
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true })
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `→`), `Linking ${spec.name} from ${spec.repo}...`)
|
||||
}
|
||||
|
||||
fs.symlinkSync(spec.repo, pluginDir, "dir")
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("green", `✓`), `Linked ${spec.name}`)
|
||||
}
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
// Git source: clone
|
||||
// Check if already installed
|
||||
if (!options.force && fs.existsSync(pluginDir)) {
|
||||
// Check if it's a git repo by trying to resolve HEAD
|
||||
try {
|
||||
await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" })
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `→`), `Plugin ${spec.name} already installed`)
|
||||
}
|
||||
return pluginDir
|
||||
} catch {
|
||||
// If git operations fail, re-clone
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up if force reinstall
|
||||
if (options.force && fs.existsSync(pluginDir)) {
|
||||
fs.rmSync(pluginDir, { recursive: true })
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `→`), `Cloning ${spec.name} from ${spec.repo}#${spec.ref}...`)
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: pluginDir,
|
||||
url: spec.repo,
|
||||
ref: spec.ref,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
noCheckout: false,
|
||||
})
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("green", `✓`), `Installed ${spec.name}`)
|
||||
}
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Install multiple plugins from Git repositories
|
||||
*/
|
||||
export async function installPlugins(
|
||||
sources: PluginInstallSource[],
|
||||
options: { verbose?: boolean; force?: boolean } = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const installed = new Map<string, string>()
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const spec = typeof source === "string" ? parsePluginSource(source) : source
|
||||
const pluginDir = await installPlugin(spec, options)
|
||||
installed.set(spec.name, pluginDir)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(styleText("red", `✗`), `Failed to install plugin: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await regeneratePluginIndex(options)
|
||||
|
||||
return installed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installation directory for a plugin
|
||||
*/
|
||||
export function getPluginDir(name: string): string {
|
||||
return path.join(PLUGINS_CACHE_DIR, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin is installed
|
||||
*/
|
||||
export function isPluginInstalled(name: string): boolean {
|
||||
return fs.existsSync(getPluginDir(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entry point for a plugin.
|
||||
* Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues.
|
||||
*/
|
||||
export function getPluginEntryPoint(name: string, subdir?: string): string {
|
||||
const pluginDir = getPluginDir(name)
|
||||
const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir
|
||||
// Check package.json exports first (most reliable)
|
||||
const pkgJsonPath = path.join(searchDir, "package.json")
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"))
|
||||
const exportEntry = pkg.exports?.["."]
|
||||
const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import
|
||||
if (importPath) {
|
||||
const resolved = path.join(searchDir, importPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
// Fall back to main/module fields
|
||||
const mainField = pkg.module ?? pkg.main
|
||||
if (mainField) {
|
||||
const resolved = path.join(searchDir, mainField)
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// package.json parse error, fall through to candidates
|
||||
}
|
||||
}
|
||||
|
||||
// Try common entry points — prefer compiled dist/ over raw src/
|
||||
const candidates = [
|
||||
path.join(searchDir, "dist", "index.js"),
|
||||
path.join(searchDir, "dist", "index.mjs"),
|
||||
path.join(searchDir, "index.js"),
|
||||
path.join(searchDir, "index.ts"),
|
||||
path.join(searchDir, "src", "index.js"),
|
||||
path.join(searchDir, "src", "index.ts"),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// If no entry found, return the search dir and let Node handle it
|
||||
return searchDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a subpath export for a plugin (e.g. "./components").
|
||||
* Uses package.json exports map, then falls back to dist/ directory structure.
|
||||
*/
|
||||
export function getPluginSubpathEntry(
|
||||
name: string,
|
||||
subpath: string,
|
||||
subdir?: string,
|
||||
): string | null {
|
||||
const pluginDir = getPluginDir(name)
|
||||
const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir
|
||||
|
||||
// Check package.json exports map
|
||||
const pkgJsonPath = path.join(searchDir, "package.json")
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"))
|
||||
const exportEntry = pkg.exports?.[subpath]
|
||||
const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import
|
||||
if (importPath) {
|
||||
const resolved = path.join(searchDir, importPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back: try dist/<subpath>/index.js
|
||||
const subpathClean = subpath.replace(/^\.\/?/, "")
|
||||
const fallbackCandidates = [
|
||||
path.join(searchDir, "dist", subpathClean, "index.js"),
|
||||
path.join(searchDir, "dist", `${subpathClean}.js`),
|
||||
path.join(searchDir, subpathClean, "index.js"),
|
||||
]
|
||||
|
||||
for (const candidate of fallbackCandidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
/**
|
||||
* Update all installed plugins
|
||||
*/
|
||||
export async function updatePlugins(options: { verbose?: boolean } = {}): Promise<void> {
|
||||
if (!fs.existsSync(PLUGINS_CACHE_DIR)) {
|
||||
console.log("No plugins installed")
|
||||
return
|
||||
}
|
||||
|
||||
const plugins = fs.readdirSync(PLUGINS_CACHE_DIR)
|
||||
|
||||
for (const pluginName of plugins) {
|
||||
const pluginDir = path.join(PLUGINS_CACHE_DIR, pluginName)
|
||||
|
||||
try {
|
||||
// Check if it's a git repo
|
||||
await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" })
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `→`), `Updating ${pluginName}...`)
|
||||
}
|
||||
|
||||
// Fetch latest
|
||||
await git.fetch({
|
||||
fs,
|
||||
http,
|
||||
dir: pluginDir,
|
||||
singleBranch: true,
|
||||
})
|
||||
|
||||
// Checkout to latest fetched commit
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: pluginDir,
|
||||
ref: "FETCH_HEAD",
|
||||
force: true,
|
||||
})
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("green", `✓`), `Updated ${pluginName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.verbose) {
|
||||
console.error(styleText("yellow", `⚠`), `Skipping ${pluginName}: Not a git repo`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean all installed plugins
|
||||
*/
|
||||
export function cleanPlugins(): void {
|
||||
if (fs.existsSync(PLUGINS_CACHE_DIR)) {
|
||||
fs.rmSync(PLUGINS_CACHE_DIR, { recursive: true })
|
||||
console.log(styleText("green", `✓`), "Cleaned all plugins")
|
||||
}
|
||||
}
|
||||
|
||||
export async function regeneratePluginIndex(options: { verbose?: boolean } = {}): Promise<void> {
|
||||
if (!fs.existsSync(PLUGINS_CACHE_DIR)) {
|
||||
return
|
||||
}
|
||||
|
||||
const plugins = fs.readdirSync(PLUGINS_CACHE_DIR).filter((name) => {
|
||||
const pluginPath = path.join(PLUGINS_CACHE_DIR, name)
|
||||
return fs.statSync(pluginPath).isDirectory()
|
||||
})
|
||||
|
||||
const exports: string[] = []
|
||||
|
||||
for (const pluginName of plugins) {
|
||||
const pluginDir = path.join(PLUGINS_CACHE_DIR, pluginName)
|
||||
const distIndex = path.join(pluginDir, "dist", "index.d.ts")
|
||||
|
||||
if (!fs.existsSync(distIndex)) {
|
||||
if (options.verbose) {
|
||||
console.log(styleText("yellow", `⚠`), `Skipping ${pluginName}: no dist/index.d.ts found`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const dtsContent = fs.readFileSync(distIndex, "utf-8")
|
||||
const exportedNames = parseExportsFromDts(dtsContent)
|
||||
|
||||
if (exportedNames.length > 0) {
|
||||
const namedExports = exportedNames.filter((e) => !e.startsWith("type "))
|
||||
const typeExports = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5))
|
||||
|
||||
if (namedExports.length > 0) {
|
||||
exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`)
|
||||
}
|
||||
if (typeExports.length > 0) {
|
||||
exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indexContent = exports.join("\n") + "\n"
|
||||
const indexPath = path.join(PLUGINS_CACHE_DIR, "index.ts")
|
||||
|
||||
fs.writeFileSync(indexPath, indexContent)
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("green", `✓`), `Regenerated plugin index with ${plugins.length} plugins`)
|
||||
}
|
||||
}
|
||||
|
||||
const INTERNAL_EXPORTS = new Set(["manifest", "default"])
|
||||
|
||||
function parseExportsFromDts(content: string): string[] {
|
||||
const exports: string[] = []
|
||||
|
||||
const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g)
|
||||
for (const match of exportMatches) {
|
||||
const fromModule = match[2]
|
||||
if (fromModule?.startsWith("@")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const names = match[1]
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean)
|
||||
for (const name of names) {
|
||||
const cleanName = name.split(" as ").pop()?.trim() || name.trim()
|
||||
if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) {
|
||||
const finalName = cleanName.replace(/^type\s+/, "")
|
||||
if (name.includes("type ")) {
|
||||
exports.push(`type ${finalName}`)
|
||||
} else {
|
||||
exports.push(finalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return exports
|
||||
}
|
||||
496
quartz/plugins/loader/index.ts
Normal file
496
quartz/plugins/loader/index.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { styleText } from "util"
|
||||
import {
|
||||
PluginManifest,
|
||||
PluginCategory,
|
||||
LoadedPlugin,
|
||||
PluginResolution,
|
||||
PluginResolutionError,
|
||||
PluginResolutionOptions,
|
||||
PluginSpecifier,
|
||||
} from "./types"
|
||||
import {
|
||||
QuartzTransformerPlugin,
|
||||
QuartzFilterPlugin,
|
||||
QuartzEmitterPlugin,
|
||||
QuartzPageTypePlugin,
|
||||
} from "../types"
|
||||
import {
|
||||
parsePluginSource,
|
||||
installPlugin,
|
||||
getPluginEntryPoint,
|
||||
toFileUrl,
|
||||
isLocalSource,
|
||||
} from "./gitLoader"
|
||||
|
||||
const MINIMUM_QUARTZ_VERSION = "4.5.0"
|
||||
|
||||
function satisfiesVersion(required: string | undefined, current: string): boolean {
|
||||
if (!required) return true
|
||||
|
||||
const parseVersion = (v: string) => {
|
||||
const parts = v.replace(/^v/, "").split(".")
|
||||
return {
|
||||
major: parseInt(parts[0]) || 0,
|
||||
minor: parseInt(parts[1]) || 0,
|
||||
patch: parseInt(parts[2]) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
const req = parseVersion(required)
|
||||
const cur = parseVersion(current)
|
||||
|
||||
if (cur.major > req.major) return true
|
||||
if (cur.major < req.major) return false
|
||||
if (cur.minor > req.minor) return true
|
||||
if (cur.minor < req.minor) return false
|
||||
return cur.patch >= req.patch
|
||||
}
|
||||
|
||||
async function tryImportPlugin(packageName: string): Promise<{
|
||||
module: unknown
|
||||
manifest: PluginManifest | null
|
||||
}> {
|
||||
try {
|
||||
const module = await import(packageName)
|
||||
|
||||
const manifest: PluginManifest | null = module.manifest ?? null
|
||||
|
||||
return { module, manifest }
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to import package: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function detectPluginType(
|
||||
module: unknown,
|
||||
): "transformer" | "filter" | "emitter" | "pageType" | null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
|
||||
const mod = module as Record<string, unknown>
|
||||
|
||||
if (typeof mod.default === "function") {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod)
|
||||
|
||||
const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some(
|
||||
(key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined),
|
||||
)
|
||||
|
||||
const hasFilterProps = ["shouldPublish"].some(
|
||||
(key) => key in mod && typeof mod[key] === "function",
|
||||
)
|
||||
|
||||
const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function")
|
||||
|
||||
if (hasPageTypeProps) return "pageType"
|
||||
if (hasEmitterProps) return "emitter"
|
||||
if (hasFilterProps) return "filter"
|
||||
if (hasTransformerProps) return "transformer"
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractPluginFactory(
|
||||
module: unknown,
|
||||
type: "transformer" | "filter" | "emitter" | "pageType",
|
||||
):
|
||||
| QuartzTransformerPlugin
|
||||
| QuartzFilterPlugin
|
||||
| QuartzEmitterPlugin
|
||||
| QuartzPageTypePlugin
|
||||
| null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
|
||||
const mod = module as Record<string, unknown>
|
||||
|
||||
const factory = mod.default ?? mod[type] ?? mod.plugin ?? null
|
||||
|
||||
if (typeof factory === "function") {
|
||||
return factory as
|
||||
| QuartzTransformerPlugin
|
||||
| QuartzFilterPlugin
|
||||
| QuartzEmitterPlugin
|
||||
| QuartzPageTypePlugin
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isGitSource(source: string): boolean {
|
||||
// Check if it's a Git-based or local file path source
|
||||
return (
|
||||
isLocalSource(source) ||
|
||||
source.startsWith("github:") ||
|
||||
source.startsWith("git+") ||
|
||||
source.startsWith("https://github.com/") ||
|
||||
source.startsWith("https://gitlab.com/") ||
|
||||
source.startsWith("https://bitbucket.org/")
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSinglePlugin(
|
||||
specifier: PluginSpecifier,
|
||||
options: PluginResolutionOptions,
|
||||
): Promise<{ plugin: LoadedPlugin | null; error: PluginResolutionError | null }> {
|
||||
let packageName: string
|
||||
let manifest: Partial<PluginManifest> = {}
|
||||
let pluginSource = "npm"
|
||||
|
||||
if (typeof specifier === "string") {
|
||||
packageName = specifier
|
||||
// Check if it's a Git-based source
|
||||
if (isGitSource(specifier)) {
|
||||
pluginSource = "git"
|
||||
}
|
||||
} else if ("name" in specifier) {
|
||||
packageName = specifier.name
|
||||
if (isGitSource(specifier.name)) {
|
||||
pluginSource = "git"
|
||||
}
|
||||
} else if ("plugin" in specifier) {
|
||||
const rawType = specifier.manifest?.category ?? "transformer"
|
||||
const type = Array.isArray(rawType) ? rawType[0] : rawType
|
||||
return {
|
||||
plugin: {
|
||||
plugin: specifier.plugin as QuartzTransformerPlugin,
|
||||
manifest: {
|
||||
name: specifier.manifest?.name ?? "inline-plugin",
|
||||
displayName: specifier.manifest?.displayName ?? "Inline Plugin",
|
||||
description: specifier.manifest?.description ?? "Inline plugin instance",
|
||||
version: specifier.manifest?.version ?? "1.0.0",
|
||||
category: rawType,
|
||||
...specifier.manifest,
|
||||
} as PluginManifest,
|
||||
type,
|
||||
source: "inline",
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: "unknown",
|
||||
message: "Invalid plugin specifier format",
|
||||
type: "invalid-manifest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginSource === "git") {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(packageName)
|
||||
await installPlugin(gitSpec, { verbose: options.verbose })
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
|
||||
|
||||
// Import the plugin
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
const importedManifest: PluginManifest | null = module.manifest ?? null
|
||||
|
||||
manifest = importedManifest ?? {}
|
||||
|
||||
const categoryOrCategories = manifest.category ?? detectPluginType(module)
|
||||
|
||||
if (!categoryOrCategories) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: "Could not detect plugin type from Git source",
|
||||
type: "invalid-manifest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to single processing category for factory extraction
|
||||
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
|
||||
type ProcessingCategory = (typeof processingCategories)[number]
|
||||
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
|
||||
? categoryOrCategories[0]
|
||||
: categoryOrCategories
|
||||
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
|
||||
? (categoryOrCategories.find((c) =>
|
||||
(processingCategories as readonly string[]).includes(c),
|
||||
) as ProcessingCategory | undefined)
|
||||
: (processingCategories as readonly string[]).includes(categoryOrCategories)
|
||||
? (categoryOrCategories as ProcessingCategory)
|
||||
: undefined
|
||||
|
||||
// Component-only plugins don't have a processing factory
|
||||
if (!processingType) {
|
||||
const fullManifest: PluginManifest = {
|
||||
name: manifest.name ?? gitSpec.name,
|
||||
displayName: manifest.displayName ?? gitSpec.name,
|
||||
description: manifest.description ?? "No description provided",
|
||||
version: manifest.version ?? "1.0.0",
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
keywords: manifest.keywords,
|
||||
category: manifest.category ?? detectedType,
|
||||
quartzVersion: manifest.quartzVersion,
|
||||
configSchema: manifest.configSchema,
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
styleText("green", `\u2713`) +
|
||||
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { plugin: null, error: null }
|
||||
}
|
||||
|
||||
const factory = extractPluginFactory(module, processingType)
|
||||
if (!factory) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: "Could not find plugin factory in Git source",
|
||||
type: "invalid-manifest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fullManifest: PluginManifest = {
|
||||
name: manifest.name ?? gitSpec.name,
|
||||
displayName: manifest.displayName ?? gitSpec.name,
|
||||
description: manifest.description ?? "No description provided",
|
||||
version: manifest.version ?? "1.0.0",
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
keywords: manifest.keywords,
|
||||
category: manifest.category ?? detectedType,
|
||||
quartzVersion: manifest.quartzVersion,
|
||||
configSchema: manifest.configSchema,
|
||||
}
|
||||
|
||||
const loadedPlugin: LoadedPlugin = {
|
||||
plugin: factory,
|
||||
manifest: fullManifest,
|
||||
type: detectedType,
|
||||
source: gitSpec.local ? `local:${gitSpec.repo}` : `${gitSpec.repo}#${gitSpec.ref}`,
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
styleText("green", `✓`) +
|
||||
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { plugin: loadedPlugin, error: null }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: `Failed to load Git plugin: ${errorMessage}`,
|
||||
type: "import-error",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { module: importedModule, manifest: importedManifest } =
|
||||
await tryImportPlugin(packageName)
|
||||
|
||||
manifest = importedManifest ?? {}
|
||||
|
||||
// Load components if the plugin declares any
|
||||
if (manifest.components && Object.keys(manifest.components).length > 0) {
|
||||
const { loadComponentsFromPackage } = await import("./componentLoader")
|
||||
await loadComponentsFromPackage(packageName, manifest as PluginManifest)
|
||||
}
|
||||
|
||||
const categoryOrCategories = manifest.category ?? detectPluginType(importedModule)
|
||||
|
||||
if (!categoryOrCategories) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: `Could not detect plugin type. Ensure the plugin exports a valid factory function or has a 'category' field in its manifest.`,
|
||||
type: "invalid-manifest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to single processing category for factory extraction
|
||||
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
|
||||
type ProcessingCategory = (typeof processingCategories)[number]
|
||||
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
|
||||
? categoryOrCategories[0]
|
||||
: categoryOrCategories
|
||||
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
|
||||
? (categoryOrCategories.find((c) =>
|
||||
(processingCategories as readonly string[]).includes(c),
|
||||
) as ProcessingCategory | undefined)
|
||||
: (processingCategories as readonly string[]).includes(categoryOrCategories)
|
||||
? (categoryOrCategories as ProcessingCategory)
|
||||
: undefined
|
||||
|
||||
if (
|
||||
manifest.quartzVersion &&
|
||||
!satisfiesVersion(manifest.quartzVersion, options.quartzVersion)
|
||||
) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: `Plugin requires Quartz ${manifest.quartzVersion} but current version is ${options.quartzVersion}`,
|
||||
type: "version-mismatch",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Component-only plugins don't have a processing factory
|
||||
if (!processingType) {
|
||||
const fullManifest: PluginManifest = {
|
||||
name: manifest.name ?? packageName,
|
||||
displayName: manifest.displayName ?? packageName,
|
||||
description: manifest.description ?? "No description provided",
|
||||
version: manifest.version ?? "1.0.0",
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
keywords: manifest.keywords,
|
||||
category: manifest.category ?? detectedType,
|
||||
quartzVersion: manifest.quartzVersion,
|
||||
configSchema: manifest.configSchema,
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
styleText("green", `\u2713`) +
|
||||
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { plugin: null, error: null }
|
||||
}
|
||||
|
||||
const factory = extractPluginFactory(importedModule, processingType)
|
||||
if (!factory) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: `Could not find plugin factory in module. Expected 'export default' or '${processingType}' export.`,
|
||||
type: "invalid-manifest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fullManifest: PluginManifest = {
|
||||
name: manifest.name ?? packageName,
|
||||
displayName: manifest.displayName ?? packageName,
|
||||
description: manifest.description ?? "No description provided",
|
||||
version: manifest.version ?? "1.0.0",
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
keywords: manifest.keywords,
|
||||
category: manifest.category ?? detectedType,
|
||||
quartzVersion: manifest.quartzVersion,
|
||||
configSchema: manifest.configSchema,
|
||||
}
|
||||
|
||||
const loadedPlugin: LoadedPlugin = {
|
||||
plugin: factory,
|
||||
manifest: fullManifest,
|
||||
type: detectedType,
|
||||
source: packageName,
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
styleText("green", `✓`) +
|
||||
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { plugin: loadedPlugin, error: null }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (errorMessage.includes("Cannot find module") || errorMessage.includes("MODULE_NOT_FOUND")) {
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: `Plugin package not found. Run 'npm install ${packageName}' to install it.`,
|
||||
type: "not-found",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: null,
|
||||
error: {
|
||||
plugin: packageName,
|
||||
message: errorMessage,
|
||||
type: "import-error",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePlugins(
|
||||
specifiers: PluginSpecifier[],
|
||||
options: PluginResolutionOptions,
|
||||
): Promise<PluginResolution> {
|
||||
const plugins: LoadedPlugin[] = []
|
||||
const errors: PluginResolutionError[] = []
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(styleText("cyan", `Resolving ${specifiers.length} external plugin(s)...`))
|
||||
}
|
||||
|
||||
for (const specifier of specifiers) {
|
||||
const { plugin, error } = await resolveSinglePlugin(specifier, options)
|
||||
|
||||
if (plugin) {
|
||||
plugins.push(plugin)
|
||||
} else if (error) {
|
||||
errors.push(error)
|
||||
console.error(
|
||||
styleText("red", `✗`) +
|
||||
` Failed to load plugin: ${styleText("yellow", error.plugin)}\n` +
|
||||
` ${error.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.verbose && plugins.length > 0) {
|
||||
const byType = plugins.reduce(
|
||||
(acc, p) => {
|
||||
acc[p.type] = (acc[p.type] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
|
||||
console.log(
|
||||
styleText("cyan", `External plugins loaded:`) +
|
||||
` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters, ${byType.pageType ?? 0} pageTypes`,
|
||||
)
|
||||
}
|
||||
|
||||
return { plugins, errors }
|
||||
}
|
||||
|
||||
export function instantiatePlugin<T>(
|
||||
loadedPlugin: LoadedPlugin,
|
||||
options?: T,
|
||||
): ReturnType<typeof loadedPlugin.plugin> {
|
||||
const factory = loadedPlugin.plugin as (opts?: T) => ReturnType<typeof loadedPlugin.plugin>
|
||||
return factory(options)
|
||||
}
|
||||
|
||||
export { satisfiesVersion, MINIMUM_QUARTZ_VERSION }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user