fix(dates): improve date string parsing

Use Luxon to parse date/datetime strings.

This avoids the `Date.parse`'s inconsistency between date-only (assumed
UTC) and datetime (assumed local timezone) strings. (closes #1615)

It also allows the date string's timezone to be carried along with the
DateTime object, producing more friendly and semantically-correct
timestamps.
This commit is contained in:
Bao Trinh 2024-12-03 15:59:35 -06:00
parent ff9e60a7fc
commit 9a967e6d0c
No known key found for this signature in database
GPG Key ID: 66B50C2AD65984B2
7 changed files with 98 additions and 40 deletions

18
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<string, DepGraph<FilePath> | 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)

View File

@ -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<QuartzPluginData>["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 <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
return <time datetime={date.toISO() || ""}>{formatDate(date, locale)}</time>
}

View File

@ -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

View File

@ -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 => `<url>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
${content.date && `<lastmod>${content.date.toISO()}</lastmod>`}
</url>`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
@ -56,13 +57,12 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toUTCString()}</pubDate>
<pubDate>${content.date?.toRFC2822()}</pubDate>
</item>`
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<Partial<Options>> = (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!,

View File

@ -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<true> | 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<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
const parseOpts = {
setZone: true,
}
return {
name: "CreatedModifiedDate",
markdownPlugins() {
@ -36,23 +65,23 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (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<Partial<Options>> = (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<Partial<Options>> = (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<Partial<Options>> = (u
declare module "vfile" {
interface DataMap {
dates: {
created: Date
modified: Date
published: Date
created: DateTime
modified: DateTime
published: DateTime
}
}
}