diff --git a/astro.config.ts b/astro.config.ts index bc3f9c4..e058d91 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ } }), icon(), - alpinejs(), + alpinejs({ entrypoint: '/src/utils/alpineSetup' }), react(), moveOgImages() ], diff --git a/package-lock.json b/package-lock.json index 4a08f59..a7836d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "astro-icon": "^1.0.2", "astro-pintora": "^0.0.3-4", "buffer": "^6.0.3", + "luxon": "^3.4.4", "mdast-util-mdx-jsx": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -51,6 +52,7 @@ "devDependencies": { "@astrojs/ts-plugin": "^1.5.3", "@types/alpinejs": "^3.13.6", + "@types/luxon": "^3.4.2", "@types/markdown-it": "^13.0.7", "@types/mdast": "^4.0.3", "@types/react": "^18.2.58", @@ -3089,6 +3091,12 @@ "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", "dev": true }, + "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 + }, "node_modules/@types/markdown-it": { "version": "13.0.7", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.7.tgz", @@ -8908,6 +8916,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", diff --git a/package.json b/package.json index 1022e17..d42da1d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "astro-icon": "^1.0.2", "astro-pintora": "^0.0.3-4", "buffer": "^6.0.3", + "luxon": "^3.4.4", "mdast-util-mdx-jsx": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -57,6 +58,7 @@ "devDependencies": { "@astrojs/ts-plugin": "^1.5.3", "@types/alpinejs": "^3.13.6", + "@types/luxon": "^3.4.2", "@types/markdown-it": "^13.0.7", "@types/mdast": "^4.0.3", "@types/react": "^18.2.58", diff --git a/src/components/i18n/LanguagePicker.astro b/src/components/i18n/LanguagePicker.astro index b8a777f..89a81dd 100644 --- a/src/components/i18n/LanguagePicker.astro +++ b/src/components/i18n/LanguagePicker.astro @@ -15,29 +15,3 @@ const t = useTranslations(currentLang) - diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f8445ba..98f14d6 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -46,7 +46,7 @@ export const languages: Record = { h1: 'Blog', subtitle: 'Dans ce blog, je parle de changement climatique, de politique, d\'économie, de religion, de cybersécurité et d\'autres sujets qui m\'intéressent.', disclaimer: 'Je n\'exprime ici que mon opinion, si vous pensez que j\'ai fait une erreur ou que mon opinion est faussée, n\'hésitez pas à me contacter !', - published: 'Publié {0}' + published: 'Publié ' }, layout: { 'language-selector': 'Changer de langue', @@ -106,7 +106,7 @@ export const languages: Record = { h1: 'Blog', subtitle: 'In this blog, I talk about climate change, politics, economics, religion, cybersecurity, and other topics I find interesting.', disclaimer: 'I only express my opinion here, if you think I made a mistake or that my opinion is flawed, feel free to contact me!', - published: 'Published {0}' + published: 'Published ' }, layout: { 'language-selector': 'Switch lang', diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index 92d6cba..dca9d57 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -64,50 +64,3 @@ export function useTranslations (lang: LocaleId, localLoc return t(key, args ?? {}, locales) } } - -export const useFormatDate = (lang: LocaleId): ((date: Date) => string) => lang === 'en' ? formatDateFromNowEn : formatDateFromNowFr - -const formatDateFromNowEn = (date: Date): string => { - let ago = 'ago' - let daysAgo = Math.floor(((new Date()).getTime() - date.getTime()) / 1000 / 3600 / 24) - if (Number.isNaN(daysAgo)) throw new Error('Could not process date') - if (daysAgo === 0) return 'today' - if (daysAgo === 1) return 'yesterday' - if (daysAgo === -1) return 'tomorrow' - if (daysAgo < 0) { - ago = 'from now' - daysAgo = -daysAgo - } - if (daysAgo < 7) return `${daysAgo} days ${ago}` - if (daysAgo < 14) return `${Math.floor(daysAgo / 7)} week ${ago}` - if (daysAgo < 30) return `${Math.floor(daysAgo / 7)} weeks ${ago}` - if (daysAgo < 60) return `${Math.floor(daysAgo / 30)} month ${ago}` - if (daysAgo < 365) return `${Math.floor(daysAgo / 30)} months ${ago}` - if (daysAgo < 365 * 2) return `${Math.floor(daysAgo / 365)} year ${ago}` - return `${Math.floor(daysAgo / 365)} years ${ago}` -} - -export const formatDateFromNowFr = (date: Date): string => { - let ago = 'il y a' - let daysAgo = Math.floor(((new Date()).getTime() - date.getTime()) / 1000 / 3600 / 24) - if (Number.isNaN(daysAgo)) throw new Error('Could not process date') - if (daysAgo === 0) return 'aujourd\'hui' - if (daysAgo === 1) return 'hier' - if (daysAgo === -1) return 'demain' - if (daysAgo < 0) { - ago = 'dans' - daysAgo = -daysAgo - } - if (daysAgo < 7) return `${ago} ${daysAgo} jours` - if (daysAgo < 14) return `${ago} ${Math.floor(daysAgo / 7)} semaine` - if (daysAgo < 30) return `${ago} ${Math.floor(daysAgo / 7)} semaines` - if (daysAgo < 60) return `${ago}${Math.floor(daysAgo / 30)} mois` - if (daysAgo < 365) return `${ago} ${Math.floor(daysAgo / 30)} mois` - if (daysAgo < 365 * 2) return `${ago} ${Math.floor(daysAgo / 365)} an` - return `${ago} ${Math.floor(daysAgo / 365)} ans` -} - -export const formatDateAbsolute = (rawDate: number | string | Date): string => { - const date = new Date(rawDate) - return `${date.getUTCFullYear().toString().padStart(4, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}` -} diff --git a/src/pages/[lang]/blog/[slug]/index.astro b/src/pages/[lang]/blog/[slug].astro similarity index 66% rename from src/pages/[lang]/blog/[slug]/index.astro rename to src/pages/[lang]/blog/[slug].astro index 1b2a32e..d0d647c 100644 --- a/src/pages/[lang]/blog/[slug]/index.astro +++ b/src/pages/[lang]/blog/[slug].astro @@ -1,19 +1,18 @@ --- import { readFile } from 'node:fs/promises' -import { convertMDXToHTML } from '../../../../utils/mdxToHtml' -import { languages } from '../../../../i18n' +import { convertMDXToHTML } from '~/utils/mdxToHtml' +import { languages } from '~/i18n' import { - formatDateAbsolute, - useFormatDate, useTranslations, validateLang -} from '../../../../i18n/utils' +} from '~/i18n/utils' import { Picture } from 'astro:assets' -import Layout from '../../../../layouts/Layout.astro' +import Layout from '~/layouts/Layout.astro' import { getCollection, type CollectionEntry } from 'astro:content' -import { slugify } from '../../../../utils' +import { slugify } from '~/utils' import sanitizeHtml from 'sanitize-html' -import MetaOGImage from '../../../../components/MetaOGImage.astro' +import MetaOGImage from '~/components/MetaOGImage.astro' +import { DateTime } from 'luxon' export async function getStaticPaths (): Promise } }>> { const blogEntries = await getCollection('blog') @@ -35,13 +34,17 @@ const { post } = Astro.props as Props const { Content } = await post.render() const { lang } = Astro.params validateLang(lang) -const formatDate = useFormatDate(lang) const t = useTranslations(lang) const truncatedPost = sanitizeHtml(await convertMDXToHTML(post.body, 297), { allowedTags: [] }) -const imageFilePath = (post.data.image != null && Object.hasOwn(post.data.image, 'fsPath')) ? (post.data.image as unknown as { fsPath: string }).fsPath : '' +const imageFilePath = (post.data.image != null && Object.hasOwn(post.data.image, 'fsPath')) ? (post.data.image as unknown as { + fsPath: string +}).fsPath : '' const image = imageFilePath != null ? await readFile(imageFilePath) : undefined +const date = DateTime.fromJSDate(post.data.date).setLocale(languages[lang].code) + +const formattedAbsoluteDate = date.toLocaleString(DateTime.DATE_FULL) --- @@ -49,20 +52,22 @@ const image = imageFilePath != null ? await readFile(imageFilePath) : undefined { (image != null) - ? - : <> + : + <> }

