Initial import

This commit is contained in:
Admin
2026-07-11 17:08:37 +00:00
commit 03e5f3452b
303 changed files with 43365 additions and 0 deletions
@@ -0,0 +1,3 @@
import rfdc from "rfdc"
export const clone = rfdc()
@@ -0,0 +1,65 @@
import { QuartzConfig } from "../cfg"
import { ProcessedContent, QuartzPluginData } from "../plugins/vfile"
import { FileTrieNode } from "./fileTrie"
import { FilePath, FullSlug } from "./path"
export interface Argv {
directory: string
verbose: boolean
output: string
serve: boolean
watch: boolean
port: number
wsPort: number
remoteDevHost?: string
concurrency?: number
}
export type BuildTimeTrieData = QuartzPluginData & {
slug: string
title: string
filePath: string
}
/**
* Mapping from logical asset names (e.g. "index.css") to their content-hashed
* filenames (e.g. "index-a3f2c1b.css"). Populated by the ComponentResources
* emitter before pages are rendered.
*/
export type HashedResourceNames = Record<string, string>
export interface BuildCtx {
buildId: string
argv: Argv
cfg: QuartzConfig
allSlugs: FullSlug[]
allFiles: FilePath[]
trie?: FileTrieNode<BuildTimeTrieData>
incremental: boolean
/** Virtual pages generated by page type plugins (e.g. tag pages, folder pages, bases pages) */
virtualPages: ProcessedContent[]
/** Content-hashed asset filenames, populated by ComponentResources emitter */
hashedResourceNames?: HashedResourceNames
/** Maps CSS content strings to their emitted hashed filenames. Populated by ComponentResources. */
componentCssMap?: Map<string, string>
/** Maps inline CSS/JS content to extracted external file paths. Populated by ComponentResources. */
extractedInlineResources?: Map<string, string>
}
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
const trie = new FileTrieNode<BuildTimeTrieData>([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
return trie
}
export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">
@@ -0,0 +1,47 @@
const U200D = String.fromCharCode(8205)
const UFE0Fg = /\uFE0F/g
export function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char)
}
function toCodePoint(unicodeSurrogates: string) {
const r = []
let c = 0,
p = 0,
i = 0
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++)
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
p = 0
} else if (55296 <= c && c <= 56319) {
p = c
} else {
r.push(c.toString(16))
}
}
return r.join("-")
}
type EmojiMap = {
codePointToName: Record<string, string>
nameToBase64: Record<string, string>
}
let emojimap: EmojiMap | undefined = undefined
export async function loadEmoji(code: string) {
if (!emojimap) {
const data = await import("./emojimap.json")
emojimap = data
}
const name = emojimap.codePointToName[`${code.toUpperCase()}`]
if (!name) throw new Error(`codepoint ${code} not found in map`)
const b64 = emojimap.nameToBase64[name]
if (!b64) throw new Error(`name ${name} not found in map`)
return b64
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export { escapeHTML, unescapeHTML } from "@quartz-community/utils"
@@ -0,0 +1,473 @@
import test, { describe, beforeEach } from "node:test"
import assert from "node:assert"
import { FileTrieNode } from "./fileTrie"
import { FullSlug } from "./path"
interface TestData {
title: string
slug: string
filePath: string
}
describe("FileTrie", () => {
let trie: FileTrieNode<TestData>
beforeEach(() => {
trie = new FileTrieNode<TestData>([])
})
describe("constructor", () => {
test("should create an empty trie", () => {
assert.deepStrictEqual(trie.children, [])
assert.strictEqual(trie.slug, "")
assert.strictEqual(trie.displayName, "")
assert.strictEqual(trie.data, null)
})
test("should set displayName from data title", () => {
const data = {
title: "Test Title",
slug: "test",
filePath: "test.md",
}
trie.add(data)
assert.strictEqual(trie.children[0].displayName, "Test Title")
})
test("should be able to set displayName", () => {
const data = {
title: "Test Title",
slug: "test",
filePath: "test.md",
}
trie.add(data)
trie.children[0].displayName = "Modified"
assert.strictEqual(trie.children[0].displayName, "Modified")
})
})
describe("add", () => {
test("should add a file at root level", () => {
const data = {
title: "Test",
slug: "test",
filePath: "test.md",
}
trie.add(data)
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "test")
assert.strictEqual(trie.children[0].data, data)
})
test("should handle index files", () => {
const data = {
title: "Index",
slug: "index",
filePath: "index.md",
}
trie.add(data)
assert.strictEqual(trie.data, data)
assert.strictEqual(trie.children.length, 0)
})
test("should add nested files", () => {
const data1 = {
title: "Nested",
slug: "folder/test",
filePath: "folder/test.md",
}
const data2 = {
title: "Really nested index",
slug: "a/b/c/index",
filePath: "a/b/c/index.md",
}
trie.add(data1)
trie.add(data2)
assert.strictEqual(trie.children.length, 2)
assert.strictEqual(trie.children[0].slug, "folder/index")
assert.strictEqual(trie.children[0].children.length, 1)
assert.strictEqual(trie.children[0].children[0].slug, "folder/test")
assert.strictEqual(trie.children[0].children[0].data, data1)
assert.strictEqual(trie.children[1].slug, "a/index")
assert.strictEqual(trie.children[1].children.length, 1)
assert.strictEqual(trie.children[1].data, null)
assert.strictEqual(trie.children[1].children[0].slug, "a/b/index")
assert.strictEqual(trie.children[1].children[0].children.length, 1)
assert.strictEqual(trie.children[1].children[0].data, null)
assert.strictEqual(trie.children[1].children[0].children[0].slug, "a/b/c/index")
assert.strictEqual(trie.children[1].children[0].children[0].data, data2)
assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0)
})
test("last-insert-wins on folder-note collision (matches emitter semantics)", () => {
const first = {
title: "First Folder Note",
slug: "foo/index",
filePath: "foo/foo.md",
}
const second = {
title: "Second Folder Note",
slug: "foo/index",
filePath: "foo/index.md",
}
trie.add(first)
trie.add(second)
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "foo/index")
assert.strictEqual(trie.children[0].data, second)
})
test("last-insert-wins on root-level index collision", () => {
const first = { title: "First", slug: "index", filePath: "a.md" }
const second = { title: "Second", slug: "index", filePath: "b.md" }
trie.add(first)
trie.add(second)
assert.strictEqual(trie.data, second)
})
test("collision does not affect sibling files in the same folder", () => {
const folderNoteA = {
title: "Folder Note A",
slug: "foo/index",
filePath: "foo/foo.md",
}
const folderNoteB = {
title: "Folder Note B",
slug: "foo/index",
filePath: "foo/index.md",
}
const sibling = {
title: "Sibling",
slug: "foo/alice",
filePath: "foo/alice.md",
}
trie.add(folderNoteA)
trie.add(sibling)
trie.add(folderNoteB)
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "foo/index")
assert.strictEqual(trie.children[0].data, folderNoteB)
assert.strictEqual(trie.children[0].children.length, 1)
assert.strictEqual(trie.children[0].children[0].data, sibling)
})
})
describe("filter", () => {
test("should filter nodes based on condition", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
trie.add(data1)
trie.add(data2)
trie.filter((node) => node.slug !== "test1")
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "test2")
})
})
describe("map", () => {
test("should apply function to all nodes", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
trie.add(data1)
trie.add(data2)
trie.map((node) => {
if (node.data) {
node.data.title = "Modified"
}
})
assert.strictEqual(trie.children[0].displayName, "Modified")
assert.strictEqual(trie.children[1].displayName, "Modified")
})
test("map over folders should work", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = {
title: "Test2",
slug: "a/b-with-space/test2",
filePath: "a/b with space/test2.md",
}
trie.add(data1)
trie.add(data2)
trie.map((node) => {
if (node.isFolder) {
node.displayName = `Folder: ${node.displayName}`
} else {
node.displayName = `File: ${node.displayName}`
}
})
assert.strictEqual(trie.children[0].displayName, "File: Test1")
assert.strictEqual(trie.children[1].displayName, "Folder: a")
assert.strictEqual(trie.children[1].children[0].displayName, "Folder: b with space")
assert.strictEqual(trie.children[1].children[0].children[0].displayName, "File: Test2")
})
})
describe("entries", () => {
test("should return all entries", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = {
title: "Test2",
slug: "a/b-with-space/test2",
filePath: "a/b with space/test2.md",
}
trie.add(data1)
trie.add(data2)
const entries = trie.entries()
assert.deepStrictEqual(
entries.map(([path, node]) => [path, node.data]),
[
["index", trie.data],
["test1", data1],
["a/index", null],
["a/b-with-space/index", null],
["a/b-with-space/test2", data2],
],
)
})
})
describe("fromEntries", () => {
test("nested", () => {
const trie = FileTrieNode.fromEntries([
["index" as FullSlug, { title: "Root", slug: "index", filePath: "index.md" }],
[
"folder/file1" as FullSlug,
{ title: "File 1", slug: "folder/file1", filePath: "folder/file1.md" },
],
[
"folder/index" as FullSlug,
{ title: "Folder Index", slug: "folder/index", filePath: "folder/index.md" },
],
[
"folder/file2" as FullSlug,
{ title: "File 2", slug: "folder/file2", filePath: "folder/file2.md" },
],
[
"folder/folder2/index" as FullSlug,
{
title: "Subfolder Index",
slug: "folder/folder2/index",
filePath: "folder/folder2/index.md",
},
],
])
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "folder/index")
assert.strictEqual(trie.children[0].children.length, 3)
assert.strictEqual(trie.children[0].children[0].slug, "folder/file1")
assert.strictEqual(trie.children[0].children[1].slug, "folder/file2")
assert.strictEqual(trie.children[0].children[2].slug, "folder/folder2/index")
assert.strictEqual(trie.children[0].children[2].children.length, 0)
})
})
describe("findNode", () => {
test("should find root node with empty path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const found = trie.findNode([])
assert.strictEqual(found, trie)
})
test("should find node at first level", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const found = trie.findNode(["test"])
assert.strictEqual(found?.data, data)
})
test("should find nested node", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const found = trie.findNode(["folder", "subfolder", "test"])
assert.strictEqual(found?.data, data)
// should find the folder and subfolder indexes too
assert.strictEqual(
trie.findNode(["folder", "subfolder", "index"]),
trie.children[0].children[0],
)
assert.strictEqual(trie.findNode(["folder", "index"]), trie.children[0])
})
test("should return undefined for non-existent path", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const found = trie.findNode(["nonexistent"])
assert.strictEqual(found, undefined)
})
test("should return undefined for partial path", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const found = trie.findNode(["folder"])
assert.strictEqual(found?.data, null)
})
})
describe("getFolderPaths", () => {
test("should return all folder paths", () => {
const data1 = {
title: "Root",
slug: "index",
filePath: "index.md",
}
const data2 = {
title: "Test",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
const data3 = {
title: "Folder Index",
slug: "abc/index",
filePath: "abc/index.md",
}
trie.add(data1)
trie.add(data2)
trie.add(data3)
const paths = trie.getFolderPaths()
assert.deepStrictEqual(paths, [
"index",
"folder/index",
"folder/subfolder/index",
"abc/index",
])
})
})
describe("sort", () => {
test("should sort nodes according to sort function", () => {
const data1 = { title: "A", slug: "a", filePath: "a.md" }
const data2 = { title: "B", slug: "b", filePath: "b.md" }
const data3 = { title: "C", slug: "c", filePath: "c.md" }
trie.add(data3)
trie.add(data1)
trie.add(data2)
trie.sort((a, b) => a.slug.localeCompare(b.slug))
assert.deepStrictEqual(
trie.children.map((n) => n.slug),
["a", "b", "c"],
)
})
})
describe("pathToNode", () => {
test("should return root node for empty path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain([])
assert.deepStrictEqual(path, [trie])
})
test("should return root node for index path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const path = trie.ancestryChain(["index"])
assert.deepStrictEqual(path, [trie])
})
test("should return path to first level node", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["test"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
test("should return path to nested node", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder", "subfolder", "test"])
assert.deepStrictEqual(path, [
trie,
trie.children[0],
trie.children[0].children[0],
trie.children[0].children[0].children[0],
])
})
test("should return undefined for non-existent path", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const path = trie.ancestryChain(["nonexistent"])
assert.strictEqual(path, undefined)
})
test("should return file data for intermediate folders", () => {
const data1 = {
title: "Root",
slug: "index",
filePath: "index.md",
}
const data2 = {
title: "Test",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
const data3 = {
title: "Folder Index",
slug: "folder/index",
filePath: "folder/index.md",
}
trie.add(data1)
trie.add(data2)
trie.add(data3)
const path = trie.ancestryChain(["folder", "subfolder"])
assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
assert.strictEqual(path[1].data, data3)
})
test("should return path for partial path", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const path = trie.ancestryChain(["folder"])
assert.deepStrictEqual(path, [trie, trie.children[0]])
})
})
})
@@ -0,0 +1,175 @@
import { ContentDetails } from "../../.quartz/plugins"
import { FullSlug, joinSegments } from "./path"
interface FileTrieData {
slug: string
title: string
filePath: string
}
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
isFolder: boolean
children: Array<FileTrieNode<T>>
private slugSegments: string[]
// prefer showing the file path segment over the slug segment
// so that folders that dont have index files can be shown as is
// without dashes in the slug
private fileSegmentHint?: string
private displayNameOverride?: string
data: T | null
constructor(segments: string[], data?: T) {
this.children = []
this.slugSegments = segments
this.data = data ?? null
this.isFolder = false
this.displayNameOverride = undefined
}
get displayName(): string {
const nonIndexTitle = this.data?.title === "index" ? undefined : this.data?.title
return (
this.displayNameOverride ?? nonIndexTitle ?? this.fileSegmentHint ?? this.slugSegment ?? ""
)
}
set displayName(name: string) {
this.displayNameOverride = name
}
get slug(): FullSlug {
const path = joinSegments(...this.slugSegments) as FullSlug
if (this.isFolder) {
return joinSegments(path, "index") as FullSlug
}
return path
}
get slugSegment(): string {
return this.slugSegments[this.slugSegments.length - 1]
}
private makeChild(path: string[], file?: T) {
const fullPath = [...this.slugSegments, path[0]]
const child = new FileTrieNode<T>(fullPath, file)
this.children.push(child)
return child
}
private insert(path: string[], file: T) {
if (path.length === 0) {
throw new Error("path is empty")
}
// if we are inserting, we are a folder
this.isFolder = true
const segment = path[0]
if (path.length === 1) {
// base case, we are at the end of the path
if (segment === "index") {
// Last-insert-wins on collision. Matches the emitter's last-write-wins
// semantics at plugins/emitters/helpers.ts so the trie's data and the
// file on disk agree on which source file "owns" a colliding slug.
// Collision detection happens upstream in build.ts; this assignment is
// the fallback for any duplicates that still reach the trie.
this.data = file
} else {
this.makeChild(path, file)
}
} else if (path.length > 1) {
// recursive case, we are not at the end of the path
const child =
this.children.find((c) => c.slugSegment === segment) ?? this.makeChild(path, undefined)
const fileParts = file.filePath.split("/")
child.fileSegmentHint = fileParts.at(-path.length)
child.insert(path.slice(1), file)
}
}
// Add new file to trie
add(file: T) {
this.insert(file.slug.split("/"), file)
}
findNode(path: string[]): FileTrieNode<T> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return this
}
return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
}
ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
return [this]
}
const child = this.children.find((c) => c.slugSegment === path[0])
if (!child) {
return undefined
}
const childPath = child.ancestryChain(path.slice(1))
if (!childPath) {
return undefined
}
return [this, ...childPath]
}
/**
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
*/
filter(filterFn: (node: FileTrieNode<T>) => boolean) {
this.children = this.children.filter(filterFn)
this.children.forEach((child) => child.filter(filterFn))
}
/**
* Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place
*/
map(mapFn: (node: FileTrieNode<T>) => void) {
mapFn(this)
this.children.forEach((child) => child.map(mapFn))
}
/**
* Sort trie nodes according to sort/compare function
*/
sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) {
this.children = this.children.sort(sortFn)
this.children.forEach((e) => e.sort(sortFn))
}
static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) {
const trie = new FileTrieNode<T>([])
entries.forEach(([, entry]) => trie.add(entry))
return trie
}
/**
* Get all entries in the trie
* in the a flat array including the full path and the node
*/
entries(): [FullSlug, FileTrieNode<T>][] {
const traverse = (node: FileTrieNode<T>): [FullSlug, FileTrieNode<T>][] => {
const result: [FullSlug, FileTrieNode<T>][] = [[node.slug, node]]
return result.concat(...node.children.map(traverse))
}
return traverse(this)
}
/**
* Get all folder paths in the trie
* @returns array containing folder state for trie
*/
getFolderPaths() {
return this.entries()
.filter(([_, node]) => node.isFolder)
.map(([path, _]) => path)
}
}
@@ -0,0 +1,22 @@
import path from "path"
import { FilePath } from "./path"
import { globby } from "globby"
export function toPosixPath(fp: string): string {
return fp.split(path.sep).join("/")
}
export async function glob(
pattern: string,
cwd: string,
ignorePatterns: string[],
): Promise<FilePath[]> {
const fps = (
await globby(pattern, {
cwd,
ignore: ignorePatterns,
gitignore: true,
})
).map(toPosixPath)
return fps as FilePath[]
}
@@ -0,0 +1,38 @@
import { Components, Jsx, toJsxRuntime } from "hast-util-to-jsx-runtime"
import { Node, Root } from "hast"
import { Fragment, jsx, jsxs } from "preact/jsx-runtime"
import { h } from "preact"
import { trace } from "./trace"
import { type FilePath } from "./path"
function childrenToString(children: unknown): string {
if (typeof children === "string") return children
if (Array.isArray(children)) return children.map(childrenToString).join("")
return String(children ?? "")
}
const customComponents: Components = {
table: (props) => (
<div class="table-container">
<table {...props} />
</div>
),
style: ({ children, ...rest }) =>
h("style", { ...rest, dangerouslySetInnerHTML: { __html: childrenToString(children) } }),
script: ({ children, ...rest }) =>
h("script", { ...rest, dangerouslySetInnerHTML: { __html: childrenToString(children) } }),
}
export function htmlToJsx(fp: FilePath, tree: Node) {
try {
return toJsxRuntime(tree as Root, {
Fragment,
jsx: jsx as Jsx,
jsxs: jsxs as Jsx,
elementAttributeNameCase: "html",
components: customComponents,
})
} catch (e) {
trace(`Failed to parse Markdown in \`${fp}\` into JSX`, e as Error)
}
}
@@ -0,0 +1 @@
export { capitalize, classNames } from "@quartz-community/utils"
@@ -0,0 +1,58 @@
import truncate from "ansi-truncate"
import readline from "readline"
export class QuartzLogger {
verbose: boolean
private spinnerInterval: NodeJS.Timeout | undefined
private spinnerText: string = ""
private updateSuffix: string = ""
private spinnerIndex: number = 0
private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
constructor(verbose: boolean) {
const isInteractiveTerminal =
process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.CI
this.verbose = verbose || !isInteractiveTerminal
}
start(text: string) {
this.spinnerText = text
if (this.verbose) {
console.log(text)
} else {
this.spinnerIndex = 0
this.spinnerInterval = setInterval(() => {
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
const columns = process.stdout.columns || 80
let output = `${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`
if (this.updateSuffix) {
output += `: ${this.updateSuffix}`
}
const truncated = truncate(output, columns)
process.stdout.write(truncated)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 50)
}
}
updateText(text: string) {
this.updateSuffix = text
}
end(text?: string) {
if (!this.verbose && this.spinnerInterval) {
clearInterval(this.spinnerInterval)
this.spinnerInterval = undefined
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
}
if (text) {
console.log(text)
}
}
}
@@ -0,0 +1,394 @@
import test, { describe } from "node:test"
import * as path from "./path"
import assert from "node:assert"
import { FullSlug, TransformOptions, SimpleSlug } from "./path"
describe("typeguards", () => {
test("isSimpleSlug", () => {
assert(path.isSimpleSlug(""))
assert(path.isSimpleSlug("abc"))
assert(path.isSimpleSlug("abc/"))
assert(path.isSimpleSlug("notindex"))
assert(path.isSimpleSlug("notindex/def"))
assert(!path.isSimpleSlug("//"))
assert(!path.isSimpleSlug("index"))
assert(!path.isSimpleSlug("https://example.com"))
assert(!path.isSimpleSlug("/abc"))
assert(!path.isSimpleSlug("abc/index"))
assert(!path.isSimpleSlug("abc#anchor"))
assert(!path.isSimpleSlug("abc?query=1"))
assert(!path.isSimpleSlug("index.md"))
assert(!path.isSimpleSlug("index.html"))
})
test("isRelativeURL", () => {
assert(path.isRelativeURL("."))
assert(path.isRelativeURL(".."))
assert(path.isRelativeURL("./abc/def"))
assert(path.isRelativeURL("./abc/def#an-anchor"))
assert(path.isRelativeURL("./abc/def?query=1#an-anchor"))
assert(path.isRelativeURL("../abc/def"))
assert(path.isRelativeURL("./abc/def.pdf"))
assert(!path.isRelativeURL("abc"))
assert(!path.isRelativeURL("/abc/def"))
assert(!path.isRelativeURL(""))
assert(!path.isRelativeURL("./abc/def.html"))
assert(!path.isRelativeURL("./abc/def.md"))
})
test("isAbsoluteURL", () => {
assert(path.isAbsoluteURL("https://example.com"))
assert(path.isAbsoluteURL("http://example.com"))
assert(path.isAbsoluteURL("ftp://example.com/a/b/c"))
assert(path.isAbsoluteURL("http://host/%25"))
assert(path.isAbsoluteURL("file://host/twoslashes?more//slashes"))
assert(!path.isAbsoluteURL("example.com/abc/def"))
assert(!path.isAbsoluteURL("abc"))
})
test("isFullSlug", () => {
assert(path.isFullSlug("index"))
assert(path.isFullSlug("abc/def"))
assert(path.isFullSlug("html.energy"))
assert(path.isFullSlug("test.pdf"))
assert(!path.isFullSlug("."))
assert(!path.isFullSlug("./abc/def"))
assert(!path.isFullSlug("../abc/def"))
assert(!path.isFullSlug("abc/def#anchor"))
assert(!path.isFullSlug("abc/def?query=1"))
assert(!path.isFullSlug("note with spaces"))
})
test("isFilePath", () => {
assert(path.isFilePath("content/index.md"))
assert(path.isFilePath("content/test.png"))
assert(!path.isFilePath("../test.pdf"))
assert(!path.isFilePath("content/test"))
assert(!path.isFilePath("./content/test"))
})
})
describe("transforms", () => {
function asserts<Inp, Out>(
pairs: [string, string][],
transform: (inp: Inp) => Out,
checkPre: (x: any) => x is Inp,
checkPost: (x: any) => x is Out,
) {
for (const [inp, expected] of pairs) {
assert(checkPre(inp), `${inp} wasn't the expected input type`)
const actual = transform(inp)
assert.strictEqual(
actual,
expected,
`after transforming ${inp}, '${actual}' was not '${expected}'`,
)
assert(checkPost(actual), `${actual} wasn't the expected output type`)
}
}
test("simplifySlug", () => {
asserts(
[
["index", "/"],
["abc", "abc"],
["abc/index", "abc/"],
["abc/def", "abc/def"],
],
path.simplifySlug,
path.isFullSlug,
path.isSimpleSlug,
)
})
test("slugifyFilePath", () => {
asserts(
[
["content/index.md", "content/index"],
["content/index.html", "content/index"],
["content/_index.md", "content/index"],
["/content/index.md", "content/index"],
["content/cool.png", "content/cool.png"],
["index.md", "index"],
["test.mp4", "test.mp4"],
["note with spaces.md", "note-with-spaces"],
["notes.with.dots.md", "notes.with.dots"],
["test/special chars?.md", "test/special-chars"],
["test/special chars #3.md", "test/special-chars-3"],
["cool/what about r&d?.md", "cool/what-about-r-and-d"],
// Obsidian "Folder Notes" convention: folder/folder.md is the folder's landing page
["characters/characters.md", "characters/index"],
["fiction/books/books.md", "fiction/books/index"],
["a/a/a.md", "a/a/index"],
// Top-level single-segment: NOT rewritten (parentFolder storage, out of scope)
["characters.md", "characters"],
// Non-matching last two segments: no rewrite
["characters/alice.md", "characters/alice"],
["characters/sub/characters.md", "characters/sub/characters"],
// Folder literally named "index" is unaffected by the rewrite
["index/index.md", "index/index"],
["docs/index/index.md", "docs/index/index"],
],
path.slugifyFilePath,
path.isFilePath,
path.isFullSlug,
)
})
test("slugifyFilePath + simplifySlug end-to-end canonicalization", () => {
// Both folder-note conventions must produce identical user-facing URLs.
const indexStyle = path.simplifySlug(path.slugifyFilePath("characters/index.md" as any))
const folderNameStyle = path.simplifySlug(
path.slugifyFilePath("characters/characters.md" as any),
)
assert.strictEqual(indexStyle, folderNameStyle)
assert.strictEqual(indexStyle, "characters/")
})
test("transformInternalLink", () => {
asserts(
[
["", "."],
[".", "."],
["./", "./"],
["./index", "./"],
["./index#abc", "./#abc"],
["./index.html", "./"],
["./index.md", "./"],
["./index.css", "./index.css"],
["content", "./content"],
["content/test.md", "./content/test"],
["content/test.pdf", "./content/test.pdf"],
["./content/test.md", "./content/test"],
["../content/test.md", "../content/test"],
["tags/", "./tags/"],
["/tags/", "./tags/"],
["content/with spaces", "./content/with-spaces"],
["content/with spaces/index", "./content/with-spaces/"],
["content/with spaces#and Anchor!", "./content/with-spaces#and-anchor"],
// Folder note convention: same-name parent triggers /index rewrite → folder path
["characters/characters", "./characters/"],
["My Folder/My Folder", "./my-folder/"],
["a/b/c/d/d", "./a/b/c/d/"],
["My Folder/My Folder#heading", "./my-folder/#heading"],
// Non-matching last segments: no folder rewrite
["characters/alice", "./characters/alice"],
// Percent-encoded spaces
["My%20Folder/My%20Note", "./my-folder/my-note"],
],
path.transformInternalLink,
(_x: string): _x is string => true,
path.isRelativeURL,
)
})
test("pathToRoot", () => {
asserts(
[
["index", "."],
["abc", "."],
["abc/def", ".."],
["abc/def/ghi", "../.."],
["abc/def/index", "../.."],
],
path.pathToRoot,
path.isFullSlug,
path.isRelativeURL,
)
})
test("joinSegments", () => {
assert.strictEqual(path.joinSegments("a", "b"), "a/b")
assert.strictEqual(path.joinSegments("a/", "b"), "a/b")
assert.strictEqual(path.joinSegments("a", "b/"), "a/b/")
assert.strictEqual(path.joinSegments("a/", "b/"), "a/b/")
// preserve leading and trailing slashes
assert.strictEqual(path.joinSegments("/a", "b"), "/a/b")
assert.strictEqual(path.joinSegments("/a/", "b"), "/a/b")
assert.strictEqual(path.joinSegments("/a", "b/"), "/a/b/")
assert.strictEqual(path.joinSegments("/a/", "b/"), "/a/b/")
// lone slash
assert.strictEqual(path.joinSegments("/a/", "b", "/"), "/a/b/")
assert.strictEqual(path.joinSegments("a/", "b" + "/"), "a/b/")
// works with protocol specifiers
assert.strictEqual(path.joinSegments("https://example.com", "a"), "https://example.com/a")
assert.strictEqual(path.joinSegments("https://example.com/", "a"), "https://example.com/a")
assert.strictEqual(path.joinSegments("https://example.com", "a/"), "https://example.com/a/")
assert.strictEqual(path.joinSegments("https://example.com/", "a/"), "https://example.com/a/")
})
})
describe("link strategies", () => {
const allSlugs = [
"a/b/c",
"a/b/d",
"a/b/index",
"e/f",
"e/g/h",
"index",
"a/test.png",
] as FullSlug[]
describe("absolute", () => {
const opts: TransformOptions = {
strategy: "absolute",
allSlugs,
}
test("from a/b/c", () => {
const cur = "a/b/c" as FullSlug
assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/")
assert.strictEqual(path.transformLink(cur, "e/f", opts), "../../e/f")
assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "../../e/g/h")
assert.strictEqual(path.transformLink(cur, "index", opts), "../../")
assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png")
assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc")
assert.strictEqual(path.transformLink(cur, "tag/test", opts), "../../tag/test")
assert.strictEqual(path.transformLink(cur, "a/b/c#test", opts), "../../a/b/c#test")
assert.strictEqual(path.transformLink(cur, "a/test.png", opts), "../../a/test.png")
})
test("from a/b/index", () => {
const cur = "a/b/index" as FullSlug
assert.strictEqual(path.transformLink(cur, "a/b/d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "a/b", opts), "../../a/b")
assert.strictEqual(path.transformLink(cur, "index", opts), "../../")
})
test("from index", () => {
const cur = "index" as FullSlug
assert.strictEqual(path.transformLink(cur, "index", opts), "./")
assert.strictEqual(path.transformLink(cur, "a/b/c", opts), "./a/b/c")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/")
})
})
describe("shortest", () => {
const opts: TransformOptions = {
strategy: "shortest",
allSlugs,
}
test("from a/b/c", () => {
const cur = "a/b/c" as FullSlug
assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/")
assert.strictEqual(path.transformLink(cur, "a/b/index.png", opts), "../../a/b/index.png")
assert.strictEqual(path.transformLink(cur, "a/b/index#abc", opts), "../../a/b/#abc")
assert.strictEqual(path.transformLink(cur, "index", opts), "../../")
assert.strictEqual(path.transformLink(cur, "index.png", opts), "../../index.png")
assert.strictEqual(path.transformLink(cur, "test.png", opts), "../../a/test.png")
assert.strictEqual(path.transformLink(cur, "index#abc", opts), "../../#abc")
})
test("from a/b/index", () => {
const cur = "a/b/index" as FullSlug
assert.strictEqual(path.transformLink(cur, "d", opts), "../../a/b/d")
assert.strictEqual(path.transformLink(cur, "h", opts), "../../e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "../../a/b/")
assert.strictEqual(path.transformLink(cur, "index", opts), "../../")
})
test("from index", () => {
const cur = "index" as FullSlug
assert.strictEqual(path.transformLink(cur, "d", opts), "./a/b/d")
assert.strictEqual(path.transformLink(cur, "h", opts), "./e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/")
assert.strictEqual(path.transformLink(cur, "index", opts), "./")
})
})
describe("relative", () => {
const opts: TransformOptions = {
strategy: "relative",
allSlugs,
}
test("from a/b/c", () => {
const cur = "a/b/c" as FullSlug
assert.strictEqual(path.transformLink(cur, "d", opts), "./d")
assert.strictEqual(path.transformLink(cur, "index", opts), "./")
assert.strictEqual(path.transformLink(cur, "../../../index", opts), "../../../")
assert.strictEqual(path.transformLink(cur, "../../../index.png", opts), "../../../index.png")
assert.strictEqual(path.transformLink(cur, "../../../index#abc", opts), "../../../#abc")
assert.strictEqual(path.transformLink(cur, "../../../", opts), "../../../")
assert.strictEqual(
path.transformLink(cur, "../../../a/test.png", opts),
"../../../a/test.png",
)
assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h")
assert.strictEqual(path.transformLink(cur, "../../../e/g/h", opts), "../../../e/g/h")
assert.strictEqual(path.transformLink(cur, "../../../e/g/h#abc", opts), "../../../e/g/h#abc")
})
test("from a/b/index", () => {
const cur = "a/b/index" as FullSlug
assert.strictEqual(path.transformLink(cur, "../../index", opts), "../../")
assert.strictEqual(path.transformLink(cur, "../../", opts), "../../")
assert.strictEqual(path.transformLink(cur, "../../e/g/h", opts), "../../e/g/h")
assert.strictEqual(path.transformLink(cur, "c", opts), "./c")
})
test("from index", () => {
const cur = "index" as FullSlug
assert.strictEqual(path.transformLink(cur, "e/g/h", opts), "./e/g/h")
assert.strictEqual(path.transformLink(cur, "a/b/index", opts), "./a/b/")
})
})
})
describe("resolveRelative", () => {
test("from index", () => {
assert.strictEqual(path.resolveRelative("index" as FullSlug, "index" as FullSlug), "./")
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc" as FullSlug), "./abc")
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def" as FullSlug),
"./abc/def",
)
assert.strictEqual(
path.resolveRelative("index" as FullSlug, "abc/def/ghi" as FullSlug),
"./abc/def/ghi",
)
})
test("from nested page", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "abc" as FullSlug), "../abc")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/def" as FullSlug),
"../abc/def",
)
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "ghi/jkl" as FullSlug),
"../ghi/jkl",
)
})
test("with index paths", () => {
assert.strictEqual(path.resolveRelative("abc/index" as FullSlug, "index" as FullSlug), "../")
assert.strictEqual(
path.resolveRelative("abc/def/index" as FullSlug, "index" as FullSlug),
"../../",
)
assert.strictEqual(path.resolveRelative("index" as FullSlug, "abc/index" as FullSlug), "./abc/")
assert.strictEqual(
path.resolveRelative("abc/def" as FullSlug, "abc/index" as FullSlug),
"../abc/",
)
})
test("with simple slugs", () => {
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "" as SimpleSlug), "../")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi" as SimpleSlug), "../ghi")
assert.strictEqual(path.resolveRelative("abc/def" as FullSlug, "ghi/" as SimpleSlug), "../ghi/")
})
})
@@ -0,0 +1,52 @@
// Re-export shared path utilities from @quartz-community/utils
export {
isFilePath,
isFullSlug,
isSimpleSlug,
isRelativeURL,
isAbsoluteURL,
getFullSlug,
slugifyFilePath,
simplifySlug,
joinSegments,
endsWith,
trimSuffix,
stripSlashes,
getFileExtension,
isFolderPath,
getAllSegmentPrefixes,
pathToRoot,
resolveRelative,
splitAnchor,
slugTag,
transformInternalLink,
transformLink,
normalizeHastElement,
} from "@quartz-community/utils"
export type {
FilePath,
FullSlug,
SimpleSlug,
RelativeURL,
TransformOptions,
} from "@quartz-community/utils"
// --- v5-specific exports below ---
export const QUARTZ = "quartz"
// from micromorph/src/utils.ts
// https://github.com/natemoo-re/micromorph/blob/main/src/utils.ts#L5
const _rebaseHtmlElement = (el: Element, attr: string, newBase: string | URL) => {
const rebased = new URL(el.getAttribute(attr)!, newBase)
el.setAttribute(attr, rebased.pathname + rebased.hash)
}
export function normalizeRelativeURLs(el: Element | Document, destination: string | URL) {
el.querySelectorAll('[href=""], [href^="./"], [href^="../"]').forEach((item) => {
_rebaseHtmlElement(item, "href", destination)
})
el.querySelectorAll('[src=""], [src^="./"], [src^="../"]').forEach((item) => {
_rebaseHtmlElement(item, "src", destination)
})
}
@@ -0,0 +1,19 @@
import pretty from "pretty-time"
import { styleText } from "util"
export class PerfTimer {
evts: { [key: string]: [number, number] }
constructor() {
this.evts = {}
this.addEvent("start")
}
addEvent(evtName: string) {
this.evts[evtName] = process.hrtime()
}
timeSince(evtName?: string): string {
return styleText("yellow", pretty(process.hrtime(this.evts[evtName ?? "start"])))
}
}
@@ -0,0 +1,3 @@
export function randomIdNonSecure() {
return Math.random().toString(36).substring(2, 8)
}
@@ -0,0 +1,82 @@
import { randomUUID } from "crypto"
import { JSX } from "preact/jsx-runtime"
import { QuartzPluginData } from "../plugins/vfile"
export type JSResource = {
loadTime: "beforeDOMReady" | "afterDOMReady"
moduleType?: "module"
spaPreserve?: boolean
} & (
| {
src: string
contentType: "external"
}
| {
script: string
contentType: "inline"
}
)
export type CSSResource = {
content: string
inline?: boolean
spaPreserve?: boolean
}
export function JSResourceToScriptElement(resource: JSResource, preserve?: boolean): JSX.Element {
const scriptType = resource.moduleType ?? "application/javascript"
const spaPreserve = preserve ?? resource.spaPreserve
if (resource.contentType === "external") {
return (
<script key={resource.src} src={resource.src} type={scriptType} data-persist={spaPreserve} />
)
} else {
const content = resource.script
return (
<script
key={randomUUID()}
type={scriptType}
data-persist={spaPreserve}
dangerouslySetInnerHTML={{ __html: content }}
></script>
)
}
}
export function CSSResourceToStyleElement(resource: CSSResource, preserve?: boolean): JSX.Element {
const spaPreserve = preserve ?? resource.spaPreserve
if (resource.inline ?? false) {
return <style dangerouslySetInnerHTML={{ __html: resource.content }} />
} else {
return (
<link
key={resource.content}
href={resource.content}
rel="stylesheet"
type="text/css"
data-persist={spaPreserve}
/>
)
}
}
export interface StaticResources {
css: CSSResource[]
js: JSResource[]
additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[]
}
export type StringResource = string | string[] | undefined
export function normalizeResource(resource: StringResource): string[] {
if (!resource) return []
if (Array.isArray(resource)) return resource
return [resource]
}
export function concatenateResources(...resources: StringResource[]): StringResource {
return resources
.filter((resource): resource is string | string[] => resource !== undefined)
.flat()
}
@@ -0,0 +1,145 @@
import test, { describe } from "node:test"
import assert from "node:assert"
import { detectSlugCollisions, formatCollisionWarning } from "./slugCollisions"
import { ProcessedContent } from "../plugins/vfile"
import { FilePath, FullSlug } from "./path"
function makeContent(
entries: Array<{ slug: string; relativePath?: string; filePath?: string }>,
): ProcessedContent[] {
return entries.map((e) => {
const vfile = {
data: {
slug: e.slug as FullSlug,
relativePath: (e.relativePath ?? `${e.slug}.md`) as FilePath,
filePath: (e.filePath ?? `/vault/${e.relativePath ?? `${e.slug}.md`}`) as FilePath,
},
}
return [{ type: "root", children: [] }, vfile] as unknown as ProcessedContent
})
}
describe("detectSlugCollisions", () => {
test("returns empty array when there are no collisions", () => {
const content = makeContent([{ slug: "alice" }, { slug: "bob" }, { slug: "characters/index" }])
assert.deepStrictEqual(detectSlugCollisions(content), [])
})
test("returns empty array for empty input", () => {
assert.deepStrictEqual(detectSlugCollisions([]), [])
})
test("detects a two-file collision with winner = last file", () => {
const content = makeContent([
{ slug: "foo/index", relativePath: "foo/foo.md" },
{ slug: "foo/index", relativePath: "foo/index.md" },
])
const collisions = detectSlugCollisions(content)
assert.strictEqual(collisions.length, 1)
assert.strictEqual(collisions[0]!.slug, "foo/index")
assert.strictEqual(collisions[0]!.files.length, 2)
assert.strictEqual(collisions[0]!.winner.relativePath, "foo/index.md")
assert.strictEqual(collisions[0]!.files[0]!.relativePath, "foo/foo.md")
assert.strictEqual(collisions[0]!.files[1]!.relativePath, "foo/index.md")
})
test("detects a three-file collision with all files listed, winner = last", () => {
const content = makeContent([
{ slug: "bar/index", relativePath: "bar/_index.md" },
{ slug: "bar/index", relativePath: "bar/bar.md" },
{ slug: "bar/index", relativePath: "bar/index.md" },
])
const collisions = detectSlugCollisions(content)
assert.strictEqual(collisions.length, 1)
assert.strictEqual(collisions[0]!.files.length, 3)
assert.strictEqual(collisions[0]!.winner.relativePath, "bar/index.md")
})
test("detects multiple separate collisions", () => {
const content = makeContent([
{ slug: "a/index", relativePath: "a/a.md" },
{ slug: "a/index", relativePath: "a/index.md" },
{ slug: "b/index", relativePath: "b/b.md" },
{ slug: "b/index", relativePath: "b/index.md" },
{ slug: "unique" },
])
const collisions = detectSlugCollisions(content)
assert.strictEqual(collisions.length, 2)
const slugs = collisions.map((c) => c.slug).sort()
assert.deepStrictEqual(slugs, ["a/index", "b/index"])
})
test("ignores entries without a slug", () => {
const content: ProcessedContent[] = [
...makeContent([{ slug: "alice" }]),
[
{ type: "root", children: [] },
{ data: { slug: undefined, relativePath: "broken.md" } },
] as unknown as ProcessedContent,
]
assert.deepStrictEqual(detectSlugCollisions(content), [])
})
test("winner annotation matches fileTrie last-insert-wins semantics", () => {
// Glob order is alphabetical: foo/foo.md sorts before foo/index.md.
// Both the fileTrie and this detector must agree that the second file wins.
const content = makeContent([
{ slug: "foo/index", relativePath: "foo/foo.md" },
{ slug: "foo/index", relativePath: "foo/index.md" },
])
const collisions = detectSlugCollisions(content)
assert.strictEqual(collisions[0]!.winner.relativePath, "foo/index.md")
})
})
describe("formatCollisionWarning", () => {
test("returns empty string for empty input", () => {
assert.strictEqual(formatCollisionWarning([]), "")
})
test("formats single collision with winner and shadowed markers", () => {
const content = makeContent([
{ slug: "foo/index", relativePath: "foo/foo.md" },
{ slug: "foo/index", relativePath: "foo/index.md" },
])
const collisions = detectSlugCollisions(content)
const output = formatCollisionWarning(collisions)
assert.match(output, /1 slug collision detected/)
assert.match(output, /foo\/index/)
assert.match(output, /foo\/foo\.md .*\(shadowed\)/)
assert.match(output, /foo\/index\.md .*\(used for this URL\)/)
})
test("formats multiple collisions with count in header", () => {
const content = makeContent([
{ slug: "a/index", relativePath: "a/a.md" },
{ slug: "a/index", relativePath: "a/index.md" },
{ slug: "b/index", relativePath: "b/b.md" },
{ slug: "b/index", relativePath: "b/index.md" },
])
const collisions = detectSlugCollisions(content)
const output = formatCollisionWarning(collisions)
assert.match(output, /2 slug collisions detected/)
assert.match(output, /a\/index/)
assert.match(output, /b\/index/)
})
test("output mentions Folder Notes convention as a common cause", () => {
const content = makeContent([
{ slug: "foo/index", relativePath: "foo/foo.md" },
{ slug: "foo/index", relativePath: "foo/index.md" },
])
const output = formatCollisionWarning(detectSlugCollisions(content))
assert.match(output, /Folder Notes/)
})
test("falls back to filePath when relativePath is missing", () => {
const content = makeContent([
{ slug: "x/index", relativePath: "", filePath: "/vault/x/x.md" },
{ slug: "x/index", relativePath: "", filePath: "/vault/x/index.md" },
])
const output = formatCollisionWarning(detectSlugCollisions(content))
assert.match(output, /\/vault\/x\/x\.md/)
assert.match(output, /\/vault\/x\/index\.md/)
})
})
@@ -0,0 +1,84 @@
import { ProcessedContent } from "../plugins/vfile"
import { FullSlug } from "./path"
/**
* A slug collision: two or more source files that produce the same FullSlug
* after slugifyFilePath. The `winner` is the file whose HTML output and trie
* data represent this slug in the final build (see fileTrie.ts and the
* emitter's last-write-wins semantics in plugins/emitters/helpers.ts).
*
* `files` is in the order the files appear in the parsed content array
* (glob order), which is deterministic. `winner` is always the last entry.
*/
export interface SlugCollision {
slug: FullSlug
files: Array<{ relativePath: string; filePath: string }>
winner: { relativePath: string; filePath: string }
}
export function detectSlugCollisions(content: ProcessedContent[]): SlugCollision[] {
const bySlug = new Map<FullSlug, Array<{ relativePath: string; filePath: string }>>()
for (const [, file] of content) {
const slug = file.data.slug
if (!slug) continue
const entry = {
relativePath: (file.data.relativePath ?? "") as string,
filePath: (file.data.filePath ?? "") as string,
}
const existing = bySlug.get(slug)
if (existing) {
existing.push(entry)
} else {
bySlug.set(slug, [entry])
}
}
const collisions: SlugCollision[] = []
for (const [slug, files] of bySlug) {
if (files.length < 2) continue
collisions.push({
slug,
files,
winner: files[files.length - 1]!,
})
}
return collisions
}
/**
* Format a list of collisions as a single human-readable warning block.
* Returns an empty string when there are no collisions so callers can
* unconditionally log the result.
*/
export function formatCollisionWarning(collisions: SlugCollision[]): string {
if (collisions.length === 0) return ""
const lines: string[] = []
const header =
collisions.length === 1
? `Warning: 1 slug collision detected.`
: `Warning: ${collisions.length} slug collisions detected.`
lines.push(header)
lines.push(
`Multiple source files produced the same URL slug. The last-processed file wins; the others are shadowed and their content will not appear in the output.`,
)
lines.push("")
for (const collision of collisions) {
lines.push(` slug \`${collision.slug}\``)
for (const file of collision.files) {
const marker = file === collision.winner ? "(used for this URL)" : "(shadowed)"
const path = file.relativePath || file.filePath || "(unknown source)"
lines.push(` - ${path} ${marker}`)
}
lines.push("")
}
lines.push(
`To resolve, rename or delete all but one file per collided slug. This may include files using the Obsidian "Folder Notes" convention (\`folder/folder.md\`) that collide with an existing \`folder/index.md\`.`,
)
return lines.join("\n")
}
@@ -0,0 +1,18 @@
import fs from "fs"
import sourceMapSupport from "source-map-support"
import { fileURLToPath } from "url"
export const options: sourceMapSupport.Options = {
// source map hack to get around query param
// import cache busting
retrieveSourceMap(source) {
if (source.includes(".quartz-cache")) {
let realSource = fileURLToPath(source.split("?", 2)[0] + ".map")
return {
map: fs.readFileSync(realSource, "utf8"),
}
} else {
return null
}
},
}
@@ -0,0 +1,351 @@
export interface ColorScheme {
light: string
lightgray: string
gray: string
darkgray: string
dark: string
secondary: string
tertiary: string
highlight: string
textHighlight: string
}
interface Colors {
lightMode: ColorScheme
darkMode: ColorScheme
}
export type FontSpecification =
| string
| {
name: string
weights?: number[]
includeItalic?: boolean
}
export interface Theme {
typography: {
title?: FontSpecification
header: FontSpecification
body: FontSpecification
code: FontSpecification
}
cdnCaching: boolean
colors: Colors
fontOrigin: "googleFonts" | "local"
}
export type ThemeKey = keyof Colors
const DEFAULT_SANS_SERIF =
'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'
const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"
export function getFontSpecificationName(spec: FontSpecification): string {
if (typeof spec === "string") {
return spec
}
return spec.name
}
function formatFontSpecification(
type: "title" | "header" | "body" | "code",
spec: FontSpecification,
) {
if (typeof spec === "string") {
spec = { name: spec }
}
const defaultIncludeWeights = type === "header" ? [400, 700] : [400, 600]
const defaultIncludeItalic = type === "body"
const weights = spec.weights ?? defaultIncludeWeights
const italic = spec.includeItalic ?? defaultIncludeItalic
const features: string[] = []
if (italic) {
features.push("ital")
}
if (weights.length > 1) {
const weightSpec = italic
? weights
.flatMap((w) => [`0,${w}`, `1,${w}`])
.sort()
.join(";")
: weights.join(";")
features.push(`wght@${weightSpec}`)
}
if (features.length > 0) {
return `${spec.name}:${features.join(",")}`
}
return spec.name
}
export function googleFontHref(theme: Theme) {
const { header, body, code } = theme.typography
const headerFont = formatFontSpecification("header", header)
const bodyFont = formatFontSpecification("body", body)
const codeFont = formatFontSpecification("code", code)
return `https://fonts.googleapis.com/css2?family=${headerFont}&family=${bodyFont}&family=${codeFont}&display=swap`
}
export function googleFontSubsetHref(theme: Theme, text: string) {
const title = theme.typography.title || theme.typography.header
const titleFont = formatFontSpecification("title", title)
return `https://fonts.googleapis.com/css2?family=${titleFont}&text=${encodeURIComponent(text)}&display=swap`
}
export interface GoogleFontFile {
url: string
filename: string
extension: string
}
const fontMimeMap: Record<string, string> = {
truetype: "ttf",
woff: "woff",
woff2: "woff2",
opentype: "otf",
}
export async function processGoogleFonts(
stylesheet: string,
baseUrl: string,
): Promise<{
processedStylesheet: string
fontFiles: GoogleFontFile[]
}> {
const fontSourceRegex =
/url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g
const fontFiles: GoogleFontFile[] = []
let processedStylesheet = stylesheet
let match
while ((match = fontSourceRegex.exec(stylesheet)) !== null) {
const url = match[1]
const filename = match[2]
const extension = fontMimeMap[match[3].toLowerCase()]
const staticUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`
processedStylesheet = processedStylesheet.replace(url, staticUrl)
fontFiles.push({ url, filename, extension })
}
return { processedStylesheet, fontFiles }
}
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) return { h: 0, s: 0, l: 0 }
const r = parseInt(result[1], 16) / 255
const g = parseInt(result[2], 16) / 255
const b = parseInt(result[3], 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) }
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h = 0
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
}
export function joinStyles(theme: Theme, ...stylesheet: string[]) {
return `
${stylesheet.join("\n\n")}
:root {
--light: ${theme.colors.lightMode.light};
--lightgray: ${theme.colors.lightMode.lightgray};
--gray: ${theme.colors.lightMode.gray};
--darkgray: ${theme.colors.lightMode.darkgray};
--dark: ${theme.colors.lightMode.dark};
--secondary: ${theme.colors.lightMode.secondary};
--tertiary: ${theme.colors.lightMode.tertiary};
--highlight: ${theme.colors.lightMode.highlight};
--textHighlight: ${theme.colors.lightMode.textHighlight};
--titleFont: "${getFontSpecificationName(theme.typography.title || theme.typography.header)}", ${DEFAULT_SANS_SERIF};
--headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF};
--bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF};
--codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO};
}
:root[saved-theme="dark"] {
--light: ${theme.colors.darkMode.light};
--lightgray: ${theme.colors.darkMode.lightgray};
--gray: ${theme.colors.darkMode.gray};
--darkgray: ${theme.colors.darkMode.darkgray};
--dark: ${theme.colors.darkMode.dark};
--secondary: ${theme.colors.darkMode.secondary};
--tertiary: ${theme.colors.darkMode.tertiary};
--highlight: ${theme.colors.darkMode.highlight};
--textHighlight: ${theme.colors.darkMode.textHighlight};
}
:root {
/* Surface colors */
--background-primary: var(--light);
--background-primary-alt: var(--light);
--background-secondary: var(--lightgray);
--background-secondary-alt: var(--lightgray);
--background-modifier-border: var(--lightgray);
--background-modifier-border-hover: var(--gray);
--background-modifier-border-focus: var(--secondary);
/* Text colors */
--text-normal: var(--darkgray);
--text-muted: var(--gray);
--text-faint: var(--gray);
--text-accent: var(--secondary);
--text-accent-hover: var(--tertiary);
--text-on-accent: var(--light);
--text-on-accent-inverted: var(--dark);
--text-highlight-bg: var(--textHighlight);
/* Interactive */
--interactive-normal: var(--light);
--interactive-hover: var(--lightgray);
--interactive-accent: var(--secondary);
--interactive-accent-hover: var(--tertiary);
/* Base scale */
--color-base-00: var(--light);
--color-base-05: var(--light);
--color-base-10: var(--light);
--color-base-20: var(--lightgray);
--color-base-25: var(--lightgray);
--color-base-30: var(--lightgray);
--color-base-35: var(--lightgray);
--color-base-40: var(--gray);
--color-base-50: var(--gray);
--color-base-60: var(--gray);
--color-base-70: var(--darkgray);
--color-base-100: var(--dark);
/* Font aliases */
--font-text: var(--bodyFont);
--font-monospace: var(--codeFont);
--font-interface: var(--bodyFont);
/* Nav/sidebar */
--nav-item-color: var(--darkgray);
--nav-item-color-hover: var(--dark);
--nav-item-color-active: var(--secondary);
--nav-item-background-hover: var(--lightgray);
--nav-item-background-active: var(--highlight);
/* Tags */
--tag-background: var(--highlight);
--tag-color: var(--secondary);
--tag-background-hover: var(--lightgray);
/* Misc */
--icon-color: var(--darkgray);
--icon-color-hover: var(--dark);
--icon-color-active: var(--secondary);
--divider-color: var(--lightgray);
--link-color: var(--secondary);
--link-color-hover: var(--tertiary);
/* Accent HSL (computed from secondary) */
--accent-h: ${hexToHsl(theme.colors.lightMode.secondary).h};
--accent-s: ${hexToHsl(theme.colors.lightMode.secondary).s}%;
--accent-l: ${hexToHsl(theme.colors.lightMode.secondary).l}%;
}
:root[saved-theme="dark"] {
/* Surface colors */
--background-primary: var(--light);
--background-primary-alt: var(--light);
--background-secondary: var(--lightgray);
--background-secondary-alt: var(--lightgray);
--background-modifier-border: var(--lightgray);
--background-modifier-border-hover: var(--gray);
--background-modifier-border-focus: var(--secondary);
/* Text colors */
--text-normal: var(--darkgray);
--text-muted: var(--gray);
--text-faint: var(--gray);
--text-accent: var(--secondary);
--text-accent-hover: var(--tertiary);
--text-on-accent: var(--light);
--text-on-accent-inverted: var(--dark);
--text-highlight-bg: var(--textHighlight);
/* Interactive */
--interactive-normal: var(--light);
--interactive-hover: var(--lightgray);
--interactive-accent: var(--secondary);
--interactive-accent-hover: var(--tertiary);
/* Base scale */
--color-base-00: var(--light);
--color-base-05: var(--light);
--color-base-10: var(--light);
--color-base-20: var(--lightgray);
--color-base-25: var(--lightgray);
--color-base-30: var(--lightgray);
--color-base-35: var(--lightgray);
--color-base-40: var(--gray);
--color-base-50: var(--gray);
--color-base-60: var(--gray);
--color-base-70: var(--darkgray);
--color-base-100: var(--dark);
/* Font aliases */
--font-text: var(--bodyFont);
--font-monospace: var(--codeFont);
--font-interface: var(--bodyFont);
/* Nav/sidebar */
--nav-item-color: var(--darkgray);
--nav-item-color-hover: var(--dark);
--nav-item-color-active: var(--secondary);
--nav-item-background-hover: var(--lightgray);
--nav-item-background-active: var(--highlight);
/* Tags */
--tag-background: var(--highlight);
--tag-color: var(--secondary);
--tag-background-hover: var(--lightgray);
/* Misc */
--icon-color: var(--darkgray);
--icon-color-hover: var(--dark);
--icon-color-active: var(--secondary);
--divider-color: var(--lightgray);
--link-color: var(--secondary);
--link-color-hover: var(--tertiary);
/* Accent HSL (computed from secondary) */
--accent-h: ${hexToHsl(theme.colors.darkMode.secondary).h};
--accent-s: ${hexToHsl(theme.colors.darkMode.secondary).s}%;
--accent-l: ${hexToHsl(theme.colors.darkMode.secondary).l}%;
}
`
}
@@ -0,0 +1,43 @@
import { styleText } from "util"
import process from "process"
import { isMainThread } from "workerpool"
const rootFile = /.*at file:/
export function trace(msg: string, err: Error) {
let stack = err.stack ?? ""
const lines: string[] = []
lines.push("")
lines.push(
"\n" +
styleText(["bgRed", "black", "bold"], " ERROR ") +
"\n\n" +
styleText("red", ` ${msg}`) +
(err.message.length > 0 ? `: ${err.message}` : ""),
)
let reachedEndOfLegibleTrace = false
for (const line of stack.split("\n").slice(1)) {
if (reachedEndOfLegibleTrace) {
break
}
if (!line.includes("node_modules")) {
lines.push(` ${line}`)
if (rootFile.test(line)) {
reachedEndOfLegibleTrace = true
}
}
}
const traceMsg = lines.join("\n")
if (!isMainThread) {
// gather lines and throw
throw new Error(traceMsg)
} else {
// print and exit
console.error(traceMsg)
process.exit(1)
}
}