diff --git a/package-lock.json b/package-lock.json index ff22d60fc..229dc5fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,13 +97,13 @@ "version": "2.10.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@citation-js/core": { "version": "0.7.14", "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.14.tgz", "integrity": "sha512-dgeGqYDSQmn2MtnWZkwPGpJQPh43yr1lAAr9jl1NJ9pIY1RXUQxtlAUZVur0V9PHdbfQC+kkvB1KC3VpgVV3MA==", + "peer": true, "dependencies": { "@citation-js/date": "^0.5.0", "@citation-js/name": "^0.4.2", @@ -2611,8 +2611,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "license": "MIT/X11", - "peer": true + "license": "MIT/X11" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -2732,8 +2731,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/comma-separated-tokens": { "version": "2.0.3", @@ -3106,6 +3104,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -3284,6 +3283,7 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3696,7 +3696,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -5854,6 +5853,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6415,7 +6415,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -6500,7 +6499,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.97.2" } @@ -6517,7 +6515,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6534,7 +6531,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6551,7 +6547,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6568,7 +6563,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6585,7 +6579,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6602,7 +6595,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6619,7 +6611,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6636,7 +6627,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6653,7 +6643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6670,7 +6659,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6687,7 +6675,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6704,7 +6691,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6721,7 +6707,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6738,7 +6723,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6755,7 +6739,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.97.2" } @@ -6772,7 +6755,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6789,7 +6771,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6959,6 +6940,7 @@ "version": "1.26.2", "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.2.tgz", "integrity": "sha512-iP7u2NA9A6JwRRCkIUREEX2cMhlYV5EBmbbSlfSRvPThwca8HBRbVkWuNWW+kw9+i6BSUZqqG6YeUs5dC2SjZw==", + "peer": true, "dependencies": { "@shikijs/core": "1.26.2", "@shikijs/engine-javascript": "1.26.2", @@ -7130,7 +7112,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7158,7 +7139,6 @@ "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -7183,7 +7163,6 @@ "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -7492,8 +7471,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vfile": { "version": "6.0.3", diff --git a/quartz/components/RecentChanges.tsx b/quartz/components/RecentChanges.tsx new file mode 100644 index 000000000..871155e03 --- /dev/null +++ b/quartz/components/RecentChanges.tsx @@ -0,0 +1,269 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" +import { QuartzPluginData } from "../plugins/vfile" +import { classNames } from "../util/lang" +import { i18n } from "../i18n" +import { GlobalConfiguration } from "../cfg" +import style from "./styles/recentChanges.scss" +import { ChangedItem } from "./utils/recentChanges" +// @ts-ignore +import script from "./scripts/recentChanges.inline" + +interface Options { + title?: string + limit: number + showCreated: boolean + showModified: boolean + detailed: boolean + filterBy: string[] + showExcerpt: boolean + showTags: boolean + showFilter: boolean + pageSize: number + linkToMore: SimpleSlug | false + pages: FullSlug[] +} + +const defaultOptions = (_cfg: GlobalConfiguration): Options => ({ + limit: 10, + showCreated: true, + showModified: true, + detailed: false, + filterBy: [], + showExcerpt: false, + showTags: false, + showFilter: false, + pageSize: 20, + linkToMore: false, + pages: [], +}) + +function formatRelativeDate(date: Date, locale: string): string { + const diffMs = Date.now() - date.getTime() + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + const abs = (n: number) => Math.abs(n) + const s = Math.round(diffMs / 1000) + const m = Math.round(diffMs / 60000) + const h = Math.round(diffMs / 3600000) + const d = Math.round(diffMs / 86400000) + const w = Math.round(diffMs / 604800000) + const mo = Math.round(diffMs / 2592000000) + const y = Math.round(diffMs / 31536000000) + if (abs(s) < 60) return rtf.format(-s, "second") + if (abs(m) < 60) return rtf.format(-m, "minute") + if (abs(h) < 24) return rtf.format(-h, "hour") + if (abs(d) < 7) return rtf.format(-d, "day") + if (abs(w) < 4) return rtf.format(-w, "week") + if (abs(mo) < 12) return rtf.format(-mo, "month") + return rtf.format(-y, "year") +} + +export default ((userOpts?: Partial) => { + const RecentChanges: QuartzComponent = ({ + allFiles, + fileData, + displayClass, + cfg, + }: QuartzComponentProps) => { + const opts = { ...defaultOptions(cfg), ...userOpts } + const t = i18n(cfg.locale).components.recentChanges + + // Only render on specified pages (empty = all pages) + if (opts.pages.length > 0 && !opts.pages.some((p) => fileData.slug === p)) { + return null + } + + // Filter files with valid dates + const validFiles = allFiles.filter( + (file: QuartzPluginData) => file.dates && (file.dates.created || file.dates.modified), + ) + + // Sort by most recent date + const sortedFiles = validFiles.sort((a: QuartzPluginData, b: QuartzPluginData) => { + const dateA = a.dates?.modified || a.dates?.created || new Date(0) + const dateB = b.dates?.modified || b.dates?.created || new Date(0) + return dateB.getTime() - dateA.getTime() + }) + + // Convert to ChangedItems + const allItems: ChangedItem[] = sortedFiles.map((file: QuartzPluginData) => { + const created = file.dates?.created + const modified = file.dates?.modified + + // A note is "Updated" only if its creation date predates the last modification by >1h. + const isModified = + created !== undefined && + modified !== undefined && + modified.getTime() - created.getTime() > 60 * 60 * 1000 + + return { + title: file.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title, + link: file.slug as FullSlug, + date: file.dates?.modified || file.dates?.created || new Date(), + createdDate: file.dates?.created ?? file.dates?.modified ?? new Date(), + type: isModified ? "modified" : "created", + id: `${file.slug}-${isModified ? "modified" : "created"}`, + excerpt: file.frontmatter?.description, + tags: file.frontmatter?.tags, + } + }) + + // Apply type filters + let filtered = allItems + if (!opts.showCreated) { + filtered = filtered.filter((item) => item.type !== "created") + } + if (!opts.showModified) { + filtered = filtered.filter((item) => item.type !== "modified") + } + + // Apply path filters + if (opts.filterBy.length > 0) { + filtered = filtered.filter((item) => opts.filterBy.some((f) => item.link.includes(f))) + } + + const remaining = Math.max(0, filtered.length - opts.limit) + + // JSON data island: all items as compact JSON for progressive client-side injection. + // Links are pre-resolved server-side since the client cannot call resolveRelative. + // The `i` field records each item's index in this array for deduplication tracking. + const allItemsJson = JSON.stringify( + filtered.map((item, idx) => ({ + i: idx, + t: item.title, + l: resolveRelative(fileData.slug!, item.link), + d: item.date.getTime(), + c: item.createdDate.getTime(), + k: item.type, + ...(opts.showExcerpt && item.excerpt ? { e: item.excerpt } : {}), + ...(opts.showTags && item.tags?.length ? { g: item.tags } : {}), + })), + ).replace(/<\//g, "<\\/") + + // For showFilter: pre-render pageSize items per type so both "New" and "Updated" tabs + // start with visible content. Each item is tagged with its index in filtered (= allData) + // via data-idx so the client can correctly initialize its deduplication set. + // For !showFilter: flat limit cap, no Load More. + const filteredWithIdx = filtered.map((item, idx) => ({ item, idx })) + const initialItems = opts.showFilter + ? [ + ...filteredWithIdx.filter(({ item }) => item.type === "created").slice(0, opts.pageSize), + ...filteredWithIdx.filter(({ item }) => item.type === "modified").slice(0, opts.pageSize), + ].sort((a, b) => b.item.date.getTime() - a.item.date.getTime()) + : filteredWithIdx.slice(0, opts.limit) + + // i18n strings passed to the client-side script via data attributes + const i18nData = JSON.stringify({ + badgeNew: t.badgeNew, + badgeUpdated: t.badgeUpdated, + noChanges: t.noChanges, + }) + + return ( +
+

+ {opts.linkToMore ? ( + + {opts.title ?? t.title} + + ) : ( + opts.title ?? t.title + )} +

+ + {opts.showFilter && ( +
+ + + +
+ )} + + {opts.showFilter &&

} + + {filtered.length === 0 ? ( +

{t.noChanges}

+ ) : ( + + )} + + {opts.showFilter && ( + // @ts-ignore — dangerouslySetInnerHTML on