import { Fragment, type ReactNode } from 'react';
import type {
MessageMarkupAttributes,
MessageMarkupOptions,
MessageMarkupSchema,
MessageMarkupTag,
MessageMetadata,
MessagePart,
} from '#/paraglide/runtime';
// derived from @inlang/paraglide-js-react (MIT, (c) Opral US Inc.). renders a paraglide message that
// carries markup: the message file decides which tags appear and where (`{#tag}…{/tag}`), while the
// `markup` prop decides how each tag renders. plain (markup-free) messages render their string
// directly. see the `{#tN}` markup tags in messages/en/*.json.
//
// the generics derive `inputs` and `markup` from the message's own type (paraglide attaches them as
// phantom MessageMetadata), so a wrong input or an unknown/missing markup tag is a type error.
/** Renders one markup tag instance: its wrapped `children`, plus the tag's `options`/`attributes`. */
type MarkupRenderer<Tag extends MessageMarkupTag = MessageMarkupTag> = (args: {
attributes: Tag['attributes'];
children?: ReactNode;
options: Tag['options'];
}) => ReactNode;
/** A compiled paraglide message function, optionally exposing `.parts()` for markup messages. */
type Message = ((...args: never[]) => string) & {
parts?: (...args: never[]) => MessagePart[];
};
type MetaOf<M> =
M extends MessageMetadata<infer Inputs, infer Options, infer Markup>
? { inputs: Inputs; markup: Markup; options: Options }
: never;
type InputsOf<M> = MetaOf<M> extends { inputs: infer Inputs } ? Inputs : Record<string, never>;
type MarkupOf<M> =
MetaOf<M> extends { markup: infer Markup extends MessageMarkupSchema } ? Markup : MessageMarkupSchema;
type InputsProp<Inputs> = keyof Inputs extends never ? { inputs?: Inputs } : { inputs: Inputs };
type MarkupProp<Markup extends MessageMarkupSchema> = keyof Markup extends never
? { markup?: never }
: {
markup: {
[K in keyof Markup & string]: MarkupRenderer<
Markup[K] extends MessageMarkupTag ? Markup[K] : MessageMarkupTag
>;
};
};
type TransProps<M> = { message: M } & InputsProp<InputsOf<M>> & MarkupProp<MarkupOf<M>>;
type AnyRenderer = (args: {
attributes: MessageMarkupAttributes;
children?: ReactNode;
options: MessageMarkupOptions;
}) => ReactNode;
interface Frame {
attributes: MessageMarkupAttributes;
children: ReactNode[];
name: string;
options: MessageMarkupOptions;
}
const toChildren = (nodes: ReactNode[]): ReactNode => {
if (nodes.length === 0) {
return null;
}
if (nodes.length === 1) {
return nodes[0] ?? null;
}
// oxlint-disable-next-line react/no-array-index-key -- message segments are positional
return nodes.map((node, i) => <Fragment key={i}>{node}</Fragment>);
};
const renderParts = (parts: MessagePart[], markup: Record<string, AnyRenderer>): ReactNode => {
const root: ReactNode[] = [];
const stack: Frame[] = [];
const append = (node: ReactNode) => {
const top = stack[stack.length - 1];
(top ? top.children : root).push(node);
};
const render = (frame: Frame): ReactNode => {
const renderer = markup[frame.name];
const children = toChildren(frame.children);
if (!renderer) {
return children;
}
return renderer({
attributes: frame.attributes,
children: children ?? undefined,
options: frame.options,
});
};
for (const part of parts) {
switch (part.type) {
case 'markup-end': {
const frame = stack.pop();
if (!frame || frame.name !== part.name) {
throw new Error(`mismatched markup: ${part.name}`);
}
append(render(frame));
break;
}
case 'markup-standalone': {
append(render({ attributes: part.attributes, children: [], name: part.name, options: part.options }));
break;
}
case 'markup-start': {
stack.push({ attributes: part.attributes, children: [], name: part.name, options: part.options });
break;
}
case 'text': {
append(part.value);
break;
}
}
}
if (stack.length > 0) {
throw new Error(`unclosed markup: ${stack[stack.length - 1]?.name}`);
}
return toChildren(root);
};
/**
* renders a paraglide message that interleaves React components.
*
* @param message the paraglide message function
* @param inputs the message's variables
* @param markup renderers for each markup tag in the message
* @returns the rendered message
*/
export const Trans = <M extends Message>(props: TransProps<NoInfer<M>> & { message: M }): ReactNode => {
const { message } = props;
const inputs = 'inputs' in props ? props.inputs : undefined;
if (typeof message.parts !== 'function') {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- `TransProps` already checked `inputs` against this message's metadata
return message(inputs as never);
}
const markup = ('markup' in props ? props.markup : undefined) as Record<string, AnyRenderer> | undefined;
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- `TransProps` already checked `inputs` against this message's metadata
return renderParts(message.parts(inputs as never), markup ?? {});
};