{post.data.title}

-

- {t('blog.published', [formatDate(post.data.date)])} +

+ {t('blog.published')}{`${lang === 'fr' ? 'le' : 'on'} ${formattedAbsoluteDate}`}

diff --git a/src/pages/[lang]/blog/index.astro b/src/pages/[lang]/blog/index.astro index bb4bc98..88491d1 100644 --- a/src/pages/[lang]/blog/index.astro +++ b/src/pages/[lang]/blog/index.astro @@ -1,5 +1,5 @@ --- -import { useFormatDate, formatDateAbsolute, useTranslatedPath, useTranslations, validateLang } from '~/i18n/utils' +import { useTranslatedPath, useTranslations, validateLang } from '~/i18n/utils' import { Picture } from 'astro:assets' import Layout from '~/layouts/Layout.astro' import { getCollection } from 'astro:content' @@ -8,6 +8,7 @@ import { languages } from '~/i18n/index.ts' import { convertMDXToHTML } from '~/utils/mdxToHtml' import portrait from '~/resources/portrait-blog.png?arraybuffer' import MetaOGImage from '~/components/MetaOGImage.astro' +import { DateTime } from 'luxon' export function getStaticPaths (): Array<{ params: { lang: string } }> { return Object.keys(languages).map(lang => ({ params: { lang } })) @@ -21,8 +22,44 @@ const localizePath = useTranslatedPath(lang) const blogPosts = (await getCollection('blog', ({ id }) => id.split('/')[0] === lang)) .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()) -const renderedBlogPosts = await Promise.all(blogPosts.map(async post => ({ ...post, html: `${await convertMDXToHTML(post.body, 100)}...` }))) -const formatDate = useFormatDate(lang) +const formatDate = (jSDate: Date, lang: string): { formatted: string, absolute: string } => { + const date = DateTime.fromJSDate(jSDate).setLocale(lang) + + const delta = Math.abs(date.diffNow().as('days')) + + const formattedAbsoluteDate = date.toLocaleString(DateTime.DATE_FULL) + const formattedRelativeDate = date.toRelative() + + if (delta <= 7) { + return { + formatted: formattedRelativeDate, + absolute: formattedAbsoluteDate + } + } + + if (delta <= 30) { + return { + formatted: `${formattedRelativeDate} (${formattedAbsoluteDate})`, + absolute: formattedAbsoluteDate + } + } + + return { + formatted: formattedAbsoluteDate, + absolute: formattedAbsoluteDate + } +} + +const renderedBlogPosts = await Promise.all(blogPosts.map(async post => { + const { formatted, absolute } = formatDate(post.data.date, languages[lang].code) + return ({ + ...post, + formattedAbsoluteDate: absolute, + formattedDate: formatted, + html: `${await convertMDXToHTML(post.body, 100)}...` + }) +})) + --- @@ -54,7 +91,7 @@ const formatDate = useFormatDate(lang)

