diff --git a/docs/configuration.md b/docs/configuration.md index 1622da6fa414a..2e904e9e70383 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,6 +40,7 @@ This part of the configuration concerns anything that can affect the whole site. - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it. - `ignorePatterns`: a list of [glob]() patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. - `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. + - Can be a list (e.g. `["created", "modified"]`) to define fallbacks (highest priority first). - `theme`: configure how the site looks. - `cdnCaching`: If `true` (default), use Google CDN to cache the fonts. This will generally will be faster. Disable (`false`) this if you want Quartz to download the fonts to be self-contained. - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here. diff --git a/docs/plugins/CreatedModifiedDate.md b/docs/plugins/CreatedModifiedDate.md index f4134c47866ca..7c644249ca0d7 100644 --- a/docs/plugins/CreatedModifiedDate.md +++ b/docs/plugins/CreatedModifiedDate.md @@ -12,6 +12,7 @@ This plugin determines the created, modified, and published dates for a document This plugin accepts the following configuration options: - `priority`: The data sources to consult for date information. Highest priority first. Possible values are `"frontmatter"`, `"git"`, and `"filesystem"`. Defaults to `["frontmatter", "git", "filesystem"]`. +- `defaultTimezone`: The timezone that is assumed (IANA format, e.g. `Africa/Algiers`) when the datetime frontmatter properties do not contain offsets/timezones. Defaults to `system`: the system's local timezone. > [!warning] > If you rely on `git` for dates, make sure `defaultDateType` is set to `modified` in `quartz.config.ts`. diff --git a/package-lock.json b/package-lock.json index 524803accc778..5db108c3beb3d 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 dfda33bf1a98d..370d7a06e8b6a 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 64c462b140045..54afb38bd2674 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/cfg.ts b/quartz/cfg.ts index 135f584994a6d..0c755b741e340 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -56,7 +56,7 @@ export interface GlobalConfiguration { /** Glob patterns to not search */ ignorePatterns: string[] /** Whether to use created, modified, or published as the default type of date */ - defaultDateType: ValidDateType + defaultDateType: ValidDateType | ValidDateType[] /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. * Quartz will avoid using this as much as possible and use relative URLs most of the time */ diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx index e378bcceed395..a091a3f2cc11e 100644 --- a/quartz/components/ContentMeta.tsx +++ b/quartz/components/ContentMeta.tsx @@ -29,8 +29,9 @@ export default ((opts?: Partial) => { if (text) { const segments: (string | JSX.Element)[] = [] - if (fileData.dates) { - segments.push() + const date = getDate(cfg, fileData) + if (date) { + segments.push() } // Display reading time if enabled diff --git a/quartz/components/Date.tsx b/quartz/components/Date.tsx index 0a92cc4c3c4f5..783a86f01c9f0 100644 --- a/quartz/components/Date.tsx +++ b/quartz/components/Date.tsx @@ -1,31 +1,36 @@ +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.`, ) } - return data.dates?.[cfg.defaultDateType] + const types = cfg.defaultDateType instanceof Array ? cfg.defaultDateType : [cfg.defaultDateType] + return types.map((p) => data.dates?.[p]).find((p) => p != null) } -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 c0538f5fa5468..58b2a1c04d2f7 100644 --- a/quartz/components/PageList.tsx +++ b/quartz/components/PageList.tsx @@ -8,13 +8,15 @@ export type SortFn = (f1: QuartzPluginData, f2: QuartzPluginData) => number export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn { return (f1, f2) => { - if (f1.dates && f2.dates) { + const f1Date = getDate(cfg, f1) + const f2Date = getDate(cfg, f2) + if (f1Date && f2Date) { // sort descending - return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime() - } else if (f1.dates && !f2.dates) { + return f2Date.toMillis() - f1Date.toMillis() + } else if (f1Date && !f2Date) { // prioritize files with dates return -1 - } else if (!f1.dates && f2.dates) { + } else if (!f1Date && f2Date) { return 1 } @@ -32,23 +34,19 @@ type Props = { export const PageList: QuartzComponent = ({ cfg, fileData, allFiles, limit, sort }: Props) => { const sorter = sort ?? byDateAndAlphabetical(cfg) - let list = allFiles.sort(sorter) - if (limit) { - list = list.slice(0, limit) - } + const list = allFiles.toSorted(sorter).slice(0, limit ?? allFiles.length) return (
    {list.map((page) => { const title = page.frontmatter?.title const tags = page.frontmatter?.tags ?? [] + const date = getDate(cfg, page) return (
  • -

    - {page.dates && } -

    +

    {date && }

    - {page.dates && ( -

    - -

    - )} +

    {date && }

    {opts.showTags && (
      {tags.map((tag) => ( diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index c0fef86d27100..f961109b46d56 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,6 +1,6 @@ import { Root } from "hast" +import { DateTime } from "luxon" import { GlobalConfiguration } from "../../cfg" -import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" @@ -8,7 +8,9 @@ import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" import DepGraph from "../../depgraph" +import { QuartzPluginData } from "../vfile" +// Describes the content index (.json) that this plugin produces, to be consumed downstream export type ContentIndex = Map export type ContentDetails = { title: string @@ -16,8 +18,13 @@ export type ContentDetails = { tags: string[] content: string richContent?: string - date?: Date - description?: string +} + +// The content index fields only used within this plugin and will not be written to the final index +type FullContentIndex = Map +type FullContentDetails = ContentDetails & { + dates: QuartzPluginData["dates"] + description: string } interface Options { @@ -36,43 +43,65 @@ const defaultOptions: Options = { includeEmptyFiles: true, } -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { +function generateSiteMap(cfg: GlobalConfiguration, idx: FullContentIndex): string { const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const createURLEntry = (slug: SimpleSlug, modified?: DateTime): string => { + // sitemap protocol specifies that lastmod should *not* be time of sitemap generation; see: https://sitemaps.org/protocol.html#lastmoddef + // so we only include explicitly set modified dates + return ` https://${joinSegments(base, encodeURI(slug))} - ${content.date && `${content.date.toISOString()}`} + ${modified == null ? "" : `${modified.toISO()}`} ` + } const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content.dates?.modified)) .join("") return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { +function generateRSSFeed(cfg: GlobalConfiguration, idx: FullContentIndex, limit?: number): string { const base = cfg.baseUrl ?? "" - - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const buildDate = DateTime.now() + + const createRSSItem = ( + slug: SimpleSlug, + content: FullContentDetails, + pubDate?: DateTime, + ): string => { + return ` ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} ${content.richContent ?? content.description} - ${content.date?.toUTCString()} + ${pubDate == null ? "" : `${pubDate.toRFC2822()}`} ` - + } const items = Array.from(idx) - .sort(([_, f1], [__, f2]) => { - if (f1.date && f2.date) { - return f2.date.getTime() - f1.date.getTime() - } else if (f1.date && !f2.date) { + .map(([slug, content]): [FullSlug, DateTime | undefined, FullContentDetails] => { + // rss clients use pubDate to determine the order of items, and which items are newly-published + // so to keep new items at the front, we use the explicitly set published date and fall back + // to the earliest other date known for the file + const { published, ...otherDates } = content.dates ?? {} + const pubDate = + published ?? + Object.values(otherDates) + .sort((d1, d2) => d1.toMillis() - d2.toMillis()) + .find((d) => d) + return [slug, pubDate, content] + }) + .sort(([, d1, f1], [, d2, f2]) => { + // sort primarily by date (descending), then break ties with titles + if (d1 && d2) { + return d2.toMillis() - d1.toMillis() || f1.title.localeCompare(f2.title) + } else if (d1 && !d2) { return -1 - } else if (!f1.date && f2.date) { + } else if (!d1 && d2) { return 1 } - return f1.title.localeCompare(f2.title) }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .slice(0, limit ?? idx.size) + .map(([slug, pubDate, content]) => createRSSItem(simplifySlug(slug), content, pubDate)) .join("") return ` @@ -83,6 +112,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( cfg.pageTitle, )} + ${buildDate.toRFC2822()} Quartz -- quartz.jzhao.xyz ${items} @@ -116,10 +146,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { async emit(ctx, content, _resources) { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] - const linkIndex: ContentIndex = new Map() + const linkIndex: FullContentIndex = new Map() for (const [tree, file] of content) { const slug = file.data.slug! - const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { title: file.data.frontmatter?.title!, @@ -129,7 +158,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { richContent: opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, - date: date, + dates: file.data.dates, description: file.data.description ?? "", }) } @@ -158,13 +187,13 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } const fp = joinSegments("static", "contentIndex") as FullSlug - const simplifiedIndex = Object.fromEntries( - Array.from(linkIndex).map(([slug, content]) => { + // explicitly annotate the type of simplifiedIndex to typecheck output file contents + const simplifiedIndex: ContentIndex = new Map( + Array.from(linkIndex, ([slug, fullContent]) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed - delete content.description - delete content.date + const { description, dates, ...content } = fullContent return [slug, content] }), ) @@ -172,7 +201,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { emitted.push( await write({ ctx, - content: JSON.stringify(simplifiedIndex), + content: JSON.stringify(Object.fromEntries(simplifiedIndex)), slug: fp, ext: ".json", }), diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index fe8c01bcfa7f9..4c89fd218ee94 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -1,34 +1,71 @@ 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" export interface Options { priority: ("frontmatter" | "git" | "filesystem")[] + /** + * The default timezone used when parsing datetime strings which lack an offset/timezone + * Valid options are "system" (default), "utc", an IANA string, or a UTC offset + * https://moment.github.io/luxon/#/zones?id=specifying-a-zone + */ + defaultTimezone: "system" | string } const defaultOptions: Options = { priority: ["frontmatter", "git", "filesystem"], + defaultTimezone: "system", } -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: DateTimeOptions = { + setZone: true, + zone: opts.defaultTimezone, + } return { name: "CreatedModifiedDate", markdownPlugins() { @@ -36,23 +73,24 @@ 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 + // birthtime can be 0 on some filesystems, so default to the earlier of ctime/mtime + created ||= DateTime.fromMillis(st.birthtimeMs || Math.min(st.ctimeMs, st.mtimeMs)) + 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 +100,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 +115,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u } file.data.dates = { - created: coerceDate(fp, created), - modified: coerceDate(fp, modified), - published: coerceDate(fp, published), + created: created, + modified: modified, + published: published, } } }, @@ -88,10 +128,10 @@ export const CreatedModifiedDate: QuartzTransformerPlugin> = (u declare module "vfile" { interface DataMap { - dates: { - created: Date - modified: Date - published: Date - } + dates: Partial<{ + created: DateTime + modified: DateTime + published: DateTime + }> } }