mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-27 23:04:05 -06:00
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:
parent
ff9e60a7fc
commit
9a967e6d0c
18
package-lock.json
generated
18
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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!,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user