Skip to content

Name placeholders with a single-key object, and keep compiled values out of the descriptor's namespace - #68

Merged
k0d13 merged 4 commits into
mainfrom
kodie/say-placeholder-names
Jul 29, 2026
Merged

Name placeholders with a single-key object, and keep compiled values out of the descriptor's namespace#68
k0d13 merged 4 commits into
mainfrom
kodie/say-placeholder-names

Conversation

@k0d13

@k0d13 k0d13 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #67. Targets main, with #66 merged in — this extends its underscore convention from JSX to plain JS.

Two gaps in the plain-JS pipeline: placeholders that cannot be named, and a value named id silently breaking the message lookup.

Naming a placeholder

An interpolated variable is already named after itself, so only the values that would otherwise be numbered need anything. Interpolate a single-key object and the key is the name:

say`Your total is ${{ cartTotal: getCartTotal() }}`; // → "Your total is {cartTotal}"

Nothing to import, and nothing survives the build — the transform reads the key and compiles only the value. It works the same in say.plural/ordinal/select selectors and in <Say> interpolations:

say.plural({ items: cart.length }, { one: '# item', other: '# items' });

<Say>Signed in as {{ who: user.profile.name }}</Say>

What is and is not claimed

Only an object written inline with exactly one non-computed key whose value is an expression. A variable holding an object, an object with two keys, a spread, a computed key, and a method are all left exactly as they were:

say`Data ${data} ${{ a: 1, b: 2 }}`; // → say.call({ id: "lnR_iw", _data: data, _0: { a: 1, b: 2 } })

This is the reason for choosing the shape: interpolating an object stringifies to [object Object] and rendering one in JSX throws, so there is no working code whose meaning changes. A function macro (ph('name', value)) was the alternative, and was rejected because it needs an import, is matched by name, and can be shadowed.

The name must be a valid identifier — a letter or underscore followed by letters, digits, or underscores — matching the rule say-tag uses. An invalid one fails the build rather than producing an unusable ICU name. Naming is opt-in per placeholder, so anything left alone keeps its number.

Recognition lives in one exported function, unwrapPlaceholder in @saykit/transform-js/parser, which @saykit/transform-jsx imports at all three sites that used to derive an identifier from an expression; its duplicate local helper is deleted rather than taught the same trick twice.

A value named id no longer breaks the lookup

generateSayCallExpression built one flat object, so a value named id emitted a duplicate key and won:

say`Hi ${id}`; // → say.call({ id: "sF5gu7", id: id })

descriptor.id became the runtime value of the variable, so messages[descriptor.id] missed and threw Message for <value> is not a string — at runtime, in whichever locale happened to be active, with no build-time signal.

Values are now compiled behind one underscore and Say#call strips exactly one back off, the same convention #66 introduced for JSX props:

say`Hi ${id} and ${name}`; // → say.call({ id: "sF5gu7", _id: id, _name: name })

Keys written without an underscore are passed through untouched, so hand-written calls and custom transformers that emit say.call({ id, ...values }) keep working — examples/custom-formatter does exactly that and is the end-to-end proof. @saykit/react stops stripping before it calls say.call and passes its props through prefixed, so there is exactly one strip in the pipeline and double-stripping is structurally impossible rather than a convention.

The generator also dedupes repeated identifiers now, so say`${a} and ${a}` emits one property instead of a duplicate key. Side benefit: _0 is a real identifier, where t.identifier('0') only worked because @babel/generator prints the name raw.

Types

say.plural(_: number, …) would reject { items: cart.length }, so the three selector parameters widened to number | Named<number> (and string | Named<string>) in both the Say class and Say.Plural/Ordinal/Select. Named<T> is a new export from saykit. This is the one cost of the syntax: those parameters are looser than they were, since TypeScript cannot express "an object with exactly one key". The build-time check is what actually validates the shape.

Breaking

  • {id} in a message is now fed by _id rather than resolving to the message's own id, which was never useful.
  • A hand-written value literally named _foo must be written __foo.
  • saykit, @saykit/transform-js, @saykit/transform-jsx and @saykit/react must be released together: a transform emitting _id against a runtime that does not strip it renders wrong output. The saykit peer range in @saykit/react is "*", so npm will not catch a mismatched pair — worth tightening separately.

