Compare commits

..

7 Commits

Author SHA1 Message Date
Jacky Zhao
3173d185ed make og images look nice 2025-03-13 10:17:19 -07:00
Jacky Zhao
de727b4686 use readline instead 2025-03-13 08:46:55 -07:00
Jacky Zhao
07ffc8681e replace spinner, use disk cache for fonts 2025-03-13 08:38:16 -07:00
Jacky Zhao
f301eca9a7 custom font spec 2025-03-12 22:56:59 -07:00
Jacky Zhao
1fb7756c49 fix 2025-03-12 22:26:39 -07:00
Jacky Zhao
c5a8b199ae make emitters async generators 2025-03-12 13:31:43 -07:00
Jacky Zhao
5d50282124 checkpoint 2025-03-12 10:08:18 -07:00
20 changed files with 148 additions and 442 deletions

View File

@ -108,25 +108,3 @@ Some plugins are included by default in the [`quartz.config.ts`](https://github.
You can see a list of all plugins and their configuration options [[tags/plugin|here]]. You can see a list of all plugins and their configuration options [[tags/plugin|here]].
If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide. If you'd like to make your own plugins, see the [[making plugins|making custom plugins]] guide.
## Fonts
Fonts can be specified as a `string` or a `FontSpecification`:
```ts
// string
typography: {
header: "Schibsted Grotesk",
...
}
// FontSpecification
typography: {
header: {
name: "Schibsted Grotesk",
weights: [400, 700],
includeItalic: true,
},
...
}
```

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.4.1", "version": "4.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"version": "4.4.1", "version": "4.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/prompts": "^0.10.0", "@clack/prompts": "^0.10.0",

View File

@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz", "name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website", "description": "🌱 publish your digital garden and notes as a website",
"private": true, "private": true,
"version": "4.4.1", "version": "4.4.0",
"type": "module", "type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>", "author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT", "license": "MIT",

View File

@ -8,15 +8,15 @@ import * as Plugin from "./quartz/plugins"
*/ */
const config: QuartzConfig = { const config: QuartzConfig = {
configuration: { configuration: {
pageTitle: "Ferdinland Docs", pageTitle: "Quartz 4",
pageTitleSuffix: " | Ferdinland Minecraft Server", pageTitleSuffix: "",
enableSPA: true, enableSPA: true,
enablePopovers: true, enablePopovers: true,
analytics: { analytics: {
provider: "plausible", provider: "plausible",
}, },
locale: "en-US", locale: "en-US",
baseUrl: "docs.ferdin.land", baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"], ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created", defaultDateType: "created",
theme: { theme: {
@ -87,7 +87,6 @@ const config: QuartzConfig = {
Plugin.Assets(), Plugin.Assets(),
Plugin.Static(), Plugin.Static(),
Plugin.NotFoundPage(), Plugin.NotFoundPage(),
// Comment out CustomOgImages to speed up build time
Plugin.CustomOgImages(), Plugin.CustomOgImages(),
], ],
}, },

View File

@ -49,15 +49,8 @@ export const defaultListPageLayout: PageLayout = {
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Flex({ Component.Search(),
components: [ Component.Darkmode(),
{
Component: Component.Search(),
grow: true,
},
{ Component: Component.Darkmode() },
],
}),
Component.Explorer(), Component.Explorer(),
], ],
right: [], right: [],

View File

@ -1,4 +1,4 @@
import { FullSlug, isFolderPath, resolveRelative } from "../util/path" import { FullSlug, resolveRelative } from "../util/path"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { Date, getDate } from "./Date" import { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentProps } from "./types"
@ -8,13 +8,6 @@ export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn { export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
return (f1, f2) => { return (f1, f2) => {
// Sort folders first
const f1IsFolder = isFolderPath(f1.slug ?? "")
const f2IsFolder = isFolderPath(f2.slug ?? "")
if (f1IsFolder && !f2IsFolder) return -1
if (!f1IsFolder && f2IsFolder) return 1
// If both are folders or both are files, sort by date/alphabetical
if (f1.dates && f2.dates) { if (f1.dates && f2.dates) {
// sort descending // sort descending
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime() return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()

View File

@ -1,14 +1,16 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import path from "path"
import style from "../styles/listPage.scss" import style from "../styles/listPage.scss"
import { PageList, SortFn } from "../PageList" import { byDateAndAlphabetical, PageList, SortFn } from "../PageList"
import { stripSlashes, simplifySlug, joinSegments, FullSlug } from "../../util/path"
import { Root } from "hast" import { Root } from "hast"
import { htmlToJsx } from "../../util/jsx" import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile" import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact" import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources" import { concatenateResources } from "../../util/resources"
import { FileTrieNode } from "../../util/fileTrie"
interface FolderContentOptions { interface FolderContentOptions {
/** /**
* Whether to display number of folders * Whether to display number of folders
@ -25,88 +27,51 @@ const defaultOptions: FolderContentOptions = {
export default ((opts?: Partial<FolderContentOptions>) => { export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts } const options: FolderContentOptions = { ...defaultOptions, ...opts }
let trie: FileTrieNode<
QuartzPluginData & {
slug: string
title: string
filePath: string
}
>
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const folderParts = folderSlug.split(path.posix.sep)
const allPagesInFolder: QuartzPluginData[] = []
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
if (!trie) {
trie = new FileTrieNode([])
allFiles.forEach((file) => { allFiles.forEach((file) => {
if (file.frontmatter) { const fileSlug = stripSlashes(simplifySlug(file.slug!))
trie.add({ const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
...file, const fileParts = fileSlug.split(path.posix.sep)
slug: file.slug!, const isDirectChild = fileParts.length === folderParts.length + 1
title: file.frontmatter.title,
filePath: file.filePath!, if (!prefixed) {
}) return
}
if (isDirectChild) {
allPagesInFolder.push(file)
} else if (options.showSubfolders) {
const subfolderSlug = joinSegments(
...fileParts.slice(0, folderParts.length + 1),
) as FullSlug
const pagesInFolder = allPagesInSubfolders.get(subfolderSlug) || []
allPagesInSubfolders.set(subfolderSlug, [...pagesInFolder, file])
} }
}) })
}
const folder = trie.findNode(fileData.slug!.split("/")) allPagesInSubfolders.forEach((files, subfolderSlug) => {
if (!folder) { const hasIndex = allPagesInFolder.some(
return null (file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
}
const allPagesInFolder: QuartzPluginData[] =
folder.children
.map((node) => {
// regular file, proceed
if (node.data) {
return node.data
}
if (node.isFolder && options.showSubfolders) {
// folders that dont have data need synthetic files
const getMostRecentDates = (): QuartzPluginData["dates"] => {
let maybeDates: QuartzPluginData["dates"] | undefined = undefined
for (const child of node.children) {
if (child.data?.dates) {
// compare all dates and assign to maybeDates if its more recent or its not set
if (!maybeDates) {
maybeDates = child.data.dates
} else {
if (child.data.dates.created > maybeDates.created) {
maybeDates.created = child.data.dates.created
}
if (child.data.dates.modified > maybeDates.modified) {
maybeDates.modified = child.data.dates.modified
}
if (child.data.dates.published > maybeDates.published) {
maybeDates.published = child.data.dates.published
}
}
}
}
return (
maybeDates ?? {
created: new Date(),
modified: new Date(),
published: new Date(),
}
) )
} if (!hasIndex) {
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
return { const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
slug: node.slug, allPagesInFolder.push({
dates: getMostRecentDates(), slug: subfolderSlug,
frontmatter: { dates: subfolderDates,
title: node.displayName, frontmatter: { title: subfolderTitle, tags: ["folder"] },
tags: [], })
},
}
} }
}) })
.filter((page) => page !== undefined) ?? []
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? [] const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = cssClasses.join(" ") const classes = cssClasses.join(" ")
const listProps = { const listProps = {

View File

@ -134,9 +134,9 @@ function createFolderNode(
} }
for (const child of node.children) { for (const child of node.children) {
const childNode = child.isFolder const childNode = child.data
? createFolderNode(currentSlug, child, opts) ? createFileNode(currentSlug, child)
: createFileNode(currentSlug, child) : createFolderNode(currentSlug, child, opts)
ul.appendChild(childNode) ul.appendChild(childNode)
} }
@ -161,7 +161,7 @@ async function setupExplorer(currentSlug: FullSlug) {
// Get folder state from local storage // Get folder state from local storage
const storageTree = localStorage.getItem("fileTree") const storageTree = localStorage.getItem("fileTree")
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : [] const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
const oldIndex = new Map<string, boolean>( const oldIndex = new Map(
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]), serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
) )
@ -186,14 +186,10 @@ async function setupExplorer(currentSlug: FullSlug) {
// Get folder paths for state management // Get folder paths for state management
const folderPaths = trie.getFolderPaths() const folderPaths = trie.getFolderPaths()
currentExplorerState = folderPaths.map((path) => { currentExplorerState = folderPaths.map((path) => ({
const previousState = oldIndex.get(path)
return {
path, path,
collapsed: collapsed: oldIndex.get(path) === true,
previousState === undefined ? opts.folderDefaultState === "collapsed" : previousState, }))
}
})
const explorerUl = explorer.querySelector(".explorer-ul") const explorerUl = explorer.querySelector(".explorer-ul")
if (!explorerUl) continue if (!explorerUl) continue
@ -263,17 +259,15 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
await setupExplorer(currentSlug) await setupExplorer(currentSlug)
// if mobile hamburger is visible, collapse by default // if mobile hamburger is visible, collapse by default
for (const explorer of document.getElementsByClassName("explorer")) { for (const explorer of document.getElementsByClassName("mobile-explorer")) {
const mobileExplorer = explorer.querySelector(".mobile-explorer") if (explorer.checkVisibility()) {
if (!mobileExplorer) return
if (mobileExplorer.checkVisibility()) {
explorer.classList.add("collapsed") explorer.classList.add("collapsed")
explorer.setAttribute("aria-expanded", "false") explorer.setAttribute("aria-expanded", "false")
} }
mobileExplorer.classList.remove("hide-until-loaded")
} }
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
}) })
function setFolderState(folderElement: HTMLElement, collapsed: boolean) { function setFolderState(folderElement: HTMLElement, collapsed: boolean) {

View File

@ -400,6 +400,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
}) })
.circle(0, 0, nodeRadius(n)) .circle(0, 0, nodeRadius(n))
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) }) .fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
.stroke({ width: isTagNode ? 2 : 0, color: color(n) })
.on("pointerover", (e) => { .on("pointerover", (e) => {
updateHoverInfo(e.target.label) updateHoverInfo(e.target.label)
oldLabelOpacity = label.alpha oldLabelOpacity = label.alpha
@ -415,10 +416,6 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
} }
}) })
if (isTagNode) {
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
}
nodesContainer.addChild(gfx) nodesContainer.addChild(gfx)
labelsContainer.addChild(label) labelsContainer.addChild(label)

View File

@ -86,7 +86,7 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement("script") const gtagScript = document.createElement("script")
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}" gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}"
gtagScript.defer = true gtagScript.async = true
document.head.appendChild(gtagScript) document.head.appendChild(gtagScript)
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
@ -121,7 +121,7 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js" umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"
umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.setAttribute("data-auto-track", "false") umamiScript.setAttribute("data-auto-track", "false")
umamiScript.defer = true umamiScript.async = true
document.head.appendChild(umamiScript) document.head.appendChild(umamiScript)
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
@ -132,7 +132,7 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const goatcounterScript = document.createElement("script") const goatcounterScript = document.createElement("script")
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}" goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
goatcounterScript.defer = true goatcounterScript.async = true
goatcounterScript.setAttribute("data-goatcounter", goatcounterScript.setAttribute("data-goatcounter",
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count") "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
document.head.appendChild(goatcounterScript) document.head.appendChild(goatcounterScript)
@ -173,13 +173,14 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
const cabinScript = document.createElement("script") const cabinScript = document.createElement("script")
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js" cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
cabinScript.defer = true cabinScript.defer = true
cabinScript.async = true
document.head.appendChild(cabinScript) document.head.appendChild(cabinScript)
`) `)
} else if (cfg.analytics?.provider === "clarity") { } else if (cfg.analytics?.provider === "clarity") {
componentResources.afterDOMLoaded.push(` componentResources.afterDOMLoaded.push(`
const clarityScript = document.createElement("script") const clarityScript = document.createElement("script")
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i; t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\` })(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
document.head.appendChild(clarityScript) document.head.appendChild(clarityScript)
@ -234,7 +235,7 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
for (const fontFile of fontFiles) { for (const fontFile of fontFiles) {
const res = await fetch(fontFile.url) const res = await fetch(fontFile.url)
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch font ${fontFile.filename}`) throw new Error(`failed to fetch font ${fontFile.filename}`)
} }
const buf = await res.arrayBuffer() const buf = await res.arrayBuffer()

View File

@ -12,7 +12,6 @@ import DepGraph from "../../depgraph"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
slug: FullSlug slug: FullSlug
filePath: FilePath
title: string title: string
links: SimpleSlug[] links: SimpleSlug[]
tags: string[] tags: string[]
@ -126,7 +125,6 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { linkIndex.set(slug, {
slug, slug,
filePath: file.data.filePath!,
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
links: file.data.links ?? [], links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [], tags: file.data.frontmatter?.tags ?? [],

View File

@ -105,10 +105,6 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
containsIndex = true containsIndex = true
} }
if (file.data.slug?.endsWith("/index")) {
continue
}
const externalResources = pageResources(pathToRoot(slug), file.data, resources) const externalResources = pageResources(pathToRoot(slug), file.data, resources)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,

View File

@ -5,13 +5,11 @@ import { escapeHTML } from "../../util/escape"
export interface Options { export interface Options {
descriptionLength: number descriptionLength: number
maxDescriptionLength: number
replaceExternalLinks: boolean replaceExternalLinks: boolean
} }
const defaultOptions: Options = { const defaultOptions: Options = {
descriptionLength: 150, descriptionLength: 150,
maxDescriptionLength: 300,
replaceExternalLinks: true, replaceExternalLinks: true,
} }
@ -39,41 +37,35 @@ export const Description: QuartzTransformerPlugin<Partial<Options>> = (userOpts)
text = text.replace(urlRegex, "$<domain>" + "$<path>") text = text.replace(urlRegex, "$<domain>" + "$<path>")
} }
if (frontMatterDescription) { const desc = frontMatterDescription ?? text
file.data.description = frontMatterDescription
file.data.text = text
return
}
// otherwise, use the text content
const desc = text
const sentences = desc.replace(/\s+/g, " ").split(/\.\s/) const sentences = desc.replace(/\s+/g, " ").split(/\.\s/)
let finalDesc = "" const finalDesc: string[] = []
const len = opts.descriptionLength
let sentenceIdx = 0 let sentenceIdx = 0
let currentDescriptionLength = 0
// Add full sentences until we exceed the guideline length if (sentences[0] !== undefined && sentences[0].length >= len) {
while (sentenceIdx < sentences.length) { const firstSentence = sentences[0].split(" ")
while (currentDescriptionLength < len) {
const sentence = firstSentence[sentenceIdx]
if (!sentence) break
finalDesc.push(sentence)
currentDescriptionLength += sentence.length
sentenceIdx++
}
finalDesc.push("...")
} else {
while (currentDescriptionLength < len) {
const sentence = sentences[sentenceIdx] const sentence = sentences[sentenceIdx]
if (!sentence) break if (!sentence) break
const currentSentence = sentence.endsWith(".") ? sentence : sentence + "." const currentSentence = sentence.endsWith(".") ? sentence : sentence + "."
const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0) finalDesc.push(currentSentence)
currentDescriptionLength += currentSentence.length
// Add the sentence if we're under the guideline length
// or if this is the first sentence (always include at least one)
if (nextLength <= opts.descriptionLength || sentenceIdx === 0) {
finalDesc += (finalDesc ? " " : "") + currentSentence
sentenceIdx++ sentenceIdx++
} else {
break
} }
} }
// truncate to max length if necessary file.data.description = finalDesc.join(" ")
file.data.description =
finalDesc.length > opts.maxDescriptionLength
? finalDesc.slice(0, opts.maxDescriptionLength) + "..."
: finalDesc
file.data.text = text file.data.text = text
} }
}, },

View File

@ -26,7 +26,7 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} else { } else {
log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`) log.updateText(`Emitting output files: ${chalk.gray(file)}`)
} }
} }
} else { } else {
@ -36,7 +36,7 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
if (ctx.argv.verbose) { if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`) console.log(`[emit:${emitter.name}] ${file}`)
} else { } else {
log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`) log.updateText(`Emitting output files: ${chalk.gray(file)}`)
} }
} }
} }

View File

@ -1,12 +1,10 @@
import test, { describe, beforeEach } from "node:test" import test, { describe, beforeEach } from "node:test"
import assert from "node:assert" import assert from "node:assert"
import { FileTrieNode } from "./fileTrie" import { FileTrieNode } from "./fileTrie"
import { FullSlug } from "./path"
interface TestData { interface TestData {
title: string title: string
slug: string slug: string
filePath: string
} }
describe("FileTrie", () => { describe("FileTrie", () => {
@ -28,24 +26,11 @@ describe("FileTrie", () => {
const data = { const data = {
title: "Test Title", title: "Test Title",
slug: "test", slug: "test",
filePath: "test.md",
} }
trie.add(data) trie.add(data)
assert.strictEqual(trie.children[0].displayName, "Test Title") 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", () => { describe("add", () => {
@ -53,7 +38,6 @@ describe("FileTrie", () => {
const data = { const data = {
title: "Test", title: "Test",
slug: "test", slug: "test",
filePath: "test.md",
} }
trie.add(data) trie.add(data)
@ -66,7 +50,6 @@ describe("FileTrie", () => {
const data = { const data = {
title: "Index", title: "Index",
slug: "index", slug: "index",
filePath: "index.md",
} }
trie.add(data) trie.add(data)
@ -78,13 +61,11 @@ describe("FileTrie", () => {
const data1 = { const data1 = {
title: "Nested", title: "Nested",
slug: "folder/test", slug: "folder/test",
filePath: "folder/test.md",
} }
const data2 = { const data2 = {
title: "Really nested index", title: "Really nested index",
slug: "a/b/c/index", slug: "a/b/c/index",
filePath: "a/b/c/index.md",
} }
trie.add(data1) trie.add(data1)
@ -111,8 +92,8 @@ describe("FileTrie", () => {
describe("filter", () => { describe("filter", () => {
test("should filter nodes based on condition", () => { test("should filter nodes based on condition", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" } const data1 = { title: "Test1", slug: "test1" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" } const data2 = { title: "Test2", slug: "test2" }
trie.add(data1) trie.add(data1)
trie.add(data2) trie.add(data2)
@ -125,8 +106,8 @@ describe("FileTrie", () => {
describe("map", () => { describe("map", () => {
test("should apply function to all nodes", () => { test("should apply function to all nodes", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" } const data1 = { title: "Test1", slug: "test1" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" } const data2 = { title: "Test2", slug: "test2" }
trie.add(data1) trie.add(data1)
trie.add(data2) trie.add(data2)
@ -140,41 +121,12 @@ describe("FileTrie", () => {
assert.strictEqual(trie.children[0].displayName, "Modified") assert.strictEqual(trie.children[0].displayName, "Modified")
assert.strictEqual(trie.children[1].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", () => { describe("entries", () => {
test("should return all entries", () => { test("should return all entries", () => {
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" } const data1 = { title: "Test1", slug: "test1" }
const data2 = { const data2 = { title: "Test2", slug: "a/b/test2" }
title: "Test2",
slug: "a/b-with-space/test2",
filePath: "a/b with space/test2.md",
}
trie.add(data1) trie.add(data1)
trie.add(data2) trie.add(data2)
@ -186,117 +138,26 @@ describe("FileTrie", () => {
["index", trie.data], ["index", trie.data],
["test1", data1], ["test1", data1],
["a/index", null], ["a/index", null],
["a/b-with-space/index", null], ["a/b/index", null],
["a/b-with-space/test2", data2], ["a/b/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", () => { describe("getFolderPaths", () => {
test("should return all folder paths", () => { test("should return all folder paths", () => {
const data1 = { const data1 = {
title: "Root", title: "Root",
slug: "index", slug: "index",
filePath: "index.md",
} }
const data2 = { const data2 = {
title: "Test", title: "Test",
slug: "folder/subfolder/test", slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
} }
const data3 = { const data3 = {
title: "Folder Index", title: "Folder Index",
slug: "abc/index", slug: "abc/index",
filePath: "abc/index.md",
} }
trie.add(data1) trie.add(data1)
@ -315,9 +176,9 @@ describe("FileTrie", () => {
describe("sort", () => { describe("sort", () => {
test("should sort nodes according to sort function", () => { test("should sort nodes according to sort function", () => {
const data1 = { title: "A", slug: "a", filePath: "a.md" } const data1 = { title: "A", slug: "a" }
const data2 = { title: "B", slug: "b", filePath: "b.md" } const data2 = { title: "B", slug: "b" }
const data3 = { title: "C", slug: "c", filePath: "c.md" } const data3 = { title: "C", slug: "c" }
trie.add(data3) trie.add(data3)
trie.add(data1) trie.add(data1)

View File

@ -4,7 +4,6 @@ import { FullSlug, joinSegments } from "./path"
interface FileTrieData { interface FileTrieData {
slug: string slug: string
title: string title: string
filePath: string
} }
export class FileTrieNode<T extends FileTrieData = ContentDetails> { export class FileTrieNode<T extends FileTrieData = ContentDetails> {
@ -12,11 +11,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
children: Array<FileTrieNode<T>> children: Array<FileTrieNode<T>>
private slugSegments: string[] 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 data: T | null
constructor(segments: string[], data?: T) { constructor(segments: string[], data?: T) {
@ -24,18 +18,10 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
this.slugSegments = segments this.slugSegments = segments
this.data = data ?? null this.data = data ?? null
this.isFolder = false this.isFolder = false
this.displayNameOverride = undefined
} }
get displayName(): string { get displayName(): string {
const nonIndexTitle = this.data?.title === "index" ? undefined : this.data?.title return this.data?.title ?? this.slugSegment ?? ""
return (
this.displayNameOverride ?? nonIndexTitle ?? this.fileSegmentHint ?? this.slugSegment ?? ""
)
}
set displayName(name: string) {
this.displayNameOverride = name
} }
get slug(): FullSlug { get slug(): FullSlug {
@ -77,9 +63,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
// recursive case, we are not at the end of the path // recursive case, we are not at the end of the path
const child = const child =
this.children.find((c) => c.slugSegment === segment) ?? this.makeChild(path, undefined) 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) child.insert(path.slice(1), file)
} }
} }
@ -89,14 +72,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
this.insert(file.slug.split("/"), file) 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))
}
/** /**
* Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
*/ */

View File

@ -22,7 +22,7 @@ export class QuartzLogger {
readline.cursorTo(process.stdout, 0) readline.cursorTo(process.stdout, 0)
process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`) process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 20) }, 100)
} }
} }

View File

@ -3,13 +3,11 @@ import { FontWeight, SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { JSXInternal } from "preact/src/jsx" import { JSXInternal } from "preact/src/jsx"
import { FontSpecification, getFontSpecificationName, ThemeKey } from "./theme" import { FontSpecification, ThemeKey } from "./theme"
import path from "path" import path from "path"
import { QUARTZ } from "./path" import { QUARTZ } from "./path"
import { formatDate, getDate } from "../components/Date" import { formatDate } from "../components/Date"
import readingTime from "reading-time" import { getDate } from "../components/Date"
import { i18n } from "../i18n"
import chalk from "chalk"
const defaultHeaderWeight = [700] const defaultHeaderWeight = [700]
const defaultBodyWeight = [400] const defaultBodyWeight = [400]
@ -27,38 +25,29 @@ export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: Fo
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
// Fetch fonts for all weights and convert to satori format in one go // Fetch fonts for all weights
const headerFontPromises = headerWeights.map(async (weight) => { const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight))
const data = await fetchTtf(headerFontName, weight) const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight))
if (!data) return null
return {
name: headerFontName,
data,
weight,
style: "normal" as const,
}
})
const bodyFontPromises = bodyWeights.map(async (weight) => { const [headerFontData, bodyFontData] = await Promise.all([
const data = await fetchTtf(bodyFontName, weight)
if (!data) return null
return {
name: bodyFontName,
data,
weight,
style: "normal" as const,
}
})
const [headerFonts, bodyFonts] = await Promise.all([
Promise.all(headerFontPromises), Promise.all(headerFontPromises),
Promise.all(bodyFontPromises), Promise.all(bodyFontPromises),
]) ])
// Filter out any failed fetches and combine header and body fonts // Convert fonts to satori font format and return
const fonts: SatoriOptions["fonts"] = [ const fonts: SatoriOptions["fonts"] = [
...headerFonts.filter((font): font is NonNullable<typeof font> => font !== null), ...headerFontData.map((data, idx) => ({
...bodyFonts.filter((font): font is NonNullable<typeof font> => font !== null), name: headerFontName,
data,
weight: headerWeights[idx],
style: "normal" as const,
})),
...bodyFontData.map((data, idx) => ({
name: bodyFontName,
data,
weight: bodyWeights[idx],
style: "normal" as const,
})),
] ]
return fonts return fonts
@ -71,11 +60,10 @@ export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: Fo
* @returns `.ttf` file of google font * @returns `.ttf` file of google font
*/ */
export async function fetchTtf( export async function fetchTtf(
rawFontName: string, fontName: string,
weight: FontWeight, weight: FontWeight,
): Promise<Buffer<ArrayBufferLike> | undefined> { ): Promise<Buffer<ArrayBufferLike>> {
const fontName = rawFontName.replaceAll(" ", "+") const cacheKey = `${fontName.replaceAll(" ", "-")}-${weight}`
const cacheKey = `${fontName}-${weight}`
const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts") const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
const cachePath = path.join(cacheDir, cacheKey) const cachePath = path.join(cacheDir, cacheKey)
@ -98,19 +86,20 @@ export async function fetchTtf(
const match = urlRegex.exec(css) const match = urlRegex.exec(css)
if (!match) { if (!match) {
console.log( throw new Error("Could not fetch font")
chalk.yellow(
`\nWarning: Failed to fetch font ${rawFontName} with weight ${weight}, got ${cssResponse.statusText}`,
),
)
return
} }
// fontData is an ArrayBuffer containing the .ttf file data // fontData is an ArrayBuffer containing the .ttf file data
const fontResponse = await fetch(match[1]) const fontResponse = await fetch(match[1])
const fontData = Buffer.from(await fontResponse.arrayBuffer()) const fontData = Buffer.from(await fontResponse.arrayBuffer())
try {
await fs.mkdir(cacheDir, { recursive: true }) await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(cachePath, fontData) await fs.writeFile(cachePath, fontData)
} catch (error) {
console.warn(`Failed to cache font: ${error}`)
// Continue even if caching fails
}
return fontData return fontData
} }
@ -183,7 +172,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
{ colorScheme }: UserOpts, { colorScheme }: UserOpts,
title: string, title: string,
description: string, description: string,
_fonts: SatoriOptions["fonts"], fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData, fileData: QuartzPluginData,
) => { ) => {
const fontBreakPoint = 32 const fontBreakPoint = 32
@ -194,16 +183,8 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
const rawDate = getDate(cfg, fileData) const rawDate = getDate(cfg, fileData)
const date = rawDate ? formatDate(rawDate, cfg.locale) : null const date = rawDate ? formatDate(rawDate, cfg.locale) : null
// Calculate reading time
const { minutes } = readingTime(fileData.text ?? "")
const readingTimeText = i18n(cfg.locale).components.contentMeta.readingTime({
minutes: Math.ceil(minutes),
})
// Get tags if available // Get tags if available
const tags = fileData.frontmatter?.tags ?? [] const tags = fileData.frontmatter?.tags ?? []
const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
const headerFont = getFontSpecificationName(cfg.theme.typography.header)
return ( return (
<div <div
@ -214,7 +195,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
width: "100%", width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light, backgroundColor: cfg.theme.colors[colorScheme].light,
padding: "2.5rem", padding: "2.5rem",
fontFamily: bodyFont, fontFamily: fonts[1].name,
}} }}
> >
{/* Header Section */} {/* Header Section */}
@ -239,7 +220,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
display: "flex", display: "flex",
fontSize: 32, fontSize: 32,
color: cfg.theme.colors[colorScheme].gray, color: cfg.theme.colors[colorScheme].gray,
fontFamily: bodyFont, fontFamily: fonts[1].name,
}} }}
> >
{cfg.baseUrl} {cfg.baseUrl}
@ -258,7 +239,7 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
style={{ style={{
margin: 0, margin: 0,
fontSize: useSmallerFont ? 64 : 72, fontSize: useSmallerFont ? 64 : 72,
fontFamily: headerFont, fontFamily: fonts[0].name,
fontWeight: 700, fontWeight: 700,
color: cfg.theme.colors[colorScheme].dark, color: cfg.theme.colors[colorScheme].dark,
lineHeight: 1.2, lineHeight: 1.2,
@ -266,7 +247,6 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
WebkitLineClamp: 2, WebkitLineClamp: 2,
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis",
}} }}
> >
{title} {title}
@ -288,9 +268,8 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
margin: 0, margin: 0,
display: "-webkit-box", display: "-webkit-box",
WebkitBoxOrient: "vertical", WebkitBoxOrient: "vertical",
WebkitLineClamp: 5, WebkitLineClamp: 4,
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis",
}} }}
> >
{description} {description}
@ -308,12 +287,11 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`, borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
}} }}
> >
{/* Left side - Date and Reading Time */} {/* Left side - Date */}
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "2rem",
color: cfg.theme.colors[colorScheme].gray, color: cfg.theme.colors[colorScheme].gray,
fontSize: 28, fontSize: 28,
}} }}
@ -336,20 +314,6 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
{date} {date}
</div> </div>
)} )}
<div style={{ display: "flex", alignItems: "center" }}>
<svg
style={{ marginRight: "0.5rem" }}
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{readingTimeText}
</div>
</div> </div>
{/* Right side - Tags */} {/* Right side - Tags */}

