Compare commits

..

10 Commits

Author SHA1 Message Date
Jacky Zhao
696403d3fa chore: bump version to 4.4.1 2025-03-13 10:55:37 -07:00
Jacky Zhao
2c30abe457 fix(analytics): always use defer over async to account for document nav event 2025-03-13 10:48:03 -07:00
Jacky Zhao
80c3196fee feat(og): add reading time to default, improve logging 2025-03-13 10:41:50 -07:00
Jacky Zhao
d9159e0ac9 feat: make og images an emitter to properly await image generation (#1826)
* checkpoint

* make emitters async generators

* fix

* custom font spec

* replace spinner, use disk cache for fonts

* use readline instead

* make og images look nice
2025-03-13 10:27:46 -07:00
Jacky Zhao
c005fe4408 fix(explorer): properly respect folderDefaultState (closes #1827)
Some checks are pending
Build and Test / build-and-test (macos-latest) (push) Waiting to run
Build and Test / build-and-test (windows-latest) (push) Waiting to run
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
2025-03-12 22:31:44 -07:00
Emile Bangma
580c1bd608 fix(typography): properly pass Google font options (#1825)
Some checks are pending
Build and Test / build-and-test (macos-latest) (push) Waiting to run
Build and Test / build-and-test (windows-latest) (push) Waiting to run
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
2025-03-12 11:27:41 -07:00
Jacky Zhao
270a5dc14a fix(explorer): show file name instead of slug if no file data (closes #1822) 2025-03-12 11:24:28 -07:00
Jacky Zhao
bfa938cc62 fix(explorer): allow setting displayName (closes #1824) 2025-03-12 10:42:07 -07:00
Jacky Zhao
e3c50caf13 fix(explorer): dont invert mobile css, properly toggle .collapsed 2025-03-12 10:15:54 -07:00
Emile Bangma
ca08ec1ae7 fix(explorer): mobile explorer toggle (#1823)
Some checks are pending
Build and Test / build-and-test (macos-latest) (push) Waiting to run
Build and Test / build-and-test (windows-latest) (push) Waiting to run
Build and Test / build-and-test (ubuntu-latest) (push) Has been skipped
Build and Test / publish-tag (push) Has been skipped
2025-03-12 14:15:16 +01:00
17 changed files with 459 additions and 173 deletions

View File

@@ -108,3 +108,25 @@ 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]].
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,
},
...
}
```

View File

@@ -107,27 +107,35 @@ export const myImage: SocialImageOptions["imageStructure"] = (...) => {
> import fs from "fs"
> import path from "path"
>
> const headerFont = joinSegments(QUARTZ, "static", "Newsreader.woff2")
> const bodyFont = joinSegments(QUARTZ, "static", "Newsreader.woff2")
>
> export async function getSatoriFont(cfg: GlobalConfiguration): Promise<SatoriOptions["fonts"]> {
> const headerWeight: FontWeight = 700
> const bodyWeight: FontWeight = 400
>
> const [header, body] = await Promise.all(
> [headerFont, bodyFont].map((font) =>
> fs.promises.readFile(path.resolve(font))
> ),
> )
>
> return [
> { name: cfg.theme.typography.header, data: header, weight: headerWeight, style: "normal" },
> { name: cfg.theme.typography.body, data: body, weight: bodyWeight, style: "normal" },
> const newsreaderFontPath = joinSegments(QUARTZ, "static", "Newsreader.woff2")
> export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
> // ... rest of implementation remains same
> const fonts: SatoriOptions["fonts"] = [
> ...headerFontData.map((data, idx) => ({
> name: headerFontName,
> data,
> weight: headerWeights[idx],
> style: "normal" as const,
> })),
> ...bodyFontData.map((data, idx) => ({
> name: bodyFontName,
> data,
> weight: bodyWeights[idx],
> style: "normal" as const,
> })),
> {
> name: "Newsreader",
> data: await fs.promises.readFile(path.resolve(newsreaderFontPath)),
> weight: 400,
> style: "normal" as const,
> },
> ]
>
> return fonts
> }
> ```
>
> This font then can be used with your custom structure
> This font then can be used with your custom structure.
## Examples

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@jackyzha0/quartz",
"version": "4.4.0",
"version": "4.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jackyzha0/quartz",
"version": "4.4.0",
"version": "4.4.1",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.10.0",
@@ -75,7 +75,6 @@
"quartz": "quartz/bootstrap-cli.mjs"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",
@@ -1585,15 +1584,6 @@
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@types/cli-spinner": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz",
"integrity": "sha512-TMO6mWltW0lCu1de8DMRq9+59OP/tEjghS+rs8ZEQ2EgYP5yV3bGw0tS14TMyJGqFaoVChNvhkVzv9RC1UgX+w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",

View File

@@ -2,7 +2,7 @@
"name": "@jackyzha0/quartz",
"description": "🌱 publish your digital garden and notes as a website",
"private": true,
"version": "4.4.0",
"version": "4.4.1",
"type": "module",
"author": "jackyzha0 <j.zhao2k19@gmail.com>",
"license": "MIT",
@@ -98,7 +98,6 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/cli-spinner": "^0.2.3",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/js-yaml": "^4.0.9",

View File

@@ -87,6 +87,8 @@ const config: QuartzConfig = {
Plugin.Assets(),
Plugin.Static(),
Plugin.NotFoundPage(),
// Comment out CustomOgImages to speed up build time
Plugin.CustomOgImages(),
],
},
}