{post.data.title}

-
{formatDate(post.data.date)}
+
{`${post.formattedAbsoluteDate}`}
diff --git a/src/utils/alpineSetup.ts b/src/utils/alpineSetup.ts new file mode 100644 index 0000000..5681721 --- /dev/null +++ b/src/utils/alpineSetup.ts @@ -0,0 +1,43 @@ +import type { Alpine } from 'alpinejs' +import { DateTime } from 'luxon' +import { navigate } from 'astro:transitions/client' +import { languages } from '~/i18n' + +const formatDate = (jSDate: Date, lang: string, shortVersion: boolean): string => { + const date = DateTime.fromJSDate(jSDate).setLocale(lang) + + const delta = Math.abs(date.diffNow().as('days')) + + const formattedAbsoluteDate = date.toLocaleString(DateTime.DATE_FULL) + const formattedRelativeDate = date.toRelative() + + if (shortVersion && delta <= 7 && formattedRelativeDate != null) return formattedRelativeDate + if (delta <= 30 && formattedRelativeDate != null) return `${formattedRelativeDate} (${formattedAbsoluteDate})` + return `${!shortVersion ? lang === 'fr-FR' ? 'le ' : 'on ' : ''}${formattedAbsoluteDate}` +} + +const switchLang = (path: string, lang: string): string => { + if (!Object.hasOwn(languages, lang)) throw new Error('Provided lang prefix is unknown') + + const splitPath = path.split('/').filter(x => x !== '') + + if (splitPath.length === 0) throw new Error('Once split, path is empty') + if (!Object.hasOwn(languages, splitPath[0])) throw new Error('Once split, lang prefix is unknown') + splitPath[0] = lang + + return `/${splitPath.join('/')}/` +} + +export default (Alpine: Alpine): void => { + Alpine.data('formatDate', (jsDateString: Date, lang: string, shortVersion: boolean) => { + return { + formattedDate: formatDate(new Date(jsDateString), lang, shortVersion) + } + }) + + Alpine.data('languageSelector', () => ({ + change ($event: Event) { + navigate(switchLang(window.location.pathname, ($event.target as HTMLSelectElement).value)).catch(console.error) + } + })) +}