@corbet-labs/cdate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @corbet-labs/cdate
2
+
3
+ Deterministic locale-correct date formats for formal correspondence. Pure
4
+ port of the [cdate](https://github.com/corbet-labs/cdate) Rust crate: zero
5
+ dependencies, zero Node APIs, synchronous, no I/O.
6
+
7
+ ```ts
8
+ import { longDate, monthYear } from '@corbet-labs/cdate';
9
+
10
+ longDate('de-CH', 2026, 9, 7); // 7. September 2026
11
+ monthYear('en', 2026, 9); // September 2026
12
+ ```
13
+
14
+ Unknown locales fall back through the base language to English. Invalid
15
+ calendar dates yield `null`, never a best effort. Behavior is defined by
16
+ `tables/*.json` at the repository root; `tests/vectors/*.json` is the
17
+ shared conformance suite.
18
+
19
+ Run `bun ./scripts/conformance.mts` and `tsc --noEmit -p ./tsconfig.json`
20
+ before pushing.
21
+
22
+ ## License
23
+
24
+ FSL-1.1-ALv2. Each published version becomes available under Apache-2.0
25
+ two years after publication.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@corbet-labs/cdate",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic locale-correct date formats for formal correspondence. Mirrors the cdate Rust crate release line.",
5
+ "license": "FSL-1.1-ALv2",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "provenance": true
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/corbet-labs/cdate.git",
13
+ "directory": "js/@corbet-labs/cdate"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/index.ts",
17
+ "types": "./src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "files": [
22
+ "src/**/*",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "bash ./scripts/sync-assets.sh",
27
+ "conformance": "bun ./scripts/conformance.mts",
28
+ "typecheck": "tsc --noEmit -p ./tsconfig.json"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "25.8.0",
32
+ "typescript": "6.0.3"
33
+ }
34
+ }
@@ -0,0 +1,3 @@
1
+ // GENERATED by scripts/sync-assets.sh — do not edit.
2
+ export const PACKAGE_VERSION = "0.1.0";
3
+ export const DATES_TABLE = {"fallback": "en", "locales": {"de": {"long": "{day}. {month_long} {yyyy}", "medium": "{dd}.{mm}.{yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], "short": "{dd}.{mm}.{yy}"}, "de-AT": {"long": "{day}. {month_long} {yyyy}", "medium": "{dd}.{mm}.{yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], "short": "{dd}.{mm}.{yy}"}, "de-CH": {"long": "{day}. {month_long} {yyyy}", "medium": "{dd}.{mm}.{yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], "short": "{dd}.{mm}.{yy}"}, "en": {"long": "{month_long} {day}, {yyyy}", "medium": "{month_short} {day}, {yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "months_short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], "short": "{month}/{day}/{yy}"}, "en-GB": {"long": "{day} {month_long} {yyyy}", "medium": "{day} {month_short} {yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "months_short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"], "short": "{dd}/{mm}/{yyyy}"}, "en-US": {"long": "{month_long} {day}, {yyyy}", "medium": "{month_short} {day}, {yyyy}", "month_year": "{month_long} {yyyy}", "months_long": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "months_short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], "short": "{month}/{day}/{yy}"}}, "supported": ["de", "de-AT", "de-CH", "en", "en-GB", "en-US"]};
package/src/index.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Deterministic locale-correct date formats for formal correspondence.
3
+ *
4
+ * Pure TypeScript port of the cdate Rust crate: zero dependencies, zero
5
+ * Node APIs, synchronous, no I/O. Behavior is defined by `tables/*.json`
6
+ * at the repository root; `tests/vectors/*.json` is the shared conformance
7
+ * suite.
8
+ */
9
+ import { DATES_TABLE } from './generated/tables.js';
10
+
11
+ export interface LocaleEntry {
12
+ long: string;
13
+ medium: string;
14
+ short: string;
15
+ month_year: string;
16
+ months_long: string[];
17
+ months_short?: string[];
18
+ }
19
+
20
+ export interface DatesTable {
21
+ locales: Record<string, LocaleEntry>;
22
+ supported: string[];
23
+ fallback: string;
24
+ }
25
+
26
+ const table = DATES_TABLE as DatesTable;
27
+
28
+ /** Lowercased table key → canonical BCP 47 key. Tables keep canonical keys
29
+ * for compatibility; inputs resolve case-insensitively through this index. */
30
+ const canonicalByLower: Record<string, string> = {};
31
+ for (const key of Object.keys(table.locales)) {
32
+ canonicalByLower[key.toLowerCase()] = key;
33
+ }
34
+
35
+ function baseLanguage(locale: string): string {
36
+ return locale.split('-')[0] ?? locale;
37
+ }
38
+
39
+ function resolveKey(locale: string): string {
40
+ if (Object.hasOwn(table.locales, locale)) return locale;
41
+ const lowered = locale.toLowerCase();
42
+ const exact = canonicalByLower[lowered];
43
+ if (exact !== undefined) return exact;
44
+ const base = canonicalByLower[baseLanguage(lowered)];
45
+ if (base !== undefined) return base;
46
+ return table.fallback;
47
+ }
48
+
49
+ function daysInMonth(year: number, month: number): number {
50
+ switch (month) {
51
+ case 1:
52
+ case 3:
53
+ case 5:
54
+ case 7:
55
+ case 8:
56
+ case 10:
57
+ case 12:
58
+ return 31;
59
+ case 4:
60
+ case 6:
61
+ case 9:
62
+ case 11:
63
+ return 30;
64
+ case 2:
65
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
66
+ default:
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ /** Whether a proleptic Gregorian calendar date exists. */
72
+ export function isValidDate(year: number, month: number, day: number): boolean {
73
+ return (
74
+ Number.isInteger(year) &&
75
+ Number.isInteger(month) &&
76
+ Number.isInteger(day) &&
77
+ month >= 1 &&
78
+ month <= 12 &&
79
+ day >= 1 &&
80
+ day <= daysInMonth(year, month)
81
+ );
82
+ }
83
+
84
+ function padTwo(value: number): string {
85
+ return value < 10 ? `0${value}` : `${value}`;
86
+ }
87
+
88
+ function padYear(year: number): string {
89
+ const text = `${year}`;
90
+ if (year < 0 || year >= 1000) return text;
91
+ if (year >= 100) return `0${text}`;
92
+ if (year >= 10) return `00${text}`;
93
+ return `000${text}`;
94
+ }
95
+
96
+ function render(entry: LocaleEntry, pattern: string, year: number, month: number, day: number): string {
97
+ return pattern
98
+ .replaceAll('{yyyy}', padYear(year))
99
+ .replaceAll('{yy}', padTwo(((year % 100) + 100) % 100))
100
+ .replaceAll('{month_long}', entry.months_long[month - 1] ?? '')
101
+ .replaceAll('{month_short}', entry.months_short?.[month - 1] ?? '')
102
+ .replaceAll('{dd}', padTwo(day))
103
+ .replaceAll('{mm}', padTwo(month))
104
+ .replaceAll('{day}', `${day}`)
105
+ .replaceAll('{month}', `${month}`);
106
+ }
107
+
108
+ function formatWith(
109
+ locale: string,
110
+ select: (entry: LocaleEntry) => string,
111
+ year: number,
112
+ month: number,
113
+ day: number,
114
+ ): string | null {
115
+ if (!isValidDate(year, month, day)) return null;
116
+ const entry = table.locales[resolveKey(locale)];
117
+ if (entry === undefined) return null;
118
+ return render(entry, select(entry), year, month, day);
119
+ }
120
+
121
+ /**
122
+ * Long date for a locale: `7. September 2026` (de),
123
+ * `September 7, 2026` (en). Unknown locales fall back through the base
124
+ * language to English. Returns `null` for invalid calendar dates.
125
+ */
126
+ export function longDate(locale: string, year: number, month: number, day: number): string | null {
127
+ return formatWith(locale, (entry) => entry.long, year, month, day);
128
+ }
129
+
130
+ /**
131
+ * Medium date for a locale: `07.09.2026` (de), `Sep 7, 2026` (en).
132
+ * Returns `null` for invalid calendar dates.
133
+ */
134
+ export function mediumDate(locale: string, year: number, month: number, day: number): string | null {
135
+ return formatWith(locale, (entry) => entry.medium, year, month, day);
136
+ }
137
+
138
+ /**
139
+ * Short numeric date for a locale: `07.09.26` (de), `9/7/26` (en).
140
+ * Returns `null` for invalid calendar dates.
141
+ */
142
+ export function shortDate(locale: string, year: number, month: number, day: number): string | null {
143
+ return formatWith(locale, (entry) => entry.short, year, month, day);
144
+ }
145
+
146
+ /**
147
+ * Month and year for a correspondence dateline: `September 2026`.
148
+ * Returns `null` for an invalid month.
149
+ */
150
+ export function monthYear(locale: string, year: number, month: number): string | null {
151
+ if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return null;
152
+ const entry = table.locales[resolveKey(locale)];
153
+ if (entry === undefined) return null;
154
+ return render(entry, entry.month_year, year, month, 1);
155
+ }
156
+
157
+ /** BCP 47 locale codes with a date-format entry, sorted. */
158
+ export function availableLocales(): string[] {
159
+ return Object.keys(table.locales).sort();
160
+ }
161
+
162
+ /** Whether a locale code is supported: present in the supported set
163
+ * directly or through its (lowercased) base language. */
164
+ export function isSupported(locale: string): boolean {
165
+ if (table.supported.includes(locale)) return true;
166
+ return table.supported.includes(baseLanguage(locale.toLowerCase()));
167
+ }