View File

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

View File

@@ -250,15 +250,25 @@ async function partialRebuildFromEntrypoint(
([_node, vfile]) => !toRemove.has(vfile.data.filePath!),
)
const emittedFps = await emitter.emit(ctx, files, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
const emitted = await emitter.emit(ctx, files, staticResources)
if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
emittedFiles += emittedFps.length
continue
}
@@ -280,15 +290,24 @@ async function partialRebuildFromEntrypoint(
.filter((file) => !toRemove.has(file))
.map((file) => contentMap.get(file)!)
const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources)
if (ctx.argv.verbose) {
for (const file of emittedFps) {
console.log(`[emit:${emitter.name}] ${file}`)
const emitted = await emitter.emit(ctx, upstreamContent, staticResources)
if (Symbol.asyncIterator in emitted) {
// Async generator case
for await (const file of emitted) {
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
console.log(`[emit:${emitter.name}] ${file}`)
}
}
}
emittedFiles += emittedFps.length
}
}

View File

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

View File

@@ -86,7 +86,7 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement("script")
gtagScript.src = "https://www.googletagmanager.com/gtag/js?id=${tagId}"
gtagScript.async = true
gtagScript.defer = true
document.head.appendChild(gtagScript)
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.setAttribute("data-website-id", "${cfg.analytics.websiteId}")
umamiScript.setAttribute("data-auto-track", "false")
umamiScript.async = true
umamiScript.defer = true
document.head.appendChild(umamiScript)
document.addEventListener("nav", () => {
@@ -132,7 +132,7 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
componentResources.afterDOMLoaded.push(`
const goatcounterScript = document.createElement("script")
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"
goatcounterScript.async = true
goatcounterScript.defer = true
goatcounterScript.setAttribute("data-goatcounter",
"https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count")
document.head.appendChild(goatcounterScript)
@@ -173,14 +173,13 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso
const cabinScript = document.createElement("script")
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
cabinScript.defer = true
cabinScript.async = true
document.head.appendChild(cabinScript)
`)
} else if (cfg.analytics?.provider === "clarity") {
componentResources.afterDOMLoaded.push(`
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)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
document.head.appendChild(clarityScript)

View File

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

View File

@@ -2,8 +2,7 @@ import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFont } from "../../util/og"
import { getFontSpecificationName } from "../../util/theme"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp"
import satori from "satori"
import { loadEmoji, getIconCode } from "../../util/emoji"
@@ -54,9 +53,9 @@ export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> =
},
async *emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration
const headerFont = getFontSpecificationName(cfg.theme.typography.header)
const bodyFont = getFontSpecificationName(cfg.theme.typography.body)
const fonts = await getSatoriFont(headerFont, bodyFont)
const headerFont = cfg.theme.typography.header
const bodyFont = cfg.theme.typography.body
const fonts = await getSatoriFonts(headerFont, bodyFont)
for (const [_tree, vfile] of content) {
// if this file defines socialImage, we can skip

View File

@@ -4,6 +4,7 @@ import { ProcessedContent } from "../plugins/vfile"
import { QuartzLogger } from "../util/log"
import { trace } from "../util/trace"
import { BuildCtx } from "../util/ctx"
import chalk from "chalk"
export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const { argv, cfg } = ctx
@@ -20,20 +21,22 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
const emitted = await emitter.emit(ctx, content, staticResources)
if (Symbol.asyncIterator in emitted) {
// Async generator case
const files: string[] = []
for await (const file of emitted) {
files.push(file)
emittedFiles++
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`)
}
}
} else {
// Array case
emittedFiles += emitted.length
if (ctx.argv.verbose) {
for (const file of emitted) {
for (const file of emitted) {
if (ctx.argv.verbose) {
console.log(`[emit:${emitter.name}] ${file}`)
} else {
log.updateText(`Emitting output files: ${emitter.name} -> ${chalk.gray(file)}`)
}
}
}

View File

@@ -5,6 +5,7 @@ import { FileTrieNode } from "./fileTrie"
interface TestData {
title: string
slug: string
filePath: string
}
describe("FileTrie", () => {
@@ -26,11 +27,24 @@ describe("FileTrie", () => {
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", () => {
@@ -38,6 +52,7 @@ describe("FileTrie", () => {
const data = {
title: "Test",
slug: "test",
filePath: "test.md",
}
trie.add(data)
@@ -50,6 +65,7 @@ describe("FileTrie", () => {
const data = {
title: "Index",
slug: "index",
filePath: "index.md",
}
trie.add(data)
@@ -61,11 +77,13 @@ describe("FileTrie", () => {
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)
@@ -92,8 +110,8 @@ describe("FileTrie", () => {
describe("filter", () => {
test("should filter nodes based on condition", () => {
const data1 = { title: "Test1", slug: "test1" }
const data2 = { title: "Test2", slug: "test2" }
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
trie.add(data1)
trie.add(data2)
@@ -106,8 +124,8 @@ describe("FileTrie", () => {
describe("map", () => {
test("should apply function to all nodes", () => {
const data1 = { title: "Test1", slug: "test1" }
const data2 = { title: "Test2", slug: "test2" }
const data1 = { title: "Test1", slug: "test1", filePath: "test1.md" }
const data2 = { title: "Test2", slug: "test2", filePath: "test2.md" }
trie.add(data1)
trie.add(data2)
@@ -121,12 +139,41 @@ describe("FileTrie", () => {
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" }
const data2 = { title: "Test2", slug: "a/b/test2" }
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)
@@ -138,8 +185,8 @@ describe("FileTrie", () => {
["index", trie.data],
["test1", data1],
["a/index", null],
["a/b/index", null],
["a/b/test2", data2],
["a/b-with-space/index", null],
["a/b-with-space/test2", data2],
],
)
})
@@ -150,14 +197,17 @@ describe("FileTrie", () => {
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)
@@ -176,9 +226,9 @@ describe("FileTrie", () => {
describe("sort", () => {
test("should sort nodes according to sort function", () => {
const data1 = { title: "A", slug: "a" }
const data2 = { title: "B", slug: "b" }
const data3 = { title: "C", slug: "c" }
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)

View File

@@ -4,6 +4,7 @@ import { FullSlug, joinSegments } from "./path"
interface FileTrieData {
slug: string
title: string
filePath: string
}
export class FileTrieNode<T extends FileTrieData = ContentDetails> {
@@ -11,6 +12,11 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
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) {
@@ -18,10 +24,18 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
this.slugSegments = segments
this.data = data ?? null
this.isFolder = false
this.displayNameOverride = undefined
}
get displayName(): string {
return this.data?.title ?? this.slugSegment ?? ""
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 {
@@ -63,6 +77,9 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
// 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)
}
}

View File

@@ -1,26 +1,43 @@
import { Spinner } from "cli-spinner"
import readline from "readline"
export class QuartzLogger {
verbose: boolean
spinner: Spinner | undefined
private spinnerInterval: NodeJS.Timeout | undefined
private spinnerText: string = ""
private spinnerIndex: number = 0
private readonly spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
constructor(verbose: boolean) {
this.verbose = verbose
}
start(text: string) {
this.spinnerText = text
if (this.verbose) {
console.log(text)
} else {
this.spinner = new Spinner(`%s ${text}`)
this.spinner.setSpinnerString(18)
this.spinner.start()
this.spinnerIndex = 0
this.spinnerInterval = setInterval(() => {
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
process.stdout.write(`${this.spinnerChars[this.spinnerIndex]} ${this.spinnerText}`)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 20)
}
}
updateText(text: string) {
this.spinnerText = text
}
end(text?: string) {
if (!this.verbose) {
this.spinner!.stop(true)
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)
}

View File

@@ -1,75 +1,108 @@
import { promises as fs } from "fs"
import { FontWeight, SatoriOptions } from "satori/wasm"
import { GlobalConfiguration } from "../cfg"
import { QuartzPluginData } from "../plugins/vfile"
import { JSXInternal } from "preact/src/jsx"
import { ThemeKey } from "./theme"
import { FontSpecification, ThemeKey } from "./theme"
import path from "path"
import { QUARTZ } from "./path"
import { formatDate, getDate } from "../components/Date"
import readingTime from "reading-time"
import { i18n } from "../i18n"
/**
* Get an array of `FontOptions` (for satori) given google font names
* @param headerFontName name of google font used for header
* @param bodyFontName name of google font used for body
* @returns FontOptions for header and body
*/
export async function getSatoriFont(headerFontName: string, bodyFontName: string) {
const headerWeight = 700 as FontWeight
const bodyWeight = 400 as FontWeight
const defaultHeaderWeight = [700]
const defaultBodyWeight = [400]
export async function getSatoriFonts(headerFont: FontSpecification, bodyFont: FontSpecification) {
// Get all weights for header and body fonts
const headerWeights: FontWeight[] = (
typeof headerFont === "string"
? defaultHeaderWeight
: (headerFont.weights ?? defaultHeaderWeight)
) as FontWeight[]
const bodyWeights: FontWeight[] = (
typeof bodyFont === "string" ? defaultBodyWeight : (bodyFont.weights ?? defaultBodyWeight)
) as FontWeight[]
// Fetch fonts
const [headerFont, bodyFont] = await Promise.all([
fetchTtf(headerFontName, headerWeight),
fetchTtf(bodyFontName, bodyWeight),
const headerFontName = typeof headerFont === "string" ? headerFont : headerFont.name
const bodyFontName = typeof bodyFont === "string" ? bodyFont : bodyFont.name
// Fetch fonts for all weights
const headerFontPromises = headerWeights.map((weight) => fetchTtf(headerFontName, weight))
const bodyFontPromises = bodyWeights.map((weight) => fetchTtf(bodyFontName, weight))
const [headerFontData, bodyFontData] = await Promise.all([
Promise.all(headerFontPromises),
Promise.all(bodyFontPromises),
])
// Convert fonts to satori font format and return
const fonts: SatoriOptions["fonts"] = [
{ name: headerFontName, data: headerFont, weight: headerWeight, style: "normal" },
{ name: bodyFontName, data: bodyFont, weight: bodyWeight, style: "normal" },
...headerFontData.map((data, idx) => ({
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
}
// Cache for memoizing font data
const fontCache = new Map<string, Promise<ArrayBuffer>>()
/**
* Get the `.ttf` file of a google font
* @param fontName name of google font
* @param weight what font weight to fetch font
* @returns `.ttf` file of google font
*/
export async function fetchTtf(fontName: string, weight: FontWeight): Promise<ArrayBuffer> {
const cacheKey = `${fontName}-${weight}`
if (fontCache.has(cacheKey)) {
return fontCache.get(cacheKey)!
export async function fetchTtf(
fontName: string,
weight: FontWeight,
): Promise<Buffer<ArrayBufferLike>> {
const cacheKey = `${fontName.replaceAll(" ", "-")}-${weight}`
const cacheDir = path.join(QUARTZ, ".quartz-cache", "fonts")
const cachePath = path.join(cacheDir, cacheKey)
// Check if font exists in cache
try {
await fs.access(cachePath)
return fs.readFile(cachePath)
} catch (error) {
// ignore errors and fetch font
}
// If not in cache, fetch and store the promise
const fontPromise = (async () => {
try {
// Get css file from google fonts
const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
)
const css = await cssResponse.text()
// Get css file from google fonts
const cssResponse = await fetch(
`https://fonts.googleapis.com/css2?family=${fontName}:wght@${weight}`,
)
const css = await cssResponse.text()
// Extract .ttf url from css file
const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
const match = urlRegex.exec(css)
// Extract .ttf url from css file
const urlRegex = /url\((https:\/\/fonts.gstatic.com\/s\/.*?.ttf)\)/g
const match = urlRegex.exec(css)
if (!match) {
throw new Error("Could not fetch font")
}
if (!match) {
throw new Error("Could not fetch font")
}
// fontData is an ArrayBuffer containing the .ttf file data (get match[1] due to google fonts response format, always contains link twice, but second entry is the "raw" link)
const fontResponse = await fetch(match[1])
return await fontResponse.arrayBuffer()
} catch (error) {
throw new Error(`Error fetching font: ${error}`)
}
})()
// fontData is an ArrayBuffer containing the .ttf file data
const fontResponse = await fetch(match[1])
const fontData = Buffer.from(await fontResponse.arrayBuffer())
fontCache.set(cacheKey, fontPromise)
return fontPromise
try {
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(cachePath, fontData)
} catch (error) {
console.warn(`Failed to cache font: ${error}`)
// Continue even if caching fails
}
return fontData
}
export type SocialImageOptions = {
@@ -141,68 +174,100 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
title: string,
description: string,
fonts: SatoriOptions["fonts"],
_fileData: QuartzPluginData,
fileData: QuartzPluginData,
) => {
const fontBreakPoint = 22
const fontBreakPoint = 32
const useSmallerFont = title.length > fontBreakPoint
const iconPath = `https://${cfg.baseUrl}/static/icon.png`
// Format date if available
const rawDate = getDate(cfg, fileData)
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
const tags = fileData.frontmatter?.tags ?? []
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
width: "100%",
backgroundColor: cfg.theme.colors[colorScheme].light,
gap: "2rem",
padding: "1.5rem 5rem",
padding: "2.5rem",
fontFamily: fonts[1].name,
}}
>
{/* Header Section */}
<div
style={{
display: "flex",
alignItems: "center",
width: "100%",
flexDirection: "row",
gap: "2.5rem",
gap: "1rem",
marginBottom: "0.5rem",
}}
>
<img src={iconPath} width={135} height={135} />
<img
src={iconPath}
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
<div
style={{
display: "flex",
color: cfg.theme.colors[colorScheme].dark,
fontSize: useSmallerFont ? 70 : 82,
fontFamily: fonts[0].name,
maxWidth: "70%",
overflow: "hidden",
textOverflow: "ellipsis",
fontSize: 32,
color: cfg.theme.colors[colorScheme].gray,
fontFamily: fonts[1].name,
}}
>
<p
style={{
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</p>
{cfg.baseUrl}
</div>
</div>
{/* Title Section */}
<div
style={{
display: "flex",
color: cfg.theme.colors[colorScheme].dark,
fontSize: 44,
fontFamily: fonts[1].name,
maxWidth: "100%",
maxHeight: "40%",
overflow: "hidden",
marginTop: "1rem",
marginBottom: "1.5rem",
}}
>
<h1
style={{
margin: 0,
fontSize: useSmallerFont ? 64 : 72,
fontFamily: fonts[0].name,
fontWeight: 700,
color: cfg.theme.colors[colorScheme].dark,
lineHeight: 1.2,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
}}
>
{title}
</h1>
</div>
{/* Description Section */}
<div
style={{
display: "flex",
flex: 1,
fontSize: 36,
color: cfg.theme.colors[colorScheme].darkgray,
lineHeight: 1.4,
}}
>
<p
@@ -210,14 +275,95 @@ export const defaultImage: SocialImageOptions["imageStructure"] = (
margin: 0,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 3,
WebkitLineClamp: 4,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{description}
</p>
</div>
{/* Footer with Metadata */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "2rem",
paddingTop: "2rem",
borderTop: `1px solid ${cfg.theme.colors[colorScheme].lightgray}`,
}}
>
{/* Left side - Date and Reading Time */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "2rem",
color: cfg.theme.colors[colorScheme].gray,
fontSize: 28,
}}
>
{date && (
<div style={{ display: "flex", alignItems: "center" }}>
<svg
style={{ marginRight: "0.5rem" }}
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{date}
</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>
{/* Right side - Tags */}
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
justifyContent: "flex-end",
maxWidth: "60%",
}}
>
{tags.slice(0, 3).map((tag: string) => (
<div
style={{
display: "flex",
padding: "0.5rem 1rem",
backgroundColor: cfg.theme.colors[colorScheme].highlight,
color: cfg.theme.colors[colorScheme].secondary,
borderRadius: "10px",
fontSize: 24,
}}
>
#{tag}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -15,7 +15,7 @@ interface Colors {
darkMode: ColorScheme
}
type FontSpecification =
export type FontSpecification =
| string
| {
name: string
@@ -135,9 +135,9 @@ ${stylesheet.join("\n\n")}
--highlight: ${theme.colors.lightMode.highlight};
--textHighlight: ${theme.colors.lightMode.textHighlight};
--headerFont: "${theme.typography.header}", ${DEFAULT_SANS_SERIF};
--bodyFont: "${theme.typography.body}", ${DEFAULT_SANS_SERIF};
--codeFont: "${theme.typography.code}", ${DEFAULT_MONO};
--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"] {