Skip to content
Draft
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,9 @@
"transform": {
"^.+\\.(j|t)sx?$": "babel-jest"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"dependencies": {
"messageformat": "^4.0.0-9"
}
}
146 changes: 125 additions & 21 deletions src/formatElements.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
MessageFormat,
type MessagePart,
type MessageLiteralPart,
type MessageMarkupPart,
} from 'messageformat'
import React, { cloneElement, Fragment, ReactElement, ReactNode } from 'react'

export const tagParsingRegex = /<(\w+) *>(.*?)<\/\1 *>|<(\w+) *\/>/
Expand All @@ -20,33 +26,131 @@ export default function formatElements(
value: string,
elements: ReactElement[] | Record<string, ReactElement> = []
): string | ReactNode[] {
const parts = value.replace(nlRe, '').split(tagParsingRegex)
const message = value
.replace(/<(\d+\/?)>/g, '{#_$1}')
.replace(/<\/(\d+)>/g, '{/_$1}')
.replace(/<(\w+\/?)>/g, '{#$1}') // open/standalone
.replace(/<(\/\w+)>/g, '{$1}') // close
const mf = new MessageFormat(undefined, message)
const list = mf.formatToParts()
const processed = ProcessPartsList(list)
const contents = HetListToDOMTree(processed, elements)
return contents
}

type PartsList = Array<MessagePart | Markup>

if (parts.length === 1) return value
class Markup {
#markup: boolean
name: string
child: PartsList

const tree = []
constructor(name: string, child: PartsList) {
this.#markup = true
this.name = name
this.child = child
}

const before = parts.shift()
if (before) tree.push(before)
static isMarkup(obj: object): boolean {
return #markup in obj
}
}

