Skip to content

Commit

Permalink
feat: enhance date formatting (#428)
Browse files Browse the repository at this point in the history
* feat: enhance date formatting

* fix: recommended corrections

* fix: recommended corrections

* feat: add support for special string date

* fix: timezone & add related test cases

* chore(deps): repair merge conflict

* feat: add support for non-string date conversion

* test: locks the test timezone

* feat: added error log information

* refactor: handleTimeWithDefaultZone logic optimization

* feat: tell Temporal timezone

* feat: convert Temporal.PlainDate to Temporal.ZonedDateTime

* refactor: non-ISO 8601 format & remove @js-temporal/polyfill

* refactor: adjust logs

* style: delete history code
  • Loading branch information
WRXinYue authored Aug 17, 2024
1 parent 1545716 commit 49e8008
Show file tree
Hide file tree
Showing 19 changed files with 284 additions and 124 deletions.
18 changes: 18 additions & 0 deletions demo/yun/components/test/TestFormatDate.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { formatDate } from 'valaxy'
import type { FormatOptionsWithTZ } from 'date-fns-tz'
defineProps<{ date?: string | number | Date, format?: string, timezone?: string, options?: FormatOptionsWithTZ }>()
</script>

<template>
<div flex="~">
<time mr-4>
{{ formatDate(date ?? new Date(), format, timezone, options) }}
</time>

<code>
{{ format }}
</code>
</div>
</template>
69 changes: 69 additions & 0 deletions demo/yun/pages/posts/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,73 @@
title: Date test
date: 2023-07-19 18:55:53
# updated: 2023-07-19 18:55:53
toc: false
---

<TestFormatDate format="MMM d" />

<TestFormatDate format="MMM d, yyyy" />

<TestFormatDate date="2023-07-19T00:00:00+08:00" format="d MMM yyyy" />

<TestFormatDate date="1847-05-16T00:01:15.000Z" format="yyyy-MM-dd HH:mm:ss" />

<TestFormatDate format="EEEE, MMMM d, yyyy" />

<TestFormatDate format="EEEE, d MMMM yyyy" />

<TestFormatDate format="yyyyMMdd" />

<TestFormatDate format="yy/MM/dd" />

<TestFormatDate format="HH:mm:ss" />

<TestFormatDate format="h:mm a" />

<TestFormatDate format="hh 'o''clock' a, zzzz" />

<TestFormatDate format="K:mm a, z" />

<TestFormatDate date="2023-07-19T00:00:00+08:00" format="EEEE, d MMMM yyyy" />

<TestFormatDate id="text-time-format-1" date="2023-07-19T00:00:00+08:00" format="yyyyMMdd" :options="{ timeZone: 'Asia/Shanghai' }" />

<TestFormatDate :date="1722589089" format="yyyyMMdd" />

<TestFormatDate id="text-time-format-2" date="2021.3.1 12:00" format="yyyyMMddHHmmss" :options="{ timeZone: 'Asia/Shanghai' }" />

<TestFormatDate format="yyMMdd" />

<TestFormatDate date="2021/3/1 12:00" format="yyyy/MM/dd" />

<TestFormatDate id="text-time-format-3" date="2021-12-03 1:07:23" format="yyyy/MM/dd HH:mm" :options="{ timeZone: 'Asia/Shanghai' }" />

<TestFormatDate format="X" />

<TestFormatDate format="x" />

<TestFormatDate date="2023-07-19 10:55:53Z" format="yyyy-MM-dd'T'HH:mm:ssXXX" />

<TestFormatDate format="yyyy-MM-dd'T'HH:mm:ss.SSSXXX" />

<TestFormatDate :date="1722589089" format="T" />

<TestFormatDate format="R" />

<TestFormatDate format="do" />

<TestFormatDate id="text-time-zone-1" date="2004-06-16T00:00:00+08:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Europe/Berlin" :options="{ timeZone: 'Europe/Berlin' }" />

<TestFormatDate id="text-time-zone-2" date="2004-06-16 00:00:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Asia/Shanghai" :options="{ timeZone: 'Europe/Berlin' }" />

<TestFormatDate id="text-time-zone-3" date="2004-06-16 00:00:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Asia/Shanghai" :options="{ timeZone: 'Asia/Shanghai' }" />

<TestFormatDate id="text-time-zone-4" date="2004-06-16T00:00:00Z" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Asia/Shanghai" :options="{ timeZone: 'Europe/Berlin' }" />

<TestFormatDate id="text-time-zone-5" date="2004-06-16 00:00:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Asia/Shanghai" :options="{ timeZone: 'Asia/Bangkok' }" />

<TestFormatDate id="text-time-zone-6" date="2004-06-16 00:00:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Europe/Berlin" :options="{ timeZone: 'Asia/Shanghai' }" />

<TestFormatDate id="text-time-zone-7" date="2004-06-16 00:00:00" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Europe/Berlin" :options="{ timeZone: 'Europe/Berlin' }" />

<TestFormatDate id="text-time-zone-8" date="2004-06-16T00:00:00Z" format="yyyy-MM-dd HH:mm:ssxxx zzz" timezone="Europe/Berlin" :options="{ timeZone: 'Asia/Shanghai' }" />
1 change: 1 addition & 0 deletions demo/yun/site.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineSiteConfig({

lang: 'zh-CN',
title: 'Valaxy Theme Yun',
timezone: 'Asia/Shanghai',
url: 'https://yun.valaxy.site/',
author: {
avatar: 'https://www.yunyoujun.cn/images/avatar.jpg',
Expand Down
48 changes: 0 additions & 48 deletions docs/pages/faq/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,51 +71,3 @@ So you can create a new file named `.nojekyll` in the `public` folder of the pro
|-- public
| |-- .nojekyll
```

## 显示的文章创建/修改时间不正确 {lang="zh-CN"}

## The displayed article creation/modification time is incorrect {lang="en"}

::: zh-CN
根据[这份 `YAML` 规范](https://yaml.org/type/timestamp.html),符合 `ISO 8601` 标准的时间格式都会被解析为 `Date` 类型,且**不显式标注时区的时间戳都会作 UTC 处理**

但是为了方便写作与从其他框架迁移,我们将未显式标注时区的时间戳解析为**系统时区**对应的时间(即 `2024-07-06 12:00:00``Asia/Shanghai` 下会解析 为 `2024-07-06T12:00:00+08:00`)。

无论如何,我们建议**显式**添加时区信息,例如:

```yaml
date: 2024-07-06 12:00:00 +8
```
如果你不喜欢显式标记,请**务必**在构建前指定系统时区:
```bash
sudo timedatectl set-timezone Asia/Shanghai
```

这样就能正确解析为 UTC+8 时区的 `2024-07-06 12:00:00`

主题作者也能通过在 `scaffolds/post.md` 中使用 `date: <%=date%> +8` 来实现这一点。
:::

::: en
According to [this `YAML` specification](https://yaml.org/type/timestamp.html), time formats that conform to the `ISO 8601` standard will be parsed as `Date` type, and **timestamps without explicitly marked time zones will be treated as UTC**.

However, for the convenience of writing and migrating from other frameworks, we parse timestamps without explicitly marked timezones under system timezone (i.e., `2024-07-06 12:00:00` will be parsed as `2024-07-06T12:00:00+08:00` in `Asia/Shanghai`).

If you don't like explicit marking, **be sure** to specify the system time zone before building:

```bash
sudo timedatectl set-timezone Asia/Shanghai
```

Nevertheless, we recommend **explicitly** adding time zone information, for example:

```yaml
date: 2024-07-06 12:00:00 +8
```
This way, it can be correctly parsed as `2024-07-06 12:00:00` in the UTC+8 time zone.

Theme authors can also achieve this by using `date: <%=date%> +8` in `scaffolds/post.md`.
:::
30 changes: 30 additions & 0 deletions e2e/theme-yun/time.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test'
import { env } from '../env'

test.use({
baseURL: env['theme-yun'],
})

test.beforeEach(async ({ page }) => {
await page.goto('/posts/date')
})

test.describe('Frontmatter', () => {
test('time format validation', async ({ page }) => {
await expect(page.locator('#text-time-format-1 time')).toHaveText('20230719')
await expect(page.locator('#text-time-format-2 time')).toHaveText('20210301120000')
await expect(page.locator('#text-time-format-3 time')).toHaveText('2021/12/03 01:07')
})

test('timezone format validation', async ({ page }) => {
await expect(page.locator('#text-time-zone-1 time')).toHaveText('2004-06-15 18:00:00+02:00 GMT+2')
await expect(page.locator('#text-time-zone-2 time')).toHaveText('2004-06-15 18:00:00+02:00 GMT+2')
await expect(page.locator('#text-time-zone-3 time')).toHaveText('2004-06-16 00:00:00+08:00 GMT+8')
await expect(page.locator('#text-time-zone-4 time')).toHaveText('2004-06-16 02:00:00+02:00 GMT+2')
await expect(page.locator('#text-time-zone-5 time')).toHaveText('2004-06-15 23:00:00+07:00 GMT+7')
await expect(page.locator('#text-time-zone-6 time')).toHaveText('2004-06-16 06:00:00+08:00 GMT+8')
await expect(page.locator('#text-time-zone-6 time')).toHaveText('2004-06-16 06:00:00+08:00 GMT+8')
await expect(page.locator('#text-time-zone-7 time')).toHaveText('2004-06-16 00:00:00+02:00 GMT+2')
await expect(page.locator('#text-time-zone-8 time')).toHaveText('2004-06-16 08:00:00+08:00 GMT+8')
})
})
3 changes: 2 additions & 1 deletion packages/valaxy-theme-yun/components/YunPostMeta.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { Post } from 'valaxy'
import { formatDate, formatTimestamp, useSiteConfig } from 'valaxy'
import { formatDate, useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { formatTimestamp } from '../utils'
defineProps<{
// FrontMatter
Expand Down
9 changes: 9 additions & 0 deletions packages/valaxy-theme-yun/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatDate } from 'valaxy'
import noneImg from '../assets/images/none.jpg'

/**
Expand All @@ -9,3 +10,11 @@ export function onImgError(e: Event, defaultImg = noneImg) {
targetEl.setAttribute('data-src', targetEl.src)
targetEl.src = defaultImg
}

/**
* date-fns format date with 'yyyy-MM-dd HH:mm:ss'
* @param date
*/
export function formatTimestamp(date: string | number | Date): string {
return formatDate(date, 'yyyy-MM-dd HH:mm:ss')
}
68 changes: 56 additions & 12 deletions packages/valaxy/client/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,65 @@
import { format } from 'date-fns'
import type { ToDateOptionsWithTZ } from 'date-fns-tz'
import { format as formatWithTZ, toZonedTime } from 'date-fns-tz'
import { format, toDate } from 'date-fns'
import { useSiteConfig } from 'valaxy'
import { useI18n } from 'vue-i18n'
import { DateTime } from 'luxon'
import type { Post } from '../../types'

const referenceDate = new Date(1986, 3 /* Apr */, 4, 10, 32, 0, 900)

/**
* date-fns format date
* @param date
* @param template
* format the date
* @param date the original date
* @param formatStr the string of tokens
* @param timezone the time zone of this local time, can be an offset or IANA time zone
* @param options the object with options. See [Options]{@link https://date-fns.org/docs/Options}
*/
export function formatDate(date: string | number | Date, template = 'yyyy-MM-dd') {
return format(date, template)
export function formatDate(date: string | number | Date, formatStr = 'yyyy-MM-dd', timezone?: string, options?: ToDateOptionsWithTZ): string {
const { locale } = useI18n()
const siteConfig = useSiteConfig()

const mergedOptions: ToDateOptionsWithTZ = Object.assign({ locale: { code: locale.value } }, options)
const clientTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone

try {
/**
* Format the timezone-less date to ISO. If none is specified, use the client's timezone.
* If the input date is already in ISO format, the timezone won't be applied.
*/
date = handleTimeWithZone(date, timezone || siteConfig.value.timezone || clientTimezone).toString()
// Convert to the client's timezone unless the user specifies otherwise
const zonedDate = toZonedTime(date, options?.timeZone || clientTimezone, mergedOptions)
// The format function will never change the underlying date
return formatWithTZ(zonedDate, formatStr, { timeZone: options?.timeZone })
}
catch (error) {
console.error(
'The date format provided is non-standard. The recommended format is \'yyyy-MM-dd HH:mm:ss\'',
'\nError formatting date:',
date.toString(),
error,
)
return format(referenceDate, formatStr)
}
}

/**
* date-fns format date with 'yyyy-MM-dd HH:mm:ss'
* @param date
*/
export function formatTimestamp(date: string | number | Date): string {
return format(date, 'yyyy-MM-dd HH:mm:ss')
function handleTimeWithZone(date: string | number | Date, timezone: string) {
if (typeof date !== 'string')
date = toDate(date).toISOString()

let dateTime = DateTime.fromISO(date, { setZone: true })

const toDateTime = (date: string, zone: string) => {
// Attempt to format the date using a function that handles non-ISO 8601 formats
const isoDate = format(date, 'yyyy-MM-dd\'T\'HH:mm:ss')
return DateTime.fromISO(isoDate, { zone })
}

if (!dateTime.isValid || !dateTime.zoneName)
dateTime = toDateTime(date, timezone)

return dateTime
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/valaxy/node/config/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const defaultSiteConfig: SiteConfig = {
url: '/',
lang: 'en',
languages: ['en', 'zh-CN'],
timezone: '',
title: 'Valaxy Blog',
description: 'A blog generated by Valaxy.',
subtitle: 'Next Generation Static Blog Framework.',
Expand Down
2 changes: 1 addition & 1 deletion packages/valaxy/node/modules/fuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cyan, dim } from 'picocolors'
import type { Argv } from 'yargs'

import type { FuseListItem } from 'valaxy/types'
import { matterOptions } from '../utils/matterOptions'
import { matterOptions } from '../plugins/markdown/transform/matter'
import { resolveOptions } from '../options'
import { setEnvProd } from '../utils/env'
import { commonOptions } from '../cli/options'
Expand Down
2 changes: 1 addition & 1 deletion packages/valaxy/node/modules/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Author, FeedOptions, Item } from 'feed'
import { Feed } from 'feed'
import consola from 'consola'
import { getCreatedTime, getUpdatedTime } from '../utils/date'
import { matterOptions } from '../utils/matterOptions'
import { matterOptions } from '../plugins/markdown/transform/matter'
import { type ResolvedValaxyOptions, resolveOptions } from '../options'
import { ensurePrefix, isExternal } from '../utils'
import { commonOptions } from '../cli/options'
Expand Down
14 changes: 1 addition & 13 deletions packages/valaxy/node/plugins/markdown/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import type { Plugin } from 'vite'
import Markdown from 'unplugin-vue-markdown/vite'

import type MarkdownIt from 'markdown-it'
import type { PageFrontMatter } from 'valaxy/types'
import { matterOptions } from '../../../utils/matterOptions'
import type { ResolvedValaxyOptions } from '../../../options'
import { highlight } from '../plugins/highlight'
import { defaultCodeTheme, setupMarkdownPlugins } from '../setup'
import { matterOptions } from './matter'
import { createTransformIncludes } from './include'
import { transformMermaid } from './mermaid'

Expand All @@ -26,17 +25,6 @@ export async function createMarkdownPlugin(
frontmatter: true,
exportFrontmatter: false,
frontmatterOptions: { grayMatterOptions: matterOptions },
frontmatterPreprocess(frontmatter, mdOptions, _, defaultHeadProcess) {
const fm = frontmatter as PageFrontMatter
if (fm.date)
fm.date = new Date(fm.date)
if (fm.updated)
fm.updated = new Date(fm.updated)
return {
head: defaultHeadProcess(frontmatter, mdOptions),
frontmatter,
}
},

// v-pre
escapeCodeTagInterpolation: true,
Expand Down
16 changes: 16 additions & 0 deletions packages/valaxy/node/plugins/markdown/transform/matter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import yaml, { CORE_SCHEMA } from 'js-yaml'
import type matter from 'gray-matter'
import { EXCERPT_SEPARATOR } from '../../../constants'

type GrayMatterOptions = matter.GrayMatterOption<string, GrayMatterOptions>

export const matterOptions: GrayMatterOptions = {
excerpt_separator: EXCERPT_SEPARATOR,
engines: {
yaml: {
// Use the CORE_SCHEMA with more basic support to manually handle time (#409)
parse: (str: string) => yaml.load(str, { schema: CORE_SCHEMA }) as object,
stringify: (data: any) => yaml.dump(data),
},
},
}
8 changes: 1 addition & 7 deletions packages/valaxy/node/plugins/vueRouter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import VueRouter from 'unplugin-vue-router/vite'
import fs from 'fs-extra'
import { resolve } from 'pathe'
import { isDate } from '@antfu/utils'
import { convert } from 'html-to-text'
import type { ExcerptType, Page } from 'valaxy/types'
import type { RouteMeta } from 'vue-router'
import MarkdownIt from 'markdown-it'
import matter from 'gray-matter'
import type { ValaxyNode } from '../types'

import { matterOptions } from '../utils/matterOptions'
import { matterOptions } from './markdown/transform/matter'
import { presetStatistics } from './presets/statistics'
import { setupMarkdownPlugins } from './markdown'

Expand Down Expand Up @@ -124,11 +123,6 @@ export async function createRouterPlugin(valaxyApp: ValaxyNode) {
mdFm.updated = fs.statSync(path).ctime
}

if (!isDate(mdFm.date))
mdFm.date = new Date(mdFm.date)
if (!isDate(mdFm.updated))
mdFm.updated = new Date(mdFm.updated!)

if (mdFm.from) {
if (Array.isArray(mdFm.from)) {
mdFm.from.forEach((from) => {
Expand Down
1 change: 1 addition & 0 deletions packages/valaxy/node/utils/date.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'fs-extra'
import { getGitTimestamp } from './getGitTimestamp'

/**
* get created time of file
* @param file
Expand Down
Loading

1 comment on commit 49e8008

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 Published on https://yun.valaxy.site as production
🚀 Deployed on https://66c0a0e2c2489885cd946bc2--valaxy.netlify.app

Please sign in to comment.