Initial import
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
|
||||
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { glob } from "../../util/glob"
|
||||
import { Argv, BuildCtx } from "../../util/ctx"
|
||||
import { QuartzConfig } from "../../cfg"
|
||||
|
||||
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) => {
|
||||
const src = joinSegments(argv.directory, fp) as FilePath
|
||||
|
||||
const name = slugifyFilePath(fp)
|
||||
const dest = joinSegments(argv.output, name) as FilePath
|
||||
|
||||
const dir = path.dirname(dest) as FilePath
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
await fs.promises.copyFile(src, dest)
|
||||
return dest
|
||||
}
|
||||
|
||||
export const Assets: QuartzEmitterPlugin = () => {
|
||||
return {
|
||||
name: "Assets",
|
||||
async *emit(ctx) {
|
||||
const excludeExtensions = getPageTypeExtensions(ctx)
|
||||
const fps = await filesToCopy(ctx.argv, ctx.cfg, excludeExtensions)
|
||||
for (const fp of fps) {
|
||||
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" || excludeExtensions.has(ext)) continue
|
||||
|
||||
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
||||
yield copyFile(ctx.argv, changeEvent.path)
|
||||
} else if (changeEvent.type === "delete") {
|
||||
const name = slugifyFilePath(changeEvent.path)
|
||||
const dest = joinSegments(ctx.argv.output, name) as FilePath
|
||||
await fs.promises.unlink(dest)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { createHash } from "crypto"
|
||||
import { FullSlug, joinSegments } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
|
||||
// @ts-ignore
|
||||
import spaRouterScript from "../../components/scripts/spa.inline"
|
||||
// @ts-ignore
|
||||
import popoverScript from "../../components/scripts/popover.inline"
|
||||
import baseStyles from "../../styles/base.scss"
|
||||
import customStyles from "../../styles/custom.scss"
|
||||
import popoverStyle from "../../components/styles/popover.scss"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { normalizeResource } from "../../util/resources"
|
||||
import { componentRegistry } from "../../components/registry"
|
||||
import {
|
||||
googleFontHref,
|
||||
googleFontSubsetHref,
|
||||
joinStyles,
|
||||
processGoogleFonts,
|
||||
} from "../../util/theme"
|
||||
import { Features, transform } from "lightningcss"
|
||||
import { transform as transpile } from "esbuild"
|
||||
import { write } from "./helpers"
|
||||
|
||||
function hashContent(content: string | Buffer): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 8)
|
||||
}
|
||||
|
||||
type ComponentResources = {
|
||||
css: string[]
|
||||
beforeDOMLoaded: string[]
|
||||
afterDOMLoaded: string[]
|
||||
componentCssStrings: Set<string>
|
||||
}
|
||||
|
||||
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) {
|
||||
allComponents.add(component)
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of componentRegistry.getAllComponents()) {
|
||||
allComponents.add(component)
|
||||
}
|
||||
|
||||
const componentResources = {
|
||||
css: new Set<string>(),
|
||||
beforeDOMLoaded: new Set<string>(),
|
||||
afterDOMLoaded: new Set<string>(),
|
||||
}
|
||||
|
||||
for (const component of allComponents) {
|
||||
const { css, beforeDOMLoaded, afterDOMLoaded } = component
|
||||
for (const c of normalizeResource(css)) componentResources.css.add(c)
|
||||
for (const b of normalizeResource(beforeDOMLoaded)) componentResources.beforeDOMLoaded.add(b)
|
||||
for (const a of normalizeResource(afterDOMLoaded)) componentResources.afterDOMLoaded.add(a)
|
||||
}
|
||||
|
||||
return {
|
||||
css: [...componentResources.css],
|
||||
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
|
||||
afterDOMLoaded: [...componentResources.afterDOMLoaded],
|
||||
componentCssStrings: new Set(componentResources.css),
|
||||
}
|
||||
}
|
||||
|
||||
async function joinScripts(scripts: string[]): Promise<string> {
|
||||
// wrap with iife to prevent scope collision
|
||||
const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
|
||||
|
||||
// minify with esbuild
|
||||
const res = await transpile(script, {
|
||||
minify: true,
|
||||
})
|
||||
|
||||
return res.code
|
||||
}
|
||||
|
||||
function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentResources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
|
||||
// popovers
|
||||
if (cfg.enablePopovers) {
|
||||
componentResources.afterDOMLoaded.push(popoverScript)
|
||||
componentResources.css.push(popoverStyle)
|
||||
}
|
||||
|
||||
if (cfg.analytics?.provider === "google") {
|
||||
const tagId = cfg.analytics.tagId
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const gtagScript = document.createElement('script');
|
||||
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
|
||||
gtagScript.defer = true;
|
||||
gtagScript.onload = () => {
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${tagId}', { send_page_view: false });
|
||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
||||
document.addEventListener('nav', () => {
|
||||
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(gtagScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "plausible") {
|
||||
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const plausibleScript = document.createElement('script');
|
||||
plausibleScript.src = '${plausibleHost}/js/script.manual.js';
|
||||
plausibleScript.setAttribute('data-domain', location.hostname);
|
||||
plausibleScript.defer = true;
|
||||
plausibleScript.onload = () => {
|
||||
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
|
||||
plausible('pageview');
|
||||
document.addEventListener('nav', () => {
|
||||
plausible('pageview');
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(plausibleScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "umami") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const umamiScript = document.createElement("script");
|
||||
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js";
|
||||
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
|
||||
umamiScript.setAttribute("data-auto-track", "true");
|
||||
umamiScript.defer = true;
|
||||
|
||||
document.head.appendChild(umamiScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "goatcounter") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const goatcounterScriptPre = document.createElement('script');
|
||||
goatcounterScriptPre.textContent = \`
|
||||
window.goatcounter = { no_onload: true };
|
||||
\`;
|
||||
document.head.appendChild(goatcounterScriptPre);
|
||||
|
||||
const endpoint = "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count";
|
||||
const goatcounterScript = document.createElement('script');
|
||||
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
|
||||
goatcounterScript.defer = true;
|
||||
goatcounterScript.setAttribute('data-goatcounter', endpoint);
|
||||
goatcounterScript.onload = () => {
|
||||
window.goatcounter.endpoint = endpoint;
|
||||
goatcounter.count({ path: location.pathname });
|
||||
document.addEventListener('nav', () => {
|
||||
goatcounter.count({ path: location.pathname });
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(goatcounterScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "posthog") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const posthogScript = document.createElement("script");
|
||||
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('${cfg.analytics.apiKey}', {
|
||||
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
|
||||
capture_pageview: false,
|
||||
});
|
||||
document.addEventListener('nav', () => {
|
||||
posthog.capture('$pageview', { path: location.pathname });
|
||||
})\`
|
||||
|
||||
document.head.appendChild(posthogScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "tinylytics") {
|
||||
const siteId = cfg.analytics.siteId
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const tinylyticsScript = document.createElement('script');
|
||||
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
|
||||
tinylyticsScript.defer = true;
|
||||
tinylyticsScript.onload = () => {
|
||||
window.tinylytics.triggerUpdate();
|
||||
document.addEventListener('nav', () => {
|
||||
window.tinylytics.triggerUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(tinylyticsScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "cabin") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const cabinScript = document.createElement("script")
|
||||
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
|
||||
cabinScript.defer = true
|
||||
document.head.appendChild(cabinScript)
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "clarity") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const clarityScript = document.createElement("script")
|
||||
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
|
||||
document.head.appendChild(clarityScript)
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "matomo") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const matomoScript = document.createElement("script");
|
||||
matomoScript.innerHTML = \`
|
||||
let _paq = window._paq = window._paq || [];
|
||||
|
||||
// Track SPA navigation
|
||||
// https://developer.matomo.org/guides/spa-tracking
|
||||
document.addEventListener("nav", () => {
|
||||
_paq.push(['setCustomUrl', location.pathname]);
|
||||
_paq.push(['setDocumentTitle', document.title]);
|
||||
_paq.push(['trackPageView']);
|
||||
});
|
||||
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
const u="//${cfg.analytics.host}/";
|
||||
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||
_paq.push(['setSiteId', ${cfg.analytics.siteId}]);
|
||||
const d=document, g=d.createElement('script'), s=d.getElementsByTagName
|
||||
('script')[0];
|
||||
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
\`
|
||||
document.head.appendChild(matomoScript);
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "vercel") {
|
||||
/**
|
||||
* script from {@link https://vercel.com/docs/analytics/quickstart?framework=html#add-the-script-tag-to-your-site|Vercel Docs}
|
||||
*/
|
||||
componentResources.beforeDOMLoaded.push(`
|
||||
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
|
||||
`)
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const vercelInsightsScript = document.createElement("script")
|
||||
vercelInsightsScript.src = "/_vercel/insights/script.js"
|
||||
vercelInsightsScript.defer = true
|
||||
document.head.appendChild(vercelInsightsScript)
|
||||
`)
|
||||
} else if (cfg.analytics?.provider === "rybbit") {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
const rybbitScript = document.createElement("script");
|
||||
rybbitScript.src = "${cfg.analytics.host ?? "https://app.rybbit.io"}/api/script.js";
|
||||
rybbitScript.setAttribute("data-site-id", "${cfg.analytics.siteId}");
|
||||
rybbitScript.async = true;
|
||||
rybbitScript.defer = true;
|
||||
|
||||
document.head.appendChild(rybbitScript);
|
||||
`)
|
||||
}
|
||||
|
||||
if (cfg.enableSPA) {
|
||||
componentResources.afterDOMLoaded.push(spaRouterScript)
|
||||
} else {
|
||||
componentResources.afterDOMLoaded.push(`
|
||||
window.spaNavigate = (url, _) => window.location.assign(url)
|
||||
window.addCleanup = () => {}
|
||||
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
|
||||
document.dispatchEvent(event)
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// This emitter should not update the `resources` parameter. If it does, partial
|
||||
// rebuilds may not work as expected.
|
||||
export const ComponentResources: QuartzEmitterPlugin = () => {
|
||||
return {
|
||||
name: "ComponentResources",
|
||||
async *emit(ctx, _content, resources) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
// component specific scripts and styles
|
||||
const componentResources = getComponentResources(ctx)
|
||||
let googleFontsStyleSheet = ""
|
||||
if (cfg.theme.fontOrigin === "local") {
|
||||
// let the user do it themselves in css
|
||||
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
|
||||
// when cdnCaching is true, we link to google fonts in Head.tsx
|
||||
const theme = ctx.cfg.configuration.theme
|
||||
const response = await fetch(googleFontHref(theme))
|
||||
googleFontsStyleSheet = await response.text()
|
||||
|
||||
if (theme.typography.title) {
|
||||
const title = ctx.cfg.configuration.pageTitle
|
||||
const response = await fetch(googleFontSubsetHref(theme, title))
|
||||
googleFontsStyleSheet += `\n${await response.text()}`
|
||||
}
|
||||
|
||||
if (!cfg.baseUrl) {
|
||||
throw new Error(
|
||||
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
|
||||
)
|
||||
}
|
||||
|
||||
const { processedStylesheet, fontFiles } = await processGoogleFonts(
|
||||
googleFontsStyleSheet,
|
||||
cfg.baseUrl,
|
||||
)
|
||||
googleFontsStyleSheet = processedStylesheet
|
||||
|
||||
// Download and save font files
|
||||
for (const fontFile of fontFiles) {
|
||||
const res = await fetch(fontFile.url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch font ${fontFile.filename}`)
|
||||
}
|
||||
|
||||
const buf = await res.arrayBuffer()
|
||||
yield write({
|
||||
ctx,
|
||||
slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug,
|
||||
ext: `.${fontFile.extension}`,
|
||||
content: Buffer.from(buf),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// important that this goes *after* component scripts
|
||||
// as the "nav" event gets triggered here and we should make sure
|
||||
// that everyone else had the chance to register a listener for it
|
||||
addGlobalPageResources(ctx, componentResources)
|
||||
|
||||
const useHashing = !ctx.argv.serve
|
||||
|
||||
// Separate global CSS (added by addGlobalPageResources, e.g. popover CSS)
|
||||
// from component CSS. Global CSS was pushed onto componentResources.css
|
||||
// AFTER getComponentResources() returned, so it's not in componentCssStrings.
|
||||
const globalCss = componentResources.css.filter(
|
||||
(c) => !componentResources.componentCssStrings.has(c),
|
||||
)
|
||||
|
||||
// Core CSS: theme + fonts + global CSS + base styles (no per-component CSS)
|
||||
const quartzBase = joinStyles(
|
||||
ctx.cfg.configuration.theme,
|
||||
googleFontsStyleSheet,
|
||||
...globalCss,
|
||||
baseStyles,
|
||||
)
|
||||
const stylesheet = `@layer quartz-base {\n${quartzBase}\n}\n${customStyles}`
|
||||
|
||||
const prescript = await joinScripts(componentResources.beforeDOMLoaded)
|
||||
|
||||
let postscript: string
|
||||
if (!useHashing) {
|
||||
// Serve mode: monolithic IIFE bundle for fast rebuilds
|
||||
postscript = await joinScripts(componentResources.afterDOMLoaded)
|
||||
} else {
|
||||
// Production: emit each afterDOMLoaded script as an individual cached file,
|
||||
// then generate an orchestrator that imports them with correct ordering.
|
||||
// The last script is always the SPA router (pushed last by addGlobalPageResources),
|
||||
// which must execute after all other scripts register their nav listeners.
|
||||
const scripts = componentResources.afterDOMLoaded
|
||||
const scriptFilenames: string[] = []
|
||||
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const hash = hashContent(scripts[i])
|
||||
const slug = `static/scripts/script-${i}-${hash}`
|
||||
const filename = `${slug}.js`
|
||||
scriptFilenames.push(filename)
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: slug as FullSlug,
|
||||
ext: ".js",
|
||||
content: scripts[i],
|
||||
})
|
||||
}
|
||||
|
||||
// Generate orchestrator: import all component scripts in parallel,
|
||||
// then import SPA router last (it dispatches the initial nav event)
|
||||
const componentImports = scriptFilenames
|
||||
.slice(0, -1)
|
||||
.map((f) => `import("./${f}")`)
|
||||
.join(",\n ")
|
||||
|
||||
const spaImport = `await import("./${scriptFilenames[scriptFilenames.length - 1]}");`
|
||||
|
||||
postscript = [`await Promise.all([\n ${componentImports}\n]);`, spaImport]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
const lightningTargets = {
|
||||
safari: (15 << 16) | (6 << 8), // 15.6
|
||||
ios_saf: (15 << 16) | (6 << 8), // 15.6
|
||||
edge: 115 << 16,
|
||||
firefox: 102 << 16,
|
||||
chrome: 109 << 16,
|
||||
}
|
||||
|
||||
const cssContent = transform({
|
||||
filename: "index.css",
|
||||
code: Buffer.from(stylesheet),
|
||||
minify: true,
|
||||
targets: lightningTargets,
|
||||
include: Features.MediaQueries,
|
||||
}).code.toString()
|
||||
|
||||
const cssStringToFilename = new Map<string, string>()
|
||||
for (const cssString of componentResources.componentCssStrings) {
|
||||
if (cssStringToFilename.has(cssString)) continue
|
||||
|
||||
const wrapped = `@layer quartz-base {\n${cssString}\n}`
|
||||
const minified = transform({
|
||||
filename: "component.css",
|
||||
code: Buffer.from(wrapped),
|
||||
minify: true,
|
||||
targets: lightningTargets,
|
||||
include: Features.MediaQueries,
|
||||
}).code.toString()
|
||||
|
||||
const hash = hashContent(minified)
|
||||
const slug = `component-${hash}`
|
||||
const filename = `${slug}.css`
|
||||
cssStringToFilename.set(cssString, filename)
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: slug as FullSlug,
|
||||
ext: ".css",
|
||||
content: minified,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.componentCssMap = cssStringToFilename
|
||||
|
||||
// Extract inline CSS/JS from plugin externalResources() into external files.
|
||||
// This prevents large inline payloads (e.g. theme CSS) from being duplicated
|
||||
// into every HTML page's <head>.
|
||||
const extractedInlineResources = new Map<string, string>()
|
||||
for (const cssResource of resources.css) {
|
||||
if (!(cssResource.inline ?? false)) continue
|
||||
|
||||
let output: string
|
||||
try {
|
||||
output = transform({
|
||||
filename: "plugin-resource.css",
|
||||
code: Buffer.from(cssResource.content),
|
||||
minify: true,
|
||||
targets: lightningTargets,
|
||||
include: Features.MediaQueries,
|
||||
}).code.toString()
|
||||
} catch {
|
||||
output = cssResource.content
|
||||
}
|
||||
|
||||
const hash = hashContent(output)
|
||||
const slug = `static/resource-style-${hash}`
|
||||
const filename = `${slug}.css`
|
||||
extractedInlineResources.set(cssResource.content, filename)
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: slug as FullSlug,
|
||||
ext: ".css",
|
||||
content: output,
|
||||
})
|
||||
}
|
||||
|
||||
for (const jsResource of resources.js) {
|
||||
if (jsResource.contentType !== "inline") continue
|
||||
|
||||
const minified = await joinScripts([jsResource.script])
|
||||
const hash = hashContent(minified)
|
||||
const loadTimePrefix = jsResource.loadTime === "beforeDOMReady" ? "before" : "after"
|
||||
const slug = `static/resource-${loadTimePrefix}-${hash}`
|
||||
const filename = `${slug}.js`
|
||||
extractedInlineResources.set(jsResource.script, filename)
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: slug as FullSlug,
|
||||
ext: ".js",
|
||||
content: minified,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.extractedInlineResources = extractedInlineResources
|
||||
|
||||
const cssHash = useHashing ? hashContent(cssContent) : null
|
||||
const prescriptHash = useHashing ? hashContent(prescript) : null
|
||||
const postscriptHash = useHashing ? hashContent(postscript) : null
|
||||
|
||||
const cssSlug = cssHash ? `index-${cssHash}` : "index"
|
||||
const prescriptSlug = prescriptHash ? `prescript-${prescriptHash}` : "prescript"
|
||||
const postscriptSlug = postscriptHash ? `postscript-${postscriptHash}` : "postscript"
|
||||
|
||||
ctx.hashedResourceNames = {
|
||||
"index.css": `${cssSlug}.css`,
|
||||
"prescript.js": `${prescriptSlug}.js`,
|
||||
"postscript.js": `${postscriptSlug}.js`,
|
||||
}
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: cssSlug as FullSlug,
|
||||
ext: ".css",
|
||||
content: cssContent,
|
||||
})
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: prescriptSlug as FullSlug,
|
||||
ext: ".js",
|
||||
content: prescript,
|
||||
})
|
||||
|
||||
yield write({
|
||||
ctx,
|
||||
slug: postscriptSlug as FullSlug,
|
||||
ext: ".js",
|
||||
content: postscript,
|
||||
})
|
||||
},
|
||||
async *partialEmit() {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import { FilePath, FullSlug, joinSegments } from "../../util/path"
|
||||
import { Readable } from "stream"
|
||||
|
||||
type WriteOptions = {
|
||||
ctx: BuildCtx
|
||||
slug: FullSlug
|
||||
ext: `.${string}` | ""
|
||||
content: string | Buffer | Readable
|
||||
}
|
||||
|
||||
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
|
||||
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
|
||||
const dir = path.dirname(pathToPage)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(pathToPage, content)
|
||||
return pathToPage
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Assets } from "./assets"
|
||||
export { Static } from "./static"
|
||||
export { ComponentResources } from "./componentResources"
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import fs from "fs"
|
||||
import { glob } from "../../util/glob"
|
||||
import { dirname } from "path"
|
||||
|
||||
export const Static: QuartzEmitterPlugin = () => ({
|
||||
name: "Static",
|
||||
async *emit({ argv, cfg }) {
|
||||
const staticPath = joinSegments(QUARTZ, "static")
|
||||
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
|
||||
const outputStaticPath = joinSegments(argv.output, "static")
|
||||
await fs.promises.mkdir(outputStaticPath, { recursive: true })
|
||||
for (const fp of fps) {
|
||||
const src = joinSegments(staticPath, fp) as FilePath
|
||||
const dest = joinSegments(outputStaticPath, fp) as FilePath
|
||||
await fs.promises.mkdir(dirname(dest), { recursive: true })
|
||||
await fs.promises.copyFile(src, dest)
|
||||
yield dest
|
||||
}
|
||||
},
|
||||
async *partialEmit() {},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
|
||||
export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
|
||||
const staticResources: StaticResources = {
|
||||
css: [],
|
||||
js: [],
|
||||
additionalHead: [],
|
||||
}
|
||||
|
||||
for (const transformer of [...ctx.cfg.plugins.transformers, ...ctx.cfg.plugins.emitters]) {
|
||||
const res = transformer.externalResources ? transformer.externalResources(ctx) : {}
|
||||
if (res?.js) {
|
||||
staticResources.js.push(...res.js)
|
||||
}
|
||||
if (res?.css) {
|
||||
staticResources.css.push(...res.css)
|
||||
}
|
||||
if (res?.additionalHead) {
|
||||
staticResources.additionalHead.push(...res.additionalHead)
|
||||
}
|
||||
}
|
||||
|
||||
// if serving locally, listen for rebuilds and reload the page
|
||||
if (ctx.argv.serve) {
|
||||
const wsUrl = ctx.argv.remoteDevHost
|
||||
? `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}`
|
||||
: `ws://localhost:${ctx.argv.wsPort}`
|
||||
|
||||
staticResources.js.push({
|
||||
loadTime: "afterDOMReady",
|
||||
contentType: "inline",
|
||||
script: `
|
||||
const socket = new WebSocket('${wsUrl}')
|
||||
// reload(true) ensures resources like images and scripts are fetched again in firefox
|
||||
socket.addEventListener('message', () => document.location.reload(true))
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
return staticResources
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,71 @@
|
||||
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,
|
||||
): Promise<void> {
|
||||
if (!manifest?.components) return
|
||||
|
||||
try {
|
||||
const componentsPath = getPluginSubpathEntry(pluginName, "./components")
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
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,
|
||||
PluginSource,
|
||||
QuartzPluginsJson,
|
||||
LayoutConfig,
|
||||
PluginLayoutDeclaration,
|
||||
FlexGroupConfig,
|
||||
} from "./types"
|
||||
import {
|
||||
parsePluginSource,
|
||||
installPlugin,
|
||||
installNativeDeps,
|
||||
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: PluginSource): string {
|
||||
if (typeof source === "object" && source !== null) {
|
||||
if (source.name) return source.name
|
||||
return extractPluginName(source.repo)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function formatSourceDisplay(source: PluginSource): string {
|
||||
if (typeof source === "string") return source
|
||||
const parts = [source.repo]
|
||||
if (source.subdir) parts.push(`(subdir: ${source.subdir})`)
|
||||
if (source.ref) parts.push(`(ref: ${source.ref})`)
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
function sourceKey(source: PluginSource): string {
|
||||
if (typeof source === "string") return source
|
||||
return JSON.stringify(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(sourceKey(entry.source), entry)
|
||||
nameToSource.set(extractPluginName(entry.source), sourceKey(entry.source))
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.enabled) continue
|
||||
const manifest = manifests.get(sourceKey(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}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graph = new Map<string, string[]>()
|
||||
for (const entry of entries) {
|
||||
const manifest = manifests.get(sourceKey(entry.source))
|
||||
if (manifest?.dependencies?.length) {
|
||||
graph.set(sourceKey(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: PluginSource): Promise<PluginManifest | null> {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(source)
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name)
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
return module.manifest ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifestFromPackageJson(source: PluginSource): 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: PluginSource): 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 native deps
|
||||
const allNativeDeps = new Map<string, Map<string, string>>()
|
||||
for (const entry of enabledEntries) {
|
||||
try {
|
||||
const gitSpec = parsePluginSource(entry.source)
|
||||
const result = await installPlugin(gitSpec, { verbose: false })
|
||||
if (result.nativeDeps.size > 0) {
|
||||
allNativeDeps.set(gitSpec.name, result.nativeDeps)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
styleText("red", `✗`) +
|
||||
` Failed to install plugin: ${styleText("yellow", formatSourceDisplay(entry.source))}\n` +
|
||||
` ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allNativeDeps.size > 0) {
|
||||
installNativeDeps(allNativeDeps, { verbose: false })
|
||||
}
|
||||
|
||||
// Collect manifests (requires native deps to be installed first)
|
||||
for (const entry of enabledEntries) {
|
||||
try {
|
||||
const manifest = await getManifest(entry.source)
|
||||
if (manifest) {
|
||||
manifests.set(sourceKey(entry.source), manifest)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
styleText("red", `✗`) +
|
||||
` Failed to load manifest: ${styleText("yellow", formatSourceDisplay(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(sourceKey(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)
|
||||
try {
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
// If the module exports an init() function, call it with merged options
|
||||
// so component-only plugins can receive user configuration from YAML.
|
||||
if (typeof module.init === "function") {
|
||||
const initOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
|
||||
const options = { ...manifest?.defaultOptions, ...entry.options, ...initOverrides }
|
||||
await module.init(Object.keys(options).length > 0 ? options : undefined)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest)
|
||||
}
|
||||
} else {
|
||||
const entryPoint = getPluginEntryPoint(gitSpec.name)
|
||||
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)
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
if (hasFrames) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest)
|
||||
}
|
||||
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)
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
if (manifest?.components && Object.keys(manifest.components).length > 0) {
|
||||
await loadComponentsFromPackage(gitSpec.name, manifest)
|
||||
}
|
||||
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
|
||||
await loadFramesFromPackage(gitSpec.name, manifest)
|
||||
}
|
||||
|
||||
const factory = findFactory(module, expectedCategory)
|
||||
if (!factory) {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". ` +
|
||||
`Ensure your plugin exports a default function, a "plugin" named export, or a single exported function.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const pluginOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
|
||||
const options = { ...manifest?.defaultOptions, ...entry.options, ...pluginOverrides }
|
||||
const instance = factory(Object.keys(options).length > 0 ? options : undefined)
|
||||
if (!instance || typeof instance !== "object") {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Plugin "${extractPluginName(entry.source)}" factory did not return a valid plugin instance. Skipping.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!validateCategory(instance, expectedCategory)) {
|
||||
console.warn(
|
||||
styleText("yellow", `⚠`) +
|
||||
` Plugin "${extractPluginName(entry.source)}" declares category "${expectedCategory}" ` +
|
||||
`but its factory returned an instance missing the required methods. Skipping.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
instances.push(instance)
|
||||
} 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"
|
||||
|
||||
/**
|
||||
* Validate that a plugin instance has the required methods for its declared category.
|
||||
* Called AFTER real instantiation — never used to probe/discover category.
|
||||
*/
|
||||
function validateCategory(
|
||||
instance: Record<string, unknown>,
|
||||
expected: ProcessingCategory,
|
||||
): boolean {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the factory function from a plugin module by export convention.
|
||||
* Prefers `default` export, then `plugin` named export, then the sole exported function.
|
||||
* For multi-export modules with an expectedCategory, probes candidate functions to find
|
||||
* the one matching the category shape.
|
||||
*/
|
||||
function findFactory(
|
||||
module: Record<string, unknown>,
|
||||
expectedCategory?: ProcessingCategory,
|
||||
): Function | null {
|
||||
if (typeof module.default === "function") {
|
||||
return module.default as Function
|
||||
}
|
||||
if (typeof module.plugin === "function") {
|
||||
return module.plugin as Function
|
||||
}
|
||||
|
||||
const exportedFunctions = Object.entries(module).filter(
|
||||
([key, value]) => typeof value === "function" && !key.startsWith("__"),
|
||||
)
|
||||
|
||||
if (exportedFunctions.length === 1) {
|
||||
return exportedFunctions[0][1] as Function
|
||||
}
|
||||
|
||||
// Multiple exports: probe candidates to find the one matching the expected category.
|
||||
// This is the only code path that calls factory() for discovery, and only when
|
||||
// there is no default/plugin export and multiple functions are exported.
|
||||
if (exportedFunctions.length > 1 && expectedCategory) {
|
||||
for (const [, fn] of exportedFunctions) {
|
||||
try {
|
||||
const instance = (fn as Function)()
|
||||
if (
|
||||
instance &&
|
||||
typeof instance === "object" &&
|
||||
validateCategory(instance, expectedCategory)
|
||||
) {
|
||||
return fn as Function
|
||||
}
|
||||
} catch {
|
||||
// This export doesn't work without args — skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function detectCategoryFromModule(module: unknown): ProcessingCategory | null {
|
||||
if (!module || typeof module !== "object") return null
|
||||
const mod = module as Record<string, unknown>
|
||||
|
||||
// Prefer static category marker on the factory if available
|
||||
const factory = findFactory(mod as Record<string, unknown>)
|
||||
if (factory && "quartzCategory" in factory) {
|
||||
const cat = (factory as Record<string, unknown>).quartzCategory
|
||||
if (cat === "transformer" || cat === "filter" || cat === "emitter" || cat === "pageType") {
|
||||
return cat
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try instantiating with no args and inspect the result.
|
||||
// This may fail for plugins that do I/O or require options during construction.
|
||||
if (typeof factory === "function") {
|
||||
try {
|
||||
const instance = factory()
|
||||
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 {
|
||||
// Factory requires arguments or does I/O — cannot detect category by probing.
|
||||
// Plugin should declare category in package.json quartz.category field.
|
||||
}
|
||||
}
|
||||
|
||||
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 footerOverrides = componentRegistry.getOptionOverrides("footer")
|
||||
const opts = { ...footerEntry.options, ...footerOverrides }
|
||||
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(`${formatSourceDisplay(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 tsOverrides = componentRegistry.getOptionOverrides(name)
|
||||
const opts = { ...entry.options, ...tsOverrides }
|
||||
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)
|
||||
}
|
||||
const groupMembers = groupedComponents.get(item.group)
|
||||
if (groupMembers) {
|
||||
groupMembers.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)
|
||||
if (!members) continue
|
||||
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) ?? 50, 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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
): Promise<void> {
|
||||
if (!manifest?.frames) return
|
||||
|
||||
try {
|
||||
const framesPath = getPluginSubpathEntry(pluginName, "./frames")
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
||||
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,
|
||||
validatePluginExternals,
|
||||
} 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)
|
||||
|
||||
// Import the plugin
|
||||
const module = await import(toFileUrl(entryPoint))
|
||||
const importedManifest: PluginManifest | null = module.manifest ?? null
|
||||
|
||||
validatePluginExternals(gitSpec.name, entryPoint, { verbose: options.verbose })
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
import { installPlugins, parsePluginSource } from "./gitLoader.js"
|
||||
import config from "../../../quartz.js"
|
||||
|
||||
async function main() {
|
||||
const quartzConfig: any = config
|
||||
const externalPlugins = quartzConfig.externalPlugins || []
|
||||
|
||||
if (externalPlugins.length === 0) {
|
||||
console.log("No external plugins to install.")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Installing ${externalPlugins.length} plugin(s) from Git...`)
|
||||
|
||||
const specs = externalPlugins.map((source: string) => parsePluginSource(source))
|
||||
const installed = await installPlugins(specs, { verbose: true })
|
||||
|
||||
if (installed.size === externalPlugins.length) {
|
||||
console.log("✓ All plugins installed successfully")
|
||||
} else {
|
||||
console.error(`✗ Only ${installed.size}/${externalPlugins.length} plugins installed`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to install plugins:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types"
|
||||
export * from "./index"
|
||||
export * from "./conditions"
|
||||
export { loadQuartzConfig, loadQuartzLayout } from "./config-loader"
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
QuartzTransformerPlugin,
|
||||
QuartzFilterPlugin,
|
||||
QuartzEmitterPlugin,
|
||||
QuartzPageTypePlugin,
|
||||
} from "../types"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
|
||||
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
|
||||
|
||||
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"
|
||||
|
||||
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
|
||||
|
||||
/**
|
||||
* Component manifest metadata
|
||||
*/
|
||||
export interface ComponentManifest {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
version: string
|
||||
quartzVersion?: string
|
||||
author?: string
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout defaults for a component declared in a plugin manifest.
|
||||
* These are used as fallback values when no user layout config is specified.
|
||||
*/
|
||||
export interface ComponentLayoutDefaults {
|
||||
displayName: string
|
||||
description?: string
|
||||
defaultPosition?: LayoutPosition
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manifest metadata for discovery and documentation.
|
||||
*
|
||||
* This corresponds to the `quartz` field in a plugin's `package.json`.
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
version: string
|
||||
author?: string
|
||||
homepage?: string
|
||||
keywords?: string[]
|
||||
category?: PluginCategory | PluginCategory[]
|
||||
quartzVersion?: string
|
||||
/** Plugin sources this plugin depends on (e.g., "github:quartz-community/crawl-links") */
|
||||
dependencies?: string[]
|
||||
/** Default numeric execution order (0-100 convention, lower = runs first). Defaults to 50. */
|
||||
defaultOrder?: number
|
||||
/** Whether the plugin is enabled by default on install. Defaults to true. */
|
||||
defaultEnabled?: boolean
|
||||
/** Default options applied when no user options are specified */
|
||||
defaultOptions?: Record<string, unknown>
|
||||
/** JSON Schema for the plugin's options object, used for validation and TUI generation */
|
||||
configSchema?: object
|
||||
/** Components provided by this plugin, keyed by component export name */
|
||||
components?: Record<string, ComponentManifest & ComponentLayoutDefaults>
|
||||
/** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */
|
||||
frames?: Record<string, { exportName: string }>
|
||||
/** Whether the plugin requires `npm install` after cloning (e.g. for native dependencies like sharp). */
|
||||
requiresInstall?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded plugin with metadata
|
||||
*/
|
||||
export interface LoadedPlugin {
|
||||
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin
|
||||
manifest: PluginManifest
|
||||
type: PluginCategory
|
||||
source: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin resolution result
|
||||
*/
|
||||
export interface PluginResolution {
|
||||
/** Successfully loaded plugins */
|
||||
plugins: LoadedPlugin[]
|
||||
/** Errors that occurred during resolution */
|
||||
errors: PluginResolutionError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin resolution error
|
||||
*/
|
||||
export interface PluginResolutionError {
|
||||
/** Plugin name that failed to load */
|
||||
plugin: string
|
||||
/** Error message */
|
||||
message: string
|
||||
/** Error type */
|
||||
type: "not-found" | "invalid-manifest" | "version-mismatch" | "import-error"
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for plugin resolution
|
||||
*/
|
||||
export interface PluginResolutionOptions {
|
||||
/** Current Quartz version for compatibility checking */
|
||||
quartzVersion: string
|
||||
/** Build context for logging */
|
||||
ctx: BuildCtx
|
||||
/** Whether to enable verbose logging */
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin specifier - can be:
|
||||
* - String package name (e.g., "@quartz-community/my-plugin")
|
||||
* - Object with name and options (e.g., { name: "@quartz-community/my-plugin", options: {...} })
|
||||
* - Inline plugin object (already loaded plugin instance)
|
||||
*/
|
||||
export type PluginSpecifier =
|
||||
| string
|
||||
| { name: string; options?: unknown }
|
||||
| { plugin: LoadedPlugin["plugin"]; manifest?: Partial<PluginManifest> }
|
||||
|
||||
/** Layout declaration for a component-providing plugin in quartz.config.yaml */
|
||||
export interface PluginLayoutDeclaration {
|
||||
position: LayoutPosition
|
||||
priority: number
|
||||
display?: LayoutDisplay
|
||||
condition?: string
|
||||
group?: string
|
||||
groupOptions?: {
|
||||
grow?: boolean
|
||||
shrink?: boolean
|
||||
basis?: string
|
||||
order?: number
|
||||
align?: "start" | "end" | "center" | "stretch"
|
||||
justify?: "start" | "end" | "center" | "between" | "around"
|
||||
}
|
||||
}
|
||||
|
||||
/** Object form of a plugin source (for monorepo / advanced config) */
|
||||
export interface PluginSourceObject {
|
||||
repo: string
|
||||
subdir?: string
|
||||
ref?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** A plugin source can be a string shorthand or an object with additional fields */
|
||||
export type PluginSource = string | PluginSourceObject
|
||||
|
||||
/** A single plugin entry in quartz.config.yaml */
|
||||
export interface PluginJsonEntry {
|
||||
source: PluginSource
|
||||
enabled: boolean
|
||||
options?: Record<string, unknown>
|
||||
order?: number
|
||||
layout?: PluginLayoutDeclaration
|
||||
}
|
||||
|
||||
/** Flex group configuration in the top-level layout section */
|
||||
export interface FlexGroupConfig {
|
||||
/** Explicit priority for the group. Overrides first-member priority. Lower = renders first. */
|
||||
priority?: number
|
||||
direction?: "row" | "row-reverse" | "column" | "column-reverse"
|
||||
wrap?: "nowrap" | "wrap" | "wrap-reverse"
|
||||
gap?: string
|
||||
}
|
||||
|
||||
/** Per-page-type layout overrides */
|
||||
export interface PageTypeLayoutOverride {
|
||||
exclude?: string[]
|
||||
positions?: Partial<Record<LayoutPosition, PluginLayoutDeclaration[]>>
|
||||
/** Override the page frame template (e.g. "default", "full-width", "minimal") */
|
||||
template?: string
|
||||
}
|
||||
|
||||
/** Top-level layout section of quartz.config.yaml */
|
||||
export interface LayoutConfig {
|
||||
groups?: Record<string, FlexGroupConfig>
|
||||
byPageType?: Record<string, PageTypeLayoutOverride>
|
||||
}
|
||||
|
||||
/** Root type for quartz.config.yaml */
|
||||
export interface QuartzPluginsJson {
|
||||
$schema?: string
|
||||
configuration: Record<string, unknown>
|
||||
plugins: PluginJsonEntry[]
|
||||
layout?: LayoutConfig
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QuartzPageTypePlugin } from "../types"
|
||||
import { match } from "./matchers"
|
||||
import { NotFound } from "../../components"
|
||||
import { defaultProcessedContent } from "../vfile"
|
||||
import { i18n } from "../../i18n"
|
||||
import { FullSlug } from "../../util/path"
|
||||
|
||||
export const NotFoundPageType: QuartzPageTypePlugin = () => ({
|
||||
name: "404",
|
||||
priority: -1,
|
||||
match: match.none(),
|
||||
generate({ cfg }) {
|
||||
const notFound = i18n(cfg.locale).pages.error.title
|
||||
const slug = "404" as FullSlug
|
||||
const [, vfile] = defaultProcessedContent({
|
||||
slug,
|
||||
text: notFound,
|
||||
description: notFound,
|
||||
frontmatter: { title: notFound, tags: [] },
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
slug,
|
||||
title: notFound,
|
||||
data: vfile.data,
|
||||
},
|
||||
]
|
||||
},
|
||||
layout: "404",
|
||||
frame: "minimal",
|
||||
body: NotFound,
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance, TreeTransform } from "../types"
|
||||
import { QuartzComponent, QuartzComponentProps } from "../../components/types"
|
||||
import { pageResources, renderPage } from "../../components/renderPage"
|
||||
import { FullPageLayout } from "../../cfg"
|
||||
import { FilePath, FullSlug, pathToRoot } from "../../util/path"
|
||||
import { ProcessedContent, defaultProcessedContent } from "../vfile"
|
||||
import { write } from "../emitters/helpers"
|
||||
import { BuildCtx, trieFromAllFiles } from "../../util/ctx"
|
||||
import { StaticResources } from "../../util/resources"
|
||||
import { render } from "preact-render-to-string"
|
||||
import { fromHtml } from "hast-util-from-html"
|
||||
import { Root as HtmlRoot } from "hast"
|
||||
|
||||
function getPageTypes(ctx: BuildCtx): QuartzPageTypePluginInstance[] {
|
||||
return (ctx.cfg.plugins.pageTypes ?? []) as unknown as QuartzPageTypePluginInstance[]
|
||||
}
|
||||
|
||||
function resolveLayout(
|
||||
pageType: QuartzPageTypePluginInstance,
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): FullPageLayout {
|
||||
const overrides = byPageType[pageType.layout] ?? {}
|
||||
// Frame priority: config override > page type declaration > default
|
||||
const frame = overrides.frame ?? pageType.frame ?? "default"
|
||||
return {
|
||||
head: overrides.head ?? sharedDefaults.head!,
|
||||
header: overrides.header ?? sharedDefaults.header ?? [],
|
||||
beforeBody: overrides.beforeBody ?? sharedDefaults.beforeBody ?? [],
|
||||
pageBody: pageType.body(undefined),
|
||||
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
|
||||
left: overrides.left ?? sharedDefaults.left ?? [],
|
||||
right: overrides.right ?? sharedDefaults.right ?? [],
|
||||
footer: overrides.footer ?? sharedDefaults.footer!,
|
||||
frame,
|
||||
}
|
||||
}
|
||||
|
||||
function collectComponents(
|
||||
pageTypes: QuartzPageTypePluginInstance[],
|
||||
sharedDefaults: Partial<FullPageLayout>,
|
||||
byPageType: Record<string, Partial<FullPageLayout>>,
|
||||
): QuartzComponent[] {
|
||||
const seen = new Set<QuartzComponent>()
|
||||
for (const pt of pageTypes) {
|
||||
const layout = resolveLayout(pt, sharedDefaults, byPageType)
|
||||
const all = [
|
||||
layout.head,
|
||||
...layout.header,
|
||||
...layout.beforeBody,
|
||||
layout.pageBody,
|
||||
...layout.afterBody,
|
||||
...layout.left,
|
||||
...layout.right,
|
||||
layout.footer,
|
||||
]
|
||||
for (const c of all) {
|
||||
if (c) seen.add(c)
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
interface DispatcherOptions {
|
||||
defaults: Partial<FullPageLayout>
|
||||
byPageType: Record<string, Partial<FullPageLayout>>
|
||||
}
|
||||
|
||||
async function emitPage(
|
||||
ctx: BuildCtx,
|
||||
slug: FullSlug,
|
||||
tree: ProcessedContent[0],
|
||||
fileData: ProcessedContent[1]["data"],
|
||||
allFiles: ProcessedContent[1]["data"][],
|
||||
layout: FullPageLayout,
|
||||
resources: StaticResources,
|
||||
treeTransforms?: TreeTransform[],
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
// For the 404 page, use an absolute base path so assets resolve correctly
|
||||
// when the hosting provider serves 404.html from any URL depth.
|
||||
// During local dev (--serve), the dev server strips baseDir itself and
|
||||
// serves files from root, so the 404 page must use "/" to avoid requesting
|
||||
// assets under a path prefix that the dev server doesn't serve.
|
||||
const baseDir =
|
||||
slug === "404"
|
||||
? ((ctx.argv.serve
|
||||
? "/"
|
||||
: new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug)
|
||||
: pathToRoot(slug)
|
||||
const externalResources = pageResources(baseDir, resources, ctx)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree,
|
||||
allFiles,
|
||||
}
|
||||
|
||||
return write({
|
||||
ctx,
|
||||
content: renderPage(cfg, slug, componentData, layout, externalResources, treeTransforms),
|
||||
slug,
|
||||
ext: ".html",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render each virtual page's Body component to HTML and parse it to a hast tree,
|
||||
* populating both the ProcessedContent tree and vfile.data.htmlAst so that
|
||||
* transclusion (e.g. ![[file.canvas]]) can inline the virtual page's content.
|
||||
*/
|
||||
function populateVirtualPageHtmlAst(
|
||||
virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}>,
|
||||
ctx: BuildCtx,
|
||||
allFiles: ProcessedContent[1]["data"][],
|
||||
resources: StaticResources,
|
||||
) {
|
||||
const cfg = ctx.cfg.configuration
|
||||
for (const ve of virtualEntries) {
|
||||
const BodyComponent = ve.layout.pageBody
|
||||
const externalResources = pageResources(pathToRoot(ve.vpSlug), resources, ctx)
|
||||
const componentData: QuartzComponentProps = {
|
||||
ctx,
|
||||
fileData: ve.vfile.data,
|
||||
externalResources,
|
||||
cfg,
|
||||
children: [],
|
||||
tree: ve.tree,
|
||||
allFiles,
|
||||
}
|
||||
try {
|
||||
const htmlString = render(BodyComponent(componentData))
|
||||
const htmlAst = fromHtml(htmlString, { fragment: true }) as HtmlRoot
|
||||
ve.vfile.data.htmlAst = htmlAst
|
||||
} catch {
|
||||
// Body rendering failed — leave htmlAst empty so transclusion falls
|
||||
// back to the default title-only display.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>> = (userOpts) => {
|
||||
const defaults = userOpts?.defaults ?? {}
|
||||
const byPageType = userOpts?.byPageType ?? {}
|
||||
|
||||
return {
|
||||
name: "PageTypeDispatcher",
|
||||
getQuartzComponents(ctx) {
|
||||
const pageTypes = getPageTypes(ctx)
|
||||
return collectComponents(pageTypes, defaults, byPageType)
|
||||
},
|
||||
async *emit(ctx, content, resources) {
|
||||
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
// Collect tree transforms from all page type plugins
|
||||
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
|
||||
(pt) => pt.treeTransforms?.(ctx) ?? [],
|
||||
)
|
||||
|
||||
// Ensure trie is available for components that need folder hierarchy (e.g. FolderContent)
|
||||
ctx.trie ??= trieFromAllFiles(allFiles)
|
||||
|
||||
// Phase 1: Generate all virtual pages first so their data is available in allFiles
|
||||
// for transclude resolution in renderPage (e.g. ![[file.canvas]], ![[file.base]])
|
||||
const virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}> = []
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
for (const vp of virtualPages) {
|
||||
const vpSlug = vp.slug as FullSlug
|
||||
const vpRelativePath = (vpSlug + ".md") as FilePath
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vpSlug,
|
||||
relativePath: vpRelativePath,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
if (vpSlug !== "404") {
|
||||
ctx.virtualPages.push([tree, vfile])
|
||||
}
|
||||
virtualEntries.push({ tree, vfile, layout, vpSlug })
|
||||
}
|
||||
}
|
||||
|
||||
// Merge virtual page data into allFiles before populating htmlAst so that
|
||||
// Body components rendered during populateVirtualPageHtmlAst can resolve
|
||||
// cross-virtual-page embeds (e.g. a .base file embedded in a .canvas file).
|
||||
// The vfile.data objects are shared by reference, so htmlAst set on earlier
|
||||
// entries becomes visible to later entries in the same pass.
|
||||
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
|
||||
|
||||
// Render Body components to populate htmlAst for transclusion
|
||||
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
|
||||
|
||||
// Phase 2: Emit regular pages (with virtual page data available for transclusion)
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
const fileData = file.data
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(
|
||||
ctx,
|
||||
slug,
|
||||
tree,
|
||||
fileData,
|
||||
allFilesWithVirtual,
|
||||
layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Emit virtual pages
|
||||
for (const ve of virtualEntries) {
|
||||
yield emitPage(
|
||||
ctx,
|
||||
ve.vpSlug,
|
||||
ve.tree,
|
||||
ve.vfile.data,
|
||||
allFilesWithVirtual,
|
||||
ve.layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
}
|
||||
},
|
||||
async *partialEmit(ctx, content, resources, changeEvents) {
|
||||
const pageTypes = [...getPageTypes(ctx)].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
const cfg = ctx.cfg.configuration
|
||||
const allFiles = content.map((c) => c[1].data)
|
||||
|
||||
// Collect tree transforms from all page type plugins
|
||||
const treeTransforms: TreeTransform[] = pageTypes.flatMap(
|
||||
(pt) => pt.treeTransforms?.(ctx) ?? [],
|
||||
)
|
||||
|
||||
// Rebuild trie on partial emit to reflect file changes
|
||||
ctx.trie = trieFromAllFiles(allFiles)
|
||||
|
||||
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!)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Generate all virtual pages first so their data is available in allFiles
|
||||
const virtualEntries: Array<{
|
||||
tree: ProcessedContent[0]
|
||||
vfile: ProcessedContent[1]
|
||||
layout: FullPageLayout
|
||||
vpSlug: FullSlug
|
||||
}> = []
|
||||
for (const pt of pageTypes) {
|
||||
if (!pt.generate) continue
|
||||
const virtualPages = pt.generate({ content, cfg, ctx })
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
for (const vp of virtualPages) {
|
||||
const vpSlug = vp.slug as FullSlug
|
||||
const vpRelativePath = (vpSlug + ".md") as FilePath
|
||||
const [tree, vfile] = defaultProcessedContent({
|
||||
slug: vpSlug,
|
||||
relativePath: vpRelativePath,
|
||||
frontmatter: { title: vp.title, tags: [] },
|
||||
...vp.data,
|
||||
})
|
||||
if (vpSlug !== "404") {
|
||||
ctx.virtualPages.push([tree, vfile])
|
||||
}
|
||||
virtualEntries.push({ tree, vfile, layout, vpSlug })
|
||||
}
|
||||
}
|
||||
|
||||
const allFilesWithVirtual = [...allFiles, ...virtualEntries.map((ve) => ve.vfile.data)]
|
||||
|
||||
// Render Body components to populate htmlAst for transclusion
|
||||
populateVirtualPageHtmlAst(virtualEntries, ctx, allFilesWithVirtual, resources)
|
||||
|
||||
// Phase 2: Emit changed regular pages
|
||||
for (const [tree, file] of content) {
|
||||
const slug = file.data.slug!
|
||||
if (!changedSlugs.has(slug)) continue
|
||||
|
||||
const fileData = file.data
|
||||
for (const pt of pageTypes) {
|
||||
if (pt.match({ slug, fileData, cfg })) {
|
||||
const layout = resolveLayout(pt, defaults, byPageType)
|
||||
yield emitPage(
|
||||
ctx,
|
||||
slug,
|
||||
tree,
|
||||
fileData,
|
||||
allFilesWithVirtual,
|
||||
layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Emit virtual pages
|
||||
for (const ve of virtualEntries) {
|
||||
yield emitPage(
|
||||
ctx,
|
||||
ve.vpSlug,
|
||||
ve.tree,
|
||||
ve.vfile.data,
|
||||
allFilesWithVirtual,
|
||||
ve.layout,
|
||||
resources,
|
||||
treeTransforms,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { match } from "./matchers"
|
||||
export { NotFoundPageType } from "./404"
|
||||
export { PageTypeDispatcher } from "./dispatcher"
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PageMatcher } from "../types"
|
||||
|
||||
export const match = {
|
||||
ext: (extension: string): PageMatcher => {
|
||||
const normalized = extension.startsWith(".") ? extension : `.${extension}`
|
||||
return ({ slug }) => slug.endsWith(normalized) || !slug.includes(".")
|
||||
},
|
||||
|
||||
slugPrefix: (prefix: string): PageMatcher => {
|
||||
return ({ slug }) => slug.startsWith(prefix)
|
||||
},
|
||||
|
||||
frontmatter: (key: string, predicate: (value: unknown) => boolean): PageMatcher => {
|
||||
return ({ fileData }) => {
|
||||
const fm = fileData.frontmatter as Record<string, unknown> | undefined
|
||||
return fm ? predicate(fm[key]) : false
|
||||
}
|
||||
},
|
||||
|
||||
and: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.every((m) => m(args))
|
||||
},
|
||||
|
||||
or: (...matchers: PageMatcher[]): PageMatcher => {
|
||||
return (args) => matchers.some((m) => m(args))
|
||||
},
|
||||
|
||||
not: (matcher: PageMatcher): PageMatcher => {
|
||||
return (args) => !matcher(args)
|
||||
},
|
||||
|
||||
all: (): PageMatcher => {
|
||||
return () => true
|
||||
},
|
||||
|
||||
none: (): PageMatcher => {
|
||||
return () => false
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Quartz Plugins Configuration Schema",
|
||||
"description": "Schema for validating quartz.plugins.json configuration files",
|
||||
"type": "object",
|
||||
"required": ["configuration", "plugins"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference"
|
||||
},
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"required": ["pageTitle", "enableSPA", "locale", "theme"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pageTitle": {
|
||||
"type": "string",
|
||||
"description": "The title of the website"
|
||||
},
|
||||
"pageTitleSuffix": {
|
||||
"type": "string",
|
||||
"description": "Suffix appended to page titles"
|
||||
},
|
||||
"enableSPA": {
|
||||
"type": "boolean",
|
||||
"description": "Enable single-page application mode"
|
||||
},
|
||||
"enablePopovers": {
|
||||
"type": "boolean",
|
||||
"description": "Enable hover popovers for links"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string",
|
||||
"description": "Locale code for the site"
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"description": "Base URL for the site"
|
||||
},
|
||||
"theme": {
|
||||
"type": "object",
|
||||
"required": ["fontOrigin", "cdnCaching", "typography", "colors"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fontOrigin": {
|
||||
"type": "string",
|
||||
"enum": ["googleFonts", "local"],
|
||||
"description": "Source of fonts"
|
||||
},
|
||||
"cdnCaching": {
|
||||
"type": "boolean",
|
||||
"description": "Enable CDN caching"
|
||||
},
|
||||
"typography": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Font family for headers"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Font family for body text"
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Font family for code"
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"required": ["lightMode", "darkMode"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"lightMode": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"light": {
|
||||
"type": "string",
|
||||
"description": "Light color"
|
||||
},
|
||||
"lightgray": {
|
||||
"type": "string",
|
||||
"description": "Light gray color"
|
||||
},
|
||||
"gray": {
|
||||
"type": "string",
|
||||
"description": "Gray color"
|
||||
},
|
||||
"darkgray": {
|
||||
"type": "string",
|
||||
"description": "Dark gray color"
|
||||
},
|
||||
"dark": {
|
||||
"type": "string",
|
||||
"description": "Dark color"
|
||||
},
|
||||
"secondary": {
|
||||
"type": "string",
|
||||
"description": "Secondary color"
|
||||
},
|
||||
"tertiary": {
|
||||
"type": "string",
|
||||
"description": "Tertiary color"
|
||||
},
|
||||
"highlight": {
|
||||
"type": "string",
|
||||
"description": "Highlight color"
|
||||
},
|
||||
"textHighlight": {
|
||||
"type": "string",
|
||||
"description": "Text highlight color"
|
||||
}
|
||||
}
|
||||
},
|
||||
"darkMode": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"light": {
|
||||
"type": "string",
|
||||
"description": "Light color"
|
||||
},
|
||||
"lightgray": {
|
||||
"type": "string",
|
||||
"description": "Light gray color"
|
||||
},
|
||||
"gray": {
|
||||
"type": "string",
|
||||
"description": "Gray color"
|
||||
},
|
||||
"darkgray": {
|
||||
"type": "string",
|
||||
"description": "Dark gray color"
|
||||
},
|
||||
"dark": {
|
||||
"type": "string",
|
||||
"description": "Dark color"
|
||||
},
|
||||
"secondary": {
|
||||
"type": "string",
|
||||
"description": "Secondary color"
|
||||
},
|
||||
"tertiary": {
|
||||
"type": "string",
|
||||
"description": "Tertiary color"
|
||||
},
|
||||
"highlight": {
|
||||
"type": "string",
|
||||
"description": "Highlight color"
|
||||
},
|
||||
"textHighlight": {
|
||||
"type": "string",
|
||||
"description": "Text highlight color"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"type": "object",
|
||||
"description": "Analytics configuration"
|
||||
},
|
||||
"ignorePatterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Patterns to ignore during processing"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"description": "Array of plugin configurations",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["source", "enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"source": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Plugin source path or identifier. Supports github:user/repo, git+https://, and https:// URLs. Append #ref to pin to a specific branch or tag (e.g., github:user/repo#my-branch). Local file paths (e.g., ./my-plugin, ../sibling-plugin, /absolute/path) are also supported for local development or airgapped environments."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["repo"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Override name for the plugin directory. If omitted, the name is derived from the repository URL."
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Git repository URL (e.g., https://github.com/user/repo.git). Supports the same URL formats as the string source."
|
||||
},
|
||||
"subdir": {
|
||||
"type": "string",
|
||||
"description": "Subdirectory within the repository containing the plugin. Used when the plugin is not at the repository root."
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "Git ref (branch or tag) to pin to. If omitted, the default branch is used."
|
||||
}
|
||||
},
|
||||
"description": "Object source with explicit repo URL and optional subdirectory, ref, and name overrides for monorepo-style plugins."
|
||||
}
|
||||
],
|
||||
"description": "Plugin source: either a string path/identifier or an object with repo/subdir configuration."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the plugin is enabled"
|
||||
},
|
||||
"order": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Plugin execution order"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Plugin-specific options"
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "string",
|
||||
"enum": ["left", "right", "beforeBody", "afterBody", "body"],
|
||||
"description": "Layout position"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"description": "Layout priority"
|
||||
},
|
||||
"display": {
|
||||
"type": "string",
|
||||
"enum": ["all", "mobile-only", "desktop-only"],
|
||||
"description": "Display mode"
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": "Conditional display logic"
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"description": "Layout group name"
|
||||
},
|
||||
"groupOptions": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"grow": {
|
||||
"type": "boolean",
|
||||
"description": "Flex grow"
|
||||
},
|
||||
"shrink": {
|
||||
"type": "boolean",
|
||||
"description": "Flex shrink"
|
||||
},
|
||||
"basis": {
|
||||
"type": "string",
|
||||
"description": "Flex basis"
|
||||
},
|
||||
"order": {
|
||||
"type": "number",
|
||||
"description": "Flex order"
|
||||
},
|
||||
"align": {
|
||||
"type": "string",
|
||||
"description": "Alignment"
|
||||
},
|
||||
"justify": {
|
||||
"type": "string",
|
||||
"description": "Justification"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"description": "Flex direction"
|
||||
},
|
||||
"wrap": {
|
||||
"type": "boolean",
|
||||
"description": "Flex wrap"
|
||||
},
|
||||
"gap": {
|
||||
"type": "string",
|
||||
"description": "Gap between items"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Layout groups configuration"
|
||||
},
|
||||
"byPageType": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Excluded plugins for this page type"
|
||||
},
|
||||
"positions": {
|
||||
"type": "object",
|
||||
"description": "Position overrides for this page type"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Layout configuration by page type"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { PluggableList } from "unified"
|
||||
import { StaticResources } from "../util/resources"
|
||||
import { ProcessedContent, QuartzPluginData } from "./vfile"
|
||||
import {
|
||||
QuartzComponent,
|
||||
QuartzComponentConstructor,
|
||||
QuartzComponentProps,
|
||||
} from "../components/types"
|
||||
import { FilePath, FullSlug } from "../util/path"
|
||||
import { BuildCtx } from "../util/ctx"
|
||||
import { GlobalConfiguration } from "../cfg"
|
||||
import { VFile } from "vfile"
|
||||
import { Root } from "hast"
|
||||
|
||||
export interface PluginTypes {
|
||||
transformers: QuartzTransformerPluginInstance[]
|
||||
filters: QuartzFilterPluginInstance[]
|
||||
emitters: QuartzEmitterPluginInstance[]
|
||||
pageTypes?: PageTypePluginEntry[]
|
||||
}
|
||||
|
||||
type OptionType = object | undefined
|
||||
type ExternalResourcesFn = (ctx: BuildCtx) => Partial<StaticResources> | undefined
|
||||
export type QuartzTransformerPlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzTransformerPluginInstance
|
||||
export type QuartzTransformerPluginInstance = {
|
||||
name: string
|
||||
textTransform?: (ctx: BuildCtx, src: string) => string
|
||||
markdownPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
htmlPlugins?: (ctx: BuildCtx) => PluggableList
|
||||
externalResources?: ExternalResourcesFn
|
||||
}
|
||||
|
||||
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzFilterPluginInstance
|
||||
export type QuartzFilterPluginInstance = {
|
||||
name: string
|
||||
shouldPublish(ctx: BuildCtx, content: ProcessedContent): boolean
|
||||
}
|
||||
|
||||
export type ChangeEvent = {
|
||||
type: "add" | "change" | "delete"
|
||||
path: FilePath
|
||||
file?: VFile
|
||||
}
|
||||
|
||||
export type QuartzEmitterPlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzEmitterPluginInstance
|
||||
export type QuartzEmitterPluginInstance = {
|
||||
name: string
|
||||
emit: (
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
) => Promise<FilePath[]> | AsyncGenerator<FilePath>
|
||||
partialEmit?: (
|
||||
ctx: BuildCtx,
|
||||
content: ProcessedContent[],
|
||||
resources: StaticResources,
|
||||
changeEvents: ChangeEvent[],
|
||||
) => Promise<FilePath[]> | AsyncGenerator<FilePath> | null
|
||||
/**
|
||||
* Returns the components (if any) that are used in rendering the page.
|
||||
* This helps Quartz optimize the page by only including necessary resources
|
||||
* for components that are actually used.
|
||||
*/
|
||||
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
|
||||
externalResources?: ExternalResourcesFn
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PageType Plugin Types
|
||||
// ============================================================================
|
||||
|
||||
export type PageMatcher = (args: {
|
||||
slug: string
|
||||
fileData: QuartzPluginData
|
||||
cfg: GlobalConfiguration
|
||||
[key: string]: unknown
|
||||
}) => boolean
|
||||
|
||||
export interface VirtualPage {
|
||||
slug: string
|
||||
title: string
|
||||
data: Partial<QuartzPluginData> & Record<string, unknown>
|
||||
}
|
||||
|
||||
export type PageGenerator = (args: {
|
||||
content: ProcessedContent[]
|
||||
cfg: GlobalConfiguration
|
||||
ctx: BuildCtx
|
||||
[key: string]: unknown
|
||||
}) => VirtualPage[]
|
||||
|
||||
/** A function that mutates a HAST tree at render time, when allFiles is available. */
|
||||
export type TreeTransform = (
|
||||
root: Root,
|
||||
slug: FullSlug,
|
||||
componentData: QuartzComponentProps,
|
||||
) => void
|
||||
|
||||
export type QuartzPageTypePlugin<Options extends OptionType = undefined> = (
|
||||
opts?: Options,
|
||||
) => QuartzPageTypePluginInstance
|
||||
|
||||
export interface QuartzPageTypePluginInstance {
|
||||
name: string
|
||||
priority?: number
|
||||
fileExtensions?: string[]
|
||||
match: PageMatcher
|
||||
generate?: PageGenerator
|
||||
layout: string
|
||||
/** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
||||
frame?: string
|
||||
body: QuartzComponentConstructor
|
||||
/** Optional render-time HAST tree transforms (e.g. resolving inline codeblocks). */
|
||||
treeTransforms?: (ctx: BuildCtx) => TreeTransform[]
|
||||
}
|
||||
|
||||
// Structural supertype accepted in plugin configuration arrays.
|
||||
// Community plugins use a differently-branded FullSlug in their PageMatcher,
|
||||
// making them incompatible with the internal PageMatcher under strict
|
||||
// function-parameter contravariance. This wider entry type avoids forcing
|
||||
// casts in quartz.ts while the dispatcher safely calls match/generate
|
||||
// with the correct arguments at runtime.
|
||||
export interface PageTypePluginEntry {
|
||||
name: string
|
||||
priority?: number
|
||||
fileExtensions?: string[]
|
||||
match: (...args: never[]) => boolean
|
||||
generate?: (...args: never[]) => VirtualPage[]
|
||||
layout: string
|
||||
/** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
|
||||
frame?: string
|
||||
body: QuartzComponentConstructor
|
||||
treeTransforms?: (...args: never[]) => TreeTransform[]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Root as HtmlRoot } from "hast"
|
||||
import { Root as MdRoot } from "mdast"
|
||||
import { Data, VFile } from "vfile"
|
||||
|
||||
export type QuartzPluginData = Data
|
||||
export type MarkdownContent = [MdRoot, VFile]
|
||||
export type ProcessedContent = [HtmlRoot, VFile]
|
||||
|
||||
export function defaultProcessedContent(vfileData: Partial<QuartzPluginData>): ProcessedContent {
|
||||
const root: HtmlRoot = { type: "root", children: [] }
|
||||
const vfile = new VFile("")
|
||||
vfile.data = vfileData
|
||||
return [root, vfile]
|
||||
}
|
||||
Reference in New Issue
Block a user