@eouia/intl-msg 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/README.md +525 -0
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  Native `Intl`-based i18n message formatting for modern Node.js, browsers, and Electron, with no runtime dependencies.
4
4
 
5
+ ## Why this exists
6
+
7
+ `intl-msg` started from practical localization problems that show up once a project grows beyond a simple translation table.
8
+
9
+ Typical pain points look like this:
10
+
11
+ - translation files become expensive to maintain because every regional or user-specific variant turns into a full copy
12
+ - locale-specific differences in spelling, grammar, date formatting, and number formatting leak into application code
13
+ - fallback behavior is too simple for real users who may prefer chains such as `fr-CA -> fr -> en-CA -> en`
14
+ - user overrides and minor-locale customizations are awkward to support cleanly
15
+
16
+ The goal of this project is to keep localization lightweight while still supporting:
17
+
18
+ - partial dictionaries instead of full duplicated dictionary snapshots
19
+ - locale-aware formatting driven by native `Intl`
20
+ - explicit locale fallback chains
21
+ - custom formatter behavior that can live near translation data instead of in app logic
22
+ - user or language-pack overrides without forcing the main application to ship every variation
23
+
24
+ In short, `intl-msg` is not just a string lookup helper. It is a small dictionary resolution and message formatting layer built around native `Intl`.
25
+
26
+ For the longer project rationale, see [VISION.md](/Users/seongnohyi/Workspace/work/intl-msg/VISION.md).
27
+
5
28
  ## Status
6
29
 
7
30
  The package now builds from a single source file and publishes both CommonJS and ESM outputs:
@@ -223,6 +246,22 @@ The library includes these built-in formatters:
223
246
  - `duration`
224
247
  - `humanizedRelativeTime`
225
248
 
249
+ ### Formatter overview
250
+
251
+ | Formatter | Input shape | Primary purpose |
252
+ | --- | --- | --- |
253
+ | `select` | scalar | Pick a string from `options` by value |
254
+ | `pluralRules` | number | Pick a string from `rules` by plural category |
255
+ | `pluralRange` | `{ start, end }` | Pick a string from `rules` by plural range category |
256
+ | `list` | array | Render a natural-language list |
257
+ | `number` | number | Locale-aware number formatting |
258
+ | `numberRange` | `{ start, end }` | Locale-aware numeric range formatting |
259
+ | `dateTime` | date-like value | Locale-aware date/time formatting |
260
+ | `dateTimeRange` | `{ start, end }` | Locale-aware date/time range formatting |
261
+ | `relativeTime` | number | Relative time phrase with an explicit unit |
262
+ | `humanizedRelativeTime` | date-like value | Relative time phrase with an inferred unit |
263
+ | `duration` | duration record | Duration formatting via `Intl.DurationFormat` |
264
+
226
265
  ### Example
227
266
 
