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}`; } @@ -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 task or drag one across from <1>To do.'); + ).toBe('<0>{days}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} of <1>{b} this week'); + ).toBe('<0>1<1>2'); }); - 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 docs.'); + }); + + 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>brave 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>brave world!', + 'Hello <0>brave world!', ); }); it('keeps no space where the source has none', () => { expect(extract('(brave)')).toBe('(<0>brave)'); }); -}); -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 docs and<1>the examples 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 task or drag one across from <1>To do.'); }); - it('trims around a leading and trailing element', () => { - expect( - extract(` - Hello, world - `), - ).toBe('<0>Hello, 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 server.', - ); + 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 team, 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>set ?'); + 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 docs (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}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 docs, or ask us. + + + Drag one across from{' '} + To do. + +// Drag one across from <0>To do. +``` + - 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