Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e3c6a1c
feat: add Vietnamese (vi) locale
nhannht Apr 12, 2026
39214ab
fix(vi): test fixes and parser edge-case corrections
nhannht Apr 12, 2026
03c9e68
fix(vi): VIWeekdayParser — make modifier capturing, fix 'quả' → 'qua'…
nhannht Apr 15, 2026
00dd9e3
fix(vi): VITimeUnitCasualRelativeFormatParser — correct suffix modifi…
nhannht Apr 15, 2026
a020ebc
fix(vi): VICasualTimeParser — trưa (noon) must set Meridiem.PM not AM
nhannht Apr 15, 2026
52b7f5d
fix(vi): wire strictMode in Ago/Later/Within parsers — was stored but…
nhannht Apr 15, 2026
f38fba6
refactor(vi): drop dead STRICT_PATTERN alias in Ago/Later/Within parsers
nhannht Apr 15, 2026
c699571
feat(vi): add corpus accuracy test + fix bare BC year parsing
nhannht Apr 15, 2026
610e6d4
test(vi): full test coverage — month_year, casual_time, negative case…
nhannht Apr 15, 2026
f81dc4d
fix(vi): bug fixes, word-form months, hôm kia, sau khi, test coverage
nhannht Apr 15, 2026
37b4b3c
style(vi): apply prettier formatting to VIMonthYearParser and VIWeekd…
nhannht Apr 15, 2026
a7d1d6b
remove corpus test and fixtures per maintainer review
nhannht Apr 20, 2026
71a3470
revert tsconfig.build.json changes per maintainer review
nhannht Apr 20, 2026
52f6042
test(vi): add result.index assertions to match EN test conventions
nhannht Apr 20, 2026
ed5f061
test(vi): add strict mode, forwardDate, isCertain, and negative tests
nhannht Apr 20, 2026
86f0076
fix(vi): fix trưa meridiem bug, \b word boundary bug, revert lockfile…
nhannht Apr 20, 2026
8a476a6
test(vi): add minute validation, toBeDate assertions, strict/forward …
nhannht Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const chrono = require('chrono-node');
### What's changed in the v2
For Users
* Chrono’s default now handles only international English. While in the previous version, it tried to parse with all known languages.
* In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, and `uk`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`.
* In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, `uk`, and `vi`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`.

