isodoc-i18n 1.4.5 → 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55c6824f8f206976c960950016c22c0df5db0af0768096a6c655ead3bdda3d13
4
- data.tar.gz: 784a50d213f341fdb8f513fadd785841f1824275b23db65275e12eda80340fc5
3
+ metadata.gz: 7698b9379cd9b2bfea54973ec2cdaef465b92963e2e6e38715d20ef50edf58c1
4
+ data.tar.gz: c6370d69fcc7c5a5301b98582538853f29c6bd9455082b1b2e2e0b882e3ec0f3
5
5
  SHA512:
6
- metadata.gz: cd7fc02a863332d9b4ea8c23a9a41f08fec2e5e90fe64d8e7f9d2d0029b381c8673049aa3f9896f02b887639b651ecd80c0d61f3292363599c6c3fbcb9c68808
7
- data.tar.gz: c5e9c27438f1c6ec808e2371269b1f64ee87c7839c1067cd2280f79b1c85e6a8255209e6d4bb674f7588f83ed887a39e20cbebe3d641df3f156f767f3b908987
6
+ metadata.gz: 7815dca37d2d415d53afe5a41260f650d8ff8f5b61a5d1fe200d0ad8de4fb008813f9e0737ceca81abf30c9dad15bd341c2e4db478787ff55d34a9901ef15b9b
7
+ data.tar.gz: 58f22f0df18829864fac021c3cb7e66c7d520c82eb92c385ecde375005341f27bb9f6546f8e4f4963f57826b05328bcb1cc4ba7fec11928a153002261d5b08a7
data/README.adoc CHANGED
@@ -7,4 +7,49 @@ image:https://img.shields.io/github/issues-pr-raw/metanorma/isodoc-i18n.svg["Pul
7
7
  image:https://img.shields.io/github/commits-since/metanorma/isodoc-i18n/latest.svg["Commits since latest",link="https://github.com/metanorma/isodoc-i18n/releases"]
8
8
 
9
9
 
