Compare commits

..

6 Commits

Author SHA1 Message Date
Jacky Zhao
1bb6f09db1 make fancy logger dumb in ci 2025-03-16 14:12:43 -07:00
Jacky Zhao
40a72eba44 update docs 2025-03-16 13:46:16 -07:00
Jacky Zhao
bdc15ecb05 smol doc update 2025-03-16 12:11:05 -07:00
Jacky Zhao
5ccb9ddc70 properly splice changes array 2025-03-16 12:07:48 -07:00
Jacky Zhao
7681a86815 incremental all the things 2025-03-15 22:00:37 -07:00
Jacky Zhao
f528d6139e checkpoint 2025-03-14 09:18:21 -07:00
14 changed files with 94 additions and 250 deletions

View File

@@ -25,7 +25,7 @@ jobs:
with:
fetch-depth: 1
- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@v5.1.0
uses: rlespinasse/github-slug-action@v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -57,7 +57,7 @@ const config: QuartzConfig = {
transformers: [
Plugin.FrontMatter(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "git", "filesystem"],
priority: ["git", "frontmatter", "filesystem"],
}),
Plugin.SyntaxHighlighting({
theme: {

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 { Date, getDate } from "./Date"
import { QuartzComponent, QuartzComponentProps } from "./types"
@@ -8,13 +8,6 @@ export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number
export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
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) {
// sort descending
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()

View File

@@ -1,14 +1,16 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
import path from "path"
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 { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
import { FileTrieNode } from "../../util/fileTrie"
interface FolderContentOptions {
/**
* Whether to display number of folders
@@ -25,88 +27,51 @@ const defaultOptions: FolderContentOptions = {
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
let trie: FileTrieNode<
QuartzPluginData & {
slug: string
title: string
filePath: string
}
>
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const folderParts = folderSlug.split(path.posix.sep)
if (!trie) {
trie = new FileTrieNode([])
allFiles.forEach((file) => {
if (file.frontmatter) {
trie.add({
...file,
slug: file.slug!,
title: file.frontmatter.title,
filePath: file.filePath!,
})
}
})
}
const allPagesInFolder: QuartzPluginData[] = []
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
const folder = trie.findNode(fileData.slug!.split("/"))
if (!folder) {
return null
}
allFiles.forEach((file) => {
const fileSlug = stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
const allPagesInFolder: QuartzPluginData[] =
folder.children
.map((node) => {
// regular file, proceed
if (node.data) {
return node.data
}
if (!prefixed) {
return
}
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 (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])
}
})
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(),
}
)
}
return {
slug: node.slug,
dates: getMostRecentDates(),
frontmatter: {
title: node.displayName,
tags: [],
},
}
}
allPagesInSubfolders.forEach((files, subfolderSlug) => {
const hasIndex = allPagesInFolder.some(
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
)
if (!hasIndex) {
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
allPagesInFolder.push({
slug: subfolderSlug,
dates: subfolderDates,
frontmatter: { title: subfolderTitle, tags: ["folder"] },
})
.filter((page) => page !== undefined) ?? []
}
})
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = cssClasses.join(" ")
const listProps = {

View File

@@ -134,9 +134,9 @@ function createFolderNode(
}
for (const child of node.children) {
const childNode = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
const childNode = child.data
? createFileNode(currentSlug, child)
: createFolderNode(currentSlug, child, opts)
ul.appendChild(childNode)
}

View File

@@ -52,8 +52,6 @@
overflow: hidden;
flex-shrink: 0;
align-self: flex-start;
margin-top: auto;
margin-bottom: auto;
}
button.mobile-explorer {

View File

@@ -1,7 +1,7 @@
import { QuartzEmitterPlugin } from "../types"
import { i18n } from "../../i18n"
import { unescapeHTML } from "../../util/escape"
import { FullSlug, getFileExtension, joinSegments, QUARTZ } from "../../util/path"
import { FullSlug, getFileExtension } from "../../util/path"
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
import sharp from "sharp"
import satori, { SatoriOptions } from "satori"
@@ -10,8 +10,6 @@ import { Readable } from "stream"
import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
import { QuartzPluginData } from "../vfile"
import fs from "node:fs/promises"
import chalk from "chalk"
const defaultOptions: SocialImageOptions = {
colorScheme: "lightMode",
@@ -30,25 +28,7 @@ async function generateSocialImage(
userOpts: SocialImageOptions,
): Promise<Readable> {
const { width, height } = userOpts
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
let iconBase64: string | undefined = undefined
try {
const iconData = await fs.readFile(iconPath)
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`
} catch (err) {
console.warn(chalk.yellow(`Warning: Could not find icon at ${iconPath}`))
}
const imageComponent = userOpts.imageStructure({
cfg,
userOpts,
title,
description,
fonts,
fileData,
iconBase64,
})
const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData)
const svg = await satori(imageComponent, {
width,
height,

View File

@@ -1,4 +1,5 @@
import fs from "fs"
import path from "path"
import { Repository } from "@napi-rs/simple-git"
import { QuartzTransformerPlugin } from "../types"
import chalk from "chalk"
@@ -34,23 +35,13 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
return [
() => {
let repo: Repository | undefined = undefined
if (opts.priority.includes("git")) {
try {
repo = Repository.discover(ctx.argv.directory)
} catch (e) {
console.log(
chalk.yellow(`\nWarning: couldn't find git repository for ${ctx.argv.directory}`),
)
}
}
return async (_tree, file) => {
let created: MaybeDate = undefined
let modified: MaybeDate = undefined
let published: MaybeDate = undefined
const fp = file.data.relativePath!
const fullFp = file.data.filePath!
const fullFp = path.posix.join(ctx.argv.directory, fp)
for (const source of opts.priority) {
if (source === "filesystem") {
const st = await fs.promises.stat(fullFp)
@@ -60,13 +51,21 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
created ||= file.data.frontmatter.created as MaybeDate
modified ||= file.data.frontmatter.modified as MaybeDate
published ||= file.data.frontmatter.published as MaybeDate
} else if (source === "git" && repo) {
} else if (source === "git") {
if (!repo) {
// Get a reference to the main git repo.
// It's either the same as the workdir,
// or 1+ level higher in case of a submodule/subtree setup
repo = Repository.discover(ctx.argv.directory)
}
try {
modified ||= await repo.getFileLatestModifiedDateAsync(fullFp)
} catch {
console.log(
chalk.yellow(
`\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`,
`\nWarning: ${file.data
.filePath!} isn't yet tracked by git, last modification date is not available for this file`,
),
)
}

View File

@@ -172,7 +172,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
workerType: "thread",
})
const errorHandler = (err: any) => {
console.error(err)
console.error(`${err}`.replace(/^error:\s*/i, ""))
process.exit(1)
}
@@ -201,7 +201,7 @@ export async function parseMarkdown(ctx: BuildCtx, fps: FilePath[]): Promise<Pro
const markdownToHtmlPromises: WorkerPromise<ProcessedContent[]>[] = []
processedFiles = 0
for (const mdChunk of mdResults) {
for (const [mdChunk, _] of mdResults) {
markdownToHtmlPromises.push(pool.exec("processHtml", [serializableCtx, mdChunk]))
}
const results: ProcessedContent[][] = await Promise.all(

View File

@@ -1,7 +1,6 @@
import test, { describe, beforeEach } from "node:test"
import assert from "node:assert"
import { FileTrieNode } from "./fileTrie"
import { FullSlug } from "./path"
interface TestData {
title: string
@@ -193,94 +192,6 @@ describe("FileTrie", () => {
})
})
describe("fromEntries", () => {
test("nested", () => {
const trie = FileTrieNode.fromEntries([
["index" as FullSlug, { title: "Root", slug: "index", filePath: "index.md" }],
[
"folder/file1" as FullSlug,
{ title: "File 1", slug: "folder/file1", filePath: "folder/file1.md" },
],
[
"folder/index" as FullSlug,
{ title: "Folder Index", slug: "folder/index", filePath: "folder/index.md" },
],
[
"folder/file2" as FullSlug,
{ title: "File 2", slug: "folder/file2", filePath: "folder/file2.md" },
],
[
"folder/folder2/index" as FullSlug,
{
title: "Subfolder Index",
slug: "folder/folder2/index",
filePath: "folder/folder2/index.md",
},
],
])
assert.strictEqual(trie.children.length, 1)
assert.strictEqual(trie.children[0].slug, "folder/index")
assert.strictEqual(trie.children[0].children.length, 3)
assert.strictEqual(trie.children[0].children[0].slug, "folder/file1")
assert.strictEqual(trie.children[0].children[1].slug, "folder/file2")
assert.strictEqual(trie.children[0].children[2].slug, "folder/folder2/index")
assert.strictEqual(trie.children[0].children[2].children.length, 0)
})
})
describe("findNode", () => {
test("should find root node with empty path", () => {
const data = { title: "Root", slug: "index", filePath: "index.md" }
trie.add(data)
const found = trie.findNode([])
assert.strictEqual(found, trie)
})
test("should find node at first level", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const found = trie.findNode(["test"])
assert.strictEqual(found?.data, data)
})
test("should find nested node", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const found = trie.findNode(["folder", "subfolder", "test"])
assert.strictEqual(found?.data, data)
// should find the folder and subfolder indexes too
assert.strictEqual(
trie.findNode(["folder", "subfolder", "index"]),
trie.children[0].children[0],
)
assert.strictEqual(trie.findNode(["folder", "index"]), trie.children[0])
})
test("should return undefined for non-existent path", () => {
const data = { title: "Test", slug: "test", filePath: "test.md" }
trie.add(data)
const found = trie.findNode(["nonexistent"])
assert.strictEqual(found, undefined)
})
test("should return undefined for partial path", () => {
const data = {
title: "Nested",
slug: "folder/subfolder/test",
filePath: "folder/subfolder/test.md",
}
trie.add(data)
const found = trie.findNode(["folder"])
assert.strictEqual(found?.data, null)
})
})
describe("getFolderPaths", () => {
test("should return all folder paths", () => {
const data1 = {

View File

@@ -89,14 +89,6 @@ export class FileTrieNode<T extends FileTrieData = ContentDetails> {
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
*/

View File

@@ -35,7 +35,7 @@ export class QuartzLogger {
const truncated = truncate(output, columns)
process.stdout.write(truncated)
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerChars.length
}, 50)
}, 20)
}
}

View File

@@ -13,7 +13,6 @@ import chalk from "chalk"
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[] = (
@@ -135,12 +134,21 @@ export type SocialImageOptions = {
excludeRoot: boolean
/**
* JSX to use for generating image. See satori docs for more info (https://github.com/vercel/satori)
* @param cfg global quartz config
* @param userOpts options that can be set by user
* @param title title of current page
* @param description description of current page
* @param fonts global font that can be used for styling
* @param fileData full fileData of current page
* @returns prepared jsx to be used for generating image
*/
imageStructure: (
options: ImageOptions & {
userOpts: UserOpts
iconBase64?: string
},
cfg: GlobalConfiguration,
userOpts: UserOpts,
title: string,
description: string,
fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData,
) => JSXInternal.Element
}
@@ -170,17 +178,17 @@ export type ImageOptions = {
}
// This is the default template for generated social image.
export const defaultImage: SocialImageOptions["imageStructure"] = ({
cfg,
userOpts,
title,
description,
fileData,
iconBase64,
}) => {
const { colorScheme } = userOpts
export const defaultImage: SocialImageOptions["imageStructure"] = (
cfg: GlobalConfiguration,
{ colorScheme }: UserOpts,
title: string,
description: string,
_fonts: SatoriOptions["fonts"],
fileData: QuartzPluginData,
) => {
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)
@@ -218,16 +226,14 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ({
marginBottom: "0.5rem",
}}
>
{iconBase64 && (
<img
src={iconBase64}
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
)}
<img
src={iconPath}
width={56}
height={56}
style={{
borderRadius: "50%",
}}
/>
<div
style={{
display: "flex",

View File

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