Compare commits

...

10 Commits

Author SHA1 Message Date
saberzero1
afe98e6b1a fix: excalidraw panning on mobile and .jxl support
Some checks failed
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Build and Test / publish-tag (push) Has been cancelled
Deploy v5 Preview / Deploy v5 to Cloudflare Pages (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
2026-05-27 20:56:47 +02:00
saberzero1
f15aa0032a fix: allow updating when repository is generated from template 2026-05-27 20:43:56 +02:00
saberzero1
9e4d4518bd fix: anchor links without alias follow obsidian reading mode 2026-05-27 00:33:38 +02:00
saberzero1
3138a88bb1 chore: updated layouts to include table of contents 2026-05-27 00:18:06 +02:00
saberzero1
db5aca2b8d fix: apply default position when installing plugins 2026-05-27 00:17:45 +02:00
saberzero1
dd1b36c148 fix: recent notes default date type 2026-05-26 22:26:21 +02:00
saberzero1
4579e49465 test: regression tests for junctions 2026-05-26 21:15:36 +02:00
saberzero1
882d3b3f50 fix: fall back to junctions on windows 2026-05-26 21:15:20 +02:00
saberzero1
667d487a0f fix: re-render graph on mode change 2026-05-26 19:47:54 +02:00
saberzero1
36b3542371 fix: block link casing 2026-05-26 18:16:22 +02:00
11 changed files with 340 additions and 36 deletions

2
package-lock.json generated
View File

@@ -1857,7 +1857,7 @@
},
"node_modules/@quartz-community/utils": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/quartz-community/utils.git#7a43501ac1c728e8a7bfa75db35e8791bfa1f41c",
"resolved": "git+ssh://git@github.com/quartz-community/utils.git#e09d8b0bbccb46073b23c00affaf3e40a2c3b0c4",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -70,6 +70,9 @@ plugins:
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
@@ -142,7 +145,7 @@ plugins:
enabled: true
layout:
position: right
priority: 30
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:

View File