10
- Internationalisation for Metanorma rendering
10
+ Internationalisation for Metanorma rendering.
11
+
12
+ `isodoc-i18n` is the shared i18n/l10n base layer used across the
13
+ metanorma flavours. It provides:
14
+
15
+ * **YAML-driven locale labels** (`IsoDoc::I18n#labels`, `populate`) —
16
+ per-language label sets loaded from i18n YAML, with cross-file
17
+ references and Liquid template interpolation.
18
+ * **CLDR-backed l10n of punctuation and spacing** (`#l10n`) — CJK
19
+ contextual half-width/full-width punctuation, French spacing rules,
20
+ bidi wrapping for RTL scripts.
21
+ * **Grammatical inflection and ordinals** (`#inflect`, `#inflect_ordinal`)
22
+ — number/case/gender/person inflection driven by per-language YAML
23
+ inflection tables, plus CLDR `OrdinalRules` and `SpelloutRules`
24
+ formatting.
25
+ * **Liquid filter integration** (`IsoDoc::I18n::Liquid`) —
26
+ `inflect`, `ordinal_num`, `ordinal_word`, etc. registered globally
27
+ on `Liquid::Environment.default` for use in metanorma templates.
28
+ * **Boolean conjunction list rendering** (`#boolean_conj`) —
29
+ locale-correct "A, B, and C"-style joins.
30
+ * **Extended date formatting** (`IsoDoc::I18n#date`,
31
+ `IsoDoc::ExtendedDateFormatter`) — see below.
32
+
33
+ == Extended date formatting
34
+
35
+ `isodoc-i18n` ships an extended `strftime`-style date formatter,
36
+ `IsoDoc::ExtendedDateFormatter`, that adds POSIX-flavoured era and
37
+ alternative-numbering surfaces (Japanese era years, kanji spell-out,
38
+ Han digits, Roman months) on top of Ruby's `Date#strftime`. It is the
39
+ engine behind `IsoDoc::I18n#date` and metanorma's
40
+ `<date value="…" format="…"/>` element, and can also be used standalone:
41
+
42
+ [source,ruby]
43
+ ----
44
+ require "isodoc-i18n"
45
+
46
+ f = IsoDoc::ExtendedDateFormatter.new(lang: "ja", script: "Jpan")
47
+ f.format("2024-09-30", "%EY年%-m月%-d日") # => "令和6年9月30日"
48
+
49
+ g = IsoDoc::ExtendedDateFormatter.new(lang: "en", script: "Latn")
50
+ g.format("2024-09-30", "%-d.%Om{roman}.%Y") # => "30.IX.2024"
51
+ ----
52
+
53
+ Full token reference, calendar dispatch rules, and a comparison with
54
+ `twitter_cldr`, `japanese_calendar`, `ffi-icu`, and other adjacent
55
+ tooling: link:docs/extended-date-format.adoc[`docs/extended-date-format.adoc`].
@@ -0,0 +1,278 @@
1
+ = Extended date formatting in isodoc-i18n
2
+
3
+ == Scope
4
+
5
+ This document covers **only the extensions** introduced by
6
+ `isodoc-i18n` on top of Ruby's standard `Date#strftime`. Specifically:
7
+
8
+ - Era-year tokens (`%EY`, `%Ey`, `%EC`)
9
+ - Alternative-numbering tokens (`%Om`, `%Od`, `%OY`, `%Oy`)
10
+ - The legacy `%_`-as-literal-space alias kept for backwards compatibility
11
+
12
+ Everything else — the full set of `%Y`, `%m`, `%d`, `%H`, `%M`, `%S`,
13
+ `%F`, `%T`, `%j`, `%U`, `%W`, `%Z`, `%z`, etc. — is plain Ruby
14
+ `Date#strftime` and is documented at
15
+ https://docs.ruby-lang.org/en/master/strftime_formatting_rdoc.html.
16
+ Read that page first; this document is the *delta*.
17
+
18
+ The localised name directives (`%B`, `%b`, `%h`, `%A`, `%a`, `%P`,
19
+ `%p`, including their `%^B` etc. uppercase variants) are also part of
20
+ Ruby's standard strftime, but `isodoc-i18n` reroutes them through CLDR
21
+ locale data so the names come out in the configured `lang`/`script`
22
+ rather than in Ruby's hard-coded English. The rerouting is byte-identical
23
+ in surface — the *tokens* are unchanged from Ruby — so it is
24
+ documented in the *Localised names* section below as a pointer rather
25
+ than a re-specification.
26
+
27
+
28
+ == Background and dependencies
29
+
30
+ === twitter_cldr
31
+
32
+ `twitter_cldr` is the Ruby port of the
33
+ https://cldr.unicode.org/[Unicode Common Locale Data Repository (CLDR)] — the canonical
34
+ multilingual data set for month names, day names, period markers
35
+ (am/pm), spell-out numbering rules, and so on. Anything in this
36
+ formatter that depends on a locale (the localised-name directives,
37
+ the `spellout` numbering system, the `hanidec`-friendly digit table)
38
+ is ultimately a query against CLDR through twitter_cldr. Repository
39
+ and API docs: https://github.com/twitter/twitter-cldr-rb.
40
+
41
+ === japanese_calendar
42
+
43
+ `japanese_calendar` is a small Ruby gem that adds `Date#era_year` and
44
+ the `%JN`/`%Jy`/`%JH` strftime tokens for the Japanese era
45
+ (*gengō*) calendar. Used by the formatter for `cal=japanese`.
46
+ Repository: https://github.com/junmt/japanese_calendar.
47
+
48
+ === POSIX `%E*` / `%O*` modifier convention
49
+
50
+ The token namespace `%E[YyC]` and `%O[mdYy]` is borrowed from POSIX
51
+ strftime, where `%E` introduces *alternative era representation* and
52
+ `%O` introduces *alternative numeric symbols* for a directive. POSIX
53
+ itself only defines the modifier prefixes; the actual era and
54
+ numbering data is locale-driven. The richest documented expansion is
55
+ GNU strftime, mirrored in Perl as
56
+ https://metacpan.org/dist/POSIX-strftime-GNU[`POSIX::strftime::GNU`]; we follow its intent in
57
+ spirit. Ruby's own `Date#strftime` accepts `%E`/`%O` but treats them
58
+ as no-ops (passing through to the unmodified directive) — claiming
59
+ this surface for actual era/numbering behaviour is the central
60
+ extension here.
61
+
62
+ === "Era", concretely
63
+
64
+ An *era* in this context is a regnal or calendar epoch named by a
65
+ character or short label, with a year count that resets at the start
66
+ of each new epoch. The Japanese calendar is the canonical example
67
+ in metanorma's user base: 令和 (Reiwa, started 2019-05-01), 平成
68
+ (Heisei, 1989-2019), 昭和 (Shōwa, 1926-1989), and so on. Year 6 of
69
+ Reiwa (`令和6`) is calendar-year 2024. The era handler also
70
+ accommodates the Republic of China (Minguo) and Thai Buddhist
71
+ calendars conceptually, where the "era" is a single perpetual count
72
+ that doesn't reset (Minguo year = Gregorian − 1911; Buddhist year =
73
+ Gregorian + 543) — see *Calendar dispatch* below for the current
74
+ wiring status.
75
+
76
+
77
+ == Quick reference
78
+
79
+ [source,ruby]
80
+ ----
81
+ require "isodoc-i18n"
82
+
83
+ f = IsoDoc::ExtendedDateFormatter.new(lang: "ja", script: "Jpan")
84
+
85
+ f.format("2024-09-30", "%EY年%-m月%-d日")
86
+ # => "令和6年9月30日"
87
+
88
+ f.format("2024-09-30", "%EY{spellout}年%Om{spellout}月%Od{spellout}日")
89
+ # => "令和六年九月三十日"
90
+
91
+ g = IsoDoc::ExtendedDateFormatter.new(lang: "en", script: "Latn")
92
+
93
+ g.format("2024-09-30", "%-d.%Om{roman}.%Y")
94
+ # => "30.IX.2024"
95
+
96
+ g.format("2024-09-30", "%-d %B %Y")
97
+ # => "30 September 2024"
98
+ ----
99
+
100
+ `format` accepts an ISO-8601 string, a `Date`, a `DateTime`, or a
101
+ `Time` as the first argument.
102
+
103
+
104
+ == Token reference
105
+
106
+ === Era year — `%EY`, `%Ey`, `%EC`
107
+
108
+ [cols="1,3"]
109
+ |===
110
+ | Token | Meaning
111
+
112
+ | `%EY` | Era name + era year (e.g. `令和6` for 2024-09-30 in `cal=japanese`)
113
+ | `%Ey` | Era year only (e.g. `6`)
114
+ | `%EC` | Era name only (e.g. `令和`)
115
+ |===
116
+
117
+ The argument block selects the numbering system used to render the
118
+ year, and (optionally) the calendar:
119
+
120
+ [source]
121
+ ----
122
+ %EY{numeric} # 令和6 (default)
123
+ %EY{spellout} # 令和六
124
+ %EY{cal=japanese} # 令和6
125
+ %EY{spellout, cal=japanese}
126
+ ----
127
+
128
+ The default calendar is `japanese` when `lang: "ja"`, otherwise
129
+ `gregorian`. Under `cal=gregorian`, `%EY` collapses to the four-digit
130
+ year, `%Ey` to the two-digit year, and `%EC` to an empty string —
131
+ the era surface degrades cleanly for locales without an era
132
+ convention.
133
+
134
+ For pre-Meiji dates (before 1868), `japanese_calendar.era_year`
135
+ raises; the formatter catches this and falls back to the plain
136
+ Gregorian year.
137
+
138
+ === Alternative numbering — `%Om`, `%Od`, `%OY`, `%Oy`
139
+
140
+ [cols="1,3"]
141
+ |===
142
+ | Token | Meaning
143
+
144
+ | `%Om` | Month rendered in an alternative numbering system
145
+ | `%Od` | Day rendered in an alternative numbering system
146
+ | `%OY` | Four-digit year in an alternative numbering system
147
+ | `%Oy` | Two-digit year in an alternative numbering system
148
+ |===
149
+
150
+ The argument block names the numbering system. Supported values:
151
+
152
+ [cols="1,2,3"]
153
+ |===
154
+ | Keyword | Behaviour | Example for 30
155
+
156
+ | `numeric` (default) | Latin digits | `30`
157
+ | `latn` | Alias for `numeric` | `30`
158
+ | `spellout` | CLDR `spellout-cardinal` rules in `lang` | `三十` (ja)
159
+ | `hanidec` | Han decimal digits, digit-for-digit | `三〇`
160
+ | `roman` | Roman numerals, uppercase | `XXX`
161
+ | `roman-lower` | Roman numerals, lowercase | `xxx`
162
+ |===
163
+
164
+ `spellout` and `hanidec` are visually similar for single-digit
165
+ numbers but diverge above 9: `spellout` produces positional kanji
166
+ (`三十` for 30) while `hanidec` is digit-for-digit substitution
167
+ (`三〇`). Pick based on which output the document style requires.
168
+
169
+ `roman` and `roman-lower` ignore `lang` — the digits are
170
+ language-independent. They are intended for Continental European
171
+ bibliographic styles such as `30.IX.2024` and standards documents
172
+ that quote dates with Roman months as a typographic convention.
173
+
174
+ === Localised names — `%B`, `%b`, `%h`, `%A`, `%a`, `%P`, `%p`
175
+
176
+ These are standard Ruby strftime tokens — see the canonical Ruby
177
+ reference at
178
+ https://docs.ruby-lang.org/en/master/strftime_formatting_rdoc.html.
179
+ `isodoc-i18n` does not change their syntax; it changes their
180
+ *output* by reading the localised month/day/period names from CLDR
181
+ via `twitter_cldr` for the configured `lang`/`script`. The CLDR data
182
+ keys consulted are
183
+ `calendar_data[:months][:format][:wide|:abbreviated]`,
184
+ `calendar_data[:days][:format][:wide|:abbreviated]`, and
185
+ `periods[:am|:pm]`; see the twitter_cldr README at
186
+ https://github.com/twitter/twitter-cldr-rb#calendars-amp-dates for
187
+ the underlying API.
188
+
189
+ `%^B`, `%^A`, etc. uppercase the localised result. Behaviour is
190
+ byte-identical to the previous `IsoDoc::I18n#date` implementation,
191
+ so existing format strings such as `%-d %B %Y` continue to produce
192
+ the same output.
193
+
194
+ === Legacy — `%_`
195
+
196
+ `%_` is treated as a literal space, preserving the legacy
197
+ `IsoDoc::I18n#date` convention. Note that this overrides Ruby's
198
+ POSIX `%_` flag (which means "space-pad the next directive"); kept
199
+ for backwards compatibility with existing metanorma format strings.
200
+
201
+ === Calendar dispatch (extension surface)
202
+
203
+ The `cal=` argument is a CLDR calendar identifier. Two calendars are
204
+ wired up today:
205
+
206
+ [cols="1,3"]
207
+ |===
208
+ | Calendar | Status
209
+
210
+ | `gregorian` | Default for non-Japanese locales. Era tokens degrade as described above.
211
+ | `japanese` | Default for `lang: "ja"`. Backed by `japanese_calendar`.
212
+ |===
213
+
214
+ The following identifiers are reserved as extension points and raise
215
+ `NotImplementedError` today, with a clear message naming the
216
+ supported set:
217
+
218
+ `roc`, `buddhist`, `persian`, `islamic`, `indian`, `hebrew`.
219
+
220
+ These map to CLDR data already shipped by `twitter_cldr`. They are
221
+ documented as future-friendly entry points so user code can be
222
+ written against the eventual API; wiring is a configuration matter,
223
+ not a research project, and will be added on demand.
224
+
225
+
226
+ == Comparison of alternatives
227
+
228
+ When `isodoc-i18n` doesn't fit, these are the gems and standards that
229
+ cover adjacent surface area:
230
+
231
+ [cols="2,3,3"]
232
+ |===
233
+ | Resource | Scope | Limitation relative to isodoc-i18n
234
+
235
+ | https://github.com/twitter/twitter-cldr-rb[`twitter_cldr`]
236
+ | CLDR locale data; CLDR-skeleton formatting (`to_additional_s`); spell-out numbering (`to_rbnf_s`)
237
+ | No POSIX `%E*`/`%O*` surface; alternative numbering systems not exposed cleanly per date component
238
+
239
+ | https://github.com/junmt/japanese_calendar[`japanese_calendar`]
240
+ | `Date#era_year` plus `%JN`/`%Jy`/`%JH` strftime tokens
241
+ | Japan-only; no alternative numbering for the regnal year (we layer twitter_cldr on top)
242
+
243
+ | https://github.com/sho-h/era_ja[`era_ja`]
244
+ | Like `japanese_calendar`, plus `%K` for the "元年" first-year convention
245
+ | Japan-only; standalone — would duplicate `japanese_calendar`'s surface
246
+
247
+ | https://github.com/ffi/ffi-icu[`ffi-icu`]
248
+ | Full ICU bindings: native era, numbering systems, locale-aware everything
249
+ | Requires `libicu` system dependency; steep API; heavyweight if you only need date formatting
250
+
251
+ | https://github.com/ruby-i18n/ruby-cldr[`ruby-cldr`]
252
+ | CLDR data export
253
+ | Data only, no formatting API
254
+
255
+ | https://metacpan.org/dist/POSIX-strftime-GNU[`POSIX::strftime::GNU` (Perl)]
256
+ | Reference implementation of GNU strftime `%E*`/`%O*` modifiers
257
+ | Perl only; no Ruby port — used here as a design reference
258
+
259
+ | https://rubygems.org/gems/roman-numerals[`roman-numerals`]
260
+ | Roman numeral conversion
261
+ | No date-formatting surface; isodoc-i18n wraps it for `%Om{roman}`
262
+
263
+ | https://shopify.github.io/liquid/filters/date[Liquid `date` filter]
264
+ | Vanilla Ruby strftime in Liquid templates
265
+ | No locale, no era, no Roman; a Liquid filter built on this formatter is the wrapper that adds those
266
+
267
+ | https://liquidjs.com/filters/date.html[LiquidJS `date_to_xmlschema` / `date`]
268
+ | Liquid for JavaScript runtimes; adds `%q` ordinal
269
+ | No era, no alternative numbering; not relevant to a Ruby pipeline but listed for completeness
270
+
271
+ | https://cldr.unicode.org/[CLDR]
272
+ | The data source `twitter_cldr` re-exposes
273
+ | Data spec, not a formatting API
274
+
275
+ | https://unicode-org.github.io/icu/[ICU]
276
+ | C/C++ implementation behind `ffi-icu`
277
+ | C library, requires bindings
278
+ |===
data/isodoc-i18n.gemspec CHANGED
@@ -20,12 +20,14 @@ Gem::Specification.new do |spec|
20
20
  f.match(%r{^(test|spec|features|bin|.github)/}) \
