Skip to content

Accept ICU skeletons as an argument style - #90

Merged
k0d13 merged 2 commits into
mainfrom
kodie/icu-skeletons
Aug 5, 2026
Merged

Accept ICU skeletons as an argument style#90
k0d13 merged 2 commits into
mainfrom
kodie/icu-skeletons

Conversation

@k0d13

@k0d13 k0d13 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

@messageformat/icu-messageformat-1 renders an argument's style into MF2's own option vocabulary, and that vocabulary tops out at { length, fields } for a date. {d, date, ::yyyyMMdd} has nowhere to land, so it survives only as an unrecognised mf1:argStyle that the formatter reports as a bad option.

This replaces that package with an in-tree conversion so a style can skip the vocabulary. Both skeleton parsers already emit Intl option bags, so a style now resolves to one when the message is compiled, rides through the message data whole, and goes straight to Intl at format time — ::yyyyMMdd and ::currency/EUR arrive by the same route as short and integer, and cost the same.

say`Total ${say.number(total, { style: '::currency/EUR' })}`;  // → "Total €1,234.50"
say`${say.number(views, { style: '::compact-short' })} views`; // → "12K views"
say`Since ${say.date(joined, { style: '::yMMMM' })}`;          // → "Since January 2020"

Layout

packages/integration/src/messageformat/, split along the pipeline:

file role
styles.ts argument style → Intl options — the part upstream cannot do
options.ts the seam those options travel through
convert.ts MF1 tree → MF2 message (select-flattening, adapted from upstream, Apache-2.0)
values.ts / functions.ts what the formatter runs

Validated at build time

validateArgumentStyle now accepts skeletons and checks them by resolving them with the same parsers the runtime formats with, so ::qqqq fails with a file and a line rather than reaching a reader. An unresolvable style still falls back to the bare formatter at runtime — a message authored once and read by everyone is better slightly wrong than broken.

Two bugs fixed along the way

Both found while covering the new folder:

  • toParts dropped dir, losing bidi information for formatToParts consumers.
  • Selecting one argument twice at different offsets crashed with duplicate-declaration, since both selectors declared the same name. Confirmed upstream throws identically, so not a regression — later selectors now get a generated name and a local declaration, and a plural reports the number the message was given rather than the already-offset one.

Notes

  • No breaking changes; every existing style keeps its behaviour.
  • A skeleton's bare ::percent writes the sign but does not scale (0.250.25%). The named percent style scales too, which is ::percent scale/100. That is ICU's semantics; both are tested.
  • packages/integration/src/messageformat is at 100% statements, branches, and functions. Three genuinely unreachable guards carry /* v8 ignore */ with the reason rather than a contorted test.
  • Docs corrected — they previously stated skeletons were unsupported and currency was impossible.

630 tests pass; build, lint, and typecheck clean across all packages.

Summary by CodeRabbit

  • New Features

    • Added support for ICU skeletons when formatting numbers, dates and times.
    • Added currency, percentage scaling, duration, plural and ordinal formatting capabilities.
    • Improved compatibility with ICU MessageFormat messages, including selectors and plural offsets.
    • Added graceful fallbacks for unsupported or invalid formatting styles.
  • Documentation

    • Updated API and integration guidance with skeleton syntax and currency examples.
    • Added validation guidance and examples for supported formatting styles.
  • Bug Fixes

    • Improved handling of formatted values, parts, dates, durations and invalid inputs.

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 44fb239

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
saykit Minor
@saykit/config Minor
@saykit/carbon Minor
@saykit/react Minor
@saykit/format-json Minor
@saykit/format-po Minor
babel-plugin-saykit Minor
unplugin-saykit Minor
@saykit/transform-js Minor
@saykit/transform-jsx Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
saykit Ready Ready Preview Aug 5, 2026 5:13am

@github-actions github-actions Bot added dependencies Updates or changes related to project dependencies tests Modifications, additions, or fixes related to testing package: core Related to the core saykit package package: react Related to @saykit/react package: config Related to @saykit/config and the CLI website Updates to the documentation website package: transform-js Related to @saykit/transform-js labels Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@k0d13, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f97cfc13-2879-4de7-a751-c9d602f3097c

📥 Commits

Reviewing files that changed from the base of the PR and between e876c1e and 44fb239.

