diff --git a/package-lock.json b/package-lock.json index 9584e53..38deaaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "devDependencies": { "@eslint/js": "^10.0.1", + "@js-temporal/polyfill": "^0.5.1", "@types/html-minifier-terser": "^7.0.2", - "@types/js-yaml": "^4.0.9", "@types/node": "^24.13.2", "@types/prismjs": "^1.26.3", "canvas": "^3.0.1", @@ -18,7 +18,7 @@ "globals": "^17.6.0", "html-minifier-terser": "^7.2.0", "husky": "^9.1.7", - "js-yaml": "^4.1.0", + "js-yaml": "^5.2.3", "lightningcss": "^1.32.0", "lint-staged": "^17.0.7", "marked": "^18.0.7", @@ -971,6 +971,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -1050,13 +1063,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1704,6 +1710,29 @@ } } }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2668,9 +2697,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, "funding": [ { @@ -2687,9 +2716,16 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", diff --git a/package.json b/package.json index a7b4c74..a27fe52 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@js-temporal/polyfill": "^0.5.1", "@types/html-minifier-terser": "^7.0.2", - "@types/js-yaml": "^4.0.9", "@types/node": "^24.13.2", "@types/prismjs": "^1.26.3", "canvas": "^3.0.1", @@ -33,7 +33,7 @@ "globals": "^17.6.0", "html-minifier-terser": "^7.2.0", "husky": "^9.1.7", - "js-yaml": "^4.1.0", + "js-yaml": "^5.2.3", "lightningcss": "^1.32.0", "lint-staged": "^17.0.7", "marked": "^18.0.7", diff --git a/src/build/feeds/create-atom-feed.ts b/src/build/feeds/create-atom-feed.ts index 1a9a1ae..ec185f0 100644 --- a/src/build/feeds/create-atom-feed.ts +++ b/src/build/feeds/create-atom-feed.ts @@ -16,15 +16,15 @@ export async function createAtomFeed(posts: Post[]): Promise { ${TITLE} - ${BUILD.date.toISOString()} + ${BUILD.date.toString({ smallestUnit: 'millisecond' })} ${BUILD.baseUrl} ${AUTHOR.name} ${AUTHOR.email} ${map(posts, (post) => { - const date = post.meta.date.toISOString(); - const updated = post.meta.updated?.toISOString() || date; + const date = post.meta.date.toString(); + const updated = post.meta.updated?.toString() || date; return html` ${post.meta.title} diff --git a/src/build/feeds/create-rss-feed.ts b/src/build/feeds/create-rss-feed.ts index ae6eb60..cfb2321 100644 --- a/src/build/feeds/create-rss-feed.ts +++ b/src/build/feeds/create-rss-feed.ts @@ -1,5 +1,6 @@ import type { Post } from '#types'; import path from 'node:path'; +import { formatDateUtcString } from '../../utils/date.js'; import { html, map } from '../../utils/html.js'; import { createFile } from '../../utils/node.js'; import { AUTHOR, BUILD, TITLE } from '../../config.js'; @@ -9,7 +10,7 @@ const FILE_NAME = 'rss.xml'; export async function createRssFeed(posts: Post[]): Promise { const fileHref = new URL(FILE_NAME, BUILD.baseUrl).href; - const buildDate = BUILD.date.toUTCString(); + const buildDate = formatDateUtcString(BUILD.date); // It is important there is no whitespace before the XML tag. const contents = html` @@ -30,13 +31,12 @@ export async function createRssFeed(posts: Post[]): Promise { Web ${buildDate} ${map(posts, (post) => { - const date = post.meta.date.toUTCString(); return html` ${post.meta.title} ${post.meta.url} ${post.meta.url} - ${date} + ${formatDateUtcString(post.meta.date)} ${post.meta.description}> ${AUTHOR.email} (${AUTHOR.name}) diff --git a/src/build/feeds/create-sitemap.ts b/src/build/feeds/create-sitemap.ts index 96e3d9b..f0f4569 100644 --- a/src/build/feeds/create-sitemap.ts +++ b/src/build/feeds/create-sitemap.ts @@ -1,6 +1,6 @@ import type { PageData, RenderedPages } from '#types'; import path from 'node:path'; -import { formatDateIso } from '../../utils/date.js'; +import { formatPlainDate } from '../../utils/date.js'; import { html, map } from '../../utils/html.js'; import { createFile } from '../../utils/node.js'; import { BUILD } from '../../config.js'; @@ -12,7 +12,7 @@ function pagesToSitemapEntries(pages: PageData[]): string { ({ url, updated, date }) => html` ${url} - ${formatDateIso(updated || date || BUILD.date)} + ${formatPlainDate(updated || date || BUILD.date)} `, ); diff --git a/src/build/posts/get-posts.ts b/src/build/posts/get-posts.ts index 871fb12..f75e1e8 100644 --- a/src/build/posts/get-posts.ts +++ b/src/build/posts/get-posts.ts @@ -1,6 +1,7 @@ import type { ParsedFrontMatter, Post } from '#types'; import { promises as fs } from 'node:fs'; -import yamlToJs from 'js-yaml'; +import { Temporal } from '@js-temporal/polyfill'; +import { load } from 'js-yaml'; import { convert } from 'quote-quote'; import { findFilesByExtension } from '../../utils/node.js'; import { isObject } from '../../utils/object.js'; @@ -11,6 +12,9 @@ import { parseMarkdown } from './parse-markdown.js'; // Markdown front matter is at the top of the file between triple-dashed lines. const frontMatterRegex = /^---([\s\S]*?)---/; +// Verify a string is in the format of `yyyy-mm-dd hh:mm:ss`. +const dateTimeRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + /** * Return all Markdown files transformed to posts. */ @@ -19,7 +23,9 @@ export async function getPosts(): Promise { const result = await Promise.all( mdFiles.map((file) => transformMarkdownFileToPost(file)), ); - return result.sort((a, b) => b.meta.date.getTime() - a.meta.date.getTime()); + return result.sort((a, b) => + Temporal.Instant.compare(b.meta.date, a.meta.date), + ); } async function transformMarkdownFileToPost(file: string): Promise { @@ -40,29 +46,33 @@ async function transformMarkdownFileToPost(file: string): Promise { }; } -function parseFrontMatter(fm: string | undefined): ParsedFrontMatter { - if (fm) { - const parsed = yamlToJs.load(fm); - if (isValidFrontMatter(parsed)) { - return parsed; +function parseFrontMatter(rawFm: string | undefined): ParsedFrontMatter { + if (rawFm) { + const fm = load(rawFm); + + if ( + isObject(fm) && + 'title' in fm && + 'date' in fm && + dateTimeRegex.test(fm.date as string) && + (!('tags' in fm) || isValidTags(fm.tags)) + ) { + fm.date = stringToUtcDateTime(fm.date as string); + + if ('updated' in fm && dateTimeRegex.test(fm.updated as string)) { + fm.updated = stringToUtcDateTime(fm.updated as string); + } + + return fm as ParsedFrontMatter; } } throw new Error('Invalid front matter'); } -/** - * Check if front matter has all required properties in the correct format. This - * ensures data is complete, but also parsed as expected by `js-yaml`. - */ -function isValidFrontMatter(fm: unknown): fm is ParsedFrontMatter { - return ( - isObject(fm) && - 'title' in fm && - 'date' in fm && - fm.date instanceof Date && - (!('tags' in fm) || isValidTags(fm.tags)) && - (!('updated' in fm) || fm.updated instanceof Date) - ); +function stringToUtcDateTime(fmTimestamp: string): Temporal.Instant { + return Temporal.PlainDateTime.from(fmTimestamp.replace(' ', 'T')) + .toZonedDateTime('UTC') + .toInstant(); } function isValidTags(tags: unknown): tags is string[] { diff --git a/src/config.ts b/src/config.ts index 43e9403..99955af 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { Temporal } from '@js-temporal/polyfill'; import { BASE_URL_DEV, BASE_URL_PROD } from './constants.js'; type Env = 'DEV' | 'TEST' | 'PROD'; @@ -14,7 +15,7 @@ export const AUTHOR = { export const BUILD = { ...getBaseUrlAndEnv(), - date: new Date(), + date: Temporal.Now.instant(), }; function getBaseUrlAndEnv(): { env: Env; baseUrl: string } { diff --git a/src/includes/post-preview.ts b/src/includes/post-preview.ts index 021e38c..0fce0f3 100644 --- a/src/includes/post-preview.ts +++ b/src/includes/post-preview.ts @@ -1,5 +1,5 @@ import type { Post } from '#types'; -import { formatDateIso, formatDateShort } from '../utils/date.js'; +import { formatDateShort, formatPlainDate } from '../utils/date.js'; import { html, when } from '../utils/html.js'; import { tagList } from './tag-list.js'; @@ -8,7 +8,7 @@ function showParagraphWithDate(post: Post): string { '

', (paragraphTag) => html` ${paragraphTag} -

${post.meta.title}

-