Initial import
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user