getElements(parts).forEach(([key, children, after], realIndex: number) => {
const element =
// @ts-ignore
elements[key as string] || <Fragment />
function ProcessPartsList(parts: MessagePart[]): PartsList {
// Make a copy of `parts` so we can modify it
const toDo = [...parts]

tree.push(
cloneElement(
element,
{ key: realIndex },
// ProcessNodes() processes a flat list of message parts
// into a tree structure.
// (Currently only handles one level of nesting.)
// `accum` is the list of already-processed subtrees.
// The individual elements in the list are all `MessageParts`,
// but the lists in the returned value may be nested arbitrarily.
function ProcessNodes(accum: PartsList): PartsList {
if (toDo.length === 0) {
return accum
}
// Markup node: should be an `open` node if the output of formatToParts()
// is valid.
if (toDo[0].type === 'markup') {
const markupNode = toDo[0] as MessageMarkupPart
if (markupNode.kind === 'open') {
const openNode = toDo.shift() as MessageMarkupPart
// Recursively process everything between the open and close nodes
const tree = ProcessNodes([])
const closeNode = toDo.shift() as MessageMarkupPart
if (closeNode.kind !== 'close') {
console.log('Warning: unmatched tags!')
}
// Append a new subtree representing the tree denoted by this markup open/close pair
// TODO: To handle arbitrary nesting, we really want `tree` and not `...tree`
const subtree = new Markup(openNode.name, tree)
return ProcessNodes(accum.toSpliced(accum.length, 0, subtree))
}
// When we see a close tag, we just return the accumulator
if (markupNode.kind === 'close') {
return accum
}
}
// Default case (not markup): append onto the existing list
return ProcessNodes(accum.toSpliced(accum.length, 0, toDo.shift()!))
}
return ProcessNodes([])
}

// format children for pair tags
// unpaired tags might have children if it's a component passed as a variable
children ? formatElements(children, elements) : element.props.children
)
)
function handleMarkupName(name: string): string {
if (name.charAt(0) === '_') return name.substring(1)
return name
}

if (after) tree.push(after)
// hetList is really a list of arbitrarily-nested lists where all the
// leaf elements are MessageParts
function HetListToDOMTree(
hetList: PartsList,
components: Record<string, ReactElement> | Array<ReactElement>
): ReactElement[] {
return hetList.flatMap((part) => {
// part is either a (nested) list of MessageParts, or a single MessagePart
if (Markup.isMarkup(part)) {
// `subtree` is all the nodes between the open and the close
const markup = part as Markup
const subtree = HetListToDOMTree(markup.child, components)
// Use the name of the open node to look up the component in the map
// (we assume open.name === close.name)
// TODO: this means overlapping tags don't work
const component = components[handleMarkupName(markup.name)] //assert
// Finally, wrap the sublist in a component of the kind
// that matches its markup's name
return component
? React.cloneElement(component, undefined, ...subtree)
: subtree
}
if (Array.isArray(part)) {
return HetListToDOMTree(part, components)
}
// If part is not an array, it must be a MessagePart
const messagePart = part as MessagePart
switch (messagePart.type) {
case 'literal':
// Literals are just strings
return <>{(messagePart as MessageLiteralPart).value}</>
case 'markup':
// assert part.kind=standalone
return React.cloneElement(
components[(messagePart as MessageMarkupPart).name]
)
case 'number':
case 'datetime': {
return (
<>{messagePart.parts?.reduce((acc, part) => acc + part.value, '')}</>
)
}
case 'fallback': {
return <>{`{${messagePart.source}}`}</>
}
default: {
throw new Error(`unreachable: ${messagePart.type}`)
}
}
})

return tree
}
51 changes: 35 additions & 16 deletions src/transCore.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MessageFormat } from 'messageformat'
import {
I18nConfig,
I18nDictionary,
Expand Down Expand Up @@ -210,6 +211,10 @@ function plural(
return key
}

function convertMessageSyntax(message: string): string {
return message
}

/**
* Replace {{variables}} to query values
*/
Expand All @@ -234,22 +239,36 @@ function interpolation({
suffix = '}}',
} = config.interpolation || {}

const regexEnd =
suffix === '' ? '' : `(?:[\\s,]+([\\w-]*))?\\s*${escapeRegex(suffix)}`
return Object.keys(query).reduce((all, varKey) => {
const regex = new RegExp(
`${escapeRegex(prefix)}\\s*${varKey}${regexEnd}`,
'gm'
)
// $1 is the first match group
return all.replace(regex, (_match, $1) => {
// $1 undefined can mean either no formatting requested: "{{name}}"
// or no format name given: "{{name, }}" -> ignore
return $1 && format
? (format(query[varKey], $1, lang) as string)
: (query[varKey] as string)
})
}, text)
// This is a "dead" branch. BNEF doesn't use the interpolation feature and some of the test cases
// are convoluted enough that attempting to make it work with MF2 would be a massive waste of time
// so if format is set, we use the original logic but this is never practically the case for us,
// it's just to not have false negatives in the test suite.
if (format) {
const regexEnd =
suffix === '' ? '' : `(?:[\\s,]+([\\w-]*))?\\s*${escapeRegex(suffix)}`
return Object.keys(query).reduce((all, varKey) => {
const regex = new RegExp(
`${escapeRegex(prefix)}\\s*${varKey}${regexEnd}`,
'gm'
)
// $1 is the first match group
return all.replace(regex, (_match, $1) => {
// $1 undefined can mean either no formatting requested: "{{name}}"
// or no format name given: "{{name, }}" -> ignore
return $1 && format
? (format(query[varKey], $1, lang) as string)
: (query[varKey] as string)
})
}, text)
}

const whitespacesRE = '(?:\\s*)?'
const prefixRE = prefix ? `${escapeRegex(prefix)}${whitespacesRE}` : ''
const suffixRE = suffix ? `${whitespacesRE}${escapeRegex(suffix)}` : ''
const varRE = new RegExp(`${prefixRE}([\\d\\w]+)(?:,.*?)?${suffixRE}`, 'g')
const mfText = text.replaceAll(varRE, '{$$$1}')
const mf = new MessageFormat(lang, mfText)
return mf.format(query)
}

function objectInterpolation({
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4492,6 +4492,11 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==

messageformat@^4.0.0-9:
version "4.0.0-9"
resolved "https://registry.yarnpkg.com/messageformat/-/messageformat-4.0.0-9.tgz#c8e49b6059ee561a9be8b01b85f0366adb0f851f"
integrity sha512-4QHEgN5EiK0w8XmMztn+hdnuJuaChp0bv6D1iw1GvFMR9FVDF9sDk0KfIM9XnBjqa0ekK8wBsX7lyMNfD53dqQ==

methods@^1.1.2, methods@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
Expand Down