diff --git a/quartz/cli/plugin-data.js b/quartz/cli/plugin-data.js index f5b4aa0c0..8171295f9 100644 --- a/quartz/cli/plugin-data.js +++ b/quartz/cli/plugin-data.js @@ -83,7 +83,20 @@ export function writeLockfile(lockfile) { fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n") } +export function isLocalSource(source) { + if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) { + return true + } + // Windows absolute paths (e.g. C:\ or D:/) + if (/^[A-Za-z]:[\\/]/.test(source)) { + return true + } + return false +} export function extractPluginName(source) { + if (isLocalSource(source)) { + return path.basename(source.replace(/[\/]+$/, "")) + } if (source.startsWith("github:")) { const withoutPrefix = source.replace("github:", "") const [repoPath] = withoutPrefix.split("#") @@ -110,6 +123,11 @@ export function readManifestFromPackageJson(pluginDir) { } export function parseGitSource(source) { + if (isLocalSource(source)) { + const resolved = path.resolve(source) + const name = path.basename(resolved) + return { name, url: resolved, ref: undefined, local: true } + } if (source.startsWith("github:")) { const [repoPath, ref] = source.replace("github:", "").split("#") const [owner, repo] = repoPath.split("/") diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 568c85268..086e4d908 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -13,6 +13,7 @@ import { getGitCommit, PLUGINS_DIR, LOCKFILE_PATH, + isLocalSource, } from "./plugin-data.js" const INTERNAL_EXPORTS = new Set(["manifest", "default"]) @@ -187,6 +188,37 @@ export async function handlePluginInstall() { for (const [name, entry] of Object.entries(lockfile.plugins)) { const pluginDir = path.join(PLUGINS_DIR, name) + // Local plugin: ensure symlink exists + if (entry.commit === "local") { + try { + if (fs.existsSync(pluginDir)) { + const stat = fs.lstatSync(pluginDir) + if (stat.isSymbolicLink() && fs.readlinkSync(pluginDir) === entry.resolved) { + console.log(styleText("gray", ` ✓ ${name} (local) already linked`)) + installed++ + continue + } + // Wrong target or not a symlink — remove and re-link + if (stat.isSymbolicLink()) fs.unlinkSync(pluginDir) + else fs.rmSync(pluginDir, { recursive: true }) + } + if (!fs.existsSync(entry.resolved)) { + console.log(styleText("red", ` ✗ ${name}: local path missing: ${entry.resolved}`)) + failed++ + continue + } + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + fs.symlinkSync(entry.resolved, pluginDir, "dir") + console.log(styleText("green", ` ✓ ${name} (local) linked`)) + pluginsToBuild.push({ name, pluginDir }) + installed++ + } catch { + console.log(styleText("red", ` ✗ ${name}: failed to link local path`)) + failed++ + } + continue + } + if (fs.existsSync(pluginDir)) { try { const currentCommit = getGitCommit(pluginDir) @@ -270,7 +302,7 @@ export async function handlePluginAdd(sources) { for (const source of sources) { try { - const { name, url, ref } = parseGitSource(source) + const { name, url, ref, local } = parseGitSource(source) const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { @@ -278,25 +310,45 @@ export async function handlePluginAdd(sources) { continue } - console.log(styleText("cyan", `→ Adding ${name} from ${url}...`)) - - if (ref) { - execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) + if (local) { + // Local path: create symlink instead of git clone + const resolvedPath = path.resolve(url) + if (!fs.existsSync(resolvedPath)) { + console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`)) + continue + } + console.log(styleText("cyan", `→ Adding ${name} from local path ${resolvedPath}...`)) + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + fs.symlinkSync(resolvedPath, pluginDir, "dir") + lockfile.plugins[name] = { + source, + resolved: resolvedPath, + commit: "local", + installedAt: new Date().toISOString(), + } + addedPlugins.push({ name, pluginDir, source }) + console.log(styleText("green", `✓ Added ${name} (local symlink)`)) } else { - execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) - } + console.log(styleText("cyan", `→ Adding ${name} from ${url}...`)) - const commit = getGitCommit(pluginDir) - lockfile.plugins[name] = { - source, - resolved: url, - commit, - ...(ref && { ref }), - installedAt: new Date().toISOString(), - } + if (ref) { + execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) + } else { + execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) + } - addedPlugins.push({ name, pluginDir, source }) - console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) + const commit = getGitCommit(pluginDir) + lockfile.plugins[name] = { + source, + resolved: url, + commit, + ...(ref && { ref }), + installedAt: new Date().toISOString(), + } + + addedPlugins.push({ name, pluginDir, source }) + console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) + } } catch (error) { console.log(styleText("red", `✗ Failed to add ${source}: ${error}`)) } @@ -504,6 +556,17 @@ export async function handlePluginCheck() { const results = [] for (const [name, entry] of Object.entries(lockfile.plugins)) { + // Local plugins: show "local" status, skip git checks + if (entry.commit === "local") { + results.push({ + name, + installed: "local", + latest: "—", + status: "local", + }) + continue + } + try { const lsRemoteRef = entry.ref ? `refs/heads/${entry.ref}` : "HEAD" const latestCommit = execSync(`git ls-remote ${entry.resolved} ${lsRemoteRef}`, { @@ -536,7 +599,11 @@ export async function handlePluginCheck() { for (const r of results) { const color = - r.status === "up to date" ? "green" : r.status === "check failed" ? "red" : "yellow" + r.status === "up to date" || r.status === "local" + ? "green" + : r.status === "check failed" + ? "red" + : "yellow" console.log( `${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText( color, @@ -571,6 +638,13 @@ export async function handlePluginUpdate(names) { continue } + // Local plugins: just rebuild, no git operations + if (entry.commit === "local") { + console.log(styleText("cyan", `→ Rebuilding local plugin ${name}...`)) + updatedPlugins.push({ name, pluginDir }) + continue + } + try { console.log(styleText("cyan", `→ Updating ${name}...`)) const fetchRef = entry.ref || "" @@ -624,6 +698,20 @@ export async function handlePluginList() { for (const [name, entry] of Object.entries(lockfile.plugins)) { const pluginDir = path.join(PLUGINS_DIR, name) const exists = fs.existsSync(pluginDir) + + // Local plugins: special display + if (entry.commit === "local") { + const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink() + const status = isLinked ? styleText("green", "✓") : styleText("red", "✗") + console.log(` ${status} ${styleText("bold", name)}`) + console.log(` Source: ${entry.source}`) + console.log(` Type: local symlink`) + console.log(` Target: ${entry.resolved}`) + console.log(` Installed: ${new Date(entry.installedAt).toLocaleDateString()}`) + console.log() + continue + } + let currentCommit = entry.commit if (exists) { @@ -676,6 +764,26 @@ export async function handlePluginRestore() { continue } + // Local plugin: re-symlink + if (entry.commit === "local") { + try { + if (!fs.existsSync(entry.resolved)) { + console.log(styleText("red", ` ✗ ${name}: local path missing: ${entry.resolved}`)) + failed++ + continue + } + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + fs.symlinkSync(entry.resolved, pluginDir, "dir") + console.log(styleText("green", `✓ ${name} restored (local symlink)`)) + restoredPlugins.push({ name, pluginDir }) + installed++ + } catch { + console.log(styleText("red", `✗ ${name}: failed to restore local symlink`)) + failed++ + } + continue + } + try { console.log( styleText("cyan", `→ ${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)}...`), @@ -785,13 +893,18 @@ export async function handlePluginResolve({ dryRun = false } = {}) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }) } - // Find config entries whose source is a git-resolvable URL and not yet in lockfile + // Find config entries whose source is a git/local-resolvable URL and not yet in lockfile const missing = pluginsJson.plugins.filter((entry) => { const name = extractPluginName(entry.source) if (lockfile.plugins[name]) return false - // Only attempt sources that parseGitSource can handle + // Only attempt sources that parseGitSource can handle (git URLs + local paths) const src = entry.source - return src.startsWith("github:") || src.startsWith("git+") || src.startsWith("https://") + return ( + src.startsWith("github:") || + src.startsWith("git+") || + src.startsWith("https://") || + isLocalSource(src) + ) }) if (missing.length === 0) { @@ -818,10 +931,21 @@ export async function handlePluginResolve({ dryRun = false } = {}) { for (const entry of missing) { try { - const { name, url, ref } = parseGitSource(entry.source) + const { name, url, ref, local } = parseGitSource(entry.source) const pluginDir = path.join(PLUGINS_DIR, name) if (fs.existsSync(pluginDir)) { + if (local) { + console.log(styleText("yellow", `⚠ ${name} directory already exists, updating lockfile`)) + lockfile.plugins[name] = { + source: entry.source, + resolved: url, + commit: "local", + installedAt: new Date().toISOString(), + } + installed.push({ name, pluginDir }) + continue + } console.log(styleText("yellow", `⚠ ${name} directory already exists, updating lockfile`)) const commit = getGitCommit(pluginDir) lockfile.plugins[name] = { @@ -835,25 +959,46 @@ export async function handlePluginResolve({ dryRun = false } = {}) { continue } - console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`)) - - if (ref) { - execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) + if (local) { + // Local path: symlink + const resolvedPath = path.resolve(url) + if (!fs.existsSync(resolvedPath)) { + console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`)) + failed++ + continue + } + console.log(styleText("cyan", `→ Linking ${name} from ${resolvedPath}...`)) + fs.mkdirSync(path.dirname(pluginDir), { recursive: true }) + fs.symlinkSync(resolvedPath, pluginDir, "dir") + lockfile.plugins[name] = { + source: entry.source, + resolved: resolvedPath, + commit: "local", + installedAt: new Date().toISOString(), + } + installed.push({ name, pluginDir }) + console.log(styleText("green", `✓ Linked ${name} (local)`)) } else { - execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) - } + console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`)) - const commit = getGitCommit(pluginDir) - lockfile.plugins[name] = { - source: entry.source, - resolved: url, - commit, - ...(ref && { ref }), - installedAt: new Date().toISOString(), - } + if (ref) { + execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) + } else { + execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) + } - installed.push({ name, pluginDir }) - console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`)) + const commit = getGitCommit(pluginDir) + lockfile.plugins[name] = { + source: entry.source, + resolved: url, + commit, + ...(ref && { ref }), + installedAt: new Date().toISOString(), + } + + installed.push({ name, pluginDir }) + console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`)) + } } catch (error) { console.log(styleText("red", `✗ Failed to resolve ${entry.source}: ${error}`)) failed++ diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index d3806248d..c0398ce31 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -13,7 +13,13 @@ import { PluginLayoutDeclaration, FlexGroupConfig, } from "./types" -import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" +import { + parsePluginSource, + installPlugin, + getPluginEntryPoint, + toFileUrl, + isLocalSource, +} from "./gitLoader" import { loadComponentsFromPackage } from "./componentLoader" import { loadFramesFromPackage } from "./frameLoader" import { componentRegistry } from "../../components/registry" @@ -44,6 +50,10 @@ function readPluginsJson(): QuartzPluginsJson | null { } function extractPluginName(source: string): string { + // Local file paths: use directory basename + if (isLocalSource(source)) { + return path.basename(source.replace(/[\/]+$/, "")) + } if (source.startsWith("github:")) { const withoutPrefix = source.replace("github:", "") const [repoPath] = withoutPrefix.split("#") diff --git a/quartz/plugins/loader/gitLoader.ts b/quartz/plugins/loader/gitLoader.ts index 1c79fa250..aaf7c470a 100644 --- a/quartz/plugins/loader/gitLoader.ts +++ b/quartz/plugins/loader/gitLoader.ts @@ -21,27 +21,52 @@ export function toFileUrl(filePath: string): string { export interface GitPluginSpec { /** Plugin name (used for directory) */ name: string - /** Git repository URL (https://github.com/user/repo.git or just github:user/repo) */ + /** Git repository URL or absolute local path */ repo: string /** Git ref (branch, tag, or commit hash). Defaults to 'main' */ ref?: string /** Optional subdirectory within the repo if plugin is not at root */ subdir?: string + /** Whether this is a local path source */ + local?: boolean } export type PluginInstallSource = string | GitPluginSpec const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins") +/** + * Check if a source string refers to a local file path. + * Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\). + */ +export function isLocalSource(source: string): boolean { + if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) { + return true + } + // Windows absolute paths (e.g. C:\ or D:/) + if (/^[A-Za-z]:[\\/]/.test(source)) { + return true + } + return false +} + /** * Parse a plugin source string into a GitPluginSpec * Supports: + * - "./path/to/plugin" or "/absolute/path" -> local path * - "github:user/repo" -> https://github.com/user/repo.git * - "github:user/repo#ref" -> https://github.com/user/repo.git with specific ref * - "git+https://..." -> direct git URL * - "https://github.com/..." -> direct https URL */ export function parsePluginSource(source: string): GitPluginSpec { + // Handle local paths + if (isLocalSource(source)) { + const resolved = path.resolve(source) + const name = path.basename(resolved) + return { name, repo: resolved, local: true } + } + // Handle github shorthand: github:user/repo or github:user/repo#ref if (source.startsWith("github:")) { const withoutPrefix = source.replace("github:", "") @@ -94,7 +119,7 @@ function extractRepoName(url: string): string { } /** - * Install a plugin from a Git repository + * Install a plugin from a Git repository, or symlink a local plugin. */ export async function installPlugin( spec: GitPluginSpec, @@ -102,6 +127,57 @@ export async function installPlugin( ): Promise { const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name) + // Local source: symlink instead of clone + if (spec.local) { + if (!fs.existsSync(spec.repo)) { + throw new Error(`Local plugin path does not exist: ${spec.repo}`) + } + + if (!options.force && fs.existsSync(pluginDir)) { + // Check if existing entry is already a symlink to the right place + try { + const stat = fs.lstatSync(pluginDir) + if (stat.isSymbolicLink() && fs.realpathSync(pluginDir) === fs.realpathSync(spec.repo)) { + if (options.verbose) { + console.log(styleText("cyan", `→`), `Plugin ${spec.name} already linked`) + } + return pluginDir + } + } catch { + // stat failed, recreate + } + } + + // Clean up if force reinstall or existing non-symlink entry + if (fs.existsSync(pluginDir)) { + const stat = fs.lstatSync(pluginDir) + if (stat.isSymbolicLink()) { + fs.unlinkSync(pluginDir) + } else { + fs.rmSync(pluginDir, { recursive: true }) + } + } + + // Ensure parent directory exists + const parentDir = path.dirname(pluginDir) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + + if (options.verbose) { + console.log(styleText("cyan", `→`), `Linking ${spec.name} from ${spec.repo}...`) + } + + fs.symlinkSync(spec.repo, pluginDir, "dir") + + if (options.verbose) { + console.log(styleText("green", `✓`), `Linked ${spec.name}`) + } + + return pluginDir + } + + // Git source: clone // Check if already installed if (!options.force && fs.existsSync(pluginDir)) { // Check if it's a git repo by trying to resolve HEAD diff --git a/quartz/plugins/loader/index.ts b/quartz/plugins/loader/index.ts index dad4a0cea..d034515ea 100644 --- a/quartz/plugins/loader/index.ts +++ b/quartz/plugins/loader/index.ts @@ -14,7 +14,13 @@ import { QuartzEmitterPlugin, QuartzPageTypePlugin, } from "../types" -import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" +import { + parsePluginSource, + installPlugin, + getPluginEntryPoint, + toFileUrl, + isLocalSource, +} from "./gitLoader" const MINIMUM_QUARTZ_VERSION = "4.5.0" @@ -115,8 +121,9 @@ function extractPluginFactory( } function isGitSource(source: string): boolean { - // Check if it's a Git-based source + // 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/") || @@ -267,7 +274,7 @@ async function resolveSinglePlugin( plugin: factory, manifest: fullManifest, type: detectedType, - source: `${gitSpec.repo}#${gitSpec.ref}`, + source: gitSpec.local ? `local:${gitSpec.repo}` : `${gitSpec.repo}#${gitSpec.ref}`, } if (options.verbose) { diff --git a/quartz/plugins/quartz-plugins.schema.json b/quartz/plugins/quartz-plugins.schema.json index a4c2cfe90..563cebcf0 100644 --- a/quartz/plugins/quartz-plugins.schema.json +++ b/quartz/plugins/quartz-plugins.schema.json @@ -192,7 +192,7 @@ "properties": { "source": { "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)." + "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." }, "enabled": { "type": "boolean",