21
21
  || f.match(%r{Rakefile|bin/rspec})
22
22
  end
23
- spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
23
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
24
24
 
25
25
  spec.add_dependency "base64"
26
26
  spec.add_dependency "htmlentities", "~> 4.3.4"
27
+ spec.add_dependency "japanese_calendar"
27
28
  spec.add_dependency "liquid", "~> 5"
28
29
  spec.add_dependency "metanorma-utils", ">= 1.7.0"
30
+ spec.add_dependency "roman-numerals"
29
31
  spec.add_dependency "twitter_cldr"
30
32
 
31
33
  spec.add_development_dependency "canon"
data/lib/isodoc/date.rb CHANGED
@@ -1,60 +1,13 @@
1
1
  require "date"
2
+ require_relative "extended_date"
2
3
 
3
4
  module IsoDoc
4
5
  class I18n
5
6
  def date(value, format)
6
- date_i18n(DateTime.iso8601(value)
7
- .strftime(convert_date_format(format)))
8
- end
9
-
10
- def convert_date_format(fmt)
11
- fmt.gsub(/%_/, " ")
12
- .gsub(/%(\^?)([BbhPpAa])/, "%\u200c\\1\\2<%\\2>")
13
- end
14
-
15
- def date_i18n(val)
16
- day_i18n(month_i18n(am_pm_i18n(val)))
17
- end
18
-
19
- def am_pm_i18n(val)
20
- val.gsub(/%\u200cP<am>/, @cal.periods[:am].downcase)
21
- .gsub(/%\u200cP<pm>/, @cal.periods[:pm].downcase)
22
- .gsub(/%\u200cp<AM>/, @cal.periods[:am].upcase)
23
- .gsub(/%\u200cp<PM>/, @cal.periods[:pm].upcase)
24
- end
25
-
26
- def month_i18n(val)
27
- { B: :wide, b: :abbreviated, h: :abbreviated }.each do |f, t|
28
- @cal_en.calendar_data[:months][:format][t].each do |k, v|
29
- m = @cal.calendar_data[:months][:format][t][k]
30
- val.gsub!(/%\u200c#{f}<#{v}>/, m)
31
- val.gsub!(/%\u200c\^#{f}<#{v}>/, m.upcase)
32
- end
33
- end
34
- val
35
- end
36
-
37
- def day_i18n(val)
38
- { A: :wide, a: :abbreviated }.each do |f, t|
39
- @cal_en.calendar_data[:days][:format][t].each do |k, v|
40
- m = @cal.calendar_data[:days][:format][t][k]
41
- val.gsub!(/%\u200c#{f}<#{v}>/, m)
42
- val.gsub!(/%\u200c\^#{f}<#{v}>/, m.upcase)
43
- end
44
- end
45
- val
7
+ ExtendedDateFormatter.new(
8
+ lang: @lang, script: @script,
9
+ calendar: @cal, calendar_en: @cal_en
10
+ ).format(value, format)
46
11
  end
47
12
  end
48
13
  end
49
-
50
- # %B - The full month name (``January'')
51
- # %^B uppercased (``JANUARY'')
52
- # %b - The abbreviated month name (``Jan'')
53
- # %^b uppercased (``JAN'')
54
- # %h - Equivalent to %b
55
- # %P - Meridian indicator, lowercase (``am'' or ``pm'')
56
- # %p - Meridian indicator, uppercase (``AM'' or ``PM'')
57
- # %A - The full weekday name (``Sunday'')
58
- # %^A uppercased (``SUNDAY'')
59
- # %a - The abbreviated name (``Sun'')
60
- # %^a uppercased (``SUN'')
@@ -0,0 +1,227 @@
1
+ require "date"
2
+ require "twitter_cldr"
3
+
4
+ module IsoDoc
5
+ # Extended strftime-style date formatter on top of Ruby's +Date#strftime+.
6
+ #
7
+ # Adds three POSIX-flavoured surfaces:
8
+ #
9
+ # %E[YyC](?:\{ARGS\})? - era year (calendar-aware)
10
+ # %O[mdYy](?:\{ARGS\})? - alternative numbering for date components
11
+ # %_ - legacy alias for a literal space (kept for
12
+ # backwards compatibility with IsoDoc::I18n#date)
13
+ #
14
+ # The localised name directives (%B, %b, %h, %A, %a, %P, %p) are also
15
+ # routed through the formatter so they pick up CLDR locale data without
16
+ # the previous ZWNJ-marker hack in IsoDoc::I18n.
17
+ #
18
+ # ARGS is a comma-separated list of either a positional numbering-system
19
+ # name (numeric, spellout, hanidec, roman, roman-lower) or +key=value+
20
+ # pairs. The only key currently honoured is +cal=+ (japanese|gregorian);
21
+ # other CLDR calendar identifiers (roc, buddhist, persian, islamic,
22
+ # indian, hebrew) are reserved as documented extension points and raise
23
+ # NotImplementedError today.
24
+ class ExtendedDateFormatter
25
+ TOKEN_RX = /
26
+ %_ |
27
+ %\^?[BbhPpAa] |
28
+ %E[YyC](?:\{[^}]*\})? |
29
+ %O[mdYy](?:\{[^}]*\})?
30
+ /x.freeze
31
+
32
+ DAY_KEYS = %i[sun mon tue wed thu fri sat].freeze
33
+ HANIDEC_FROM = "0123456789".freeze
34
+ HANIDEC_TO = "〇一二三四五六七八九".freeze
35
+
36
+ SUPPORTED_CALENDARS = %i[gregorian japanese].freeze
37
+
38
+ def self.format(value, fmt, **opts)
39
+ new(**opts).format(value, fmt)
40
+ end
41
+
42
+ attr_reader :lang, :script
43
+
44
+ def initialize(lang:, script: nil, calendar: nil, calendar_en: nil)
45
+ @lang = lang.to_s
46
+ @script = script
47
+ @cal = calendar || twitter_cldr_calendar
48
+ @cal_en = calendar_en || TwitterCldr::Shared::Calendar.new(:en)
49
+ end
50
+
51
+ def format(value, fmt)
52
+ time = coerce(value)
53
+ tokenise(fmt).map { |kind, payload| render(time, kind, payload) }.join
54
+ end
55
+
56
+ private
57
+
58
+ def coerce(value)
59
+ case value
60
+ when DateTime, Time then value
61
+ when Date then value.to_datetime
62
+ else DateTime.iso8601(value.to_s)
63
+ end
64
+ end
65
+
66
+ def tokenise(fmt)
67
+ out = []
68
+ last = 0
69
+ fmt.to_enum(:scan, TOKEN_RX).each do
70
+ m = Regexp.last_match
71
+ out << [:strftime, fmt[last...m.begin(0)]] if m.begin(0) > last
72
+ out << [:token, m[0]]
73
+ last = m.end(0)
74
+ end
75
+ out << [:strftime, fmt[last..-1]] if last < fmt.length
76
+ out
77
+ end
78
+
79
+ def render(time, kind, payload)
80
+ case kind
81
+ when :strftime then payload.empty? ? "" : time.strftime(payload)
82
+ when :token then render_token(time, payload)
83
+ end
84
+ end
85
+
86
+ def render_token(time, tok)
87
+ return " " if tok == "%_"
88
+
89
+ case tok
90
+ when /\A%(\^?)([BbhPpAa])\z/
91
+ render_localised_name(time, Regexp.last_match(1) == "^",
92
+ Regexp.last_match(2))
93
+ when /\A%E([YyC])(?:\{([^}]*)\})?\z/
94
+ render_era(time, Regexp.last_match(1),
95
+ parse_args(Regexp.last_match(2)))
96
+ when /\A%O([mdYy])(?:\{([^}]*)\})?\z/
97
+ render_alt_num(time, Regexp.last_match(1),
98
+ parse_args(Regexp.last_match(2)))
99
+ end
100
+ end
101
+
102
+ def render_localised_name(time, upcase, letter)
103
+ day = DAY_KEYS[time.wday]
104
+ raw = case letter
105
+ when "B" then @cal.calendar_data[:months][:format][:wide][time.month]
106
+ when "b", "h"
107
+ @cal.calendar_data[:months][:format][:abbreviated][time.month]
108
+ when "A" then @cal.calendar_data[:days][:format][:wide][day]
109
+ when "a" then @cal.calendar_data[:days][:format][:abbreviated][day]
110
+ when "P" then @cal.periods[am_pm(time)].downcase
111
+ when "p" then @cal.periods[am_pm(time)].upcase
112
+ end
113
+ upcase ? raw.upcase : raw
114
+ end
115
+
116
+ def am_pm(time)
117
+ time.respond_to?(:hour) && time.hour >= 12 ? :pm : :am
118
+ end
119
+
120
+ def render_era(time, letter, args)
121
+ cal = (args[:cal] || default_calendar).to_sym
122
+ case cal
123
+ when :japanese then render_japanese_era(time, letter, args)
124
+ when :gregorian then render_gregorian_era(time, letter, args)
125
+ else
126
+ raise NotImplementedError,
127
+ "ExtendedDateFormatter: calendar #{cal.inspect} is a " \
128
+ "documented extension point but is not yet wired up. " \
129
+ "Supported: #{SUPPORTED_CALENDARS.inspect}."
130
+ end
131
+ end
132
+
133
+ def render_japanese_era(time, letter, args)
134
+ require "japanese_calendar"
135
+ d = time.is_a?(Date) ? time : Date.new(time.year, time.month, time.day)
136
+ year = format_number(d.era_year, args)
137
+ case letter
138
+ when "Y" then "#{d.strftime('%JN')}#{year}"
139
+ when "y" then year
140
+ when "C" then d.strftime("%JN")
141
+ end
142
+ rescue StandardError
143
+ case letter
144
+ when "Y", "y" then format_number(time.year, args)
145
+ when "C" then ""
146
+ end
147
+ end
148
+
149
+ def render_gregorian_era(time, letter, args)
150
+ case letter
151
+ when "Y" then format_number(time.year, args)
152
+ when "y" then format_number(time.year % 100, args)
153
+ when "C" then ""
154
+ end
155
+ end
156
+
157
+ def render_alt_num(time, letter, args)
158
+ n = case letter
159
+ when "m" then time.month
160
+ when "d" then time.day
161
+ when "Y" then time.year
162
+ when "y" then time.year % 100
163
+ end
164
+ format_number(n, args)
165
+ end
166
+
167
+ def format_number(num, args)
168
+ sys = args[:_positional] || args[:numbering] || "numeric"
169
+ case sys
170
+ when "numeric", "latn" then num.to_s
171
+ when "spellout" then spellout(num)
172
+ when "hanidec" then num.to_s.tr(HANIDEC_FROM, HANIDEC_TO)
173
+ when "roman" then roman(num)
174
+ when "roman-lower" then roman(num).downcase
175
+ else
176
+ raise ArgumentError,
177
+ "ExtendedDateFormatter: numbering system #{sys.inspect} not " \
178
+ "supported. Use one of: numeric, spellout, hanidec, roman, " \
179
+ "roman-lower."
180
+ end
181
+ end
182
+
183
+ def spellout(num)
184
+ num.to_i.localize(twitter_cldr_lang)
185
+ .to_rbnf_s("SpelloutRules", "spellout-cardinal")
186
+ end
187
+
188
+ def roman(num)
189
+ require "roman-numerals"
190
+ RomanNumerals.to_roman(num.to_i)
191
+ end
192
+
193
+ def parse_args(str)
194
+ out = {}
195
+ return out if str.nil? || str.empty?
196
+
197
+ str.split(",").each do |arg|
198
+ arg = arg.strip
199
+ if arg.include?("=")
200
+ k, v = arg.split("=", 2)
201
+ out[k.strip.to_sym] = v.strip
202
+ else
203
+ out[:_positional] ||= arg
204
+ end
205
+ end
206
+ out
207
+ end
208
+
209
+ def default_calendar
210
+ @lang == "ja" ? :japanese : :gregorian
211
+ end
212
+
213
+ def twitter_cldr_lang
214
+ case [@lang, @script]
215
+ when ["zh", "Hans"] then :"zh-cn"
216
+ when ["zh", "Hant"] then :"zh-tw"
217
+ else @lang.to_sym
218
+ end
219
+ end
220
+
221
+ def twitter_cldr_calendar
222
+ TwitterCldr::Shared::Calendar.new(twitter_cldr_lang)
223
+ rescue StandardError
224
+ TwitterCldr::Shared::Calendar.new(:en)
225
+ end
226
+ end
227
+ end
@@ -1,5 +1,5 @@
1
1
  module IsoDoc