📒 Files selected for processing (8)
  • packages/config/src/features/messages/format.test.ts
  • packages/integration/src/messageformat/convert.ts
  • packages/integration/src/messageformat/functions.ts
  • packages/integration/src/messageformat/index.test.ts
  • packages/integration/src/messageformat/styles.ts
  • packages/integration/src/messageformat/values.ts
  • website/content/core-concepts/messages.mdx
  • website/content/integrations/react.mdx

Walkthrough

The PR adds ICU skeleton validation, MessageFormat 1-to-2 conversion, locale-aware runtime handlers, skeleton-aware types, tests, and documentation. It also replaces the previous ICU formatter integration with the local compiler.

Changes

ICU skeleton formatting

Layer / File(s) Summary
Skeleton style validation
packages/config/..., .changeset/tidy-pugs-tickle.md
Configuration validates number, date, and time skeletons. Tests cover valid, invalid, empty, and unsupported skeletons.
Formatting contracts and value resolution
packages/integration/src/messageformat/options.ts, styles.ts, values.ts, types.ts
The integration defines skeleton-aware styles and types, option handling, numeric and temporal coercion, formatted values, and duration formatting.
MessageFormat 1 compiler
packages/integration/src/messageformat/convert.ts, index.ts, index.test.ts, packages/integration/package.json
The integration parses MessageFormat 1 messages, converts selectors and formatters to MessageFormat 2, and tests compilation and value behaviour.
Runtime handler integration
packages/integration/src/messageformat/functions.ts, packages/integration/src/runtime.ts, packages/integration/src/runtime.test.ts
The runtime uses the local compiler and resolves number, datetime, duration, plural, ordinal, and string handlers.
Public API and documentation coverage
packages/integration-react/src/runtime/index.ts, packages/transform-js/src/parser.test.ts, website/content/...
Public examples, API types, parser tests, and website documentation describe ICU skeleton support and currency formatting.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SourceMessage
  participant MessageFormatParser
  participant toMessage
  participant functions
  participant MessageFormat
  SourceMessage->>MessageFormatParser: parse MessageFormat 1 source
  MessageFormatParser->>toMessage: provide parsed tokens
  toMessage->>MessageFormat: create MessageFormat 2 message
  MessageFormat->>functions: resolve formatting handler
  functions-->>MessageFormat: return formatted value
Loading

Possibly related PRs

  • k0d13/saykit#82: Adds the earlier ICU argument-style support extended by this PR.

Poem

“Skeletons bloom in patterns bright,
Currency glints in locale light.
Plurals turn and dates align,
The compiler makes each message shine.
Hop, hop, tests all pass today!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: support for ICU skeletons as argument styles.
Docstring Coverage ✅ Passed Docstring coverage is 94.74% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kodie/icu-skeletons

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the upstream MF1 converter with an in-tree conversion pipeline so ICU number and date skeletons can travel as resolved Intl options.

  • Adds build-time skeleton validation and runtime style resolution.
  • Reimplements MF1 selector flattening and formatter value wrappers.
  • Adds skeleton support to public types, React APIs, tests, and documentation.
  • Preserves bidi direction in formatted parts and supports repeated selectors with different offsets.

Confidence Score: 4/5

The partial-skeleton fallback failure and invalid rounded duration output should be fixed before merging.

Partially invalid date skeletons silently retain recognized fields instead of falling back, while duration values near a minute boundary can render an unnormalized 60-second component.

Files Needing Attention: packages/integration/src/messageformat/styles.ts, packages/integration/src/messageformat/values.ts

Prompt To Fix All With AI
### Issue 1
packages/integration/src/messageformat/styles.ts:59-63
**Partial skeleton errors are ignored**

When a date or time skeleton contains both supported and unsupported fields, `getDateTimeFormatOptions` records an error but returns a non-empty option bag, so the unsupported fields are silently discarded instead of activating the documented default-format fallback.

```suggestion
    const opt = getDateTimeFormatOptions(tokens, (_type, message) => errors.push(message));
    if (errors.length > 0) throw new StyleError(errors[0]!);
    // A skeleton that yielded nothing usable is a typo, not a format. Falling
    // back to the locale default here would silently show the wrong fields.
    if (Object.keys(opt).length === 0) throw new StyleError(`Empty skeleton ${style}`);
    return opt;
```

### Issue 2
packages/integration/src/messageformat/values.ts:138-147
**Rounded seconds do not carry**

When fractional seconds round to `60.000`, the duration formatter emits that value unchanged instead of carrying it into the minutes, causing invalid output such as `0:60.000` or `59:60.000`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Accept ICU skeletons as an argument styl..." | Re-trigger Greptile

