Skip to content

Commit 915a0f5

Browse files
feat(cli): code formatting support for advanced mdx (#656)
* feat(cli): code formatting support for advanced `mdx` * feat: changeset
1 parent 7a314b1 commit 915a0f5

3 files changed

Lines changed: 111 additions & 20 deletions

File tree

.changeset/warm-paws-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": minor
3+
---
4+
5+
code formatting support for advanced mdx
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from "vitest";
2+
import { createMdxFormatLoader } from "./mdx";
3+
4+
// Helper to traverse mdast tree
5+
function traverse(node: any, visitor: (n: any) => void) {
6+
visitor(node);
7+
if (node && Array.isArray(node.children)) {
8+
node.children.forEach((child: any) => traverse(child, visitor));
9+
}
10+
}
11+
12+
describe("mdx loader", () => {
13+
const mdxSample = `\n# Heading\n\nHere is some code:\n\n\u0060\u0060\u0060js\nconsole.log("hello");\n\u0060\u0060\u0060\n\nSome inline \u0060world\u0060 and more text.\n`;
14+
15+
describe("createMdxFormatLoader", () => {
16+
it("should strip values of code and inlineCode nodes on pull", async () => {
17+
const loader = createMdxFormatLoader();
18+
loader.setDefaultLocale("en");
19+
20+
const ast = await loader.pull("en", mdxSample);
21+
22+
// Assert that every code or inlineCode node now has an empty value
23+
traverse(ast, (node) => {
24+
if (node?.type === "code" || node?.type === "inlineCode") {
25+
expect(node.value).toBe("");
26+
}
27+
});
28+
});
29+
30+
it("should preserve original code & inlineCode content on push when incoming value is empty", async () => {
31+
const loader = createMdxFormatLoader();
32+
loader.setDefaultLocale("en");
33+
34+
const pulledAst = await loader.pull("en", mdxSample);
35+
const output = await loader.push("es", pulledAst);
36+
37+
// The serialized output must still contain the original code and inline code content
38+
expect(output).toContain('console.log("hello");');
39+
expect(output).toMatch(/`world`/);
40+
});
41+
});
42+
});

packages/cli/src/cli/loaders/mdx.ts

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,54 @@
11
import _ from "lodash";
22
import { unified } from "unified";
33
import remarkParse from "remark-parse";
4-
import remarkMdx from "remark-mdx";
54
import remarkFrontmatter from "remark-frontmatter";
65
import remarkGfm from "remark-gfm";
76
import remarkStringify from "remark-stringify";
87
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
98
import { VFile } from "vfile";
10-
import { Root } from "mdast";
9+
import { Root, RootContent, RootContentMap } from "mdast";
1110
import { ILoader } from "./_types";
1211
import { createLoader } from "./_utils";
1312

14-
const parser = unified()
15-
.use(remarkParse)
16-
.use(remarkMdx)
17-
.use(remarkFrontmatter, ["yaml"])
18-
.use(remarkMdxFrontmatter)
19-
.use(remarkGfm);
20-
21-
const serializer = unified()
22-
.use(remarkStringify)
23-
.use(remarkMdx)
24-
.use(remarkFrontmatter, ["yaml"])
25-
.use(remarkMdxFrontmatter)
26-
.use(remarkGfm);
13+
const parser = unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm);
14+
const serializer = unified().use(remarkStringify).use(remarkFrontmatter, ["yaml"]).use(remarkGfm);
2715

2816
export function createMdxFormatLoader(): ILoader<string, Record<string, any>> {
17+
const skippedTypes: (keyof RootContentMap | "root")[] = ["code", "inlineCode"];
2918
return createLoader({
3019
async pull(locale, input) {
3120
const file = new VFile(input);
3221
const ast = parser.parse(file);
33-
return JSON.parse(JSON.stringify(ast));
22+
23+
const result = _.cloneDeep(ast);
24+
25+
traverseMdast(result, (node) => {
26+
if (skippedTypes.includes(node.type)) {
27+
if ("value" in node) {
28+
node.value = "";
29+
}
30+
}
31+
});
32+
33+
return result;
3434
},
3535

36-
async push(locale, data) {
37-
const ast = data as unknown as Root;
38-
const content = String(serializer.stringify(ast));
39-
return content;
36+
async push(locale, data, originalInput, originalLocale, pullInput, pullOutput) {
37+
const file = new VFile(originalInput);
38+
const ast = parser.parse(file);
39+
40+
const result = _.cloneDeep(ast);
41+
42+
traverseMdast(result, (node, indexPath) => {
43+
if ("value" in node) {
44+
const incomingValue = findNodeByIndexPath(data, indexPath);
45+
if (incomingValue && "value" in incomingValue && !_.isEmpty(incomingValue.value)) {
46+
node.value = incomingValue.value;
47+
}
48+
}
49+
});
50+
51+
return String(serializer.stringify(result));
4052
},
4153
});
4254
}
@@ -73,3 +85,35 @@ export function createMdxStructureLoader(): ILoader<Record<string, any>, Record<
7385
},
7486
});
7587
}
88+
89+
function traverseMdast(
90+
ast: Root | RootContent,
91+
visitor: (node: Root | RootContent, path: number[]) => void,
92+
indexPath: number[] = [],
93+
) {
94+
visitor(ast, indexPath);
95+
96+
if ("children" in ast && Array.isArray(ast.children)) {
97+
for (let i = 0; i < ast.children.length; i++) {
98+
traverseMdast(ast.children[i], visitor, [...indexPath, i]);
99+
}
100+
}
101+
}
102+
103+
function findNodeByIndexPath(ast: Root | RootContent, indexPath: number[]): Root | RootContent | null {
104+
let result: Root | RootContent | null = null;
105+
106+
const stringifiedIndexPath = indexPath.join(".");
107+
traverseMdast(ast, (node, path) => {
108+
if (result) {
109+
return;
110+
}
111+
112+
const currentStringifiedPath = path.join(".");
113+
if (currentStringifiedPath === stringifiedIndexPath) {
114+
result = node;
115+
}
116+
});
117+
118+
return result;
119+
}

0 commit comments

Comments
 (0)