2
2
  class I18n
3
- VERSION = "1.4.5".freeze
3
+ VERSION = "1.5.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isodoc-i18n
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.5
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 4.3.4
41
+ - !ruby/object:Gem::Dependency
42
+ name: japanese_calendar
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: liquid
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +80,20 @@ dependencies:
66
80
  - - ">="
67
81
  - !ruby/object:Gem::Version
68
82
  version: 1.7.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: roman-numerals
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: twitter_cldr
71
99
  requirement: !ruby/object:Gem::Requirement
@@ -263,10 +291,12 @@ files:
263
291
  - Gemfile
264
292
  - LICENSE
265
293
  - README.adoc
294
+ - docs/extended-date-format.adoc
266
295
  - isodoc-i18n.gemspec
267
296
  - lib/isodoc-i18n.rb
268
297
  - lib/isodoc-yaml/i18n-en.yaml
269
298
  - lib/isodoc/date.rb
299
+ - lib/isodoc/extended_date.rb
270
300
  - lib/isodoc/i18n-yaml.rb
271
301
  - lib/isodoc/i18n.rb
272
302
  - lib/isodoc/i18n/version.rb
@@ -286,7 +316,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
286
316
  requirements:
287
317
  - - ">="
288
318
  - !ruby/object:Gem::Version
289
- version: 2.7.0
319
+ version: 3.2.0
290
320
  required_rubygems_version: !ruby/object:Gem::Requirement
291
321
  requirements:
292
322
  - - ">="