diff --git a/package-lock.json b/package-lock.json index 524803acc..5db108c3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", "lightningcss": "^1.28.2", + "luxon": "^3.5.0", "mdast-util-find-and-replace": "^3.0.1", "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", @@ -80,6 +81,7 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", + "@types/luxon": "^3.4.2", "@types/node": "^22.10.2", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", @@ -1922,6 +1924,13 @@ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==" }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mathjax": { "version": "0.0.40", "resolved": "https://registry.npmjs.org/@types/mathjax/-/mathjax-0.0.40.tgz", @@ -4604,6 +4613,15 @@ "node": "20 || >=22" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/markdown-table": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", diff --git a/package.json b/package.json index dfda33bf1..370d7a06e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", "lightningcss": "^1.28.2", + "luxon": "^3.5.0", "mdast-util-find-and-replace": "^3.0.1", "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", @@ -103,6 +104,7 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", + "@types/luxon": "^3.4.2", "@types/node": "^22.10.2", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", diff --git a/quartz/build.ts b/quartz/build.ts index 64c462b14..54afb38bd 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -19,6 +19,7 @@ import { options } from "./util/sourcemap" import { Mutex } from "async-mutex" import DepGraph from "./depgraph" import { getStaticResourcesFromPlugins } from "./plugins" +import { Settings as LuxonSettings } from "luxon" type Dependencies = Record | null> @@ -53,6 +54,8 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const perf = new PerfTimer() const output = argv.output + LuxonSettings.defaultLocale = cfg.configuration.locale + const pluginCount = Object.values(cfg.plugins).flat().length const pluginNames = (key: "transformers" | "filters" | "emitters") => cfg.plugins[key].map((plugin) => plugin.name) diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 0a92cc4c3..a75d02859 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -1,15 +1,16 @@ +import { DateTime } from "luxon" import { GlobalConfiguration } from "../cfg" import { ValidLocale } from "../i18n" import { QuartzPluginData } from "../plugins/vfile" interface Props { - date: Date + date: DateTime locale?: ValidLocale } export type ValidDateType = keyof Required["dates"] -export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined { +export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): DateTime | undefined { if (!cfg.defaultDateType) { throw new Error( `Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`, @@ -18,14 +19,17 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date return data.dates?.[cfg.defaultDateType] } -export function formatDate(d: Date, locale: ValidLocale = "en-US"): string { - return d.toLocaleDateString(locale, { - year: "numeric", - month: "short", - day: "2-digit", - }) +export function formatDate(d: DateTime, locale: ValidLocale = "en-US"): string { + return d.toLocaleString( + { + year: "numeric", + month: "short", + day: "2-digit", + }, + { locale: locale }, + ) } export function Date({ date, locale }: Props) { - return + return } diff --git a/quartz/components/PageList.tsx b/quartz/components/PageList.tsx index c0538f5fa..33bd8d730 100644 --- a/quartz/components/PageList.tsx +++ b/quartz/components/PageList.tsx @@ -10,7 +10,7 @@ export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn { return (f1, f2) => { if (f1.dates && f2.dates) { // sort descending - return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime() + return getDate(cfg, f2)!.toMillis() - getDate(cfg, f1)!.toMillis() } else if (f1.dates && !f2.dates) { // prioritize files with dates return -1 diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index c0fef86d2..acd553f53 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,4 +1,5 @@ import { Root } from "hast" +import { DateTime } from "luxon" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" @@ -16,7 +17,7 @@ export type ContentDetails = { tags: string[] content: string richContent?: string - date?: Date + date?: DateTime description?: string } @@ -40,7 +41,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` https://${joinSegments(base, encodeURI(slug))} - ${content.date && `${content.date.toISOString()}`} + ${content.date && `${content.date.toISO()}`} ` const urls = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) @@ -56,13 +57,12 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} ${content.richContent ?? content.description} - ${content.date?.toUTCString()} + ${content.date?.toRFC2822()} ` - const items = Array.from(idx) .sort(([_, f1], [__, f2]) => { if (f1.date && f2.date) { - return f2.date.getTime() - f1.date.getTime() + return f2.date.toMillis() - f1.date.toMillis() } else if (f1.date && !f2.date) { return -1 } else if (!f1.date && f2.date) { @@ -119,7 +119,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const linkIndex: ContentIndex = new Map() for (const [tree, file] of content) { const slug = file.data.slug! - const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() + const date = getDate(ctx.cfg.configuration, file.data) ?? DateTime.now() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { title: file.data.frontmatter?.title!, diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index fe8c01bcf..3dd772a4d 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -1,5 +1,6 @@ import fs from "fs" import path from "path" +import { DateTime, DateTimeOptions } from "luxon" import { Repository } from "@napi-rs/simple-git" import { QuartzTransformerPlugin } from "../types" import chalk from "chalk" @@ -12,23 +13,51 @@ const defaultOptions: Options = { priority: ["frontmatter", "git", "filesystem"], } -function coerceDate(fp: string, d: any): Date { - const dt = new Date(d) - const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0 - if (invalidDate && d !== undefined) { +function parseDateString( + fp: string, + d?: string | number | unknown, + opts?: DateTimeOptions, +): DateTime | undefined { + if (d == null) return + // handle cases where frontmatter property is a number (e.g. YYYYMMDD or even just YYYY) + if (typeof d === "number") d = d.toString() + if (typeof d !== "string") { + console.log( + chalk.yellow(`\nWarning: unexpected type (${typeof d}) for date "${d}" in \`${fp}\`.`), + ) + return + } + + const dt = [ + // ISO 8601 format, e.g. "2024-09-09T00:00:00[Africa/Algiers]", "2024-09-09T00:00+01:00", "2024-09-09" + DateTime.fromISO, + // RFC 2822 (used in email & RSS) format, e.g. "Mon, 09 Sep 2024 00:00:00 +0100" + DateTime.fromRFC2822, + // Luxon is stricter about the format of the datetime string than `Date` + // fallback to `Date` constructor iff Luxon fails to parse datetime + (s: string, o: DateTimeOptions) => DateTime.fromJSDate(new Date(s), o), + ] + .values() + .map((f) => f(d, opts)) + // find the first valid parse result + .find((dt) => dt != null && dt.isValid) + + if (dt == null) { console.log( chalk.yellow( `\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`, ), ) + return } - - return invalidDate ? new Date() : dt + return dt } -type MaybeDate = undefined | string | number export const CreatedModifiedDate: QuartzTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } + const parseOpts = { + setZone: true, + } return { name: "CreatedModifiedDate", markdownPlugins() { @@ -36,23 +65,23 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u () => { let repo: Repository | undefined = undefined return async (_tree, file) => { - let created: MaybeDate = undefined - let modified: MaybeDate = undefined - let published: MaybeDate = undefined + let created: DateTime | undefined = undefined + let modified: DateTime | undefined = undefined + let published: DateTime | undefined = undefined const fp = file.data.filePath! const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp) for (const source of opts.priority) { if (source === "filesystem") { const st = await fs.promises.stat(fullFp) - created ||= st.birthtimeMs - modified ||= st.mtimeMs + created ||= DateTime.fromMillis(st.birthtimeMs) + modified ||= DateTime.fromMillis(st.mtimeMs) } else if (source === "frontmatter" && file.data.frontmatter) { - created ||= file.data.frontmatter.date as MaybeDate - modified ||= file.data.frontmatter.lastmod as MaybeDate - modified ||= file.data.frontmatter.updated as MaybeDate - modified ||= file.data.frontmatter["last-modified"] as MaybeDate - published ||= file.data.frontmatter.publishDate as MaybeDate + created ||= parseDateString(fp, file.data.frontmatter.date, parseOpts) + modified ||= parseDateString(fp, file.data.frontmatter.lastmod, parseOpts) + modified ||= parseDateString(fp, file.data.frontmatter.updated, parseOpts) + modified ||= parseDateString(fp, file.data.frontmatter["last-modified"], parseOpts) + published ||= parseDateString(fp, file.data.frontmatter.publishDate, parseOpts) } else if (source === "git") { if (!repo) { // Get a reference to the main git repo. @@ -62,7 +91,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u } try { - modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!) + modified ||= DateTime.fromMillis( + await repo.getFileLatestModifiedDateAsync(file.data.filePath!), + ) } catch { console.log( chalk.yellow( @@ -75,9 +106,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u } file.data.dates = { - created: coerceDate(fp, created), - modified: coerceDate(fp, modified), - published: coerceDate(fp, published), + created: created ?? DateTime.now(), + modified: modified ?? DateTime.now(), + published: published ?? DateTime.now(), } } }, @@ -89,9 +120,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u declare module "vfile" { interface DataMap { dates: { - created: Date - modified: Date - published: Date + created: DateTime + modified: DateTime + published: DateTime } } }