Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-donkeys-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@saykit/transform-jsx': minor
---

Extract JSX whitespace exactly as JSX renders it, and read a literal expression child such as `{' '}`, `{'\n'}`, or `{10}` as the text it renders as
5 changes: 5 additions & 0 deletions .changeset/olive-pugs-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@saykit/config': minor
---

Escape braces in literal text so a message meaning `{` reaches the catalogue as text rather than as an argument, and stop trimming whitespace from the edges of a message
2 changes: 1 addition & 1 deletion examples/expo/src/habit-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function HabitCard({ habit, onToggle }: { habit: Habit; onToggle: () => v

<Text style={styles.progress}>
<Say>
<Text style={styles.strong}>{habit.thisWeek}</Text> of
<Text style={styles.strong}>{habit.thisWeek}</Text> of{' '}
<Text style={styles.strong}>{habit.target}</Text> this week
</Say>
</Text>
Expand Down
6 changes: 3 additions & 3 deletions examples/react/src/components/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function BoardColumn({ column }: { column: Column }) {
still uses these exact React elements, with their handlers intact.
*/}
<Say>
Nothing here. <a href="#new">Add a task</a> or drag one across from
Nothing here. <a href="#new">Add a task</a> or drag one across from{' '}
<strong>To do</strong>.
</Say>
</p>
Expand Down Expand Up @@ -62,7 +62,7 @@ export function Board() {
`one` and `other`.
*/}
<Say>
Welcome back, {currentMember}. This is your
Welcome back, {currentMember}. This is your{' '}
<Say.Ordinal _={sprintNumber} one="#st" two="#nd" few="#rd" other="#th" /> sprint.
</Say>
</p>
Expand All @@ -87,7 +87,7 @@ export function Board() {
*/}
<Say>
<Say.Number _={{ complete: completionRatio() }} style="percent" /> complete. This sprint
ends on <Say.Date _={{ sprintEndsAt }} style="medium" />, at
ends on <Say.Date _={{ sprintEndsAt }} style="medium" />, at{' '}
<Say.Time _={{ sprintEndsAt }} style="short" />.
</Say>
</p>
Expand Down
9 changes: 4 additions & 5 deletions examples/tanstack-start/src/routes/{-$locale}/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ function SessionRow({ session }: { session: Session }) {
<>
{' — '}
<Say>
their
their{' '}
<Say.Ordinal
_={session.previousTalks + 1}
one="#st"
two="#nd"
few="#rd"
other="#th"
/>
/>{' '}
time on this stage
</Say>
</>
Expand Down Expand Up @@ -108,9 +108,8 @@ function SchedulePage() {
free through the fallback chain, without restating it.
*/}
<Say>
The full program —
<Say.Plural _={sessions.length} one="# session" other="# sessions" />, including
<Say.Plural _={workshops} one="# workshop" other="# workshops" />.
The full program — <Say.Plural _={sessions.length} one="# session" other="# sessions" />
, including <Say.Plural _={workshops} one="# workshop" other="# workshops" />.
</Say>
</p>
</header>
Expand Down
87 changes: 83 additions & 4 deletions packages/config/src/features/messages/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,81 @@ describe('convertMessageToIcu', () => {
.toMatchInlineSnapshot('"Hello"');
});

/**
* A brace in literal text is text. Left as it is, ICU reads it as an
* argument the catalogue never declared — `Use {name} here` stops being a
* sentence about braces and starts being a sentence with a hole in it.
*
* The round trip is what these assert against: each escaped string below is
* fed back through the runtime formatter in `packages/integration`, and has
* to come out as the text it started as.
*/
describe('escaping literal text', () => {
it.each([
['a brace', 'Use {name} here', `Use '{'name'}' here`],
['a run of braces', 'a {{ b }} c', `a '{{' b '}}' c`],
// Doubled only where a quote could start. In `don't` the apostrophe is
// followed by a letter, so ICU already reads it as an apostrophe.
['an apostrophe in front of a brace', "'{", `'''{'`],
['an apostrophe in front of an apostrophe', "it''s", `it'''s`],
['an apostrophe in front of neither', "don't {x}", `don't '{'x'}'`],
['an apostrophe in front of nothing at all', "the '80s'", `the '80s'`],
// Otherwise it quotes the `#`, which is how ICU spells a literal one —
// an escape nobody wrote and the sentence does not mean.
['an apostrophe in front of a hash', "it's '#1", `it's ''#1`],
])('escapes %s', (_, text, expected) => {
expect(convertMessageToIcu(new LiteralMessage(text))).toBe(expected);
});

// Doubling every apostrophe would be valid ICU and would rewrite the id of
// every message that has ever contained one, for nothing.
it('leaves an ordinary apostrophe alone', () => {
expect(convertMessageToIcu(new LiteralMessage("It's a test"))).toBe("It's a test");
});

/**
* The character a literal runs into is not always one of its own. An
* apostrophe at the end of a literal sits against whatever the message puts
* next, and quoting there steals syntax that belongs to a sibling.
*/
it('doubles an apostrophe that runs into a placeholder', () => {
const message = new CompositeMessage(
{},
[],
[],
[
new LiteralMessage("Click '"),
new ArgumentMessage('name', dummy),
new LiteralMessage("'"),
],
dummy,
);
expect(convertMessageToIcu(message)).toBe(`Click ''{name}'`);
});

it('doubles an apostrophe that runs into the end of a branch', () => {
const message = new ChoiceMessage(
'plural',
'n',
[{ identifier: 'other', value: new LiteralMessage("the boys'") }],
dummy,
);
expect(convertMessageToIcu(message)).toContain("other {the boys''}");
});

// Nothing can be quoted at the end of the string, so the id of a message
// that simply ends in an apostrophe does not move.
it('leaves an apostrophe at the end of a message alone', () => {
expect(convertMessageToIcu(new LiteralMessage("the boys'"))).toBe("the boys'");
});

// Inside a plural this is the number being formatted, which is the whole
// reason to write one.
it('leaves a hash alone', () => {
expect(convertMessageToIcu(new LiteralMessage('issue #1'))).toBe('issue #1');
});
});

it('should generate argument messages', () => {
const message = new ArgumentMessage('name', dummy);
expect(convertMessageToIcu(message)) //
Expand Down Expand Up @@ -210,20 +285,24 @@ describe('convertMessageToIcu', () => {
.toMatchInlineSnapshot('"Hello, {name}!"');
});

it('should normalise jsx related whitespace', () => {
// Collapsing a message's own indentation is the JSX parser's job, and it
// does it the way JSX does. By the time text arrives here it is the text the
// message means, edges included — a space at either end is as deliberate as
// one in the middle, and `{' '}` is how JSX asks for it.
it('keeps whitespace at the edges of a message', () => {
const message = new CompositeMessage(
{},
[],
[],
[
new LiteralMessage('\n Hello, '),
new LiteralMessage(' Hello, '),
new ArgumentMessage('name', dummy),
new LiteralMessage('!\n'),
new LiteralMessage('! '),
],
dummy,
);
expect(convertMessageToIcu(message)) //
.toMatchInlineSnapshot('"Hello, {name}!"');
.toMatchInlineSnapshot(`" Hello, {name}! "`);
});

it('throws for an unknown message type', () => {
Expand Down
41 changes: 33 additions & 8 deletions packages/config/src/features/messages/convert.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { escapeIcuLiteral } from './escape.js';
import { getBranchCase } from './identifier.js';
import {
ArgumentMessage,
Expand All @@ -9,10 +10,27 @@ import {
} from './types.js';

export function convertMessageToIcu(message: Message) {
function internalConvertMessageToIcu(message: Message): string {
/**
* Convert a run of children, walking it backwards so each one is converted
* knowing the character it will run into. A child that converts to nothing
* passes its own follower along, since it puts nothing between them.
*/
function convertChildren(messages: Message[], following: string) {
const parts: string[] = [];

for (let i = messages.length - 1; i >= 0; i--) {
const part = internalConvertMessageToIcu(messages[i]!, following);
parts.unshift(part);
following = part[0] ?? following;
}

return parts.join('');
}

function internalConvertMessageToIcu(message: Message, following: string): string {
switch (true) {
case message instanceof LiteralMessage:
return String(message.text);
return escapeIcuLiteral(String(message.text), following);

case message instanceof ArgumentMessage: {
const parts = [String(message.identifier)];
Expand All @@ -27,15 +45,18 @@ export function convertMessageToIcu(message: Message) {
// source: an element written as a pair stays a pair, even when its
// children happen to render to nothing.
if (message.children.length === 0) return `<${String(message.identifier)}/>`;
const children = message.children.map((m) => internalConvertMessageToIcu(m)).join('');
// The closing tag follows the children, and `<` is not ICU syntax.
const children = convertChildren(message.children, '<');
return `<${String(message.identifier)}>${children}</${String(message.identifier)}>`;
}

case message instanceof ChoiceMessage: {
const branches = message.branches
.map(({ identifier, value }) => ({
identifier: getBranchCase(message.kind, identifier),
value: internalConvertMessageToIcu(value),
// A branch is closed by a brace, which a trailing apostrophe would
// otherwise quote — taking the end of the branch with it.
value: internalConvertMessageToIcu(value, '}'),
}))
.map(({ identifier, value }) => ` ${identifier} {${value}}\n`)
.join('');
Expand All @@ -50,14 +71,18 @@ export function convertMessageToIcu(message: Message) {
}

case message instanceof CompositeMessage:
return Object.entries(message.children)
.map(([, m]) => internalConvertMessageToIcu(m))
.join('');
return convertChildren(message.children, following);

default:
throw new Error('Unknown message type', { cause: message });
}
}

return internalConvertMessageToIcu(message).trim();
// Not trimmed. A message carries the text it was written with, and a space
// at either end is as deliberate as one in the middle — `{' '}` is how a JSX
// message asks for one, and every character of a template literal is already
// exactly what it says. Trimming here would quietly overrule both.
// Nothing follows a whole message, so its last character runs into the end
// of the string, where no quoting can start.
return internalConvertMessageToIcu(message, '');
}
63 changes: 63 additions & 0 deletions packages/config/src/features/messages/escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Quoting a message's literal text for the format its catalogue is written in.
*
* The contract this exists to keep is that a message is written in text, not
* in a message format. Whatever a sentence contains means itself, and every
* character the format reserves is quoted here, on the way into the catalogue.
* Nothing upstream escapes anything: the parsers hand over the characters an
* author typed, so no source file, and no message anyone writes, spells an
* escape.
*
* That is also what makes the format replaceable. Escaping belongs to the
* format rather than to the message — MF2 quotes with backslashes and treats
* neither the apostrophe nor `#` as syntax — so a second format brings its own
* escaper alongside its own converter, and the messages already written carry
* over untouched.
*
* @param text The characters the message means, exactly as they were written.
* @param following The character this text runs into once the message is
* assembled. Not always one of its own: text sits against whatever the
* message puts next, and quoting reaches across that seam.
*/
export type EscapeLiteral = (text: string, following: string) => string;

/**
* Quote the characters ICU reads as syntax, so text a message means literally
* arrives as text rather than as an argument the catalogue never declared.
*
* A brace is quoted as `'{'`, which is ICU's own escape. The apostrophe doing
* that quoting therefore has to escape itself: one written in a message is
* doubled wherever ICU would otherwise read it as opening a quote — in front
* of a brace, a `#`, or another apostrophe. Everywhere else it is already
* literal, and doubling it would rewrite the id of every message that contains
* one.
*
* A bare `#` is left alone. Inside a plural that is the number being formatted,
* which is the one piece of ICU a message does write on purpose.
*
* `following` matters because an apostrophe at the very end of a literal sits
* against whatever comes next, and if that is the `{` of a placeholder or the
* `}` closing a branch, it quotes syntax belonging to somebody else. `Click '`
* beside `{name}` is `Click '{name}`, which ICU reads as the literal text
* "Click {name}" — the placeholder swallowed whole.
*/
export const escapeIcuLiteral: EscapeLiteral = (text, following) => {
let escaped = '';

for (let i = 0; i < text.length; i++) {
const character = text[i]!;

if (character === '{' || character === '}') {
// One quoted run for a whole stretch of braces, so `{{` is `'{{'`.
const start = i;
while (text[i + 1] === '{' || text[i + 1] === '}') i++;
escaped += `'${text.slice(start, i + 1)}'`;
} else if (character === "'" && /['{}#]/.test(text[i + 1] ?? following)) {
escaped += "''";
} else {
escaped += character;
}
}

return escaped;
};
30 changes: 30 additions & 0 deletions packages/integration/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,36 @@ describe('formatted arguments', () => {
expect(format(message, { tier })).toBe(expected);
});

/**
* The other half of the round trip. `convertMessageToIcu` in `@saykit/config`
* escapes literal text on the way into a catalogue; these are the exact
* strings it produces for the cases asserted there, and each one has to come
* back out as the text an author wrote. A catalogue that formats to anything
* else is a catalogue that quietly lost a character.
*/
it.each([
[`Use '{'name'}' here`, 'Use {name} here'],
[`a '{{' b '}}' c`, 'a {{ b }} c'],
[`'''{'`, "'{"],
[`it'''s`, "it''s"],
[`don't '{'x'}'`, "don't {x}"],
// Escaped by nothing, because ICU already reads them as text.
[`It's a test`, "It's a test"],
[`the boys'`, "the boys'"],
])('formats the escaped literal %j back to its text', (message, expected) => {
expect(format(message, {})).toBe(expected);
});

// An apostrophe that runs into a placeholder rather than into text: quoted
// wrongly, it swallows the placeholder whole and the value never appears.
it('keeps a placeholder after an escaped apostrophe', () => {
expect(plain(format(`Click ''{name}'`, { name: 'Ada' }))).toBe(`Click 'Ada'`);
});

it('keeps a branch closed after an escaped apostrophe', () => {
expect(format(`{n, plural, other {the boys'' #}}`, { n: 2 })).toBe(`the boys' 2`);
});

it('applies a plural offset', () => {
const message = '{n, plural, offset:1 one {you and # other} other {you and # others}}';
expect(format(message, { n: 3 })).toBe('you and 2 others');
Expand Down
8 changes: 8 additions & 0 deletions packages/transform-js/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ describe('createJsTransformer.extract', () => {
expect(message!.references).toEqual(['file.ts:1']);
});

// A brace in a template is text — the template's own interpolation is `${}`,
// so nothing here is asking for an ICU argument. Escaping it is what keeps a
// message from turning into a placeholder the catalogue never declared.
it('escapes a brace written in a template', () => {
const [message] = transformer.extract('const x = say`Use {name} here`;', 'file.ts');
expect(message!.message).toBe(`Use '{'name'}' here`);
});

it('carries through an explicit id and context', () => {
const [message] = transformer.extract(
"const g = say({ id: 'greeting', context: 'formal' })`Hi`;",
Expand Down
Loading
Loading