From 50b3e8e7a740acb842703a402896199fa96275db Mon Sep 17 00:00:00 2001 From: Stan Chang Date: Mon, 12 Jan 2026 20:50:54 +0800 Subject: [PATCH] feat: Add support for BC dates (negative years in ISO 8601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement parsing and stringification of BC dates using ISO 8601 extended year format with negative year numbers. This follows the astronomical year numbering system where year 0 = 1 BC, year -1 = 2 BC, etc. Changes: - Parser: Handle optional leading dash before year component - Stringify: Format negative years with proper padding (e.g., -0044) - Add comprehensive test coverage for BC dates, including: - Full dates, year-month, and year-only formats - Year 0 (1 BC in proleptic Gregorian calendar) - BC dates with time and timezone components - Round-trip parsing and stringification Examples: - parseTemporal('-0044-03-15') → year: -44 (44 BC) - parseTemporal('0000-01-01') → year: 0 (1 BC) - stringifyDate({ year: -44, month: 3, day: 15 }) → '-0044-03-15' The implementation maintains backward compatibility and follows ISO 8601:2004 extended year format specification. --- .changeset/bc-date-support.md | 38 ++++++++++++++++++++++ README.md | 5 +++ src/parser.test.ts | 57 +++++++++++++++++++++++++++++++++ src/parser.ts | 9 +++++- src/stringify.test.ts | 59 +++++++++++++++++++++++++++++++++++ src/stringify.ts | 12 ++++++- 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 .changeset/bc-date-support.md diff --git a/.changeset/bc-date-support.md b/.changeset/bc-date-support.md new file mode 100644 index 0000000..ca89c40 --- /dev/null +++ b/.changeset/bc-date-support.md @@ -0,0 +1,38 @@ +--- +'@taskade/temporal-parser': minor +--- + +feat: Add support for BC dates (negative years in ISO 8601) + +Implement parsing and stringification of BC dates using ISO 8601 extended year format with negative year numbers. This follows the astronomical year numbering system where year 0 = 1 BC, year -1 = 2 BC, etc. + +**Key features:** +- Parse BC dates with negative year notation: `-0044-03-15` (44 BC) +- Support year 0 representing 1 BC: `0000-01-01` +- Handle BC dates with time and timezone components +- Proper year padding in output: `-0044`, `-0001` +- Full round-trip compatibility + +**Supported formats:** +- BC year only: `-0100` +- BC year-month: `-0753-04` +- BC full date: `-0044-03-15` +- BC datetime: `-0044-03-15T12:00:00` +- BC with timezone: `-0044-03-15T12:00:00Z` + +**Examples:** +```typescript +// Parse the Ides of March, 44 BC +const bcDate = parseTemporal('-0044-03-15'); +// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... } + +// Stringify BC date +stringifyDate({ kind: 'Date', year: -44, month: 3, day: 15 }); +// Returns: '-0044-03-15' + +// Year 0 represents 1 BC in ISO 8601 +parseTemporal('0000-01-01'); +// { kind: 'DateTime', date: { year: 0, month: 1, day: 1 }, ... } +``` + +This implementation maintains full backward compatibility and follows ISO 8601:2004 extended year format specification. diff --git a/README.md b/README.md index 52b4628..af62f0e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ const duration = parseTemporal('P1Y2M3DT4H5M6S'); // Parse a date range const range = parseTemporal('2025-01-01/2025-12-31'); // { kind: 'Range', start: {...}, end: {...} } + +// Parse BC dates (negative years in ISO 8601) +const bcDate = parseTemporal('-0044-03-15'); +// { kind: 'DateTime', date: { year: -44, month: 3, day: 15 }, ... } ``` ## Supported Formats @@ -50,6 +54,7 @@ const range = parseTemporal('2025-01-01/2025-12-31'); - Year: `2025` - Year-Month: `2025-01` - Full date: `2025-01-12` +- BC dates (negative years): `-0044-03-15` (44 BC), `0000-01-01` (1 BC) ### Times - Hour-Minute: `T10:30` diff --git a/src/parser.test.ts b/src/parser.test.ts index a81a038..29fcf2a 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -32,6 +32,63 @@ describe('parseTemporal', () => { annotations: [], }); }); + + it('should parse BC date (negative year)', () => { + const ast = parseTemporal('-0044-03-15'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: -44, month: 3, day: 15 }, + annotations: [], + }); + }); + + it('should parse BC year only', () => { + const ast = parseTemporal('-0100'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: -100 }, + annotations: [], + }); + }); + + it('should parse BC year-month', () => { + const ast = parseTemporal('-0753-04'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: -753, month: 4 }, + annotations: [], + }); + }); + + it('should parse year 0 (1 BC in ISO 8601)', () => { + const ast = parseTemporal('0000-01-01'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: 0, month: 1, day: 1 }, + annotations: [], + }); + }); + + it('should parse BC datetime with time', () => { + const ast = parseTemporal('-0044-03-15T12:00:00'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: -44, month: 3, day: 15 }, + time: { kind: 'Time', hour: 12, minute: 0, second: 0 }, + annotations: [], + }); + }); + + it('should parse BC datetime with timezone', () => { + const ast = parseTemporal('-0044-03-15T12:00:00Z'); + expect(ast).toMatchObject({ + kind: 'DateTime', + date: { kind: 'Date', year: -44, month: 3, day: 15 }, + time: { kind: 'Time', hour: 12, minute: 0, second: 0 }, + offset: { kind: 'UtcOffset' }, + annotations: [], + }); + }); }); describe('time parsing', () => { diff --git a/src/parser.ts b/src/parser.ts index 0d33d80..5293cdd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -140,8 +140,15 @@ class Parser { } private parseDate(): DateAst { + // Check for optional leading dash (negative year / BC date) + // ISO 8601: Year 0 = 1 BC, Year -1 = 2 BC, etc. + const isNegative = this.tryEat(TokType.Dash); + const yTok = this.eat(TokType.Number); - const year = toInt(yTok.value, 'year', this.i); + let year = toInt(yTok.value, 'year', this.i); + if (isNegative) { + year = -year; + } // Optional -MM if (!this.tryEat(TokType.Dash)) { diff --git a/src/stringify.test.ts b/src/stringify.test.ts index 9312fbe..630d303 100644 --- a/src/stringify.test.ts +++ b/src/stringify.test.ts @@ -641,3 +641,62 @@ describe('RFC 9557 format', () => { expect(result).toBe('2025[u-ca=gregory]'); }); }); + +describe('BC dates (negative years)', () => { + it('should stringify BC date (negative year)', () => { + // Manually construct AST for 44 BC (year -43 in ISO 8601, since year 0 exists) + const result = stringifyDate({ kind: 'Date', year: -43, month: 3, day: 15 }); + expect(result).toBe('-0043-03-15'); + }); + + it('should stringify BC year only', () => { + const result = stringifyDate({ kind: 'Date', year: -100 }); + expect(result).toBe('-0100'); + }); + + it('should stringify BC year-month', () => { + const result = stringifyDate({ kind: 'Date', year: -753, month: 4 }); + expect(result).toBe('-0753-04'); + }); + + it('should stringify year 1 BC (year 0 in ISO 8601)', () => { + const result = stringifyDate({ kind: 'Date', year: 0, month: 1, day: 1 }); + expect(result).toBe('0000-01-01'); + }); + + it('should stringify BC datetime', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: -43, month: 3, day: 15 }, + time: { kind: 'Time', hour: 12, minute: 0, second: 0 }, + annotations: [], + }); + expect(result).toBe('-0043-03-15T12:00:00'); + }); + + it('should handle negative year padding', () => { + const result = stringifyDate({ kind: 'Date', year: -1, month: 1, day: 1 }); + expect(result).toBe('-0001-01-01'); + }); + + it('should round-trip BC date parsing and stringifying', () => { + const input = '-0044-03-15'; + const ast = parseTemporal(input); + const result = stringifyTemporal(ast); + expect(result).toBe(input); + }); + + it('should round-trip BC year only', () => { + const input = '-0100'; + const ast = parseTemporal(input); + const result = stringifyTemporal(ast); + expect(result).toBe(input); + }); + + it('should round-trip BC datetime', () => { + const input = '-0044-03-15T12:00:00Z'; + const ast = parseTemporal(input); + const result = stringifyTemporal(ast); + expect(result).toBe(input); + }); +}); diff --git a/src/stringify.ts b/src/stringify.ts index 851e639..31f64b1 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -43,7 +43,17 @@ export function stringifyTemporal(ast: TemporalAst): string { * @returns Date string (YYYY, YYYY-MM, or YYYY-MM-DD) */ export function stringifyDate(date: DateAst): string { - const parts: string[] = [date.year.toString().padStart(4, '0')]; + // Handle negative years (BC dates) - ISO 8601 uses negative years + // Year 0 = 1 BC, Year -1 = 2 BC, etc. + let yearStr: string; + if (date.year < 0) { + // For negative years, pad the absolute value and prepend the minus sign + yearStr = '-' + Math.abs(date.year).toString().padStart(4, '0'); + } else { + yearStr = date.year.toString().padStart(4, '0'); + } + + const parts: string[] = [yearStr]; if (date.month != null) { parts.push(date.month.toString().padStart(2, '0'));