feat: support npm scoped package specifiers for plugins

Plugins can now be referenced as "@quartz-community/<name>" in
quartz.config.yaml and via 'quartz plugin add'. The npm path skips
git installation and resolves from node_modules.

Changes:
- gitLoader.ts: detect @scope/name as npm package in parsePluginSource()
- config-loader.ts: skip git install for npm packages, read manifests
  from node_modules via createRequire
- install-plugins.ts: filter npm packages from prebuild, fallback to
  YAML config parsing to avoid loading full quartz.ts
- plugin-data.js: npm detection in CLI parseGitSource()
- plugin-git-handlers.js: npm install path in handlePluginAdd()
- package.json: add missing hast-util-from-html dependency
This commit is contained in:
saberzero1
2026-07-23 01:17:53 +02:00
parent 9cf87ff1c2
commit 5b34917e98
7 changed files with 369 additions and 8 deletions
+1
View File
@@ -47,6 +47,7 @@
"esbuild-sass-plugin": "^3.6.0",
"github-slugger": "^2.0.0",
"globby": "^16.1.0",
"hast-util-from-html": "^2.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"isomorphic-git": "^1.36.3",
"lightningcss": "^1.31.1",
+279
View File
@@ -0,0 +1,279 @@
configuration:
pageTitle: Quartz 5
pageTitleSuffix: ""
enableSPA: true
enablePopovers: true
analytics:
provider: plausible
locale: en-US
baseUrl: quartz.jzhao.xyz
ignorePatterns:
- private
- templates
- .obsidian
theme:
fontOrigin: googleFonts
cdnCaching: true
typography:
header: Schibsted Grotesk
body: Source Sans Pro
code: IBM Plex Mono
colors:
lightMode:
light: "#faf8f8"
lightgray: "#e5e5e5"
gray: "#b8b8b8"
darkgray: "#4e4e4e"
dark: "#2b2b2b"
secondary: "#284b63"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#fff23688"
darkMode:
light: "#161618"
lightgray: "#393639"
gray: "#646464"
darkgray: "#d4d4d4"
dark: "#ebebec"
secondary: "#7b97aa"
tertiary: "#84a59d"
highlight: rgba(143, 159, 169, 0.15)
textHighlight: "#b3aa0288"
plugins:
- source: ../created-modified-date
enabled: true
options:
defaultDateType: modified
priority:
- frontmatter
- git
- filesystem
order: 10
- source: ../syntax-highlighting
enabled: true
options:
theme:
light: github-light
dark: github-dark
keepBackground: false
order: 20
- source: ../obsidian-flavored-markdown
enabled: true
options:
enableInHtmlEmbed: false
enableCheckbox: true
order: 30
- source: ../github-flavored-markdown
enabled: true
order: 40
- source: ../table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: ../crawl-links
enabled: true
options:
markdownLinkResolution: shortest
order: 60
- source: ../description
enabled: true
order: 70
- source: ../latex
enabled: true
options:
renderEngine: katex
order: 80
- source: ../citations
enabled: false
order: 85
- source: ../hard-line-breaks
enabled: false
order: 90
- source: ../ox-hugo
enabled: false
order: 91
- source: ../roam
enabled: false
order: 92
- source: ../fonts
enabled: true
- source: ../remove-draft
enabled: true
- source: ../explicit-publish
enabled: false
- source: ../alias-redirects
enabled: true
- source: ../content-index
enabled: true
options:
enableSiteMap: true
enableRSS: true
- source: ../favicon
enabled: true
- source: ../og-image
enabled: true
- source: ../cname
enabled: true
- source: ../canvas-page
enabled: true
- source: ../content-page
enabled: true
- source: ../folder-page
enabled: true
- source: ../tag-page
enabled: true
- source: ../explorer
enabled: true
layout:
position: left
priority: 50
- source: ../graph
enabled: true
layout:
position: right
priority: 10
- source: ../search
enabled: true
layout:
position: left
priority: 20
group: toolbar
groupOptions:
grow: true
- source: ../backlinks
enabled: true
layout:
position: right
priority: 50
- source: ../article-title
enabled: true
layout:
position: beforeBody
priority: 10
- source: ../content-meta
enabled: true
layout:
position: beforeBody
priority: 20
- source: ../tag-list
enabled: false
layout:
position: beforeBody
priority: 30
- source: ../page-title
enabled: true
layout:
position: left
priority: 10
- source: ../darkmode
enabled: true
layout:
position: left
priority: 30
group: toolbar
- source: ../reader-mode
enabled: true
layout:
position: left
priority: 35
group: toolbar
- source: ../breadcrumbs
enabled: true
layout:
position: beforeBody
priority: 5
condition: not-index
- source: ../comments
enabled: false
options:
provider: giscus
options: {}
layout:
position: afterBody
priority: 10
- source: ../footer
enabled: true
options:
links:
GitHub: https://github.com/jackyzha0/quartz
Discord Community: https://discord.gg/cRFFHYye7t
- source: ../recent-notes
enabled: false
- source: ../spacer
enabled: true
options: {}
order: 25
layout:
position: left
priority: 25
display: mobile-only
- source: ../bases-page
enabled: true
options: {}
order: 50
- source: ../note-properties
enabled: true
options:
includeAll: false
includedProperties:
- description
- tags
- aliases
excludedProperties: []
hidePropertiesView: false
delimiters: ---
language: yaml
order: 5
layout:
position: beforeBody
priority: 15
display: all
- source: ../unlisted-pages
enabled: true
options: {}
order: 45
- source: ../encrypted-pages
enabled: true
options:
iterations: 600000
passwordField: password
unlistWhenEncrypted: false
outputPath: static/encryptedContentIndex.json
order: 900
layout:
position: body
priority: 100
display: all
- source: ../stacked-pages
enabled: false
layout:
position: afterBody
priority: 50
display: all
layout:
groups:
toolbar:
priority: 35
direction: row
gap: 0.5rem
byPageType:
"404":
positions:
beforeBody: []
left: []
right: []
content: {}
folder:
exclude:
- reader-mode
positions:
right: []
tag:
exclude:
- reader-mode
positions:
right: []
canvas: {}
bases: {}
+5
View File
@@ -190,6 +190,11 @@ export function parseGitSource(source) {
typeof source === "object" && source.name ? source.name : path.basename(parsed, ".git")
return { name, url: parsed, ref, subdir }
}
// Handle npm scoped packages
if (typeof url === "string" && url.startsWith("@") && url.includes("/") && !url.includes(":")) {
const name = typeof source === "object" && source.name ? source.name : url
return { name, url: "", npmPackage: true, subdir }
}
throw new Error(`Cannot parse plugin source: ${formatSource(source)}`)
}
+10 -1
View File
@@ -1,7 +1,7 @@
import fs from "fs"
import path from "path"
import os from "os"
import { exec as execCb } from "child_process"
import { exec as execCb, execSync } from "child_process"
import { styleText, promisify } from "util"
import {
readPluginsJson,
@@ -1190,6 +1190,15 @@ export async function handlePluginAdd(
for (const source of sources) {
try {
const parsed = parseGitSource(source)
if (parsed.npmPackage) {
const name = nameOverride ?? parsed.name
console.log(styleText("cyan", `→ Installing ${name} from npm...`))
execSync(`npm install ${parsed.name}`, { cwd: process.cwd(), stdio: "inherit" })
const configSource = nameOverride ? { repo: parsed.name, name: nameOverride } : parsed.name
const pluginDir = path.join(process.cwd(), "node_modules", ...parsed.name.split("/"))
addedPlugins.push({ name, pluginDir, source: parsed.name, configSource })
continue
}
const name = nameOverride ?? parsed.name
const url = parsed.url
const ref = parsed.ref
+12 -2
View File
@@ -2,6 +2,7 @@ import fs from "fs"
import path from "path"
import YAML from "yaml"
import { styleText } from "util"
import { createRequire } from "node:module"
import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg"
import { QuartzComponent, QuartzComponentConstructor } from "../../components/types"
import { PluginTypes } from "../types"
@@ -200,8 +201,14 @@ async function resolvePluginManifest(source: PluginSource): Promise<PluginManife
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")
const require = createRequire(import.meta.url)
let pkgPath: string
if (gitSpec.npmPackage) {
pkgPath = require.resolve(`${gitSpec.name}/package.json`, { paths: [process.cwd()] })
} else {
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
pkgPath = path.join(pluginDir, "package.json")
}
if (!fs.existsSync(pkgPath)) return null
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
@@ -259,6 +266,9 @@ export async function loadQuartzConfig(
for (const entry of enabledEntries) {
try {
const gitSpec = parsePluginSource(entry.source)
if (gitSpec.npmPackage) {
continue
}
const result = await installPlugin(gitSpec, { verbose: false })
if (result.nativeDeps.size > 0) {
allNativeDeps.set(gitSpec.name, result.nativeDeps)
+8
View File
@@ -31,6 +31,8 @@ export interface GitPluginSpec {
subdir?: string
/** Whether this is a local path source */
local?: boolean
/** Whether this is an npm package (installed in node_modules) */
npmPackage?: boolean
}
export type PluginInstallSource = string | GitPluginSpec
@@ -87,6 +89,7 @@ export function parsePluginSource(source: PluginSource): GitPluginSpec {
ref: ref || expanded.ref || undefined,
subdir,
local: expanded.local,
npmPackage: expanded.npmPackage,
}
}
@@ -129,6 +132,11 @@ export function parsePluginSource(source: PluginSource): GitPluginSpec {
return { name, repo: url, ref: ref || undefined }
}
// Handle npm scoped packages (e.g. @quartz-community/syntax-highlighting)
if (typeof source === "string" && source.startsWith("@") && source.includes("/") && !source.includes(":")) {
return { name: source, repo: "", npmPackage: true }
}
// Assume it's a plain repo name and try github
const parts = source.split("/")
if (parts.length === 2) {
+54 -5
View File
@@ -1,19 +1,68 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import YAML from "yaml"
import { installPlugins, parsePluginSource } from "./gitLoader.js"
import config from "../../../quartz.js"
import type { PluginSource, QuartzPluginsJson } from "./types.js"
function resolveConfigPath(): string {
const configYamlPath = path.join(process.cwd(), "quartz.config.yaml")
const defaultConfigYamlPath = path.join(process.cwd(), "quartz.config.default.yaml")
const legacyPluginsJsonPath = path.join(process.cwd(), "quartz.plugins.json")
const legacyDefaultPluginsJsonPath = path.join(process.cwd(), "quartz.plugins.default.json")
if (fs.existsSync(configYamlPath)) return configYamlPath
if (fs.existsSync(legacyPluginsJsonPath)) return legacyPluginsJsonPath
if (fs.existsSync(defaultConfigYamlPath)) return defaultConfigYamlPath
if (fs.existsSync(legacyDefaultPluginsJsonPath)) return legacyDefaultPluginsJsonPath
return configYamlPath
}
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)
}
return JSON.parse(raw)
}
async function getExternalPluginSources(): Promise<PluginSource[]> {
try {
const module = await import("../../../quartz.js")
const config = module.default ?? module
const externalPlugins = config.externalPlugins
if (Array.isArray(externalPlugins) && externalPlugins.length > 0) {
return externalPlugins as PluginSource[]
}
} catch {
// fall back to config yaml parsing
}
const pluginsJson = readPluginsJson()
const entries = pluginsJson?.plugins ?? []
return entries.filter((entry) => entry.enabled !== false).map((entry) => entry.source)
}
async function main() {
const quartzConfig: any = config
const externalPlugins = quartzConfig.externalPlugins || []
const externalPlugins = await getExternalPluginSources()
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: PluginSource) => parsePluginSource(source))
.filter((spec) => !spec.npmPackage)
const specs = externalPlugins.map((source: string) => parsePluginSource(source))
if (specs.length === 0) {
console.log("No external Git plugins to install.")
return
}
console.log(`Installing ${specs.length} plugin(s) from Git...`)
const installed = await installPlugins(specs, { verbose: true })
if (installed.size === externalPlugins.length) {