Comment on lines +59 to +63
const opt = getDateTimeFormatOptions(tokens, (_type, message) => errors.push(message));
// A skeleton that yielded nothing usable is a typo, not a format. Falling
// back to the locale default here would silently show the wrong fields.
if (Object.keys(opt).length === 0) throw new StyleError(errors[0] ?? `Empty skeleton ${style}`);
return opt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial skeleton errors are ignored

When a date or time skeleton contains both supported and unsupported fields, getDateTimeFormatOptions records an error but returns a non-empty option bag, so the unsupported fields are silently discarded instead of activating the documented default-format fallback.

Suggested change
const opt = getDateTimeFormatOptions(tokens, (_type, message) => errors.push(message));
// A skeleton that yielded nothing usable is a typo, not a format. Falling
// back to the locale default here would silently show the wrong fields.
if (Object.keys(opt).length === 0) throw new StyleError(errors[0] ?? `Empty skeleton ${style}`);
return opt;
const opt = getDateTimeFormatOptions(tokens, (_type, message) => errors.push(message));
if (errors.length > 0) throw new StyleError(errors[0]!);
// A skeleton that yielded nothing usable is a typo, not a format. Falling
// back to the locale default here would silently show the wrong fields.
if (Object.keys(opt).length === 0) throw new StyleError(`Empty skeleton ${style}`);
return opt;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/integration/src/messageformat/styles.ts
Line: 59-63

Comment:
**Partial skeleton errors are ignored**

When a date or time skeleton contains both supported and unsupported fields, `getDateTimeFormatOptions` records an error but returns a non-empty option bag, so the unsupported fields are silently discarded instead of activating the documented default-format fallback.

```suggestion
    const opt = getDateTimeFormatOptions(tokens, (_type, message) => errors.push(message));
    if (errors.length > 0) throw new StyleError(errors[0]!);
    // A skeleton that yielded nothing usable is a typo, not a format. Falling
    // back to the locale default here would silently show the wrong fields.
    if (Object.keys(opt).length === 0) throw new StyleError(`Empty skeleton ${style}`);
    return opt;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 44fb239.

Verified it directly: ::yMMMdqqqq records The field q is not supported by Intl.DateTimeFormat and still returns {year, month, day}, so the quarter was being dropped in silence. Same for a literal part, ::yMMMd'x'.

Taking it for a second reason as well: validateArgumentStyle in @saykit/config already rejected on any error, so the build and the runtime disagreed about what a valid skeleton was. A hand-edited catalogue never passes through the build check, and the runtime leniency was the only guard there.

Now a skeleton fails whole, which routes it to the documented default-format fallback. I kept the empty-skeleton case as a separate check so :: still reports something readable rather than an empty message, and added tests both here and in the config suite so the two stay in step.

Comment on lines +138 to +147
const secs = value % 60;
const parts: (string | number)[] = [Math.round(secs) === secs ? secs : secs.toFixed(3)];
if (value < 60) {
// One `:` is always written, so a sub-minute duration still reads as a
// duration rather than as a bare number of seconds.
parts.unshift(0);
} else {
value = Math.round((value - Number(parts[0])) / 60);
parts.unshift(value % 60);
if (value >= 60) parts.unshift(Math.round((value - Number(parts[0])) / 60));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rounded seconds do not carry

When fractional seconds round to 60.000, the duration formatter emits that value unchanged instead of carrying it into the minutes, causing invalid output such as 0:60.000 or 59:60.000.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/integration/src/messageformat/values.ts
Line: 138-147

Comment:
**Rounded seconds do not carry**

When fractional seconds round to `60.000`, the duration formatter emits that value unchanged instead of carrying it into the minutes, causing invalid output such as `0:60.000` or `59:60.000`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 44fb239.

Reproduced all three cases you predicted:

59.9999   -> 0:60.000
119.9999  -> 1:60.000
3599.9999 -> 59:60.000

The cause was rounding the seconds field in place, after the value had already been split, so a field that rounded up to a whole minute had nowhere to carry to. (Inherited from upstream, which has the same defect.)

Fixed by rounding to the millisecond before splitting, then decomposing from the rounded total, which also let the minute/hour arithmetic collapse into plain Math.floor rather than the running subtraction it was doing. 59.9999 now gives 1:00, 3599.9999 gives 1:00:00, and 59.5 still gives 0:59.500. Tests added for the carry cases.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/integration/src/messageformat/convert.ts (1)

200-206: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Make the key comparator consistent.

The comparator is asymmetric. For two exact numbers, compare(a, b) and compare(b, a) both return -1, because the first condition matches in each direction. Array.prototype.sort requires compare(a, b) and compare(b, a) to have opposite signs.

The current output is still correct, because exact keys never compete for the same value, and the number-before-category and other-last pairs both resolve correctly. The risk is future change: any rule that depends on the order among exact keys will read an arbitrary order.

A rank function makes the intended order explicit and total.

♻️ Proposed refactor
 function sortKeys(keys: (string | number)[]) {
-  return Array.from(new Set(keys)).sort((a, b) => {
-    if (typeof a === 'number' || b === 'other') return -1;
-    if (typeof b === 'number' || a === 'other') return 1;
-    return 0;
-  });
+  // Exact numbers first, then CLDR categories, then `other`.
+  const rank = (key: string | number) => (typeof key === 'number' ? 0 : key === 'other' ? 2 : 1);
+  return Array.from(new Set(keys)).sort((a, b) => rank(a) - rank(b));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integration/src/messageformat/convert.ts` around lines 200 - 206,
Update sortKeys by introducing a rank-based ordering for each key category, then
compare the ranks and return 0 for keys with equal rank. Preserve the intended
order of numeric keys before category keys and “other” keys last, while ensuring
exact-key comparisons are antisymmetric and deterministic.
packages/integration/src/messageformat/functions.ts (1)

