@18ways/mdx-translate 0.1.0-alpha.42e08a82e9b3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import { createElement, type ReactNode } from 'react';
4
+ import { useT } from '@18ways/react';
5
+
6
+ type StaticImageLike = {
7
+ src?: string;
8
+ width?: number;
9
+ height?: number;
10
+ };
11
+
12
+ type TranslatedNodeProps = {
13
+ component: keyof JSX.IntrinsicElements;
14
+ translateProps?: string[];
15
+ children?: ReactNode;
16
+ } & Record<string, unknown>;
17
+
18
+ const isStaticImageLike = (value: unknown): value is StaticImageLike =>
19
+ Boolean(
20
+ value &&
21
+ typeof value === 'object' &&
22
+ 'src' in value &&
23
+ typeof (value as StaticImageLike).src === 'string'
24
+ );
25
+
26
+ export function TranslatedNode({
27
+ component,
28
+ translateProps = [],
29
+ children,
30
+ ...props
31
+ }: TranslatedNodeProps) {
32
+ const t = useT();
33
+ const translatedProps: Record<string, unknown> = { ...props };
34
+
35
+ for (const propName of translateProps) {
36
+ const value = translatedProps[propName];
37
+ if (typeof value === 'string' && value.trim()) {
38
+ translatedProps[propName] = t(value) as string;
39
+ }
40
+ }
41
+
42
+ if (component === 'img') {
43
+ const src = translatedProps.src;
44
+ if (isStaticImageLike(src)) {
45
+ translatedProps.src = src.src;
46
+
47
+ if (translatedProps.width == null && typeof src.width === 'number') {
48
+ translatedProps.width = src.width;
49
+ }
50
+
51
+ if (translatedProps.height == null && typeof src.height === 'number') {
52
+ translatedProps.height = src.height;
53
+ }
54
+ }
55
+
56
+ delete translatedProps.placeholder;
57
+ delete translatedProps.blurDataURL;
58
+ }
59
+
60
+ return createElement(component, translatedProps, children);
61
+ }
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compile } from '@mdx-js/mdx';
3
+ import { remarkMdxTranslate } from '../src/index';
4
+
5
+ const compileWithPlugin = async (source: string) =>
6
+ String(
7
+ await compile(source, {
8
+ path: '/virtual/apps/next-site/src/app/blog/post/page.mdx',
9
+ outputFormat: 'program',
10
+ providerImportSource: 'next-mdx-import-source-file',
11
+ remarkPlugins: [
12
+ [
13
+ remarkMdxTranslate,
14
+ {
15
+ include: /[\\/]src[\\/]app[\\/]blog[\\/]/,
16
+ },
17
+ ],
18
+ ],
19
+ })
20
+ );
21
+
22
+ describe('remarkMdxTranslate', () => {
23
+ it('wraps headings and paragraphs in T while keeping inline code inside the translated run', async () => {
24
+ const output = await compileWithPlugin(
25
+ ['## Hello *world*', '', 'Browsers send the `Accept-Language` header.'].join('\n')
26
+ );
27
+
28
+ expect(output).toContain('import {T} from "@18ways/react";');
29
+ expect(output).toContain('children: _jsxs(T, {');
30
+ expect(output).toContain('children: ["Hello "');
31
+ expect(output).toContain('children: ["Browsers send the "');
32
+ expect(output).toContain('children: "Accept-Language"');
33
+ expect(output).toContain('" header."]');
34
+ });
35
+
36
+ it('rewrites markdown images with translatable alt text to TranslatedNode', async () => {
37
+ const output = await compileWithPlugin('![Hero image](./hero.png "Cover image")');
38
+
39
+ expect(output).toContain('import {TranslatedNode} from "@18ways/mdx-translate/runtime";');
40
+ expect(output).toContain('import __mdxTranslateImg_0 from "./hero.png";');
41
+ expect(output).toContain('component: "img"');
42
+ expect(output).toContain('translateProps: ["alt", "title"]');
43
+ expect(output).toContain('alt: "Hero image"');
44
+ expect(output).toContain('title: "Cover image"');
45
+ expect(output).toContain('src: __mdxTranslateImg_0');
46
+ });
47
+
48
+ it('rewrites lowercase JSX nodes with translatable string props', async () => {
49
+ const output = await compileWithPlugin(
50
+ '<button title="Open settings" aria-label="Open settings">Settings</button>'
51
+ );
52
+
53
+ expect(output).toContain('import {TranslatedNode} from "@18ways/mdx-translate/runtime";');
54
+ expect(output).toContain('component: "button"');
55
+ expect(output).toContain('translateProps: ["title", "aria-label"]');
56
+ expect(output).toContain('title: "Open settings"');
57
+ expect(output).toContain('"aria-label": "Open settings"');
58
+ });
59
+
60
+ it('does not nest an existing T wrapper', async () => {
61
+ const output = await compileWithPlugin(
62
+ ['import { T } from "@18ways/react"', '', '## <T>Hello world</T>'].join('\n')
63
+ );
64
+
65
+ expect((output.match(/_jsx\(T/g) || []).length).toBe(1);
66
+ expect((output.match(/import \{T\} from "@18ways\/react";/g) || []).length).toBe(1);
67
+ });
68
+
69
+ it('wraps a whole paragraph even when it contains inline JSX children', async () => {
70
+ const output = await compileWithPlugin(
71
+ 'Internationalisation (often shortened to <b>i18n</b>) and <Avatar /> examples.'
72
+ );
73
+
74
+ expect(output).toContain('children: _jsxs(T, {');
75
+ expect(output).toContain('children: ["Internationalisation (often shortened to "');
76
+ expect(output).toContain('_jsx("b", {');
77
+ expect(output).toContain('children: "i18n"');
78
+ expect(output).toContain('_jsx(Avatar, {})');
79
+ expect(output).toContain('" examples."]');
80
+ });
81
+
82
+ it('wraps table cells as a single T container', () => {
83
+ const transform = remarkMdxTranslate({
84
+ include: /[\\/]src[\\/]app[\\/]blog[\\/]/,
85
+ });
86
+
87
+ const tree = {
88
+ type: 'root',
89
+ children: [
90
+ {
91
+ type: 'table',
92
+ align: [],
93
+ children: [
94
+ {
95
+ type: 'tableRow',
96
+ children: [
97
+ {
98
+ type: 'tableCell',
99
+ children: [
100
+ { type: 'text', value: 'Hello ' },
101
+ {
102
+ type: 'mdxJsxTextElement',
103
+ name: 'img',
104
+ attributes: [
105
+ { type: 'mdxJsxAttribute', name: 'src', value: '/me.png' },
106
+ { type: 'mdxJsxAttribute', name: 'alt', value: 'Me' },
107
+ ],
108
+ children: [],
109
+ },
110
+ { type: 'text', value: ' there' },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ },
117
+ ],
118
+ } as any;
119
+
120
+ transform(tree, {
121
+ path: '/virtual/apps/next-site/src/app/blog/post/page.mdx',
122
+ });
123
+
124
+ const table = tree.children.find((child: any) => child.type === 'table');
125
+ const tableCell = table.children[0].children[0];
126
+ expect(tableCell.children).toHaveLength(1);
127
+ expect(tableCell.children[0].type).toBe('mdxJsxTextElement');
128
+ expect(tableCell.children[0].name).toBe('T');
129
+ expect(tableCell.children[0].children[0].value).toBe('Hello ');
130
+ expect(tableCell.children[0].children[1].name).toBe('TranslatedNode');
131
+ expect(tableCell.children[0].children[2].value).toBe(' there');
132
+ });
133
+
134
+ it('leaves JSX code fences unchanged for whole-block translation at render time', async () => {
135
+ const output = await compileWithPlugin(
136
+ [
137
+ '```jsx',
138
+ '<h1>Hello world!</h1>',
139
+ '<p>',
140
+ ' <a href="#/">Click here</a> to view your <b>{serverResponse.productDescription}</b>',
141
+ '</p>',
142
+ '```',
143
+ ].join('\n')
144
+ );
145
+
146
+ expect(output).toContain('className: "language-jsx"');
147
+ expect(output).toContain('children: "<h1>Hello world!</h1>');
148
+ expect(output).toContain('<a href=\\"#/\\">Click here</a> to view your');
149
+ });
150
+
151
+ it('does not inject translation helpers into function code fences', async () => {
152
+ const output = await compileWithPlugin(
153
+ [
154
+ '```jsx',
155
+ 'export function Hero() {',
156
+ ' return <img alt="Company logo" src="/logo.png" />;',
157
+ '}',
158
+ '```',
159
+ ].join('\n')
160
+ );
161
+
162
+ expect(output).toContain('children: "export function Hero() {');
163
+ expect(output).toContain('return <img alt=\\"Company logo\\" src=\\"/logo.png\\" />;');
164
+ expect(output).not.toContain('useT');
165
+ });
166
+
167
+ it('does not rewrite existing T wrappers inside JSX code fences', async () => {
168
+ const output = await compileWithPlugin(
169
+ [
170
+ '```jsx',
171
+ '<h1><T>Hello world!</T></h1>',
172
+ '<p>',
173
+ ' <T vars={{ description: serverResponse.productDescription }}>',
174
+ ' <a href="#/">Click here</a> to view your <b>{description}</b>',
175
+ ' </T>',
176
+ '</p>',
177
+ '```',
178
+ ].join('\n')
179
+ );
180
+
181
+ expect(output).toContain('children: "<h1><T>Hello world!</T></h1>');
182
+ });
183
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "lib": ["ES2020", "DOM"],
7
+ "rootDir": "./src",
8
+ "outDir": "./dist",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "allowSyntheticDefaultImports": true,
17
+ "jsx": "react-jsx",
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@18ways/react": ["./src/react-runtime-shim.d.ts"]
21
+ }
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["dist", "node_modules", "tests"]
25
+ }