mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-01 18:27:57 +01:00
Compare commits
10 Commits
c5a8b199ae
...
v4.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696403d3fa | ||
|
|
2c30abe457 | ||
|
|
80c3196fee | ||
|
|
d9159e0ac9 | ||
|
|
c005fe4408 | ||
|
|
580c1bd608 | ||
|
|
270a5dc14a | ||
|
|
bfa938cc62 | ||
|
|
e3c50caf13 | ||
|
|
ca08ec1ae7 |
@@ -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,
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
14
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -87,6 +87,8 @@ const config: QuartzConfig = {
|
||||
Plugin.Assets(),
|
||||
Plugin.Static(),
|
||||
Plugin.NotFoundPage(),
|
||||
// Comment out CustomOgImages to speed up build time
|
||||
Plugin.CustomOgImages(),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user