53-62: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Build Intl.PluralRules once per placeholder, not once per selection.

selectKey constructs a new Intl.PluralRules on every call. A plural selector runs on every format of the message, so this repeats for each call. The inputs are fixed for the placeholder: ctx.locales, ctx.localeMatcher, and ordinal.

values.ts already memoises its Intl formatters, and states that constructing one is the expensive half of formatting. Apply the same treatment here.

♻️ Proposed refactor
   'say:plural': (ctx, opt, operand) => {
     const { offset = 0, ordinal = false } = options<PluralOptions>(opt.options);
     const value = numeric(operand);
     const shifted = typeof value === 'bigint' ? value - BigInt(offset) : value - offset;
 
     const result = number(ctx.locales as string[], shifted, {});
+    let rules: Intl.PluralRules | undefined;
     // The offset number is what `#` prints, but the original is what the value
     // *is* — so a second selector reading this one offsets from the number the
     // message was given rather than from an already-shifted one.
     result.valueOf = () => value;
     result.selectKey = (keys) => {
       const exact = String(value);
       if (keys.has(exact)) return exact;
       // `Intl.PluralRules` takes a number, never a bigint.
-      const category = new Intl.PluralRules(ctx.locales as string[], {
-        localeMatcher: ctx.localeMatcher,
-        type: ordinal ? 'ordinal' : 'cardinal',
-      }).select(Number(shifted));
+      rules ??= new Intl.PluralRules(ctx.locales as string[], {
+        localeMatcher: ctx.localeMatcher,
+        type: ordinal ? 'ordinal' : 'cardinal',
+      });
+      const category = rules.select(Number(shifted));
       return keys.has(category) ? category : null;
     };
     return result;
   },

