diff --git a/.changeset/light-donkeys-hear.md b/.changeset/light-donkeys-hear.md
new file mode 100644
index 00000000..81c3fb8e
--- /dev/null
+++ b/.changeset/light-donkeys-hear.md
@@ -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
diff --git a/.changeset/olive-pugs-smile.md b/.changeset/olive-pugs-smile.md
new file mode 100644
index 00000000..40f8625c
--- /dev/null
+++ b/.changeset/olive-pugs-smile.md
@@ -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
diff --git a/examples/expo/src/habit-card.tsx b/examples/expo/src/habit-card.tsx
index 1d68e0ac..2cd9c830 100644
--- a/examples/expo/src/habit-card.tsx
+++ b/examples/expo/src/habit-card.tsx
@@ -28,7 +28,7 @@ export function HabitCard({ habit, onToggle }: { habit: Habit; onToggle: () => v
- {habit.thisWeek} of
+ {habit.thisWeek} of{' '}
{habit.target} this week
diff --git a/examples/react/src/components/board.tsx b/examples/react/src/components/board.tsx
index 8e043226..a3e19e15 100644
--- a/examples/react/src/components/board.tsx
+++ b/examples/react/src/components/board.tsx
@@ -33,7 +33,7 @@ function BoardColumn({ column }: { column: Column }) {
still uses these exact React elements, with their handlers intact.
*/}
- Nothing here. Add a task or drag one across from
+ Nothing here. Add a task or drag one across from{' '}
To do.
@@ -62,7 +62,7 @@ export function Board() {
`one` and `other`.
*/}
- Welcome back, {currentMember}. This is your
+ Welcome back, {currentMember}. This is your{' '}
sprint.
@@ -87,7 +87,7 @@ export function Board() {
*/}
complete. This sprint
- ends on , at
+ ends on , at{' '}
.
diff --git a/examples/tanstack-start/src/routes/{-$locale}/index.tsx b/examples/tanstack-start/src/routes/{-$locale}/index.tsx
index 4c5cba94..fbf58230 100644
--- a/examples/tanstack-start/src/routes/{-$locale}/index.tsx
+++ b/examples/tanstack-start/src/routes/{-$locale}/index.tsx
@@ -58,14 +58,14 @@ function SessionRow({ session }: { session: Session }) {
<>
{' — '}
- their
+ their{' '}
+ />{' '}
time on this stage
>
@@ -108,9 +108,8 @@ function SchedulePage() {
free through the fallback chain, without restating it.
*/}
- The full program —
- , including
- .
+ The full program —
+ , including .
diff --git a/packages/config/src/features/messages/convert.test.ts b/packages/config/src/features/messages/convert.test.ts
index d88fa6e4..3c65194b 100644
--- a/packages/config/src/features/messages/convert.test.ts
+++ b/packages/config/src/features/messages/convert.test.ts
@@ -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)) //
@@ -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', () => {
diff --git a/packages/config/src/features/messages/convert.ts b/packages/config/src/features/messages/convert.ts
index f2ccb2ab..535ef672 100644
--- a/packages/config/src/features/messages/convert.ts
+++ b/packages/config/src/features/messages/convert.ts
@@ -1,3 +1,4 @@
+import { escapeIcuLiteral } from './escape.js';
import { getBranchCase } from './identifier.js';
import {
ArgumentMessage,
@@ -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)];
@@ -27,7 +45,8 @@ 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)}>`;
}
@@ -35,7 +54,9 @@ export function convertMessageToIcu(message: Message) {
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('');
@@ -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, '');
}
diff --git a/packages/config/src/features/messages/escape.ts b/packages/config/src/features/messages/escape.ts
new file mode 100644
index 00000000..e3f4c417
--- /dev/null
+++ b/packages/config/src/features/messages/escape.ts
@@ -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;
+};
diff --git a/packages/integration/src/runtime.test.ts b/packages/integration/src/runtime.test.ts
index 474f5ef6..3587411f 100644
--- a/packages/integration/src/runtime.test.ts
+++ b/packages/integration/src/runtime.test.ts
@@ -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');
diff --git a/packages/transform-js/src/index.test.ts b/packages/transform-js/src/index.test.ts
index e5c14aff..a989e9b8 100644
--- a/packages/transform-js/src/index.test.ts
+++ b/packages/transform-js/src/index.test.ts
@@ -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`;",
diff --git a/packages/transform-jsx/src/index.test.ts b/packages/transform-jsx/src/index.test.ts
index 0c966a12..30708ae6 100644
--- a/packages/transform-jsx/src/index.test.ts
+++ b/packages/transform-jsx/src/index.test.ts
@@ -123,6 +123,18 @@ describe('createJsxTransformer.extract', () => {
expect(message!.message).toBe('Open <0/>');
});
+ // A bare brace in JSX text is a syntax error, so a message meaning one says
+ // it with an expression or a character entity. Both are text by the time they
+ // reach the catalogue, and neither may arrive as an argument nothing
+ // supplies. See `escapeIcuLiteral` in `@saykit/config`.
+ it.each([
+ ['an expression', `Use {'{'}name{'}'} here`],
+ ['a character entity', 'Use {name} here'],
+ ])('escapes a literal brace written as %s', (_, jsx) => {
+ const [message] = transformer.extract(`const x = ${jsx};`, 'file.tsx');
+ expect(message!.message).toBe(`Use '{'name'}' here`);
+ });
+
it('returns an empty array when there are no messages', () => {
expect(transformer.extract('const x = plain
;', 'file.tsx')).toEqual([]);
});
diff --git a/packages/transform-jsx/src/parser.test.ts b/packages/transform-jsx/src/parser.test.ts
index 561a67c9..45d1bb4f 100644
--- a/packages/transform-jsx/src/parser.test.ts
+++ b/packages/transform-jsx/src/parser.test.ts
@@ -64,12 +64,28 @@ describe('parseJSXContainerElement', () => {
expect(result!.descriptor).toEqual({ id: 'msg', context: 'nav' });
});
- it('drops text children that are only whitespace', () => {
- const result = parser.parseJSXContainerElement(jsx`{name} `);
+ it('drops text children that are only a line break and indentation', () => {
+ const result = parser.parseJSXContainerElement(jsx`
+
+ {name}
+
+ `);
expect(result!.children).toHaveLength(1);
expect(result!.children[0]).toBeInstanceOf(ArgumentMessage);
});
+ it('parses a literal string expression as text, folded into its neighbour', () => {
+ const result = parser.parseJSXContainerElement(jsx`Hello,{' '}world`);
+ expect(result!.children).toEqual([new LiteralMessage('Hello, world')]);
+ });
+
+ // A spread child renders an unknown number of unknown things, so there is
+ // nothing to name it or to translate around it.
+ it('ignores spread children', () => {
+ const result = parser.parseJSXContainerElement(jsx`{...items}`);
+ expect(result!.children).toHaveLength(0);
+ });
+
it('ignores expression containers that hold no expression', () => {
const result = parser.parseJSXContainerElement(jsx`{/* empty */}`);
expect(result!.children).toHaveLength(0);
diff --git a/packages/transform-jsx/src/parser.ts b/packages/transform-jsx/src/parser.ts
index a7f4f84d..3dc12caf 100644
--- a/packages/transform-jsx/src/parser.ts
+++ b/packages/transform-jsx/src/parser.ts
@@ -29,21 +29,26 @@ export function parseJSXContainerElement(element: t.JSXElement): CompositeMessag
if (!processed) return null;
const [accessor] = processed;
- const children = element.children.reduce((p, c, i, a) => {
- if (t.isJSXText(c)) {
- const text = normalizeJSXText(c.value, i === 0, i === a.length - 1);
- if (text) p.push(new LiteralMessage(text));
- }
-
- if (t.isJSXElement(c)) {
+ // The children the JSX transform itself would compile, which is what makes a
+ // message extract as the text that renders, and keeps it that way as JSX is
+ // the thing defining what renders. Text children come back with their
+ // whitespace collapsed the way JSX collapses it — a line break and the
+ // indentation around it are layout and disappear, while two lines that both
+ // hold text rejoin with the single space that separated the words — every
+ // expression container comes back unwrapped, and a comment child comes back
+ // as nothing at all.
+ const children = t.react.buildChildren(element).reduce((p, c) => {
+ const literal = getExpressionAsLiteralText(c);
+
+ if (literal !== undefined) {
+ pushLiteral(p, literal);
+ } else if (t.isJSXElement(c)) {
p.push(parseJSXElement(c, true));
} else if (t.isJSXFragment(c)) {
p.push(new ElementMessage(AUTO_INCREMENT_IDENTIFIER, [], c));
- } else if (t.isJSXExpressionContainer(c)) {
- if (t.isExpression(c.expression)) {
- const [identifier, value] = unwrapPlaceholder(c.expression);
- p.push(new ArgumentMessage(identifier, value));
- }
+ } else if (t.isExpression(c)) {
+ const [identifier, value] = unwrapPlaceholder(c);
+ p.push(new ArgumentMessage(identifier, value));
}
return p;
@@ -170,42 +175,46 @@ function findPluralOffset(attributes: t.JSXOpeningElement['attributes']) {
}
/**
- * Punctuation that never takes a space in front of it. A formatter wrapping
- * long JSX regularly strands these at the start of a line, on their own or
- * ahead of the rest of a clause.
+ * Add text to the message, folding it into the literal in front of it when
+ * there is one, so `Hello{' '}world` is three children in the source and one
+ * run of text in the catalogue.
*/
-const CLOSING_PUNCTUATION = /^[.,;:!?)\]}»”’]/;
+function pushLiteral(messages: Message[], text: string) {
+ if (!text) return;
+
+ const last = messages.at(-1);
+ if (last instanceof LiteralMessage) {
+ messages[messages.length - 1] = new LiteralMessage(last.text + text);
+ } else {
+ messages.push(new LiteralMessage(text));
+ }
+}
/**
- * Collapse the whitespace in a JSX text child the way the source reads.
+ * The text a child renders as, when that is knowable at build time — a text
+ * child, which has already been collapsed into a string by `buildChildren`, or
+ * an expression holding a string with nothing interpolated into it.
*
- * A line break between a word and its neighbour is only how a formatter wrapped
- * a long line, so it still means a space. Dropping it would run the words
- * together — and because ids are content hashes, silently orphan every existing
- * translation for the message.
- *
- * The exception is a line that begins with punctuation, which belongs tight
- * against whatever precedes it. Only an implicit break is treated this way: a
- * space the author wrote on the same line is deliberate and always survives.
+ * The second is what makes `{' '}` and `{'\n'}` the way to write whitespace
+ * that has to survive a line break: they extract as the space and the break
+ * they render as, rather than as placeholders a translator can neither see nor
+ * move.
*/
-function normalizeJSXText(value: string, isFirst: boolean, isLast: boolean) {
- let text = value.replace(/\s+/g, ' ');
-
- if (isFirst) {
- text = text.trimStart();
- } else if (
- text.startsWith(' ') &&
- // The leading whitespace spans a line break, so it is the formatter's,
- // not the author's.
- /^[^\S\n]*\n/.test(value) &&
- CLOSING_PUNCTUATION.test(text.slice(1))
- ) {
- text = text.slice(1);
+function getExpressionAsLiteralText(expression: t.Node) {
+ if (t.isStringLiteral(expression)) return expression.value;
+ // A number renders as the text `String()` makes of it, and reads as content
+ // in the sentence rather than as a value anything supplies.
+ if (t.isNumericLiteral(expression)) return String(expression.value);
+ // A template literal with nothing interpolated is a string literal written
+ // with different quotes, and renders as one.
+ if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) {
+ const [chunk] = expression.quasis;
+ // Nothing interpolated means exactly one chunk, and a chunk only fails to
+ // cook for an escape that is a syntax error outside a tagged template.
+ /* v8 ignore next */
+ return chunk?.value.cooked ?? '';
}
-
- if (isLast) text = text.trimEnd();
-
- return text;
+ return undefined;
}
function processJSXOpeningElement(element: t.JSXOpeningElement): [t.Node, string | null] | null {
diff --git a/packages/transform-jsx/src/whitespace.test.ts b/packages/transform-jsx/src/whitespace.test.ts
index fefcd27b..99ea1fd5 100644
--- a/packages/transform-jsx/src/whitespace.test.ts
+++ b/packages/transform-jsx/src/whitespace.test.ts
@@ -12,157 +12,191 @@ function extract(jsx: string) {
}
/**
- * Prettier wraps JSX at the print width, so a translatable line routinely ends
- * with a word and continues with an element on the next line. The line break is
- * the only thing separating them, and it has to keep meaning "space" — dropping
- * it runs the words together and, because ids are content hashes, silently
- * orphans every existing translation for the message.
+ * The whole rule, and the only one worth remembering: a message extracts as the
+ * text JSX renders. A line break and the indentation around it are how the
+ * source is laid out, not part of the sentence, so they never reach a
+ * catalogue — whitespace that has to survive a break is written as `{' '}`.
*
- * Each case below is lifted from a real example app, named by where it lives.
- * They exist because they are the shapes that regressed in #63 while the whole
- * suite stayed green.
+ * Every case below is checked against what React itself would put on the page
+ * for the same JSX. Where the two could differ, this file is wrong.
*/
-describe('whitespace across a line break', () => {
- it('joins text to an element on the next line', () => {
- // examples/react/src/components/board.tsx
+describe('a line break between two children', () => {
+ it('leaves nothing between text and an element on the next line', () => {
expect(
extract(`
- Nothing here. Add a task or drag one across from
- To do.
+
+ {days}
+
+ d
`),
- ).toBe('Nothing here. <0>Add a task0> or drag one across from <1>To do1>.');
+ ).toBe('<0>{days}0>d');
});
- it('joins text between two elements that each start a line', () => {
- // examples/expo/src/habit-card.tsx
+ it('leaves nothing between two elements on their own lines', () => {
expect(
extract(`
- {a} of
- {b} this week
+ 1
+ 2
`),
- ).toBe('<0>{a}0> of <1>{b}1> this week');
+ ).toBe('<0>10><1>21>');
});
- it('joins text to a choice element on both sides', () => {
- // examples/tanstack-start/src/routes/{-$locale}/index.tsx
+ it('leaves nothing before punctuation stranded on its own line', () => {
+ expect(
+ extract(`
+ Read the docs
+ .
+ `),
+ ).toBe('Read <0>the docs0>.');
+ });
+
+ it('leaves nothing before a choice element on the next line', () => {
expect(
extract(`
their
-
- time on this stage
+
`),
- ).toBe(`their {n, selectordinal,
+ ).toBe(`their{n, selectordinal,
one {#st}
- two {#nd}
- few {#rd}
other {#th}
-} time on this stage`);
+}`);
});
+});
- it('joins text to two consecutive choice elements', () => {
- // examples/tanstack-start/src/routes/{-$locale}/index.tsx
+/**
+ * The counterpart: a break between two runs of text is the only thing that was
+ * separating the words, so it rejoins as the single space it renders as.
+ */
+describe('a line break inside a run of text', () => {
+ it('joins wrapped lines with a single space', () => {
expect(
extract(`
- The full program —
- , including
- .
+ Hello,
+ world!
`),
- ).toBe(`The full program — {s, plural,
- one {# session}
- other {# sessions}
-}, including {w, plural,
- one {# workshop}
- other {# workshops}
-}.`);
+ ).toBe('Hello, world!');
+ });
+
+ it('joins lines around a blank line with a single space', () => {
+ expect(
+ extract(`
+ Hello,
+
+ world!
+ `),
+ ).toBe('Hello, world!');
+ });
+
+ it('trims the indentation a multiline container introduces', () => {
+ expect(
+ extract(`
+ Hello, world!
+ `),
+ ).toBe('Hello, world!');
+ });
+
+ it('reads a tab as a space', () => {
+ expect(extract('\n\t\tHello,\n\t\tworld!\n\t')).toBe('Hello, world!');
});
});
-describe('whitespace on a single line', () => {
+/**
+ * Inside a line there is no break to attribute anything to, so every space is
+ * one the author typed and every one of them renders.
+ */
+describe('whitespace written inside a line', () => {
it('keeps single spaces around an element', () => {
expect(extract('Hello brave world!')).toBe(
'Hello <0>brave0> world!',
);
});
- it('collapses a run of spaces to one', () => {
+ it('keeps a run of spaces as written', () => {
expect(extract('Hello brave world!')).toBe(
- 'Hello <0>brave0> world!',
+ 'Hello <0>brave0> world!',
);
});
it('keeps no space where the source has none', () => {
expect(extract('(brave)')).toBe('(<0>brave0>)');
});
-});
-describe('whitespace at the container edges', () => {
- it('trims the indentation a multiline container introduces', () => {
+ it('keeps a space against the edge of the line the break interrupts', () => {
expect(
extract(`
- Hello, world!
+ See the docs and
+ the examples too
`),
- ).toBe('Hello, world!');
+ ).toBe('See <0>the docs0> and<1>the examples1> too');
});
+});
- it('joins wrapped lines of plain text with a single space', () => {
+/**
+ * How a space survives a break. Prettier writes `{' '}` itself when it wraps a
+ * line that ends in one, so this is the shape the formatter already produces —
+ * it extracts as the space it renders as, rather than as a placeholder no
+ * translator can see or move.
+ */
+describe('whitespace written as an expression', () => {
+ it("extracts {' '} as a space", () => {
expect(
extract(`
- Hello,
- world!
+ Nothing here. Add a task or drag one across from{' '}
+ To do.
`),
- ).toBe('Hello, world!');
+ ).toBe('Nothing here. <0>Add a task0> or drag one across from <1>To do1>.');
});
- it('trims around a leading and trailing element', () => {
- expect(
- extract(`
- Hello, world
- `),
- ).toBe('<0>Hello0>, world');
+ it("extracts {'\\n'} as a line break", () => {
+ expect(extract(`Hello,{'\\n'}world!`)).toBe('Hello,\nworld!');
});
-});
-/**
- * The counterpart to the block above: a line break usually means a space, but
- * punctuation belongs tight against what precedes it. A formatter wrapping long
- * JSX strands punctuation at the start of a line routinely, and a space in
- * front of it would ship to every locale.
- */
-describe('punctuation at the start of a line', () => {
- it('takes no space before a full stop left on its own line', () => {
- expect(
- extract(`
- Kiai Security — only authorize apps you trust. Report malicious
- integrations in
- our support server
- .
- `),
- ).toBe(
- 'Kiai Security — only authorize apps you trust. Report malicious integrations in <0>our support server0>.',
- );
+ it('extracts a template literal with nothing interpolated', () => {
+ expect(extract('Hello,{` `}world!')).toBe('Hello, world!');
});
- it('takes no space before a clause that opens with a comma', () => {
- expect(
- extract(`
- Signed,
- the team
- , with thanks
- `),
- ).toBe('Signed, <0>the team0>, with thanks');
+ it('extracts any other literal string as the text it renders as', () => {
+ expect(extract(`Hello, {'world'}!`)).toBe('Hello, world!');
+ });
+
+ // A number written into a sentence is content a translator should be able to
+ // read and move, not a value the catalogue asks the caller for.
+ it('extracts a literal number as the text it renders as', () => {
+ expect(extract('Top {10} results')).toBe('Top 10 results');
+ });
+
+ // The counterpart to the container edges being trimmed: what is trimmed is
+ // the indentation, and a space asked for explicitly is not that.
+ it('keeps a space at the edge of a message', () => {
+ expect(extract(`{' '}Hello{' '}`)).toBe(' Hello ');
});
- it('keeps a space the author wrote on the same line', () => {
- // Only an implicit line break is collapsed away; this space is deliberate.
- expect(extract('Ready set ?')).toBe('Ready <0>set0> ?');
+ it('folds the surrounding text into a single run', () => {
+ expect(extract(`Hello,{' '}world!`)).toBe('Hello, world!');
});
- it('keeps a space before an opening bracket on the next line', () => {
+ it('carries a space between two choice elements', () => {
+ // examples/tanstack-start/src/routes/{-$locale}/index.tsx
expect(
extract(`
- See the docs
- (they are short)
+ {' '}
+ ·{' '}
+
`),
- ).toBe('See <0>the docs0> (they are short)');
+ ).toBe(`{s, plural,
+ one {# session}
+ other {# sessions}
+} · {w, plural,
+ one {# workshop}
+ other {# workshops}
+}`);
+ });
+
+ it('leaves nothing behind for an empty string', () => {
+ expect(extract(`Hello,{''} world!`)).toBe('Hello, world!');
+ });
+
+ it('still extracts an interpolated value as a placeholder', () => {
+ expect(extract('Hello, {name}!')).toBe('Hello, {name}!');
});
});
diff --git a/website/content/core-concepts/messages.mdx b/website/content/core-concepts/messages.mdx
index c53ce969..6c2b73f4 100644
--- a/website/content/core-concepts/messages.mdx
+++ b/website/content/core-concepts/messages.mdx
@@ -367,20 +367,101 @@ Every macro above has a JSX counterpart. The selector is the `_` prop, branches
```tsx
- You have items, last updated on
+ You have items, last updated on{' '}
```
+
+ Branch keys that start with a digit get a leading underscore in JSX (`_1`, `_2`) because JSX prop
+ names can't start with a number. SayKit strips the underscore at extract time.
+
+
+### Whitespace
+
+A JSX message extracts as the text JSX renders, character for character. There is one rule behind
+that, and it is the one React already applies to your markup: **a line break and the indentation
+around it are layout, not content.** Two lines that both hold text are rejoined with a single
+space, because the break is all that was separating the words. A break that sits against an element
+or an expression leaves nothing behind, and the two sides render touching.
+
+```tsx
+
+ Hello,
+ world!
+
+// Hello, world!
+
+
+ {days}
+ d
+
+// <0>{days}0>d
+```
+
+Anything written inside a line is content, so every space there survives, including a run of them.
+To keep a space across a break, write it as `{' '}` — it extracts as a plain space rather than as a
+placeholder, and Prettier inserts it for you when it wraps a line that ended in one. `{'\n'}`
+extracts as a line break the same way, and a space written this way survives at the very start or
+end of a message too.
+
+Any expression holding a literal is treated the same, because a literal is text that renders:
+`{'—'}`, `` {`\n`} ``, and `{10}` all extract as the characters they put on the page. HTML entities
+need no expression at all — `·` and ` ` are decoded into the message as `·` and a real
+non-breaking space.
+
+```tsx
+
+ Read the docs
+ , or ask us.
+
+// Read <0>the docs0>, or ask us.
+
+
+ Drag one across from{' '}
+ To do.
+
+// Drag one across from <0>To do0>.
+```
+
- Avoid the `{' '}` spacing escape inside a ``. It is an expression child like any other, so it
- extracts as a placeholder (`updated {0} `) and a translator is left moving a stray
- space around the sentence. Reword so the line break falls where a space belongs.
+ Ids are content hashes, so a whitespace change is a new message and orphans the translations of
+ the old one. Check what a message extracts to before shipping it, `saykit extract` prints the
+ catalogue it produces.
+### Braces in text
+
+`{` and `}` are ICU's own syntax, and a message that means them as characters is not writing ICU.
+Write the character; extraction quotes it on the way into the catalogue, and it renders as itself.
+You never write an ICU escape yourself.
+
+In JSX a bare `{` in text is a syntax error, so it comes from an expression or a character entity —
+either works:
+
+```tsx
+Use {'{'}name{'}'} here
+Use {name} here
+// both extract as: Use '{'name'}' here → renders as: Use {name} here
+```
+
+A `say` template takes the character directly, since its own interpolation is `${}` and a brace
+there was never asking for an argument:
+
+```ts
+say`Use {name} here`;
+// Use '{'name'}' here
+```
+
+The apostrophe is the character ICU quotes with, so it escapes itself: one in a message is doubled
+wherever ICU would read it as opening a quote, and left alone everywhere else, so `It's` stays
+`It's`. A bare `#` is the exception in the other direction — inside a plural that is the number
+being formatted, which is the one piece of ICU a message does write deliberately.
+
- Branch keys that start with a digit get a leading underscore in JSX (`_1`, `_2`) because JSX prop
- names can't start with a number. SayKit strips the underscore at extract time.
+ These escapes are what the catalogue holds, so a translator working in a PO or JSON file sees
+ `'{'` and `''`, and follows the same ICU rules when they write a translation. Nothing in your
+ source has to.
See [React integration](/integrations/react) for the full setup.
diff --git a/website/content/integrations/react.mdx b/website/content/integrations/react.mdx
index 52ca1335..ff5efeef 100644
--- a/website/content/integrations/react.mdx
+++ b/website/content/integrations/react.mdx
@@ -97,7 +97,7 @@ They are fragments rather than whole messages, so they nest inside a ``:
```tsx
- You have items, due
+ You have items, due{' '}
```
@@ -107,9 +107,11 @@ They are fragments rather than whole messages, so they nest inside a ``:
[Messages](/core-concepts/messages#numbers-dates-and-times) for what each one extracts to, and why
there is no `currency`.
-
- Don't use the `{' '}` spacing escape inside a ``. It is an expression child like any other, so
- it extracts as a placeholder and leaves a translator moving a stray space around the sentence.
+
+ A line break between two children is layout, not a space, exactly as it is in JSX itself, so the
+ `{' '}` above is what keeps "due" from running into the date. It extracts as a plain space inside
+ the message, and Prettier writes it for you when it wraps a line that ends in one. See
+ [whitespace](/core-concepts/messages#whitespace).
### Descriptors