diff --git a/packages/intl/src/format/rich.spec.ts b/packages/intl/src/format/rich.spec.ts index 01f76881..ca9ddbc9 100644 --- a/packages/intl/src/format/rich.spec.ts +++ b/packages/intl/src/format/rich.spec.ts @@ -139,6 +139,67 @@ describe('intl', () => { 'second' ]) }) + + it('should support nested calls in tag handlers', () => { + const nestedTags = { + ...tags, + wrap: (chunks: (string | Node)[]) => ({ + type: 'wrap', + children: [...chunks, ...mapTags('inner', tags)] + }) + } + + expect(mapTags('outer and after', nestedTags)).toEqual([ + { + type: 'wrap', + children: [ + 'outer', + { + type: 'strong', + children: ['inner'] + } + ] + }, + ' and ', + { + type: 'strong', + children: ['after'] + } + ]) + }) + + it('should pass unique index to tag handlers', () => { + const indexedTags = { + strong: (chunks: unknown[], index: number) => ({ + type: 'strong', + index, + children: chunks + }), + br: (_: unknown[], index: number) => ({ + type: 'br', + index, + children: [] + }) + } + + expect(mapTags('First
second', indexedTags)).toEqual([ + { + type: 'strong', + index: 0, + children: ['First'] + }, + { + type: 'br', + index: 1, + children: [] + }, + { + type: 'strong', + index: 2, + children: ['second'] + } + ]) + }) }) }) }) diff --git a/packages/intl/src/format/rich.ts b/packages/intl/src/format/rich.ts index c36dcb78..a318e4cc 100644 --- a/packages/intl/src/format/rich.ts +++ b/packages/intl/src/format/rich.ts @@ -2,7 +2,7 @@ import type { Format } from './types.js' export type RichChunks = (string | T)[] -export type RichTag = (chunks: RichChunks) => T +export type RichTag = (chunks: RichChunks, index: number) => T export type RichTags = Record> @@ -72,6 +72,7 @@ interface RichFrame { /** * Maps rich-text tags in a string to chunks using provided tag handlers. + * Each handler call receives an unique index, usable as a key for framework nodes. * Self-closing tags are mapped with empty chunks. * Unknown or malformed tags are ignored as markup and only their text content is kept. * @param input - Rich-text input string. @@ -89,8 +90,12 @@ export function mapTags( } const stack = [root] let lastIndex = 0 + let index = 0 let match: RegExpExecArray | null + // reset the shared regex state possibly clobbered by an interrupted outer call + tagRegex.lastIndex = 0 + while (match = tagRegex.exec(input)) { const [, closing, tag, selfClosing] = match const current = stack[stack.length - 1] @@ -99,16 +104,18 @@ export function mapTags( current.chunks.push(input.slice(lastIndex, match.index)) } + lastIndex = tagRegex.lastIndex + if (tag in tags) { if (closing) { if (current.tag === tag) { stack.pop() stack[stack.length - 1].chunks.push( - tags[tag](current.chunks) + tags[tag](current.chunks, index++) ) } } else if (selfClosing) { - current.chunks.push(tags[tag]([])) + current.chunks.push(tags[tag]([], index++)) } else { stack.push({ tag, @@ -117,7 +124,8 @@ export function mapTags( } } - lastIndex = tagRegex.lastIndex + // restore the shared regex state possibly clobbered by a nested call in a tag handler + tagRegex.lastIndex = lastIndex } if (lastIndex < input.length) { diff --git a/website/src/content/docs/intl/api.mdx b/website/src/content/docs/intl/api.mdx index 73c03d4d..464c2689 100644 --- a/website/src/content/docs/intl/api.mdx +++ b/website/src/content/docs/intl/api.mdx @@ -410,17 +410,18 @@ $t().message({ ### `rich(tags)` / `rich(fallback, tags)` -Maps lightweight tags inside translated strings to rich chunks. +Maps lightweight tags inside translated strings to rich chunks. Self-closing tags like `
` are supported and mapped with empty chunks. Each tag handler also receives an unique index, usable as a key for framework nodes. ```ts const [$t] = messages('docs', { - message: rich('Read the docs', { - link: chunks => {chunks} + message: rich('Read the docs
today', { + link: (chunks, i) => {chunks}, + br: (_, i) =>
}) }) $t().message -// ['Read ', the docs] +// ['Read ', the docs,
, 'today'] ``` ### `markup(tags)` / `markup(fallback, tags)`