For contributors and advanced users
* The project is rewritten in TypeScript
Expand Down Expand Up @@ -212,7 +212,7 @@ chrono.en.GB.parseDate('6/10/2018'); // October 6th, 2018
chrono.ja.parseDate('昭和64年1月7日');
```

In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, and `uk`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`.
In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, `uk`, and `vi`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`.

#### Importing specific locales

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import * as uk from "./locales/uk";
import * as it from "./locales/it";
import * as sv from "./locales/sv";
import * as fi from "./locales/fi";
import * as vi from "./locales/vi";

export { de, fr, ja, pt, nl, zh, ru, es, uk, it, sv, fi };
export { de, fr, ja, pt, nl, zh, ru, es, uk, it, sv, fi, vi };

/**
* A shortcut for {@link en | chrono.en.strict}
Expand Down
113 changes: 113 additions & 0 deletions src/locales/vi/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
import { findMostLikelyADYear } from "../../calculation/years";
import { Duration } from "../../calculation/duration";
import { Timeunit } from "../../types";

export const WEEKDAY_DICTIONARY: { [word: string]: number } = {
"ch\u1ee7 nh\u1eadt": 0,
"cn": 0,
"th\u1ee9 hai": 1,
"t2": 1,
"th\u1ee9 ba": 2,
"t3": 2,
"th\u1ee9 t\u01b0": 3,
"t4": 3,
"th\u1ee9 n\u0103m": 4,
"t5": 4,
"th\u1ee9 s\u00e1u": 5,
"t6": 5,
"th\u1ee9 b\u1ea3y": 6,
"t7": 6,
};

export const MONTH_DICTIONARY: { [word: string]: number } = {
"th\u00e1ng 1": 1,
"th\u00e1ng m\u1ed9t": 1,
"th\u00e1ng gi\u00eang": 1,
"th\u00e1ng 2": 2,
"th\u00e1ng hai": 2,
"th\u00e1ng 3": 3,
"th\u00e1ng ba": 3,
"th\u00e1ng 4": 4,
"th\u00e1ng t\u01b0": 4,
"th\u00e1ng 5": 5,
"th\u00e1ng n\u0103m": 5,
"th\u00e1ng 6": 6,
"th\u00e1ng s\u00e1u": 6,
"th\u00e1ng 7": 7,
"th\u00e1ng b\u1ea3y": 7,
"th\u00e1ng 8": 8,
"th\u00e1ng t\u00e1m": 8,
"th\u00e1ng 9": 9,
"th\u00e1ng ch\u00edn": 9,
"th\u00e1ng 10": 10,
"th\u00e1ng m\u01b0\u1eddi": 10,
"th\u00e1ng 11": 11,
"th\u00e1ng m\u01b0\u1eddi m\u1ed9t": 11,
"th\u00e1ng 12": 12,
"th\u00e1ng m\u01b0\u1eddi hai": 12,
"th\u00e1ng ch\u1ea1p": 12,
};

export const INTEGER_WORD_DICTIONARY: { [word: string]: number } = {
"m\u1ed9t": 1,
"hai": 2,
"ba": 3,
"b\u1ed1n": 4,
"n\u0103m": 5,
"s\u00e1u": 6,
"b\u1ea3y": 7,
"t\u00e1m": 8,
"ch\u00edn": 9,
"m\u01b0\u1eddi": 10,
"m\u01b0\u1eddi m\u1ed9t": 11,
"m\u01b0\u1eddi hai": 12,
};

export const TIME_UNIT_DICTIONARY: { [word: string]: Timeunit } = {
"gi\u00e2y": "second",
"ph\u00fat": "minute",
"gi\u1edd": "hour",
"ng\u00e0y": "day",
"tu\u1ea7n": "week",
"th\u00e1ng": "month",
"n\u0103m": "year",
};

export const NUMBER_PATTERN = "(?:" + matchAnyPattern(INTEGER_WORD_DICTIONARY) + "|[0-9]+|[0-9]+\\.[0-9]+)";

export function parseNumberPattern(match: string): number {
const num = match.toLowerCase();
if (INTEGER_WORD_DICTIONARY[num] !== undefined) return INTEGER_WORD_DICTIONARY[num];
return parseFloat(num);
}

// YYYY, YYYY TCN (Tr\u01b0\u1edbc C\u00f4ng nguy\u00ean = BC)
export const YEAR_PATTERN = "(?:[0-9]{1,4}(?:\\s*TCN)?)";

export function parseYear(match: string): number {
const upper = match.toUpperCase();
const num = parseInt(match.replace(/[^0-9]+/g, ""));
if (/TCN/.test(upper)) return -num;
return findMostLikelyADYear(num);
}

const SINGLE_TIME_UNIT_PATTERN =
"(" + NUMBER_PATTERN + ")\\s{0,5}(" + matchAnyPattern(TIME_UNIT_DICTIONARY) + ")\\s{0,5}";
const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i");

export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN);

export function parseDuration(timeunitText: string): Duration {
const fragments: { [key: string]: number } = {};
let remainingText = timeunitText;
let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
while (match) {
const num = parseNumberPattern(match[1]);
const unit = TIME_UNIT_DICTIONARY[match[2].toLowerCase()];
fragments[unit] = num;
remainingText = remainingText.substring(match[0].length);
match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
}
return fragments as Duration;
}
73 changes: 73 additions & 0 deletions src/locales/vi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Chrono components for Vietnamese support (*parsers*, *refiners*, and *configuration*)
*
* @module
*/
import { includeCommonConfiguration } from "../../configurations";
import { Chrono, Configuration, Parser, Refiner } from "../../chrono";
import { ParsingResult, ParsingComponents, ReferenceWithTimezone } from "../../results";
import { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday } from "../../types";
import ISOFormatParser from "../../common/parsers/ISOFormatParser";
import SlashDateFormatParser from "../../common/parsers/SlashDateFormatParser";
import VIStandardParser from "./parsers/VIStandardParser";
import VIMonthYearParser from "./parsers/VIMonthYearParser";
import VIYearParser from "./parsers/VIYearParser";
import VICasualDateParser from "./parsers/VICasualDateParser";
import VICasualTimeParser from "./parsers/VICasualTimeParser";
import VIWeekdayParser from "./parsers/VIWeekdayParser";
import VITimeExpressionParser from "./parsers/VITimeExpressionParser";
import VITimeUnitAgoFormatParser from "./parsers/VITimeUnitAgoFormatParser";
import VITimeUnitLaterFormatParser from "./parsers/VITimeUnitLaterFormatParser";
import VITimeUnitWithinFormatParser from "./parsers/VITimeUnitWithinFormatParser";
import VITimeUnitCasualRelativeFormatParser from "./parsers/VITimeUnitCasualRelativeFormatParser";
import VIMergeDateRangeRefiner from "./refiners/VIMergeDateRangeRefiner";
import VIMergeDateTimeRefiner from "./refiners/VIMergeDateTimeRefiner";
import VIMergeWeekdayComponentRefiner from "./refiners/VIMergeWeekdayComponentRefiner";

export { Chrono, Parser, Refiner, ParsingResult, ParsingComponents, ReferenceWithTimezone };
export { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday };

// Shortcuts
export const casual = new Chrono(createCasualConfiguration());
export const strict = new Chrono(createConfiguration(true));

export function parse(text: string, ref?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] {
return casual.parse(text, ref, option);
}

export function parseDate(text: string, ref?: ParsingReference | Date, option?: ParsingOption): Date {
return casual.parseDate(text, ref, option);
}

export function createCasualConfiguration(littleEndian = true): Configuration {
const option = createConfiguration(false, littleEndian);
option.parsers.unshift(new VICasualTimeParser());
option.parsers.unshift(new VICasualDateParser());
option.parsers.unshift(new VITimeUnitCasualRelativeFormatParser());
return option;
}

export function createConfiguration(strictMode = true, littleEndian = true): Configuration {
return includeCommonConfiguration(
{
parsers: [
new ISOFormatParser(),
new SlashDateFormatParser(littleEndian),
new VIStandardParser(),
new VIMonthYearParser(),
new VIYearParser(),
new VIWeekdayParser(),
new VITimeExpressionParser(),
new VITimeUnitAgoFormatParser(strictMode),
new VITimeUnitLaterFormatParser(strictMode),
new VITimeUnitWithinFormatParser(strictMode),
],
refiners: [
new VIMergeWeekdayComponentRefiner(),
new VIMergeDateRangeRefiner(),
new VIMergeDateTimeRefiner(),
],
},
strictMode
);
}
31 changes: 31 additions & 0 deletions src/locales/vi/parsers/VICasualDateParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ParsingContext } from "../../../chrono";
import { ParsingComponents } from "../../../results";
import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary";
import * as references from "../../../common/casualReferences";

const PATTERN = /\b(hôm nay|hôm qua|hôm kia|ngày mai|ngày kia|bây giờ|lúc này)(?=\W|$)/i;

export default class VICasualDateParser extends AbstractParserWithWordBoundaryChecking {
innerPattern(): RegExp {
return PATTERN;
}

innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents {
switch (match[1].toLowerCase()) {
case "bây giờ":
case "lúc này":
return references.now(context.reference);
case "hôm nay":
return references.today(context.reference);
case "hôm qua":
return references.yesterday(context.reference);
case "hôm kia":
return references.theDayBefore(context.reference, 2);
case "ngày mai":
return references.tomorrow(context.reference);
case "ngày kia":
return references.theDayAfter(context.reference, 2);
}
return context.createParsingComponents();
}
}
63 changes: 63 additions & 0 deletions src/locales/vi/parsers/VICasualTimeParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ParsingContext } from "../../../chrono";
import { ParsingComponents, ParsingResult } from "../../../results";
import { Meridiem } from "../../../types";
import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary";
import { implySimilarTime } from "../../../utils/dates";

// bu\u1ed5i s\u00e1ng | bu\u1ed5i tr\u01b0a | bu\u1ed5i chi\u1ec1u | bu\u1ed5i t\u1ed1i | n\u1eeda \u0111\u00eam
const PATTERN =
/(buổi\s*)?(sáng sớm|sáng|trưa|chiều|tối|đêm|nửa đêm|bình minh)(?=\W|$)/i;

export default class VICasualTimeParser extends AbstractParserWithWordBoundaryChecking {
innerPattern(): RegExp {
return PATTERN;
}

innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents | ParsingResult {
const component = context.createParsingComponents();
implySimilarTime(component, context.refDate);
return VICasualTimeParser.extractTimeComponents(component, match[2].toLowerCase());
}

static extractTimeComponents(component: ParsingComponents, keyword: string): ParsingComponents {
switch (keyword) {
case "b\u00ecnh minh":
case "s\u00e1ng s\u1edbm":
component.imply("hour", 6);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.AM);
break;
case "s\u00e1ng":
component.imply("hour", 9);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.AM);
break;
case "tr\u01b0a":
component.imply("hour", 12);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.PM); // noon = 12:00 PM in chrono's 12-hour convention
break;
case "chi\u1ec1u":
component.imply("hour", 15);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.PM);
break;
case "t\u1ed1i":
component.imply("hour", 19);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.PM);
break;
case "\u0111\u00eam":
component.imply("hour", 22);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.PM);
break;
case "n\u1eeda \u0111\u00eam":
component.imply("hour", 0);
component.imply("minute", 0);
component.imply("meridiem", Meridiem.AM);
break;
}
return component;
}
}
35 changes: 35 additions & 0 deletions src/locales/vi/parsers/VIMonthYearParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ParsingContext } from "../../../chrono";
import { ParsingResult } from "../../../results";
import { MONTH_DICTIONARY, YEAR_PATTERN, parseYear } from "../constants";
import { matchAnyPattern } from "../../../utils/pattern";
import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary";

// tháng 3 năm 1975 | tháng ba năm 1975 | tháng chạp | tháng giêng/1975
const PATTERN = new RegExp(
"(" + matchAnyPattern(MONTH_DICTIONARY) + ")" + "(?:\\s*(?:năm|/)\\s*(" + YEAR_PATTERN + "))?" + "(?=\\W|$)",
"i"
);

const MONTH_GROUP = 1;
const YEAR_GROUP = 2;

export default class VIMonthYearParser extends AbstractParserWithWordBoundaryChecking {
innerPattern(): RegExp {
return PATTERN;
}

innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult {
const month = MONTH_DICTIONARY[match[MONTH_GROUP].toLowerCase()];
if (!month) return null;

const result = context.createParsingResult(match.index, match[0]);
result.start.assign("month", month);
result.start.imply("day", 1);
if (match[YEAR_GROUP]) {
result.start.assign("year", parseYear(match[YEAR_GROUP]));
} else {
result.start.imply("year", context.reference.getDateWithAdjustedTimezone().getFullYear());
}
return result;
}
}
45 changes: 45 additions & 0 deletions src/locales/vi/parsers/VIStandardParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ParsingContext } from "../../../chrono";
import { ParsingResult } from "../../../results";
import { findYearClosestToRef } from "../../../calculation/years";
import { YEAR_PATTERN, parseYear } from "../constants";
import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary";

// ngày 15 tháng 3 năm 1975 | 15 tháng 3 năm 1975 | 15 tháng 3
const PATTERN = new RegExp(
"(?:ng\u00e0y\\s*)?" +
"([0-9]{1,2})" +
"\\s*th\u00e1ng\\s*" +
"([0-9]{1,2})" +
"(?:\\s*n\u0103m\\s*(" +
YEAR_PATTERN +
"))?" +
"(?=\\W|$)",
"i"
);

const DAY_GROUP = 1;
const MONTH_GROUP = 2;
const YEAR_GROUP = 3;

export default class VIStandardParser extends AbstractParserWithWordBoundaryChecking {
innerPattern(): RegExp {
return PATTERN;
}

innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult {
const day = parseInt(match[DAY_GROUP]);
const month = parseInt(match[MONTH_GROUP]);
if (day > 31 || month > 12) return null;

const result = context.createParsingResult(match.index, match[0]);
result.start.assign("day", day);
result.start.assign("month", month);

if (match[YEAR_GROUP]) {
result.start.assign("year", parseYear(match[YEAR_GROUP]));
} else {
result.start.imply("year", findYearClosestToRef(context.refDate, day, month));
}
return result;
}
}
Loading
Loading