@@ -22,8 +22,8 @@
"bases-page": {
"source": "github:quartz-community/bases-page",
"resolved": "https://github.com/quartz-community/bases-page.git",
"commit": "077ebacd657a86bc92adc4ec392a238cacbfc298",
"installedAt": "2026-05-24T19:26:20.456Z"
"commit": "ec5711f91e40b1e8477a101f6e2604926e4f0222",
"installedAt": "2026-05-26T16:15:16.668Z"
},
"breadcrumbs": {
"source": "github:quartz-community/breadcrumbs",
@@ -76,8 +76,8 @@
"crawl-links": {
"source": "github:quartz-community/crawl-links",
"resolved": "https://github.com/quartz-community/crawl-links.git",
"commit": "533a2213b673c3ea2a67f6e160f4e6a1262e8a6d",
"installedAt": "2026-05-24T15:36:16.458Z"
"commit": "69497e22046d62ee601415ad832bcde89a70a544",
"installedAt": "2026-05-26T15:56:37.042Z"
},
"created-modified-date": {
"source": "github:quartz-community/created-modified-date",
@@ -142,8 +142,8 @@
"graph": {
"source": "github:quartz-community/graph",
"resolved": "https://github.com/quartz-community/graph.git",
"commit": "27eb4dd71ca10c8bd204cf1537da4448811c15f3",
"installedAt": "2026-05-24T15:36:16.989Z"
"commit": "67ac4993907618a0ea5bd33726f792c572cddbad",
"installedAt": "2026-05-26T17:45:56.210Z"
},
"hard-line-breaks": {
"source": "github:quartz-community/hard-line-breaks",
@@ -160,20 +160,20 @@
"note-properties": {
"source": "github:quartz-community/note-properties",
"resolved": "https://github.com/quartz-community/note-properties.git",
"commit": "cccd275468e93ea59115e755d4bb8599192c45b9",
"installedAt": "2026-05-24T15:36:16.987Z"
"commit": "874712a9910299b906606c885ea1fe9602114ca7",
"installedAt": "2026-05-26T16:15:17.177Z"
},
"obsidian-flavored-markdown": {
"source": "github:quartz-community/obsidian-flavored-markdown",
"resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git",
"commit": "186d6bd990a1b88ab35c4f66ea4f257f1e7b4a51",
"installedAt": "2026-05-25T23:55:00.345Z"
"commit": "7f61d4bcf7537d056528f6cecaf5c35b39f74fe8",
"installedAt": "2026-05-27T18:30:09.459Z"
},
"obsidian-plugin-excalidraw": {
"source": "github:quartz-community/obsidian-plugin-excalidraw",
"resolved": "https://github.com/quartz-community/obsidian-plugin-excalidraw.git",
"commit": "9c5ff6bd6d2ee27814aeaf98ac4ddfd025a985fa",
"installedAt": "2026-05-25T17:40:01.376Z"
"commit": "554ae8af7ac4d7fdff8d78e65bfa9b4dd53cbb87",
"installedAt": "2026-05-27T18:30:09.649Z"
},
"obsidian-plugin-ttrpg-tools-maps": {
"source": "github:quartz-community/obsidian-plugin-ttrpg-tools-maps",
@@ -206,9 +206,9 @@
"subdir": "plugin"
},
"resolved": "https://github.com/saberzero1/quartz-themes.git",
"commit": "b82021d7866c11353a0cc55b75ac3198d988d7d7",
"commit": "0da7355c49aa68cb375726913f41c64ac1f11733",
"subdir": "plugin",
"installedAt": "2026-05-25T15:05:52.311Z"
"installedAt": "2026-05-27T18:31:15.006Z"
},
"reader-mode": {
"source": "github:quartz-community/reader-mode",
@@ -219,8 +219,8 @@
"recent-notes": {
"source": "github:quartz-community/recent-notes",
"resolved": "https://github.com/quartz-community/recent-notes.git",
"commit": "74cb2588deae80fd7c13bf5fdeedebffe547d214",
"installedAt": "2026-05-24T15:36:17.428Z"
"commit": "4505adba9c213967ddf94ea5fc05039245fd9fe9",
"installedAt": "2026-05-26T20:20:42.557Z"
},
"remove-draft": {
"source": "github:quartz-community/remove-draft",

View File

@@ -22,6 +22,7 @@ import {
gitPull,
popContentFolder,
stashContentFolder,
symlinkOrCopy,
} from "./helpers.js"
import {
handlePluginRestore,
@@ -210,7 +211,7 @@ export async function handleCreate(argv) {
preserveTimestamps: true,
})
} else if (setupStrategy === "symlink") {
await fs.promises.symlink(originalFolder, contentFolder, "dir")
await symlinkOrCopy(originalFolder, contentFolder)
}
} else if (setupStrategy === "new") {
await fs.promises.writeFile(

View File

@@ -3,6 +3,7 @@ import { styleText } from "util"
import { contentCacheFolder } from "./constants.js"
import { spawnSync } from "child_process"
import fs from "fs"
import path from "path"
export function escapePath(fp) {
return fp
@@ -33,7 +34,7 @@ export async function stashContentFolder(contentFolder) {
}
export function gitPull(origin, branch) {
const flags = ["--no-rebase", "--autostash", "--no-edit"]
const flags = ["--no-rebase", "--autostash", "--no-edit", "--allow-unrelated-histories"]
const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" })
if (out.stderr) {
throw new Error(styleText("red", `Error while pulling updates: ${out.stderr}`))
@@ -52,3 +53,57 @@ export async function popContentFolder(contentFolder) {
})
await fs.promises.rm(contentCacheFolder, { force: true, recursive: true })
}
/**
* Create a directory symlink with Windows fallback.
*
* On Windows, creating symlinks requires Developer Mode or admin privileges.
* When that fails (EPERM), we try a junction first (no elevation needed),
* then fall back to a recursive copy as a last resort.
*
* @param {string} target Symlink target (may be relative)
* @param {string} linkPath Path where the link is created
*/
export function symlinkOrCopySync(target, linkPath) {
try {
fs.symlinkSync(target, linkPath, "dir")
} catch (err) {
if (err.code === "EEXIST") return
if (err.code === "EPERM" && process.platform === "win32") {
try {
fs.symlinkSync(target, linkPath, "junction")
return
} catch {
const resolvedTarget = path.resolve(path.dirname(linkPath), target)
fs.cpSync(resolvedTarget, linkPath, { recursive: true })
return
}
}
throw err
}
}
/**
* Async version of {@link symlinkOrCopySync}.
*
* @param {string} target Symlink target (may be relative)
* @param {string} linkPath Path where the link is created
*/
export async function symlinkOrCopy(target, linkPath) {
try {
await fs.promises.symlink(target, linkPath, "dir")
} catch (err) {
if (err.code === "EEXIST") return
if (err.code === "EPERM" && process.platform === "win32") {
try {
await fs.promises.symlink(target, linkPath, "junction")
return
} catch {
const resolvedTarget = path.resolve(path.dirname(linkPath), target)
await fs.promises.cp(resolvedTarget, linkPath, { recursive: true })
return
}
}
throw err
}
}

237
quartz/cli/helpers.test.js Normal file
View File

@@ -0,0 +1,237 @@
import test, { describe, beforeEach, afterEach, mock } from "node:test"
import assert from "node:assert"
import fs from "fs"
import path from "path"
import os from "os"
import { symlinkOrCopySync, symlinkOrCopy } from "./helpers.js"
function makeTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "quartz-symlink-test-"))
}
function makeTarget(tmpDir) {
const target = path.join(tmpDir, "target")
fs.mkdirSync(target)
fs.writeFileSync(path.join(target, "marker.txt"), "hello")
return target
}
describe("symlinkOrCopySync", () => {
let tmpDir
beforeEach(() => {
tmpDir = makeTmpDir()
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test("creates a symlink on success", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
symlinkOrCopySync(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isSymbolicLink())
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
})
test("silently succeeds when link already exists (EEXIST)", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
symlinkOrCopySync(target, linkPath)
assert.doesNotThrow(() => symlinkOrCopySync(target, linkPath))
})
test("re-throws non-EPERM errors", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "nonexistent-parent", "link")
assert.throws(
() => symlinkOrCopySync(target, linkPath),
(err) => {
return err.code === "ENOENT"
},
)
})
test("falls back to junction on Windows EPERM", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
let callCount = 0
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (t, lp, type) => {
callCount++
if (callCount === 1 && type === "dir") {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
}
return originalSymlinkSync(t, lp, type)
})
try {
symlinkOrCopySync(target, linkPath)
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
assert.strictEqual(callCount, 2)
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("falls back to copy when both symlink and junction fail on Windows", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
})
try {
symlinkOrCopySync(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isDirectory(), "fallback should produce a real directory, not a symlink")
assert.strictEqual(fs.readFileSync(path.join(linkPath, "marker.txt"), "utf-8"), "hello")
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("does not fall back on EPERM when not on Windows", () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
const originalSymlinkSync = fs.symlinkSync
mock.method(fs, "symlinkSync", (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
throw err
})
try {
assert.throws(
() => symlinkOrCopySync(target, linkPath),
(err) => err.code === "EPERM",
)
} finally {
fs.symlinkSync.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
})
describe("symlinkOrCopy", () => {
let tmpDir
beforeEach(() => {
tmpDir = makeTmpDir()
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
test("creates a symlink on success", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
await symlinkOrCopy(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isSymbolicLink())
assert.ok(fs.existsSync(path.join(linkPath, "marker.txt")))
})
test("silently succeeds when link already exists (EEXIST)", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
await symlinkOrCopy(target, linkPath)
await assert.doesNotReject(() => symlinkOrCopy(target, linkPath))
})
test("falls back to copy when both symlink and junction fail on Windows", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "win32", configurable: true })
const originalSymlink = fs.promises.symlink
mock.method(fs.promises, "symlink", async (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
err.errno = -4048
err.syscall = "symlink"
throw err
})
try {
await symlinkOrCopy(target, linkPath)
const stat = fs.lstatSync(linkPath)
assert.ok(stat.isDirectory(), "fallback should produce a real directory, not a symlink")
assert.strictEqual(fs.readFileSync(path.join(linkPath, "marker.txt"), "utf-8"), "hello")
} finally {
fs.promises.symlink.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
test("does not fall back on EPERM when not on Windows", async () => {
const target = makeTarget(tmpDir)
const linkPath = path.join(tmpDir, "link")
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
mock.method(fs.promises, "symlink", async (_t, _lp, _type) => {
const err = new Error("EPERM: operation not permitted, symlink")
err.code = "EPERM"
throw err
})
try {
await assert.rejects(
() => symlinkOrCopy(target, linkPath),
(err) => err.code === "EPERM",
)
} finally {
fs.promises.symlink.mock.restore()
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform)
}
}
})
})

View File

@@ -20,6 +20,7 @@ import {
resolveLockfileName,
getNameOverrides,
} from "./plugin-data.js"
import { symlinkOrCopySync } from "./helpers.js"
const INTERNAL_EXPORTS = new Set(["manifest", "default"])
@@ -128,12 +129,7 @@ function needsBuild(pluginDir) {
* share a single copy of packages like unified, vfile, rehype-raw, etc.
*/
function trySymlink(target, linkPath) {
try {
fs.symlinkSync(target, linkPath, "dir")
} catch (err) {
if (err.code === "EEXIST") return
throw err
}
symlinkOrCopySync(target, linkPath)
}
function linkPeerPlugins(pluginDir) {
@@ -621,7 +617,7 @@ export async function handlePluginInstallUnified({
}
console.log(styleText("cyan", `→ Linking ${name} from ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
symlinkOrCopySync(resolvedPath, pluginDir)
lockfile.plugins[name] = {
source: entry.source,
resolved: resolvedPath,
@@ -810,7 +806,7 @@ export async function handlePluginInstallUnified({
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
symlinkOrCopySync(entry.resolved, pluginDir)
console.log(styleText("green", `${name} restored (local symlink)`))
restoredPlugins.push({ name, pluginDir })
installed++
@@ -1045,7 +1041,7 @@ export async function handlePluginInstallUnified({
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
symlinkOrCopySync(entry.resolved, pluginDir)
console.log(styleText("green", `${name} (local) linked`))
pluginsToBuild.push({ name, pluginDir })
installed++
@@ -1222,7 +1218,7 @@ export async function handlePluginAdd(
}
console.log(styleText("cyan", `→ Adding ${name} from local path ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
symlinkOrCopySync(resolvedPath, pluginDir)
lockfile.plugins[name] = {
source,
resolved: resolvedPath,
@@ -1315,13 +1311,13 @@ export async function handlePluginAdd(
}
if (manifest?.components) {
const layoutPositions = new Set(["left", "right", "beforeBody", "afterBody"])
const firstComponentKey = Object.keys(manifest.components)[0]
const comp = manifest.components[firstComponentKey]
if (comp?.defaultPosition) {
if (comp?.defaultPosition && layoutPositions.has(comp.defaultPosition)) {
newEntry.layout = {
position: comp.defaultPosition,
priority: comp.defaultPriority ?? 50,
display: "all",
}
}
}

View File

@@ -72,6 +72,9 @@ plugins:
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
@@ -156,7 +159,7 @@ plugins:
enabled: true
layout:
position: right
priority: 30
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:

View File

@@ -72,6 +72,9 @@ plugins:
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
@@ -161,7 +164,7 @@ plugins:
enabled: true
layout:
position: right
priority: 30
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:

View File

@@ -82,6 +82,9 @@ plugins:
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
@@ -172,7 +175,7 @@ plugins:
enabled: true
layout:
position: right
priority: 30
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout:

View File

@@ -82,6 +82,9 @@ plugins:
- source: github:quartz-community/table-of-contents
enabled: true
order: 50
layout:
position: right
priority: 30
- source: github:quartz-community/crawl-links
enabled: true
options:
@@ -172,7 +175,7 @@ plugins:
enabled: true
layout:
position: right
priority: 30
priority: 50
- source: github:quartz-community/article-title
enabled: true
layout: