date-fns 世界语(Esperanto)locale 深度解析:从 snapshot.md 看 eo 的 format/parse 与相对时间格式化全貌
【免费下载链接】date-fns⏳ Modern JavaScript date utility library ⌛️项目地址: https://gitcode.com/gh_mirrors/da/date-fns
date-fns 的每个语言包目录下都有一份snapshot.md,它用固定测试数据集记录该 locale 在format、parse、formatDistance、formatDistanceStrict、formatRelative、formatDuration六个 API 下的完整输出快照。本文以 世界语 locale 的快照 为骨架,逐表解读 eo(Esperanto,ISO 639-2:epo)在 date-fns 中的全部格式化行为与解析回环,并结合 eo locale 源码 说明快照背后的实现机制。读完本文,你将能:理解快照文件的生成与校验方式,掌握 eo 在序数词(-a后缀)、上午/下午标记、柔性日时段、本地化日期时间(P/p系列)等维度上的全部 token 行为,并学会如何阅读任意 locale 的快照来排查本地化问题。
snapshot.md 是什么:一份可测试的本地化契约
在 date-fns 仓库中,每个 locale 目录(如 pkgs/core/src/locale/eo)都包含index.ts、_lib/下的五个实现模块,以及这份snapshot.md。快照不是手写文档,而是由构建脚本自动生成的:
- 生成脚本位于 pkgs/core/scripts/build/localeSnapshots/index.ts,它遍历所有 locale,读取
index.ts中的@language注释作为标题,然后依次调用renderFormatParse、renderFormatDistance、renderFormatDistanceStrict、renderFormatRelative、renderFormatDuration渲染出五个章节。 - 脚本强制要求
TZ=utc环境(脚本内显式校验process.env.TZ?.toLowerCase() !== "utc"即抛错),确保快照数据与时区无关、可跨机器复现。 - 对应 npm 脚本定义在 pkgs/core/package.json 中:
"locale-snapshots": "env TZ=utc tsx --tsconfig scripts/tsconfig.json -- ./scripts/build/localeSnapshots/index.ts"。 - 脚本支持两种模式:默认
generate模式把渲染结果写回各 locale 的snapshot.md;test模式则读取磁盘上的快照与重新生成的内容比对,不一致即报错——这正是 i18n 贡献指南 中所述"用快照测试 locale"的机制。
因此,snapshot.md既是开发者调试本地化输出的参考手册,也是 CI 中校验 locale 数据是否被意外改动的契约文件。阅读它的前提是理解两列语义:format结果列是把给定日期按 token 格式化后的输出,parse结果列则是把格式化结果再喂回parse得到的日期(即"格式化→解析"回环),用于验证格式化的可逆性与匹配正则的正确性。
format/parse:eo 的全部 token 行为
快照第一个章节覆盖了format与parse的完整 token 矩阵。eo 最显著的语言特征是序数词一律以-a后缀表达(对应世界语形容词词尾,如1-a即"第一"),这在ordinalNumber实现中直接体现:
// pkgs/core/src/locale/eo/_lib/localize/index.ts const ordinalNumber: LocalizeFn<number> = (dirtyNumber) => { const number = Number(dirtyNumber); return number + "-a"; };凡是以小写o结尾的 token(yo、Qo、Mo、wo、do、Ho、mo、so等)都走这一序数逻辑,输出形如1987-a、11-a。
年份与季度
| Token | 输入日期 | format结果 | parse结果 | 解读 |
|---|---|---|---|---|
| yo(公历纪年序数) | 1987-02-11 | 1987-a | 1987-01-01 | 纯公历年 +-a |
| yo | 0005-01-01 | 5-a | 0005-01-01 | 小年份补零输出(0005→5-a) |
| Yo(本地周编号年序数) | 1987-02-11 | 1987-a | 1986-12-29 | 见下方"周起始规则" |
| Yo | 0005-01-01 | 4-a | 0003-12-29 | 跨年周回退 |
| Qo / qo(季度序数) | 2019-01-01 | 1-a | 2019-01-01 | 季度 1 与 2 均验证 |
| QQQ / qqq | 2019-01-01 | K1 | 2019-01-01 | 缩写季度,数据见quarterValues |
| QQQQ / qqqq | 2019-01-01 | 1-a kvaronjaro | 2019-01-01 | 全称季度 |
| QQQQQ / qqqqq | 2019-01-01 | 1 | 2019-01-01 | 窄格式仅数字 |
Yo的"本地周编号年"行为与 eo 的周配置直接相关。在 eo 入口 中:
options: { weekStartsOn: 1 /* Monday */, firstWeekContainsDate: 4, },weekStartsOn: 1(周一为一周起点)与firstWeekContainsDate: 4(包含 1 月 4 日的那周为第一周)构成 ISO 风格周制。这正是快照中1987-02-11的Yo解析回1986-12-29(1987 年第 6 周的周一)的原因;而0005-01-01落在上一周制年(第 4 周),故Yo输出4-a并解析回0003-12-29。
月份(formatting 与 stand-alone)
Mo/Lo输出1-a~12-a;缩写(MMM/LLL)、全称(MMMM/LLLL)、窄格式(MMMMM/LLLLL)输出分别来自 localize 中的 monthValues:
| 宽度 | 输出序列 |
|---|---|
| 缩写(MMM/LLL) | jan, feb, mar, apr, maj, jun, jul, aŭg, sep, okt, nov, dec |
| 全称(MMMM/LLLL) | januaro, februaro, marto, aprilo, majo, junio, julio, aŭgusto, septembro, oktobro, novembro, decembro |
| 窄格式(MMMMM/LLLLL) | J, F, M, A, M, J, J, A, S, O, N, D |
值得注意的解析回环细节:窄格式月份存在歧义。由于 parse 阶段按parseMonthPatterns.narrow的顺序取第一个命中项(见 match 实现),/^m/会先命中 3 月(marto)而非 5 月(majo),/^j/先命中 1 月(januaro)而非 6/7 月,/^a/先命中 4 月(aprilo)而非 8 月(aŭgusto)。快照因此呈现:
| 输入(2019 年) | MMMMM格式化 | parse回环结果 |
|---|---|---|
| 01-11 | J | 2019-01-01 |
| 02-11 | F | 2019-02-01 |
| 03-11 / 05-10 | M | 2019-03-01(两个 M 都解析到 3 月) |
| 04-10 / 08-10 | A | 2019-04-01(两个 A 都解析到 4 月) |
| 06-10 / 07-10 | J | 2019-01-01(解析到 1 月) |
| 09-10 | S | 2019-09-01 |
这是一个重要的本地化事实:eo 的窄格式月份并不能唯一反解,如果业务上依赖MMMMM与parse的双向一致性,需要自行处理歧义。
周、日序数与星期
| Token | 输入日期 | format结果 | parse结果 |
|---|---|---|---|
| wo(本地周序数) | 2019-01-01 | 1-a | 2018-12-31 |
| wo | 2019-12-01 | 48-a | 2019-11-25 |
| Io(ISO 周序数) | 2019-01-01 | 1-a | 2018-12-31 |
| Io | 2019-12-01 | 48-a | 2019-11-25 |
| do(月内日序数) | 2019-02-01 / 11 / 28 | 1-a / 11-a / 28-a | 与输入日相同 |
| do MMMM | 2019-02-11 | 11-a februaro | 2019-02-11 |
| Do(年内日序数) | 2019-02-11 | 42-a | 2019-02-11 |
| Do | 2019-12-31 | 365-a | 2019-12-31 |
星期 token 家族(E/EE/EEE 相同,均输出缩写;EEEE 全称;EEEEE 窄格式;EEEEEE 短格式)与 eo 的dayValues一一对应:全称dimanĉo, lundo, mardo, merkredo, ĵaŭdo, vendredo, sabato,缩写dim, lun, mar, mer, ĵaŭ, ven, sab,窄格式D, L, M, M, Ĵ, V, S,短格式di, lu, ma, me, ĵa, ve, sa。io(ISO 星期)与eo(本地星期)输出1-a~7-a序数(周一为 1),缩写/全称输出与E系列完全一致,因为本地周与 ISO 周在 eo 中起点相同(都是周一)。stand-alone 的co系列与eo系列输出也完全一致。
上午/下午、柔性日时段与时分秒
eo 的上午/下午标记颇具特色:
| Token | 窄/缩写(a, aa, aaa) | 全称(aaaa) | 窄窄(aaaaa) |
|---|---|---|---|
| 上午 | a.t.m. | antaŭtagmeze | a |
| 下午 | p.t.m. | posttagmeze | p |
其中a.t.m./p.t.m.分别是世界语antaŭtagmeze/posttagmeze(中午之前/之后)的缩写形式。b系列(AM, PM, noon, midnight)输出与a系列相同。parse 回环中,上午解析到00:00、下午解析到12:00,符合预期。
柔性日时段(B系列)是 eo 快照中最有信息量的部分:
| Token | 11:13 | 14:13 | 19:13 | 02:13 |
|---|---|---|---|---|
| B, BB, BBB, BBBB | matene(早晨) | posttagmeze(下午) | vespere(傍晚) | nokte(夜间) |
| BBBBB | matene | posttagmeze | vespere | nokte |
对应的dayPeriodValues定义了五个时段:morning: matene、afternoon: posttagmeze、evening: vespere、night: nokte。快照同时揭示了一个边界现象:BBBBB在 14:13(下午时段)时的parse结果为Invalid Date(其余三行均可正常解析)。从 match 的 dayPeriod 正则 看,窄格式posttagmeze可被/^([ap]|(posttagmez|noktomez|tagmez|maten|vesper|nokt)[eo])/i匹配,但parseDayPeriodPatterns.afternoon的正则/^posttagmeze/i与该词元匹配后仍需与柔性日时段解析器协同确定基准时刻,此处回环失败表明 eo 的窄格式柔性日时段解析存在已知限制,业务中应避免对BBBBB输出做parse回环依赖。
小时/分钟/秒的序数 token 输出同样直观:ho([1-12])、Ho([0-23])、Ko([0-11])、ko([1-24])、mo(分钟)、so(秒)均以-a结尾,例如11-a、55-a,parse 回环完整。
本地化日期时间(P/p 系列)
P系列与p系列直接映射 formatLong 中的 dateFormats/timeFormats:
| Token | 模板 | 示例(1987-02-11) |
|---|---|---|
| P | yyyy-MM-dd | 1987-02-11 |
| PP | y-MMM-dd | 1987-feb-11 |
| PPP | y-MMMM-dd | 1987-februaro-11 |
| PPPP | EEEE, do 'de' MMMM y | merkredo, 11-a de februaro 1987 |
| p | HH:mm | 12:13 |
| pp | HH:mm:ss | 12:13:14 |
| ppp | HH:mm:ss z | 12:13:14 GMT+0 |
| pppp | Ho 'horo kaj' m:ss zzzz | 12-a horo kaj 13:14 GMT+00:00 |
| Pp / PPpp | 日期 + 时间组合 | 1987-02-11 12:13 / 1987-feb-11 12:13:14 |
快照还验证了历史日期(1453-05-29T23:59:59.999Z)在各模板下的输出,用于覆盖非当前年份的边界。注意PPPP全称日期模板中do(日序数)与MMMM(月份全称)之间以'de'(世界语"的")连接,产生dimanĉo, 11-a de januaro 1987这类地道表达;而pppp全称时间使用Ho 'horo kaj' m:ss模板,输出12-a horo kaj 13:14 GMT+00:00("12 时与 13:14")。
需要特别说明ppp/pppp/PPPppp/PPPPpppp的 parse 回环结果均为Errored:这些模板包含时区(z/zzzz),parse对含时区偏移的完整时间串的解析在快照测试中统一标记为 Errored(快照渲染器对解析异常的统一表示),这并非 eo 独有,而是p系列长模板与parse能力边界的既定事实。
formatDistance:基于"现在时刻"的相对时间
快照第二章假设"现在"为 2000-01-01 00:00(UTC),逐一验证未来与过去两个方向的相对时间输出,并分别展示includeSeconds: true与addSuffix: true两个选项的影响。输出由 formatDistance/index.ts 中的模板表驱动,核心结构是"单数(one)/复数(other)"二元组,other中的{{count}}会被实际数值替换:
| 语义 | 单数形式 | 复数形式 |
|---|---|---|
| lessThanXSeconds | malpli ol sekundo | malpli ol {{count}} sekundoj |
| xSeconds | 1 sekundo | {{count}} sekundoj |
| halfAMinute | duonminuto | —(固定字符串) |
| aboutXHours | proksimume 1 horo | proksimume {{count}} horoj |
| xDays | 1 tago | {{count}} tagoj |
| aboutXMonths | proksimume 1 monato | proksimume {{count}} monatoj |
| aboutXYears | proksimume 1 jaro | proksimume {{count}} jaroj |
| overXYears | pli ol 1 jaro | pli ol {{count}} jaroj |
| almostXYears | preskaŭ 1 jaro | preskaŭ {{count}} jaroj |
快照中的代表性结果(未来方向):
| 输入日期 | 默认结果 | includeSeconds: true | addSuffix: true |
|---|---|---|---|
| 2006-01-01 | proksimume 6 jaroj | proksimume 6 jaroj | post proksimume 6 jaroj |
| 2001-06-01 | pli ol 1 jaro | pli ol 1 jaro | post pli ol 1 jaro |
| 2000-01-01T00:45 | proksimume 1 horo | proksimume 1 horo | post proksimume 1 horo |
| 2000-01-01T00:00:25 | malpli ol minuto | duonminuto | post malpli ol minuto |
| 2000-01-01T00:00:00 | malpli ol minuto | malpli ol 5 sekundoj | antaŭ malpli ol minuto |
关键规则:未来时间使用post(之后),过去时间使用antaŭ(之前)。这一分支在 formatDistance 实现 中:
if (options?.addSuffix) { if (options?.comparison && options.comparison > 0) { return "post " + result; } else { return "antaŭ " + result; } }includeSeconds: true会在分钟以内的时间上启用秒级粒度:duonminuto(半分钟)、malpli ol 10/20 sekundoj(不到 10/20 秒)、malpli ol 5 sekundoj(不到 5 秒,用于零差时刻)。xSeconds的快照覆盖同样出现在这一节中(如25 sekundoj)。
formatDistanceStrict:精确距离
与formatDistance的"约/多于/少于"措辞不同,formatDistanceStrict输出精确数值,快照第三章节同样以 2000-01-01 00:00 为基准,并额外验证强制单位(unit: "hour")下的换算:
| 输入日期 | 默认结果 | addSuffix: true | 强制 hour 单位 |
|---|---|---|---|
| 2006-01-01 | 6 jaroj | post 6 jaroj | 52608 horoj |
| 2001-06-01 | 1 jaro | post 1 jaro | 12408 horoj |
| 2000-06-01 | 5 monatoj | post 5 monatoj | 3648 horoj |
| 2000-01-15 | 14 tagoj | post 14 tagoj | 336 horoj |
| 2000-01-01T06:00 | 6 horoj | post 6 horoj | 6 horoj |
| 2000-01-01T00:45 | 45 minutoj | post 45 minutoj | 1 horo |
| 2000-01-01T00:00:25 | 25 sekundoj | post 25 sekundoj | 0 horoj |
| 2000-01-01T00:00:00 | 0 sekundoj | antaŭ 0 sekundoj | 0 horoj |
过去方向(如 1999-12-31 23:59:55 →5 sekundoj、1999-06-01 →7 monatoj、1996-01-01 →4 jaroj)同样逐行验证,确保antaŭ前缀与强制单位换算在跨年、跨月边界下保持一致。强制hour单位时,小于 1 小时的时间差(如 45 分钟)按四舍五入得到1 horo,而00:15(15 分钟)则得到0 horoj——这是 strict 模式固定取整规则在快照中的直接体现。
formatRelative:相对日期短语
快照第四章节验证formatRelative(如"昨天/今天/明天/上周 X"),模板定义在 formatRelative/index.ts:
const formatRelativeLocale = { lastWeek: "'pasinta' eeee 'je' p", yesterday: "'hieraŭ je' p", today: "'hodiaŭ je' p", tomorrow: "'morgaŭ je' p", nextWeek: "eeee 'je' p", other: "P", };以 2000-01-01(周六)为基准,快照结果:
| 输入日期 | 结果 |
|---|---|
| 2000-01-10 | 2000-01-10(超出下周范围,回退P短日期) |
| 2000-01-05 | merkredo je 00:00(下周 X:eeee 'je' p) |
| 2000-01-02 | morgaŭ je 00:00 |
| 2000-01-01 | hodiaŭ je 00:00 |
| 1999-12-31 | hieraŭ je 00:00 |
| 1999-12-27 | pasinta lundo je 00:00(上周 X:'pasinta' eeee 'je' p) |
| 1999-12-21 | 1999-12-21(超出上周范围,回退P) |
单引号包裹的字符串('pasinta'、'je'、'hodiaŭ je'等)是 date-fns token 中的字面量转义,表示原样输出;eeee展开为星期全称(lundo、merkredo 等),p展开为HH:mm短时间。注意"上周"使用pasinta(过去的)而"下周"直接用星期名,这一不对称正是世界语的自然表达习惯。
formatDuration:时长格式化
快照最后一个章节验证formatDuration,它把时长对象({years, months, weeks, days, hours, minutes, seconds})按 eo 词汇渲染为完整短语,单复数规则与formatDistance共享1 jaro / 2 jaroj这类词形变化:
| 输入 | 结果 | 输入 | 结果 |
|---|---|---|---|
| {"years":0} | 0 jaroj | {"hours":0} | 0 horoj |
| {"years":1} | 1 jaro | {"hours":1} | 1 horo |
| {"years":2} | 2 jaroj | {"hours":2} | 2 horoj |
| {"months":0} | 0 monatoj | {"minutes":0} | 0 minutoj |
| {"months":1} | 1 monato | {"minutes":1} | 1 minuto |
| {"months":2} | 2 monatoj | {"minutes":2} | 2 minutoj |
| {"weeks":0} | 0 semajnoj | {"seconds":0} | 0 sekundoj |
| {"weeks":1} | 1 semajno | {"seconds":1} | 1 sekundo |
| {"weeks":2} | 2 semajnoj | {"seconds":2} | 2 sekundoj |
| {"days":0} | 0 tagoj | ||
| {"days":1} | 1 tago | ||
| {"days":2} | 2 tagoj |
世界语的复数词尾规律(-o→-oj、-a→-aj,以及tago→tagoj、semajno→semajnoj)在这里完整呈现。
源码对照:快照背后的五块拼图
eo locale 的实现由五个模块组成,快照的每一列都能在源码中找到对应物:
- localize/index.ts:
eraValues、quarterValues、monthValues、dayValues、dayPeriodValues五个数据表 +ordinalNumber,通过buildLocalizeFn装配,快照中所有窄/缩写/全称输出均源自这里。季度缩写K1/K2正是quarterValues.abbreviated的字面值。 - match/index.ts:为每个语义提供
matchPatterns(匹配)与parsePatterns(解析)正则。注意它对世界语特有字符做了多拼写兼容:a(ŭ|ux|uh|u)g同时接受aŭg、auxg、uhg、aug(x/h 体系转写),(ĵ|jx|jh|j)a(ŭ|ux|uh|u)do同理——这解释了快照中aŭg等含变音符输出的可解析性来源。 - formatDistance/index.ts:17 个语义 token 的
one/other模板与post/antaŭ后缀逻辑。 - formatLong/index.ts:
P/p系列四档日期、四档时间模板及{{date}} {{time}}组合模板。 - formatRelative/index.ts:五个相对位置模板 +
P回退。
入口 index.ts 将上述模块聚合成eo: Locale对象,并声明code: "eo"、weekStartsOn: 1、firstWeekContainsDate: 4——后者正是Yo/wo/eo等本地周 token 的解析基准。
实用建议
- 调试本地化输出:修改 eo 或任何 locale 的
_lib/数据后,运行pnpm --filter date-fns locale-snapshots(脚本定义见 package.json)重新生成快照并 diff,即可一眼看出哪些 token 输出变化;CI 的test模式会保证快照与实现同步。 - 警惕窄格式歧义:
MMMMM(J/F/M/A/S/O/N/D)在 parse 回环中不可唯一反解,涉及用户输入解析的业务应优先使用缩写(MMM)或全称(MMMM)token。 - 柔性日时段注意回环边界:
BBBBB的下午时段(posttagmeze)parse 结果为Invalid Date,若需双向转换请使用B~BBBB或普通a系列。 - 时区模板不可回环:
ppp/pppp等含z/zzzz的模板输出无法被parse还原(快照中统一为Errored),这是 date-fns 对长时区文本解析的既定边界,并非 eo 缺陷。
综上,eo/snapshot.md 用七组数据表完整刻画了世界语 locale 在 date-fns 中的行为契约:序数-a后缀、a.t.m./p.t.m.时段标记、post/antaŭ相对前缀、ISO 风格周制,以及P/p系列的长格式模板。理解这份快照的读法,也就掌握了阅读 date-fns 全部 90+ locale 快照的通用方法。
【免费下载链接】date-fns⏳ Modern JavaScript date utility library ⌛️项目地址: https://gitcode.com/gh_mirrors/da/date-fns
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考