Verification

  • 368 tests pass (31 new); pnpm -r check, oxlint and the website build are clean.
  • Compiled output checked directly for every case above, including the negative ones.
  • examples/vanilla and examples/react re-extract with no catalogue churn; the vanilla bundle now contains call({id:"pOt5xO",_0:Math.abs(...)}) and still builds.
  • examples/custom-formatter, which hand-writes say.call({ id, ...values }) with unprefixed keys, still renders correctly.

Duplicate names

A name belongs to a value, not a position, so the rule is the one #66 gave elements: same name, the expressions must match, or it is a build error.

say`${name} invited ${name}'s team`; // one value, one prop
say`${{ total: cart.total }} of ${{ total: cart.total }}`; // one value, one prop
say`${{ n: items.length }} ${{ n: users.length }}`; // error
say`${name} ${{ name: author.name }}`; // error

No implicit/explicit distinction is needed: an implicit name is only ever a bare identifier, so two implicit collisions are the same variable by construction, and reusing ${name} stays free. ${{ name: name }} beside ${name} is also legal, since it is the same value written two ways.

collectAssignedIdentifiers now claims value names through the same comparison it already used for tags, and isEquivalentElement is replaced by isEquivalentPlaceholder in @saykit/transform-js/parser: elements compare on their opening element alone, everything else compares whole. Both transformers pass it — transform-js passed no comparator at all before, which would have made the legal repeat an error.

One consequence worth stating: a repeat is evaluated once, so ${{ total: getTotal() }} twice calls getTotal once. Documented rather than restricted, so the rule stays one sentence.

Not in scope

Nothing outstanding.

Summary by CodeRabbit

  • New Features
    • Added named placeholders using inline single-key objects (e.g. { who: name }) across message macros and JSX.
    • Expanded plural, ordinal, and select macros to accept named numeric/string values.
  • Bug Fixes
    • Improved placeholder handling with underscore-prefixed descriptor values, including safe stripping of exactly one leading underscore.
    • Prevented collisions with message metadata and tag names; added stricter protection against invalid names and unsafe/inherited placeholder properties.
  • Documentation
    • Updated guides and references to reflect the underscore-based placeholder naming and runtime lookup behaviour.

@vercel

vercel Bot commented Jul 29, 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, Comment Jul 29, 2026 11:26am

@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc4c100

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

This PR includes changesets to release 10 packages
Name Type
@saykit/transform-js Minor
@saykit/react Minor
saykit Minor
@saykit/transform-jsx Minor
@saykit/config Minor
@saykit/carbon Minor
@saykit/format-json Minor
@saykit/format-po Minor
babel-plugin-saykit Minor
unplugin-saykit 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

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR adds named single-key placeholders, prefixes transformed interpolation values with underscores, resolves those keys at runtime, prevents id collisions, updates JavaScript/JSX/React typing and tests, and documents the behaviour with minor release metadata.

Changes

Named placeholder flow

Layer / File(s) Summary
Placeholder parsing and validation
packages/transform-js/src/parser.ts, packages/transform-jsx/src/parser.ts, packages/transform-js/src/parser.test.ts, packages/transform-jsx/src/parser.test.ts
Single-key objects provide validated placeholder names, while parser equivalence checks support repeated values.
Placeholder identifier validation
packages/config/src/features/messages/identifier.ts, packages/config/src/features/messages/identifier.test.ts, packages/transform-js/src/index.ts
Identifier assignment allows equivalent repeated values and rejects conflicting values sharing a placeholder name.
Transformer output and JSX integration
packages/transform-js/src/*, packages/transform-jsx/src/*
Generated values use underscore-prefixed keys, duplicate identifiers are consolidated, and named JSX and choice placeholders are supported.
Runtime descriptor resolution
packages/integration/src/*, packages/integration-react/src/*
Runtime formatting strips one leading underscore while preserving id; macro types accept Named values.
Documentation and release metadata
.changeset/*, website/content/**/*.mdx
Documentation describes named placeholders and underscore namespacing, with minor releases recorded.

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

Sequence Diagram(s)

sequenceDiagram
  participant SourceMacro
  participant Transformer
  participant ReactRuntime
  participant SayCall
  participant Formatter
  SourceMacro->>Transformer: interpolated values and named placeholder objects
  Transformer->>ReactRuntime: generated descriptor with underscore-prefixed values
  ReactRuntime->>SayCall: descriptor and component resolution
  SayCall->>Formatter: values after one-underscore resolution
  Formatter->>ReactRuntime: formatted message
Loading

Possibly related issues

Possibly related PRs

  • k0d13/saykit#12 — Both PRs modify transformer parser internals, including call-expression identifier and value derivation.

Suggested labels: examples

Poem

A bunny prefixes names with care,
So id stays safely in its lair.
One-key dreams become names bright,
Runtime sheds one _ in flight.
Macros hop merrily!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: single-key object placeholder naming and underscore-prefixed compiled values to avoid descriptor collisions.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kodie/say-placeholder-names

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.

@github-actions github-actions Bot added tests Modifications, additions, or fixes related to testing package: core Related to the core saykit package package: react Related to @saykit/react website Updates to the documentation website package: transform-js Related to @saykit/transform-js package: transform-jsx Related to @saykit/transform-jsx labels Jul 29, 2026
@k0d13

k0d13 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 2

🤖 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/runtime.ts`:
- Around line 12-19: Update resolveDescriptorValues to iterate only over
descriptor’s own entries and construct the result with a prototype-safe approach
such as Object.fromEntries. Preserve the existing id exclusion and
underscore-remapping behavior, while ensuring a __proto__ descriptor key remains
a normal placeholder value rather than mutating the result prototype.

In `@website/content/core-concepts/messages.mdx`:
- Around line 84-88: Update the Callout text to remove the claim that
interpolating objects never changes meaning. State that inline single-key
objects now change from object-value formatting to placeholder naming, while
objects held in variables and unsupported forms such as multiple keys, spreads,
or computed keys retain their previous behavior.
🪄 Autofix (Beta)

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: cebe025b-be47-46bf-8372-09647b629b05

📥 Commits

Reviewing files that changed from the base of the PR and between d0606d1 and 90296f6.

📒 Files selected for processing (21)
  • .changeset/brave-moons-repeat.md
  • .changeset/olive-hounds-invent.md
  • packages/integration-react/src/runtime/index.test.ts
  • packages/integration-react/src/runtime/index.ts
  • packages/integration/src/runtime.test.ts
  • packages/integration/src/runtime.ts
  • packages/integration/src/types.ts
  • packages/transform-js/src/generator.test.ts
  • packages/transform-js/src/generator.ts
  • packages/transform-js/src/index.test.ts
  • packages/transform-js/src/parser.test.ts
  • packages/transform-js/src/parser.ts
  • packages/transform-jsx/src/index.test.ts
  • packages/transform-jsx/src/parser.ts
  • website/content/core-concepts/architecture.mdx
  • website/content/core-concepts/messages.mdx
  • website/content/core-concepts/runtime.mdx
  • website/content/guides/custom-transformer.mdx
  • website/content/integrations/react.mdx
  • website/content/integrations/vite.mdx
  • website/content/reference/api/saykit.mdx

Comment thread packages/integration/src/runtime.ts Outdated
Comment thread website/content/core-concepts/messages.mdx Outdated
@github-actions github-actions Bot added the package: config Related to @saykit/config and the CLI label Jul 29, 2026
@apteryxxyz

Copy link
Copy Markdown

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@apteryxxyz

Copy link
Copy Markdown

wrong acc

@k0d13

k0d13 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

wait, can i use alt accs to bypass coderabbit 1 per hour

@k0d13
k0d13 changed the base branch from kodie/jsx-placeholder-labels to main July 29, 2026 11:17

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
website/content/core-concepts/messages.mdx (1)

289-289: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the comma before “so”.

Use “what the element does, so you can name one” for correct grammar.

🤖 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 `@website/content/core-concepts/messages.mdx` at line 289, Update the sentence
in the messages documentation to remove the comma before “so,” using the
phrasing “what the element does, so you can name one” while preserving the
surrounding explanation and link.

Source: Linters/SAST tools

🤖 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.

Outside diff comments:
In `@website/content/core-concepts/messages.mdx`:
- Line 289: Update the sentence in the messages documentation to remove the
comma before “so,” using the phrasing “what the element does, so you can name
one” while preserving the surrounding explanation and link.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f150e00-00f3-4ee7-9274-49bf84d3b3cf

📥 Commits

Reviewing files that changed from the base of the PR and between 06bee33 and bc4c100.

📒 Files selected for processing (2)
  • packages/integration-react/src/runtime/index.test.ts
  • website/content/core-concepts/messages.mdx

@k0d13
k0d13 merged commit 93a52ce into main Jul 29, 2026
10 checks passed
@k0d13
k0d13 deleted the kodie/say-placeholder-names branch July 29, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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 package: transform-jsx Related to @saykit/transform-jsx 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.

Plain say templates need placeholder naming, and an argument named id breaks the descriptor

2 participants