228
267
  ```js
@@ -340,6 +379,378 @@ msg.message('LABEL', {
340
379
 
341
380
  The `pluralRange` formatter expects `{ start, end }` and uses `Intl.PluralRules.prototype.selectRange()`.
342
381
 
382
+ ## Formatter reference
383
+
384
+ ### `select`
385
+
386
+ Use `select` when the input value should directly choose a phrase from `options`.
387
+
388
+ ```js
389
+ msg.addDictionary({
390
+ en: {
391
+ translations: {
392
+ WELCOME: 'Welcome {{gender:title}} {{name}}.',
393
+ },
394
+ formatters: {
395
+ title: {
396
+ format: 'select',
397
+ options: {
398
+ female: 'Ms.',
399
+ male: 'Mr.',
400
+ other: '',
401
+ },
402
+ },
403
+ },
404
+ },
405
+ })
406
+
407
+ msg.message('WELCOME', { gender: 'female', name: 'Taylor' })
408
+ // => 'Welcome Ms. Taylor.'
409
+ ```
410
+
411
+ Expected input:
412
+
413
+ - any scalar value that can be matched against keys in `options`
414
+
415
+ Fallback behavior:
416
+
417
+ - returns `options.other` when present
418
+ - otherwise falls back to the original input value
419
+
420
+ ### `pluralRules`
421
+
422
+ Use `pluralRules` when a number should choose a localized term by plural category.
423
+
424
+ ```js
425
+ msg.addDictionary({
426
+ en: {
427
+ translations: {
428
+ LABEL: 'There {{count:beVerb}} {{count}} {{count:unit}}.',
429
+ },
430
+ formatters: {
431
+ beVerb: {
432
+ format: 'pluralRules',
433
+ rules: {
434
+ one: 'is',
435
+ other: 'are',
436
+ },
437
+ },
438
+ unit: {
439
+ format: 'pluralRules',
440
+ rules: {
441
+ one: 'item',
442
+ other: 'items',
443
+ },
444
+ },
445
+ },
446
+ },
447
+ })
448
+
449
+ msg.message('LABEL', { count: 2 })
450
+ // => 'There are 2 items.'
451
+ ```
452
+
453
+ Expected input:
454
+
455
+ - a number
456
+
457
+ Fallback behavior:
458
+
459
+ - returns `rules.other` when available
460
+ - otherwise returns an empty string
461
+
462
+ ### `pluralRange`
463
+
464
+ Use `pluralRange` when a numeric range should choose a localized term by range category.
465
+
466
+ ```js
467
+ msg.addDictionary({
468
+ en: {
469
+ translations: {
470
+ RANGE: '{{countText}} {{count:ticketLabel}}',
471
+ },
472
+ formatters: {
473
+ ticketLabel: {
474
+ format: 'pluralRange',
475
+ rules: {
476
+ one: 'ticket',
477
+ other: 'tickets',
478
+ },
479
+ },
480
+ },
481
+ },
482
+ })
483
+
484
+ msg.message('RANGE', {
485
+ countText: '1-3',
486
+ count: { start: 1, end: 3 },
487
+ })
488
+ // => '1-3 tickets'
489
+ ```
490
+
491
+ Expected input:
492
+
493
+ - `{ start, end }`
494
+
495
+ Fallback behavior:
496
+
497
+ - warns and falls back when `Intl.PluralRules.prototype.selectRange()` is unavailable
498
+
499
+ ### `list`
500
+
501
+ Use `list` when you want locale-aware conjunctions such as "A, B, and C".
502
+
503
+ ```js
504
+ msg.addDictionary({
505
+ en: {
506
+ translations: {
507
+ COLORS: 'Colors: {{value:palette}}',
508
+ },
509
+ formatters: {
510
+ palette: {
511
+ format: 'list',
512
+ options: { style: 'long', type: 'conjunction' },
513
+ },
514
+ },
515
+ },
516
+ })
517
+
518
+ msg.message('COLORS', { value: ['Red', 'Blue', 'White'] })
519
+ // => 'Colors: Red, Blue, and White'
520
+ ```
521
+
522
+ Expected input:
523
+
524
+ - an array
525
+
526
+ Fallback behavior:
527
+
528
+ - returns the original value when the input is not an array
529
+
530
+ ### `number`
531
+
532
+ Use `number` for locale-aware numbers, currency, percent, or unit display.
533
+
534
+ ```js
535
+ msg.addDictionary({
536
+ en: {
537
+ translations: {
538
+ TOTAL: 'Total: {{amount:price}}',
539
+ },
540
+ formatters: {
541
+ price: {
542
+ format: 'number',
543
+ options: { style: 'currency', currency: 'USD' },
544
+ },
545
+ },
546
+ },
547
+ })
548
+
549
+ msg.message('TOTAL', { amount: 1234.5 })
550
+ // => 'Total: $1,234.50'
551
+ ```
552
+
553
+ Expected input:
554
+
555
+ - a number
556
+
557
+ Fallback behavior:
558
+
559
+ - validates commonly used Intl options when supported
560
+ - returns the original value when the input is not numeric or options are invalid
561
+
562
+ ### `numberRange`
563
+
564
+ Use `numberRange` when two numeric endpoints should be formatted as one localized range.
565
+
566
+ ```js
567
+ msg.addDictionary({
568
+ en: {
569
+ translations: {
570
+ BUDGET: 'Budget: {{amount:budget}}',
571
+ },
572
+ formatters: {
573
+ budget: {
574
+ format: 'numberRange',
575
+ options: { style: 'currency', currency: 'USD' },
576
+ },
577
+ },
578
+ },
579
+ })
580
+
581
+ msg.message('BUDGET', {
582
+ amount: { start: 1200, end: 3400 },
583
+ })
584
+ // => 'Budget: $1,200.00 - $3,400.00'
585
+ ```
586
+
587
+ Expected input:
588
+
589
+ - `{ start, end }`
590
+
591
+ Fallback behavior:
592
+
593
+ - warns and falls back to two separately formatted values joined by ` - ` when `formatRange()` is unavailable
594
+
595
+ ### `dateTime`
596
+
597
+ Use `dateTime` for locale-aware date or time rendering from a date-like input.
598
+
599
+ ```js
600
+ msg.addDictionary({
601
+ en: {
602
+ translations: {
603
+ TODAY: 'Today is {{value:dateLabel}}.',
604
+ },
605
+ formatters: {
606
+ dateLabel: {
607
+ format: 'dateTime',
608
+ options: { weekday: 'long', month: 'long', day: 'numeric' },
609
+ },
610
+ },
611
+ },
612
+ })
613
+
614
+ msg.message('TODAY', { value: '2026-03-23' })
615
+ // => 'Today is Monday, March 23.'
616
+ ```
617
+
618
+ Expected input:
619
+
620
+ - a `Date`, timestamp, or date-like string accepted by `new Date(...)`
621
+
622
+ Fallback behavior:
623
+
624
+ - returns the original value when the input cannot be parsed as a valid date
625
+
626
+ ### `dateTimeRange`
627
+
628
+ Use `dateTimeRange` when two date-like values should be rendered as one localized range.
629
+
630
+ ```js
631
+ msg.addDictionary({
632
+ en: {
633
+ translations: {
634
+ EVENT: 'Event: {{period:schedule}}',
635
+ },
636
+ formatters: {
637
+ schedule: {
638
+ format: 'dateTimeRange',
639
+ options: { month: 'short', day: 'numeric' },
640
+ },
641
+ },
642
+ },
643
+ })
644
+
645
+ msg.message('EVENT', {
646
+ period: { start: '2026-03-23', end: '2026-03-25' },
647
+ })
648
+ // => 'Event: Mar 23-25'
649
+ ```
650
+
651
+ Expected input:
652
+
653
+ - `{ start, end }` with date-like values
654
+
655
+ Fallback behavior:
656
+
657
+ - warns and falls back to two separately formatted dates joined by ` - ` when `formatRange()` is unavailable
658
+
659
+ ### `relativeTime`
660
+
661
+ Use `relativeTime` when the unit is known in advance.
662
+
663
+ ```js
664
+ msg.addDictionary({
665
+ en: {
666
+ translations: {
667
+ ETA: 'ETA: {{value:eta}}',
668
+ },
669
+ formatters: {
670
+ eta: {
671
+ format: 'relativeTime',
672
+ unit: 'day',
673
+ },
674
+ },
675
+ },
676
+ })
677
+
678
+ msg.message('ETA', { value: 3 })
679
+ // => 'ETA: in 3 days'
680
+ ```
681
+
682
+ Expected input:
683
+
684
+ - a number
685
+
686
+ Fallback behavior:
687
+
688
+ - validates the unit when supported
689
+ - returns the original value when the input is not numeric or the unit is invalid
690
+
691
+ ### `humanizedRelativeTime`
692
+
693
+ Use `humanizedRelativeTime` when you want the unit inferred from the distance to now.
694
+
695
+ ```js
696
+ msg.addDictionary({
697
+ en: {
698
+ translations: {
699
+ WHEN: 'Updated {{value:ago}}',
700
+ },
701
+ formatters: {
702
+ ago: {
703
+ format: 'humanizedRelativeTime',
704
+ },
705
+ },
706
+ },
707
+ })
708
+
709
+ msg.message('WHEN', { value: new Date(Date.now() - 2 * 60 * 60 * 1000) })
710
+ // => 'Updated 2 hours ago'
711
+ ```
712
+
713
+ Expected input:
714
+
715
+ - a date-like value
716
+
717
+ Fallback behavior:
718
+
719
+ - returns the original value when the input cannot be parsed as a valid date
720
+
721
+ ### `duration`
722
+
723
+ Use `duration` for explicit duration records such as hours, minutes, and seconds.
724
+
725
+ ```js
726
+ msg.addDictionary({
727
+ en: {
728
+ translations: {
729
+ ELAPSED: 'Elapsed: {{value:elapsed}}',
730
+ },
731
+ formatters: {
732
+ elapsed: {
733
+ format: 'duration',
734
+ options: { style: 'short' },
735
+ },
736
+ },
737
+ },
738
+ })
739
+
740
+ msg.message('ELAPSED', {
741
+ value: { hours: 1, minutes: 30, seconds: 5 },
742
+ })
743
+ // => 'Elapsed: 1 hr, 30 min, 5 sec'
744
+ ```
745
+
746
+ Expected input:
747
+
748
+ - an `Intl.DurationFormat`-style duration record object
749
+
750
+ Fallback behavior:
751
+
752
+ - warns and falls back gracefully when `Intl.DurationFormat` is unavailable
753
+
343
754
  ## Option validation
344
755
 
345
756
  When the runtime supports `Intl.supportedValuesOf()`, the library validates commonly used Intl options before constructing formatters.
@@ -449,6 +860,12 @@ Supported options:
449
860
  - `verbose`
450
861
  - `intlPolyfill`
451
862
 
863
+ Example:
864
+
865
+ ```js
866
+ const msg = new IntlMsg({ verbose: false })
867
+ ```
868
+
452
869
  ### `IntlMsg.factory(options?)`
453
870
 
454
871
  Convenience constructor. In addition to the constructor options, it also accepts:
@@ -456,14 +873,40 @@ Convenience constructor. In addition to the constructor options, it also accepts
456
873
  - `locales`
457
874
  - `dictionaries`
458
875
 
876
+ Example:
877
+
878
+ ```js
879
+ const msg = IntlMsg.factory({
880
+ locales: ['en-US', 'en'],
881
+ dictionaries: {
882
+ en: { translations: { HELLO: 'Hello' } },
883
+ },
884
+ })
885
+
886
+ msg.message('HELLO')
887
+ // => 'Hello'
888
+ ```
889
+
459
890
  ### `addLocale(locales)`
460
891
 
461
892
  Adds one locale or an array of locales.
462
893
 
894
+ ```js
895
+ msg.addLocale(['en-US', 'de'])
896
+ msg.getLocale()
897
+ // => ['en-US', 'de']
898
+ ```
899
+
463
900
  ### `setLocale(locales)`
464
901
 
465
902
  Replaces the current locale list.
466
903
 
904
+ ```js
905
+ msg.setLocale(['fr-CA', 'fr', 'en'])
906
+ msg.getLocale()
907
+ // => ['fr-CA', 'fr', 'en']
908
+ ```
909
+
467
910
  ### `getLocale()`
468
911
 
469
912
  Returns the current locale list.
@@ -472,34 +915,85 @@ Returns the current locale list.
472
915
 
473
916
  Merges dictionary data into the current instance.
474
917
 
918
+ ```js
919
+ msg.addDictionary({
920
+ en: {
921
+ translations: {
922
+ HELLO: 'Hello, {{name}}.',
923
+ },
924
+ },
925
+ })
926
+
927
+ msg.message('HELLO', { name: 'Taylor' })
928
+ // => 'Hello, Taylor.'
929
+ ```
930
+
475
931
  ### `getDictionary(locale)`
476
932
 
477
933
  Returns the `Dictionary` instance for a locale, or `null`.
478
934
 
935
+ ```js
936
+ msg.getDictionary('en')
937
+ // => Dictionary instance or null
938
+ ```
939
+
479
940
  ### `getDictionaryNames()`
480
941
 
481
942
  Returns the registered locale names.
482
943
 
944
+ ```js
945
+ msg.getDictionaryNames()
946
+ // => ['en', 'en-US']
947
+ ```
948
+
483
949
  ### `addTermToDictionary(locale, key, value)`
484
950
 
485
951
  Adds or replaces a translation term for a locale.
486
952
 
953
+ ```js
954
+ msg.addTermToDictionary('en', 'BYE', 'Goodbye')
955
+ msg.message('BYE')
956
+ // => 'Goodbye'
957
+ ```
958
+
487
959
  ### `getTermFromDictionary(locale, key)`
488
960
 
489
961
  Returns a term value, or `undefined`.
490
962
 
963
+ ```js
964
+ msg.getTermFromDictionary('en', 'HELLO')
965
+ // => 'Hello, {{name}}.'
966
+ ```
967
+
491
968
  ### `getRawMessage(key, locales?)`
492
969
 
493
970
  Returns the untranslated template string selected by locale lookup.
494
971
 
972
+ ```js
973
+ msg.getRawMessage('HELLO')
974
+ // => 'Hello, {{name}}.'
975
+ ```
976
+
495
977
  ### `message(key, values?)`
496
978
 
497
979
  Formats and returns the final message string.
498
980
 
981
+ ```js
982
+ msg.message('HELLO', { name: 'Taylor' })
983
+ // => 'Hello, Taylor.'
984
+ ```
985
+
499
986
  ### `registerFormatter(name, fn)`
500
987
 
501
988
  Registers a custom formatter callback.
502
989
 
990
+ ```js
991
+ msg.registerFormatter('capitalize', ({ value }) => {
992
+ const text = value == null ? '' : String(value)
993
+ return text ? text[0].toUpperCase() + text.slice(1).toLowerCase() : text
994
+ })
995
+ ```
996
+
503
997
  Dictionary formatter configs may also set `postFormat` to the name of a registered formatter. Only two stages are supported: `format`, then `postFormat`.
504
998
 
505
999
  ## Development
@@ -512,6 +1006,37 @@ npm test
512
1006
 
513
1007
  Tests currently build the package first, then run Mocha with `nyc` coverage.
514
1008
 
1009
+ ## Publishing
1010
+
1011
+ Manual publish:
1012
+
1013
+ ```sh
1014
+ npm publish --access public
1015
+ ```
1016
+
1017
+ Trusted publishing via GitHub Actions is also configured in [publish.yml](/Users/seongnohyi/Workspace/work/intl-msg/.github/workflows/publish.yml).
1018
+
1019
+ Current workflow behavior:
1020
+
1021
+ - pushes of tags matching `v*` trigger the publish workflow
1022
+ - the workflow runs `npm ci`, `npm test`, and then `npm publish --access public`
1023
+ - npm trusted publishing must be configured on npmjs.com for this repository and workflow file
1024
+
1025
+ To enable trusted publishing on npm:
1026
+
1027
+ 1. Open your package settings on npmjs.com
1028
+ 2. Add a trusted publisher for GitHub Actions
1029
+ 3. Use GitHub owner `eouia`
1030
+ 4. Use repository `intl-msg`
1031
+ 5. Use workflow file `publish.yml`
1032
+
1033
+ After that, publishing a new release is:
1034
+
1035
+ ```sh
1036
+ git tag v0.1.0
1037
+ git push origin v0.1.0
1038
+ ```
1039
+
515
1040
  ## Production use
516
1041
 
517
1042
  `intl-msg` is usable in real applications today, especially when you want:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eouia/intl-msg",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Native Intl-based i18n message formatting with dictionary fallback for Node.js and browsers",
5
5
  "type": "commonjs",
6
6
  "main": "./dist/cjs/main.cjs",