Skip to content

Commit e00a21c

Browse files
authored
Merge pull request #66 from k0d13/kodie/jsx-placeholder-labels
Add say-tag for naming JSX placeholders
2 parents 9eb4387 + f67aed7 commit e00a21c

19 files changed

Lines changed: 505 additions & 72 deletions

File tree

.changeset/lucky-cases-shake.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@saykit/transform-jsx": minor
3+
"@saykit/config": minor
4+
"@saykit/react": minor
5+
---
6+
7+
Add `say-tag` to name JSX placeholders, extract childless elements as self-closing tags, and compile message values behind an underscore so no name is reserved

examples/react/src/components/task-card.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ export function TaskCard({ task }: { task: Task }) {
3131

3232
<p className="card__meta">
3333
{/*
34-
A JSX child inside `<Say>` is extracted as a positional `<0>` tag, so a
35-
translator can move the emphasised name anywhere in the sentence
36-
without touching markup. The element itself never enters the catalogue.
34+
A JSX child inside `<Say>` is extracted as a tag, so a translator can
35+
move the emphasised name anywhere in the sentence without touching
36+
markup. The element itself never enters the catalogue. `say-tag`
37+
names that tag `<bold>` instead of a positional `<0>` — describe what
38+
the tag does, not what it wraps. The attribute is stripped at build
39+
time and never reaches the DOM.
3740
*/}
3841
{task.assignee ? (
3942
<Say>
40-
Assigned to <strong>{task.assignee}</strong>
43+
Assigned to <strong say-tag="bold">{task.assignee}</strong>
4144
</Say>
4245
) : (
4346
<Say>Nobody is on this yet</Say>

examples/react/src/locales/en.po

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ msgstr ""
1212
"Plural-Forms: \n"
1313
"X-Generator: saykit\n"
1414

15-
#: src/components/task-card.tsx:53
15+
#: src/components/task-card.tsx:56
1616
msgid ""
1717
"{0, plural,\n"
1818
" =0 {All subtasks done}\n"
@@ -38,7 +38,7 @@ msgstr ""
3838
" other {# days overdue}\n"
3939
"}"
4040

41-
#: src/components/task-card.tsx:63
41+
#: src/components/task-card.tsx:66
4242
msgid ""
4343
"{0, plural,\n"
4444
" one {# unresolved comment}\n"
@@ -108,9 +108,9 @@ msgstr ""
108108
" other {# tasks still open}\n"
109109
"}"
110110

111-
#: src/components/task-card.tsx:39
112-
msgid "Assigned to <0>{1}</0>"
113-
msgstr "Assigned to <0>{1}</0>"
111+
#: src/components/task-card.tsx:42
112+
msgid "Assigned to <bold>{0}</bold>"
113+
msgstr "Assigned to <bold>{0}</bold>"
114114

115115
#: src/components/task-card.tsx:9
116116
msgid "Due today"
@@ -120,7 +120,7 @@ msgstr "Due today"
120120
msgid "Language"
121121
msgstr "Language"
122122

123-
#: src/components/task-card.tsx:43
123+
#: src/components/task-card.tsx:46
124124
msgid "Nobody is on this yet"
125125
msgstr "Nobody is on this yet"
126126

packages/config/src/features/messages/convert.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ describe('convertMessageToIcu', () => {
2929
.toMatchInlineSnapshot('"<0>Hello world!</0>"');
3030
});
3131

32+
it('should generate childless element messages as self-closing', () => {
33+
const message = new ElementMessage('icon', [], dummy);
34+
expect(convertMessageToIcu(message)) //
35+
.toMatchInlineSnapshot('"<icon/>"');
36+
});
37+
38+
it('should keep element messages paired when a child renders to nothing', () => {
39+
const message = new ElementMessage(
40+
'bold',
41+
[new CompositeMessage({}, [], [], [], dummy)],
42+
dummy,
43+
);
44+
expect(convertMessageToIcu(message)) //
45+
.toMatchInlineSnapshot('"<bold></bold>"');
46+
});
47+
3248
it('should generate choice messages with numeric identifiers as `=n`', () => {
3349
const message = new ChoiceMessage(
3450
'plural',

packages/config/src/features/messages/convert.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export function convertMessageToIcu(message: Message) {
1717
return `{${String(message.identifier)}}`;
1818

1919
case message instanceof ElementMessage: {
20+
// A childless element is self-closing, so a translator has nowhere to
21+
// insert content that the element never expected. This tracks the
22+
// source: an element written as a pair stays a pair, even when its
23+
// children happen to render to nothing.
24+
if (message.children.length === 0) return `<${String(message.identifier)}/>`;
2025
const children = message.children.map((m) => internalConvertMessageToIcu(m)).join('');
2126
return `<${String(message.identifier)}>${children}</${String(message.identifier)}>`;
2227
}

packages/config/src/features/messages/identifier.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,77 @@ describe('assignSequenceIdentifiers', () => {
6464
expect(value.identifier).toBe('0');
6565
});
6666

67+
it('skips sequence numbers already taken by an explicit identifier', () => {
68+
const tagged = new ElementMessage('0', [], null);
69+
const auto = new ArgumentMessage(AUTO_INCREMENT_IDENTIFIER, null);
70+
assignSequenceIdentifiers(new CompositeMessage({}, [], [], [tagged, auto], null));
71+
expect(tagged.identifier).toBe('0');
72+
expect(auto.identifier).toBe('1');
73+
});
74+
75+
it('throws when two elements share a tag', () => {
76+
const message = new CompositeMessage(
77+
{},
78+
[],
79+
[],
80+
[new ElementMessage('link', [], null), new ElementMessage('link', [], null)],
81+
null,
82+
);
83+
expect(() => assignSequenceIdentifiers(message)).toThrow(
84+
"Duplicate element tag 'link', give each element in a message its own tag unless they are identical",
85+
);
86+
});
87+
88+
it('allows two elements to share a tag when they are equivalent', () => {
89+
const message = new CompositeMessage(
90+
{},
91+
[],
92+
[],
93+
[new ElementMessage('link', [], 'a'), new ElementMessage('link', [], 'a')],
94+
null,
95+
);
96+
expect(() =>
97+
assignSequenceIdentifiers(message, { current: 0 }, (a, b) => a === b),
98+
).not.toThrow();
99+
});
100+
101+
it('throws when elements sharing a tag are not equivalent', () => {
102+
const message = new CompositeMessage(
103+
{},
104+
[],
105+
[],
106+
[new ElementMessage('link', [], 'a'), new ElementMessage('link', [], 'b')],
107+
null,
108+
);
109+
expect(() => assignSequenceIdentifiers(message, { current: 0 }, (a, b) => a === b)).toThrow(
110+
"Duplicate element tag 'link'",
111+
);
112+
});
113+
114+
it('allows two arguments to share an identifier', () => {
115+
const message = new CompositeMessage(
116+
{},
117+
[],
118+
[],
119+
[new ArgumentMessage('total', null), new ArgumentMessage('total', null)],
120+
null,
121+
);
122+
expect(() => assignSequenceIdentifiers(message)).not.toThrow();
123+
});
124+
125+
it('throws when an element tag collides with an argument', () => {
126+
const message = new CompositeMessage(
127+
{},
128+
[],
129+
[],
130+
[new ElementMessage('total', [], null), new ArgumentMessage('total', null)],
131+
null,
132+
);
133+
expect(() => assignSequenceIdentifiers(message)).toThrow(
134+
"Element tag 'total' collides with an argument of the same name",
135+
);
136+
});
137+
67138
it('leaves a literal message unchanged', () => {
68139
const literal = new LiteralMessage('hi');
69140
expect(() => assignSequenceIdentifiers(literal)).not.toThrow();

packages/config/src/features/messages/identifier.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,83 @@ import {
88

99
export const AUTO_INCREMENT_IDENTIFIER = Symbol('auto-increment');
1010

11-
export function assignSequenceIdentifiers(message: Message, sequence = { current: 0 }) {
12-
if (
13-
message instanceof ArgumentMessage ||
14-
message instanceof ElementMessage ||
15-
message instanceof ChoiceMessage
16-
)
17-
if (message.identifier === AUTO_INCREMENT_IDENTIFIER)
18-
message.identifier = `${sequence.current++}`;
19-
if (message instanceof CompositeMessage || message instanceof ElementMessage)
20-
for (const child of message.children) assignSequenceIdentifiers(child, sequence);
21-
if (message instanceof ChoiceMessage)
22-
for (const branch of message.branches) {
23-
if (branch.identifier === AUTO_INCREMENT_IDENTIFIER)
24-
branch.identifier = `${sequence.current++}`;
25-
assignSequenceIdentifiers(branch.value, sequence);
11+
/**
12+
* Decides whether two elements sharing a tag are the same element, and so may
13+
* share it. Only the syntax that produced them can answer that, so the caller
14+
* supplies the comparison; by default no two elements are interchangeable.
15+
*/
16+
export type ElementEquivalence = (a: any, b: any) => boolean;
17+
18+
export function assignSequenceIdentifiers(
19+
message: Message,
20+
sequence = { current: 0 },
21+
equivalent: ElementEquivalence = () => false,
22+
) {
23+
const reserved = collectAssignedIdentifiers(message, equivalent);
24+
25+
function next() {
26+
let identifier = `${sequence.current++}`;
27+
while (reserved.has(identifier)) identifier = `${sequence.current++}`;
28+
return identifier;
29+
}
30+
31+
function walk(message: Message) {
32+
if (
33+
message instanceof ArgumentMessage ||
34+
message instanceof ElementMessage ||
35+
message instanceof ChoiceMessage
36+
)
37+
if (message.identifier === AUTO_INCREMENT_IDENTIFIER) message.identifier = next();
38+
if (message instanceof CompositeMessage || message instanceof ElementMessage)
39+
for (const child of message.children) walk(child);
40+
if (message instanceof ChoiceMessage)
41+
for (const branch of message.branches) {
42+
if (branch.identifier === AUTO_INCREMENT_IDENTIFIER) branch.identifier = next();
43+
walk(branch.value);
44+
}
45+
}
46+
47+
walk(message);
48+
}
49+
50+
/**
51+
* Collect the identifiers already assigned before this pass runs, so generated
52+
* sequence numbers never shadow an explicit one (e.g. an element tagged `0`).
53+
*
54+
* Elements that differ need their own tag: they each compile to their own prop,
55+
* and a translator has to be able to tell them apart. Repeats are fine when
56+
* nothing distinguishes them — two identical elements are one prop, the same
57+
* way the same variable interpolated twice is one value — so arguments are
58+
* never checked and elements only when they are not equivalent.
59+
*/
60+
function collectAssignedIdentifiers(message: Message, equivalent: ElementEquivalence) {
61+
const tags = new Map<string, ElementMessage>();
62+
const values = new Set<string>();
63+
64+
function walk(message: Message) {
65+
if (message instanceof ElementMessage && typeof message.identifier === 'string') {
66+
const previous = tags.get(message.identifier);
67+
if (previous && !equivalent(previous.expression, message.expression))
68+
throw new Error(
69+
`Duplicate element tag '${message.identifier}', give each element in a message its own tag unless they are identical`,
70+
);
71+
tags.set(message.identifier, message);
2672
}
73+
if (
74+
(message instanceof ArgumentMessage || message instanceof ChoiceMessage) &&
75+
typeof message.identifier === 'string'
76+
)
77+
values.add(message.identifier);
78+
if (message instanceof CompositeMessage || message instanceof ElementMessage)
79+
for (const child of message.children) walk(child);
80+
if (message instanceof ChoiceMessage) for (const branch of message.branches) walk(branch.value);
81+
}
82+
83+
walk(message);
84+
85+
for (const tag of tags.keys())
86+
if (values.has(tag))
87+
throw new Error(`Element tag '${tag}' collides with an argument of the same name`);
88+
89+
return new Set([...tags.keys(), ...values]);
2790
}

packages/integration-react/src/runtime/index.test.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('Say', () => {
2929
const element = (Say as (p: unknown) => ReactElement)({
3030
id: 'greet',
3131
whitespace: false,
32-
name: bold,
32+
_name: bold,
3333
});
3434

3535
expect(element.type).toBe(Renderer);
@@ -40,21 +40,47 @@ describe('Say', () => {
4040
};
4141
expect(props.html).toBe('<name/> world');
4242
expect(props.whitespace).toBe(false);
43-
// The descriptor passed to `say.call` has JSX-safe keys resolved and the
44-
// `whitespace` prop removed.
45-
expect(call).toHaveBeenCalledWith(expect.objectContaining({ id: 'greet', name: bold }));
43+
// The descriptor passed to `say.call` is exactly the message id and the
44+
// value props unprefixed, so a prefixed key or a renderer prop leaking
45+
// into it fails here rather than reaching the runtime.
46+
expect(call).toHaveBeenCalledWith({ id: 'greet', name: bold });
4647

4748
// A slot that maps to a valid element clones it; other slots pass the tag through.
4849
const resolved = props.components('name') as (p: object) => ReactElement;
4950
expect(typeof resolved).toBe('function');
5051
expect(isValidElement(resolved({}))).toBe(true);
5152
expect(props.components('missing')).toBe('missing');
52-
// `id` is a string, not an element, so it also passes through as the tag.
53+
// The message id is not a value, so nothing resolves for it as a tag.
5354
expect(props.components('id')).toBe('id');
5455
// Called with no tag, it returns the (undefined) tag.
5556
expect(props.components()).toBeUndefined();
5657
});
5758

59+
it('lets a value be named after one of Say’s own props', () => {
60+
const call = vi.fn(() => '<id/> and <whitespace/>');
61+
globalThis.GET_SAY = () => ({ call });
62+
63+
const bold = createElement('b', null, 'Ada');
64+
const element = (Say as (p: unknown) => ReactElement)({
65+
id: 'greet',
66+
whitespace: false,
67+
_id: bold,
68+
_whitespace: bold,
69+
});
70+
71+
const props = element.props as {
72+
whitespace?: boolean;
73+
components: (tag?: string) => unknown;
74+
};
75+
// The real id still reaches `say.call` and the tag named after it does not
76+
// displace it, while a tag named `whitespace` is a value like any other and
77+
// the flag itself still reaches the renderer.
78+
expect(call).toHaveBeenCalledWith({ id: 'greet', whitespace: bold });
79+
expect(props.whitespace).toBe(false);
80+
expect(typeof props.components('id')).toBe('function');
81+
expect(typeof props.components('whitespace')).toBe('function');
82+
});
83+
5884
it('exposes Plural, Ordinal and Select macros that throw', () => {
5985
expect(() => Say.Plural({ _: 1, other: '#' })).toThrow("'Say.Plural' is a macro");
6086
expect(() => Say.Ordinal({ _: 1, other: '#' })).toThrow("'Say.Ordinal' is a macro");

packages/integration-react/src/runtime/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from 'react';
99
import type { Disallow, NumeralOptions, SelectOptions } from 'saykit';
1010
import { Renderer } from '~/components/renderer.js';
11-
import { type PropsWithJSXSafeKeys, resolveJsxSafePropKeys } from '~/types.js';
11+
import { type PropsWithJSXSafeKeys, resolveValuePropKeys } from '~/types.js';
1212

1313
declare function GET_SAY(): import('saykit').ReadonlySay;
1414

@@ -30,15 +30,17 @@ export function Say(props: { id: string; whitespace?: boolean; [match: string]:
3030
});
3131

3232
const say = GET_SAY();
33-
const { whitespace, ...rest } = props;
34-
const descriptor = resolveJsxSafePropKeys(rest);
33+
const { id, whitespace, ...rest } = props;
34+
const values = resolveValuePropKeys(rest);
3535

3636
return createElement(Renderer, {
37-
html: say.call(descriptor),
37+
// The id is kept out of `values` and merged in last, so a message free to
38+
// name a tag `id` still cannot displace the message being looked up.
39+
html: say.call({ ...values, id }),
3840
whitespace,
3941
components(tag?: string) {
40-
if (tag && tag in descriptor && isValidElement(descriptor[tag])) {
41-
const element = descriptor[tag]! as ReactElement;
42+
if (tag && tag in values && isValidElement(values[tag])) {
43+
const element = values[tag]! as ReactElement;
4244
return (props) => cloneElement(element, { ...(element.props as object), ...props });
4345
} else {
4446
return tag;

0 commit comments

Comments
 (0)