The handler itself still runs per format call, so this removes the repeat within a single format rather than across formats. To cache across formats, key the rules by locale and ordinal in a module-level Map.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integration/src/messageformat/functions.ts` around lines 53 - 62,
Move Intl.PluralRules construction out of the per-call selectKey handler and
create one instance when the placeholder setup runs, reusing it inside
result.selectKey with the fixed ctx.locales, ctx.localeMatcher, and ordinal
options. Preserve the existing plural selection and bigint-to-number conversion,
and do not add module-level caching unless the surrounding implementation
already provides an appropriate cache.
packages/integration/package.json (1)

44-47: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the prerelease skeleton dependency decision.

@messageformat/date-skeleton and @messageformat/number-skeleton are pinned to the next prerelease 2.0.0-0, but no stable 2.x is available. If this prerelease API is intentional for the published integration package, add a short // or @deprecated reason / comment explaining why stable support is not used.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integration/package.json` around lines 44 - 47, Document the
intentional prerelease choice beside the `@messageformat/date-skeleton` and
`@messageformat/number-skeleton` dependencies in package.json, explaining that
stable 2.x support is unavailable and the 2.0.0-0 API is required for the
published integration package.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/integration/src/messageformat/index.test.ts`:
- Around line 47-51: Rename the test case around numeric(undefined) so its
description states that a missing number produces NaN rather than zero or an
error. Keep the existing assertion and implementation unchanged.

In `@packages/integration/src/messageformat/styles.ts`:
- Around line 66-69: In the named-style lookup within the date/time style
handling, replace the inherited-property check using `style in named` with an
own-property check such as `Object.hasOwn`. Preserve the existing empty-style
`named.medium` fallback and `StyleError` behavior for unsupported styles,
limiting successful lookups to the catalogue’s four named styles.

In `@website/content/core-concepts/messages.mdx`:
- Around line 436-439: Replace the uncopyable single-space inline example in the
whitespace documentation with a compilable JSX expression such as the supported
preserved-space form, and update the conflicting guidance in the React
integration documentation to match the actual extraction behavior for literal
string expressions.
- Around line 301-315: The Skeletons section should distinguish three cases: use
a genuinely unsupported skeleton to demonstrate build-time resolution failure,
use “meduim” for malformed style syntax, and use a dynamic style value to
demonstrate runtime fallback. Update the surrounding explanation and examples
accordingly, making clear that “qqqq” is valid ICU skeleton syntax even though
“::qqqq” cannot be resolved.

In `@website/content/reference/api/saykit.mdx`:
- Around line 215-221: Update the documentation surrounding the say.plural
example to state that single-key object wrappers name and interpolate their
contained value, while object variables and inline objects with multiple keys,
spreads, or computed keys retain value-based behavior; clarify that other object
expressions do not create named placeholders.

---

Nitpick comments:
In `@packages/integration/package.json`:
- Around line 44-47: Document the intentional prerelease choice beside the
`@messageformat/date-skeleton` and `@messageformat/number-skeleton` dependencies in
package.json, explaining that stable 2.x support is unavailable and the 2.0.0-0
API is required for the published integration package.

In `@packages/integration/src/messageformat/convert.ts`:
- Around line 200-206: Update sortKeys by introducing a rank-based ordering for
each key category, then compare the ranks and return 0 for keys with equal rank.
Preserve the intended order of numeric keys before category keys and “other”
keys last, while ensuring exact-key comparisons are antisymmetric and
deterministic.

In `@packages/integration/src/messageformat/functions.ts`:
- Around line 53-62: Move Intl.PluralRules construction out of the per-call
selectKey handler and create one instance when the placeholder setup runs,
reusing it inside result.selectKey with the fixed ctx.locales,
ctx.localeMatcher, and ordinal options. Preserve the existing plural selection
and bigint-to-number conversion, and do not add module-level caching unless the
surrounding implementation already provides an appropriate cache.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: db622c0d-e776-4a52-9357-d43561024e52

📥 Commits

Reviewing files that changed from the base of the PR and between 6d1dbe7 and e876c1e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • .changeset/tidy-pugs-tickle.md
  • packages/config/package.json
  • packages/config/src/features/messages/format.test.ts
  • packages/config/src/features/messages/format.ts
  • packages/integration-react/src/runtime/index.ts
  • packages/integration/package.json
  • packages/integration/src/messageformat/convert.ts
  • packages/integration/src/messageformat/functions.ts
  • packages/integration/src/messageformat/index.test.ts
  • packages/integration/src/messageformat/index.ts
  • packages/integration/src/messageformat/options.ts
  • packages/integration/src/messageformat/styles.ts
  • packages/integration/src/messageformat/values.ts
  • packages/integration/src/runtime.test.ts
  • packages/integration/src/runtime.ts
  • packages/integration/src/types.ts
  • packages/transform-js/src/parser.test.ts
  • website/content/core-concepts/messages.mdx
  • website/content/integrations/react.mdx
  • website/content/reference/api/saykit.mdx

Comment thread packages/integration/src/messageformat/index.test.ts
Comment thread packages/integration/src/messageformat/styles.ts
Comment thread website/content/core-concepts/messages.mdx Outdated
Comment thread website/content/core-concepts/messages.mdx Outdated
Comment thread website/content/reference/api/saykit.mdx
@k0d13

k0d13 commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Thanks both — pushed 44fb239.

Fixed (both P1s were real, and I reproduced each before changing anything):

  • Partial skeleton errors ignored (@greptile-apps) — ::yMMMdqqqq was dropping the quarter and rendering the rest. It also meant the runtime and validateArgumentStyle disagreed about what a valid skeleton is, and a hand-edited catalogue never passes the build check. A skeleton now fails whole and falls back.
  • Rounded seconds did not carry (@greptile-apps) — duration(59.9999) gave 0:60.000. Now rounds to the millisecond before splitting the value, which also simplified the minute/hour arithmetic. Inherited from upstream, which still has it.
  • style in named matched inherited keys (@coderabbitai) — switched to Object.hasOwn, matching what isArgumentType already does.
  • ::qqqq described as a typo (@coderabbitai) — it is valid ICU that Intl cannot show. Docs reworded.
  • Broken {' '} inline code span (@coderabbitai) — my own pnpm format churn, restored.
  • Test name said "zero", asserted NaN (@coderabbitai) — renamed.
  • Both nitpicks taken: sortKeys now ranks rather than comparing pairwise (the old comparator answered -1 in both directions for two exact keys, which is not an ordering), and Intl.PluralRules is built once per placeholder rather than per selection, matching how values.ts memoises its formatters.

Skipped, with reasons in the threads: the single-key-object boundary in saykit.mdx (pre-existing content, in the diff only because the formatter rewrapped it, and already documented in the page it links to), and splitting the Skeletons section three ways (meduim is a named style covered elsewhere, and a dynamic style is not expressible since it is baked in at build time).

One extra: chasing the whitespace comment turned up a genuine contradiction between the two docs pages. The JSX parser reads a literal string child as the text it renders as — there is a passing test on main for <Say>Hello,{' '}world</Say> — so react.mdx warning people off {' '} because it "extracts as a placeholder" was stale. Corrected.

638 tests pass, messageformat still at 100% statements/branches/functions, build and typecheck clean.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@k0d13 Thanks for the detailed update. The fixes address the reported issues, and the documented rationale for the skipped items is clear. The added runtime/build-time skeleton consistency and duration carry coverage are especially useful.

🐇 ✅

You are interacting with an AI system.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown

All six fixes verified against the diff — each one is exactly right:

  • Fail-whole skeleton: errors.length > 0 throws before any options are returned, so ::yMMMdqqqq falls back entirely rather than silently dropping qqqq. Build-time and runtime now agree on what is valid.
  • Duration carry: rounding to milliseconds on total before the % 60 split is the correct place — the old code rounded the already-split seconds field, which could never affect the minutes.
  • Object.hasOwn: consistent with isArgumentType, and the test on toString/constructor as style names confirms the guard works.
  • sortKeys rank: replacing pairwise comparison with a rank function gives a total order — no more -1 in both directions for two exact keys.
  • PluralRules lazy init: let rules: Intl.PluralRules | undefined outside selectKey, initialised on first call, matches how the Intl formatters in values.ts are memoised.
  • dir in toParts: the part() helper in values.ts now conditionally includes dir for ltr/rtl, and the test asserts part.dir === 'ltr' directly.

The test coverage for the duration carry cases (59.9999 → 1:00, 119.9999 → 2:00, 3599.9999 → 1:00:00) is particularly thorough — all three overflow points are covered. The :: empty skeleton test and the inherited-property style tests are clean additions too.

This is in good shape. Happy to approve.

@k0d13 k0d13 added the preview Publish a preview build and link it from the pull request label Aug 5, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@saykit/config

npm i https://pkg.pr.new/@saykit/config@90

@saykit/format-json

npm i https://pkg.pr.new/@saykit/format-json@90

@saykit/format-po

npm i https://pkg.pr.new/@saykit/format-po@90

saykit

npm i https://pkg.pr.new/saykit@90

@saykit/carbon

npm i https://pkg.pr.new/@saykit/carbon@90

@saykit/react

npm i https://pkg.pr.new/@saykit/react@90

babel-plugin-saykit

npm i https://pkg.pr.new/babel-plugin-saykit@90

unplugin-saykit

npm i https://pkg.pr.new/unplugin-saykit@90

@saykit/transform-js

npm i https://pkg.pr.new/@saykit/transform-js@90

@saykit/transform-jsx

npm i https://pkg.pr.new/@saykit/transform-jsx@90

commit: 44fb239

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The preview build of 44fb239 is published. Open it in the playground to run this pull request against your own code, straight from the browser.

@github-actions github-actions Bot removed the preview Publish a preview build and link it from the pull request label Aug 5, 2026
@k0d13
k0d13 merged commit ded24e4 into main Aug 5, 2026
47 checks passed
@k0d13
k0d13 deleted the kodie/icu-skeletons branch August 5, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Updates or changes related to project dependencies package: config Related to @saykit/config and the CLI package: core Related to the core saykit package package: react Related to @saykit/react package: transform-js Related to @saykit/transform-js tests Modifications, additions, or fixes related to testing website Updates to the documentation website

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant