refactor: decouple plugins from direct utility and component imports (#5)

* Initial plan

* Break component-emitter coupling by introducing shared-types module

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

* Decouple transformer from component scripts via resource registry

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

* Document plugin data dependencies with @plugin, @reads, @writes annotations

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

* Address code review feedback: improve docs and exhaustiveness checking

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

* Add exhaustiveness checking with unreachable assertions

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

* Fix getComponentJS return type and remove unnecessary null checks

Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: saberzero1 <8161064+saberzero1@users.noreply.github.com>
This commit is contained in:
Copilot 2025-11-16 21:30:06 +01:00 committed by GitHub
parent 6babcea029
commit 2d9da242dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 192 additions and 45 deletions

View File

@ -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 "../plugins/shared-types"
export default (() => {
const Head: QuartzComponent = ({
cfg,

View File

@ -0,0 +1,89 @@
/**
* Component Resources Registry
*
* This module provides a centralized registry for component scripts and styles.
* Plugins can request component resources without directly importing from components/scripts/,
* which helps decouple plugins from component implementations.
*
* This follows the decoupling strategy outlined in DESIGN_DOCUMENT_DECOUPLING.md section 3.3.
*/
import { JSResource, CSSResource } from "../util/resources"
// Import all component scripts
// @ts-ignore
import calloutScript from "./scripts/callout.inline"
// @ts-ignore
import checkboxScript from "./scripts/checkbox.inline"
// @ts-ignore
import mermaidScript from "./scripts/mermaid.inline"
import mermaidStyle from "./styles/mermaid.inline.scss"
/**
* Available component resource types that can be requested by plugins
*/
export type ComponentResourceType = "callout" | "checkbox" | "mermaid"
/**
* Get JavaScript resources for a specific component
*/
export function getComponentJS(type: ComponentResourceType): JSResource {
switch (type) {
case "callout":
return {
script: calloutScript,
loadTime: "afterDOMReady",
contentType: "inline",
}
case "checkbox":
return {
script: checkboxScript,
loadTime: "afterDOMReady",
contentType: "inline",
}
case "mermaid":
return {
script: mermaidScript,
loadTime: "afterDOMReady",
contentType: "inline",
moduleType: "module",
}
}
const _exhaustive: never = type
throw new Error(`Unhandled component type: ${_exhaustive}`)
}
/**
* Get CSS resources for a specific component
*/
export function getComponentCSS(type: ComponentResourceType): CSSResource | null {
switch (type) {
case "callout":
case "checkbox":
return null
case "mermaid":
return {
content: mermaidStyle,
inline: true,
}
}
const _exhaustive: never = type
throw new Error(`Unhandled component type: ${_exhaustive}`)
}
/**
* Get both JS and CSS resources for a component
*
* Note: This function is provided for convenience and future extensibility.
* Currently not used in the codebase as plugins call getComponentJS and
* getComponentCSS separately to handle conditional resource loading.
*/
export function getComponentResources(type: ComponentResourceType): {
js: JSResource
css: CSSResource | null
} {
return {
js: getComponentJS(type),
css: getComponentCSS(type),
}
}

View File

@ -1,6 +1,6 @@
import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { ContentDetails } from "../../plugins/shared-types"
type MaybeHTMLElement = HTMLElement | undefined

View File

@ -1,4 +1,4 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import type { ContentDetails } from "../../plugins/shared-types"
import {
SimulationNodeDatum,
SimulationLinkDatum,

View File

@ -1,5 +1,5 @@
import FlexSearch, { DefaultDocumentSearchResults } from "flexsearch"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
import { ContentDetails } from "../../plugins/shared-types"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, normalizeRelativeURLs, resolveRelative } from "../../util/path"

View File

@ -1,25 +1,16 @@
import { Root } from "hast"
import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date"
import { FilePath, FullSlug, SimpleSlug } from "../../util/path"
import { FullSlug, SimpleSlug } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import { write } from "./helpers"
import { i18n } from "../../i18n"
import { PluginUtilities } from "../plugin-context"
import { ContentDetails, ContentIndexMap } from "../shared-types"
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
}
// Re-export for backward compatibility
export type { ContentDetails, ContentIndexMap }
interface Options {
enableSiteMap: boolean

View File

@ -12,6 +12,10 @@ import { QuartzPluginData } from "../vfile"
import fs from "node:fs/promises"
import { styleText } from "util"
import { PluginUtilities } from "../plugin-context"
import { CustomOgImagesEmitterName } from "../shared-types"
// Re-export for backward compatibility
export { CustomOgImagesEmitterName }
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
@ -105,7 +109,6 @@ async function processOgImage(
})
}
export const CustomOgImagesEmitterName = "CustomOgImages"
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
const fullOptions = { ...defaultOptions, ...userOpts }

View File

@ -0,0 +1,35 @@
/**
* Shared type definitions used across plugins and components.
*
* This module breaks coupling between components and emitters by providing
* common type definitions that both can import without creating circular dependencies.
*/
import { FilePath, FullSlug, SimpleSlug } from "../util/path"
/**
* Content index entry representing metadata about a single content file.
*
* This type is used by:
* - ContentIndex emitter to generate the content index
* - Search, Explorer, and Graph components to display and navigate content
*/
export type ContentDetails = {
slug: FullSlug
filePath: FilePath
title: string
links: SimpleSlug[]
tags: string[]
content: string
richContent?: string
date?: Date
description?: string
}
export type ContentIndexMap = Map<FullSlug, ContentDetails>
/**
* Name of the custom OG images emitter.
* Used by Head component to check if custom OG images are enabled.
*/
export const CustomOgImagesEmitterName = "CustomOgImages"

View File

@ -17,6 +17,15 @@ const defaultOptions: Options = {
csl: "apa",
}
/**
* @plugin Citations
* @category Transformer
*
* @reads None
* @writes vfile.data.citations
*
* @dependencies None
*/
export const Citations: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {

View File

@ -38,6 +38,17 @@ function coerceDate(fp: string, d: any): Date {
}
type MaybeDate = undefined | string | number
/**
* @plugin CreatedModifiedDate
* @category Transformer
*
* @reads vfile.data.frontmatter.created
* @reads vfile.data.frontmatter.modified
* @reads vfile.data.frontmatter.published
* @writes vfile.data.dates
*
* @dependencies None
*/
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {

View File

@ -21,6 +21,17 @@ interface MacroType {
[key: string]: string
}
/**
* @plugin Latex
* @category Transformer
*
* @reads None
* @writes None (adds HTML but no vfile.data)
*
* @dependencies None
*
* @description Transforms markdown math notation (using remark-math) and renders LaTeX math expressions using KaTeX, MathJax, or Typst engines. Provides external CSS/JS resources for the selected rendering engine.
*/
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (opts) => {
const engine = opts?.renderEngine ?? "katex"
const macros = opts?.customMacros ?? {}

View File

@ -14,13 +14,7 @@ import rehypeRaw from "rehype-raw"
import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { JSResource, CSSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline"
// @ts-ignore
import checkboxScript from "../../components/scripts/checkbox.inline"
// @ts-ignore
import mermaidScript from "../../components/scripts/mermaid.inline"
import mermaidStyle from "../../components/styles/mermaid.inline.scss"
import { getComponentJS, getComponentCSS } from "../../components/resources"
import { FilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html"
@ -147,6 +141,23 @@ const wikilinkImageEmbedRegex = new RegExp(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
/**
* @plugin ObsidianFlavoredMarkdown
* @category Transformer
*
* @reads vfile.data.slug
* @reads vfile.data.frontmatter (for wikilink processing and tag extraction)
* @writes vfile.data.frontmatter.tags (when parseTags is enabled)
* @writes vfile.data.blocks
* @writes vfile.data.htmlAst
* @writes vfile.data.hasMermaidDiagram
*
* @dependencies None
*
* @description Processes Obsidian-flavored markdown including wikilinks, callouts,
* highlights, comments, mermaid diagrams, checkboxes, and tables. Conditionally
* registers component resources (callout, checkbox, mermaid) only if the corresponding options are enabled.
*/
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
@ -751,33 +762,20 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
const css: CSSResource[] = []
if (opts.enableCheckbox) {
js.push({
script: checkboxScript,
loadTime: "afterDOMReady",
contentType: "inline",
})
js.push(getComponentJS("checkbox"))
}
if (opts.callouts) {
js.push({
script: calloutScript,
loadTime: "afterDOMReady",
contentType: "inline",
})
js.push(getComponentJS("callout"))
}
if (opts.mermaid) {
js.push({
script: mermaidScript,
loadTime: "afterDOMReady",
contentType: "inline",
moduleType: "module",
})
js.push(getComponentJS("mermaid"))
css.push({
content: mermaidStyle,
inline: true,
})
const mermaidCSSRes = getComponentCSS("mermaid")
if (mermaidCSSRes) {
css.push(mermaidCSSRes)
}
}
return { js, css }