View File

@ -247,7 +247,7 @@ export function transformLink(src: FullSlug, target: string, opts: TransformOpti
} }
// path helpers // path helpers
export function isFolderPath(fplike: string): boolean { function isFolderPath(fplike: string): boolean {
return ( return (
fplike.endsWith("/") || fplike.endsWith("/") ||
endsWith(fplike, "index") || endsWith(fplike, "index") ||

View File

@ -135,9 +135,9 @@ ${stylesheet.join("\n\n")}
--highlight: ${theme.colors.lightMode.highlight}; --highlight: ${theme.colors.lightMode.highlight};
--textHighlight: ${theme.colors.lightMode.textHighlight}; --textHighlight: ${theme.colors.lightMode.textHighlight};
--headerFont: "${getFontSpecificationName(theme.typography.header)}", ${DEFAULT_SANS_SERIF}; --headerFont: "${theme.typography.header}", ${DEFAULT_SANS_SERIF};
--bodyFont: "${getFontSpecificationName(theme.typography.body)}", ${DEFAULT_SANS_SERIF}; --bodyFont: "${theme.typography.body}", ${DEFAULT_SANS_SERIF};
--codeFont: "${getFontSpecificationName(theme.typography.code)}", ${DEFAULT_MONO}; --codeFont: "${theme.typography.code}", ${DEFAULT_MONO};
} }
:root[saved-theme="dark"] { :root[saved-theme="dark"] {