Skip to content

Commit ce8c75c

Browse files
authored
feat: add EJS (Embedded JavaScript) templating engine support (#956)
1 parent 7dffbb3 commit ce8c75c

File tree

8 files changed

+506
-0
lines changed

8 files changed

+506
-0
lines changed

.changeset/add-ejs-support.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"lingo.dev": minor
3+
"@lingo.dev/_spec": minor
4+
---
5+
6+
feat: add EJS (Embedded JavaScript) templating engine support
7+
8+
- Added EJS loader to support parsing and translating EJS template files
9+
- EJS loader extracts translatable text while preserving EJS tags and expressions
10+
- Updated spec package to include "ejs" in supported bucket types
11+
- Added comprehensive test suite covering various EJS scenarios including conditionals, loops, includes, and mixed content
12+
- Automatically installed EJS dependency (@types/ejs) for TypeScript support

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"@modelcontextprotocol/sdk": "^1.5.0",
131131
"@openrouter/ai-sdk-provider": "^0.7.1",
132132
"@paralleldrive/cuid2": "^2.2.2",
133+
"@types/ejs": "^3.1.5",
133134
"ai": "^4.3.15",
134135
"bitbucket": "^2.12.0",
135136
"chalk": "^5.4.1",
@@ -142,6 +143,7 @@
142143
"dedent": "^1.5.3",
143144
"diff": "^7.0.0",
144145
"dotenv": "^16.4.7",
146+
"ejs": "^3.1.10",
145147
"express": "^5.1.0",
146148
"external-editor": "^3.1.0",
147149
"figlet": "^1.8.0",
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect } from "vitest";
2+
import createEjsLoader from "./ejs";
3+
4+
describe("EJS Loader", () => {
5+
const loader = createEjsLoader().setDefaultLocale("en");
6+
7+
describe("pull", () => {
8+
it("should extract translatable text from simple EJS template", async () => {
9+
const input = `
10+
<h1>Welcome to our website</h1>
11+
<p>Hello <%= name %>, you have <%= messages.length %> messages.</p>
12+
<footer>© 2024 Our Company</footer>
13+
`;
14+
15+
const result = await loader.pull("en", input);
16+
17+
// Check that we have extracted some translatable content
18+
expect(Object.keys(result).length).toBeGreaterThan(0);
19+
20+
// Check that the EJS variables are not included in the translatable text
21+
const allValues = Object.values(result).join(' ');
22+
expect(allValues).not.toContain('<%= name %>');
23+
expect(allValues).not.toContain('<%= messages.length %>');
24+
25+
// Check that we have the main content
26+
expect(allValues).toContain('Welcome to our website');
27+
expect(allValues).toContain('Hello');
28+
expect(allValues).toContain('messages');
29+
expect(allValues).toContain('© 2024 Our Company');
30+
});
31+
32+
it("should handle EJS templates with various tag types", async () => {
33+
const input = `
34+
<div>
35+
<h2>User Dashboard</h2>
36+
<% if (user.isAdmin) { %>
37+
<p>Admin Panel</p>
38+
<% } %>
39+
<%# This is a comment %>
40+
<p>Welcome back, <%- user.name %></p>
41+
<span>Last login: <%= formatDate(user.lastLogin) %></span>
42+
</div>
43+
`;
44+
45+
const result = await loader.pull("en", input);
46+
47+
expect(result).toHaveProperty("text_0");
48+
expect(result).toHaveProperty("text_1");
49+
expect(Object.keys(result).length).toBeGreaterThan(0);
50+
});
51+
52+
it("should handle empty input", async () => {
53+
const result = await loader.pull("en", "");
54+
expect(result).toEqual({});
55+
});
56+
57+
it("should handle input with only EJS tags", async () => {
58+
const input = "<%= variable %><% if (condition) { %><% } %>";
59+
const result = await loader.pull("en", input);
60+
expect(result).toEqual({});
61+
});
62+
63+
it("should handle mixed content", async () => {
64+
const input = `
65+
Welcome <%= user.name %>!
66+
<% for (let i = 0; i < items.length; i++) { %>
67+
Item: <%= items[i].name %>
68+
<% } %>
69+
Thank you for visiting.
70+
`;
71+
72+
const result = await loader.pull("en", input);
73+
expect(Object.keys(result).length).toBeGreaterThan(0);
74+
expect(Object.values(result).some(text => text.includes("Welcome"))).toBe(true);
75+
expect(Object.values(result).some(text => text.includes("Thank you"))).toBe(true);
76+
});
77+
});
78+
79+
describe("push", () => {
80+
it("should reconstruct EJS template with translated content", async () => {
81+
const originalInput = `<h1>Welcome</h1><p>Hello <%= name %></p>`;
82+
83+
// First pull to get the structure
84+
const pulled = await loader.pull("en", originalInput);
85+
86+
// Static translated data object based on actual loader behavior
87+
const translated = {
88+
text_0: "Bienvenido",
89+
text_1: "Hola"
90+
};
91+
92+
const result = await loader.push("es", translated);
93+
94+
// Test against the expected reconstructed string
95+
const expectedOutput = `<h1>Bienvenido</h1><p>Hola <%= name %></p>`;
96+
97+
expect(result).toBe(expectedOutput);
98+
});
99+
100+
it("should handle complex EJS templates", async () => {
101+
const originalInput = `<h2>Dashboard</h2><% if (user) { %><p>Welcome</p><% } %>`;
102+
103+
const pulled = await loader.pull("en", originalInput);
104+
105+
// Static translated data object
106+
const translated = {
107+
text_0: "Tablero",
108+
text_1: "Bienvenido"
109+
};
110+
111+
const result = await loader.push("es", translated);
112+
113+
// Test against the expected reconstructed string
114+
const expectedOutput = `<h2>Tablero</h2><% if (user) { %><p>Bienvenido</p><% } %>`;
115+
116+
expect(result).toBe(expectedOutput);
117+
});
118+
119+
it("should handle missing original input", async () => {
120+
const translated = {
121+
text_0: "Hello World",
122+
text_1: "This is a test"
123+
};
124+
125+
const result = await loader.push("es", translated);
126+
127+
expect(result).toContain("Hello World");
128+
expect(result).toContain("This is a test");
129+
});
130+
});
131+
132+
describe("round trip", () => {
133+
it("should maintain EJS functionality after round trip", async () => {
134+
const originalInput = `
135+
<h1>Welcome <%= title %></h1>
136+
<% if (showMessage) { %>
137+
<p>Hello <%= user.name %>, you have <%= count %> new messages.</p>
138+
<% } %>
139+
<ul>
140+
<% items.forEach(function(item) { %>
141+
<li><%= item.name %> - $<%= item.price %></li>
142+
<% }); %>
143+
</ul>
144+
<footer>Contact us at [email protected]</footer>
145+
`;
146+
147+
// Pull original content
148+
const pulled = await loader.pull("en", originalInput);
149+
150+
// Push back without translation (should be identical)
151+
const reconstructed = await loader.push("en", pulled);
152+
153+
// Verify EJS tags are preserved
154+
expect(reconstructed).toContain("<%= title %>");
155+
expect(reconstructed).toContain("<% if (showMessage) { %>");
156+
expect(reconstructed).toContain("<%= user.name %>");
157+
expect(reconstructed).toContain("<%= count %>");
158+
expect(reconstructed).toContain("<% items.forEach(function(item) { %>");
159+
expect(reconstructed).toContain("<%= item.name %>");
160+
expect(reconstructed).toContain("<%= item.price %>");
161+
expect(reconstructed).toContain("<% }); %>");
162+
expect(reconstructed).toContain("Contact us at [email protected]");
163+
});
164+
});
165+
});

packages/cli/src/cli/loaders/ejs.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as ejs from "ejs";
2+
import { ILoader } from "./_types";
3+
import { createLoader } from "./_utils";
4+
5+
interface EjsParseResult {
6+
content: string;
7+
translatable: Record<string, string>;
8+
}
9+
10+
function parseEjsForTranslation(input: string): EjsParseResult {
11+
const translatable: Record<string, string> = {};
12+
let counter = 0;
13+
14+
// Regular expression for all EJS tags
15+
const ejsTagRegex = /<%[\s\S]*?%>/g;
16+
17+
// Split content by EJS tags, preserving both text and EJS parts
18+
const parts: Array<{ type: 'text' | 'ejs', content: string }> = [];
19+
let lastIndex = 0;
20+
let match;
21+
22+
while ((match = ejsTagRegex.exec(input)) !== null) {
23+
// Add text before the tag
24+
if (match.index > lastIndex) {
25+
parts.push({
26+
type: 'text',
27+
content: input.slice(lastIndex, match.index)
28+
});
29+
}
30+
// Add the EJS tag
31+
parts.push({
32+
type: 'ejs',
33+
content: match[0]
34+
});
35+
lastIndex = match.index + match[0].length;
36+
}
37+
38+
// Add remaining text after the last tag
39+
if (lastIndex < input.length) {
40+
parts.push({
41+
type: 'text',
42+
content: input.slice(lastIndex)
43+
});
44+
}
45+
46+
// Build the template and extract translatable content
47+
let template = '';
48+
49+
for (const part of parts) {
50+
if (part.type === 'ejs') {
51+
// Keep EJS tags as-is
52+
template += part.content;
53+
} else {
54+
// For text content, extract translatable parts while preserving HTML structure
55+
const textContent = part.content;
56+
57+
// Extract text content from HTML tags while preserving structure
58+
const htmlTagRegex = /<[^>]+>/g;
59+
const textParts: Array<{ type: 'html' | 'text', content: string }> = [];
60+
let lastTextIndex = 0;
61+
let htmlMatch;
62+
63+
while ((htmlMatch = htmlTagRegex.exec(textContent)) !== null) {
64+
// Add text before the HTML tag
65+
if (htmlMatch.index > lastTextIndex) {
66+
const textBefore = textContent.slice(lastTextIndex, htmlMatch.index);
67+
if (textBefore.trim()) {
68+
textParts.push({ type: 'text', content: textBefore });
69+
} else {
70+
textParts.push({ type: 'html', content: textBefore });
71+
}
72+
}
73+
// Add the HTML tag
74+
textParts.push({ type: 'html', content: htmlMatch[0] });
75+
lastTextIndex = htmlMatch.index + htmlMatch[0].length;
76+
}
77+
78+
// Add remaining text after the last HTML tag
79+
if (lastTextIndex < textContent.length) {
80+
const remainingText = textContent.slice(lastTextIndex);
81+
if (remainingText.trim()) {
82+
textParts.push({ type: 'text', content: remainingText });
83+
} else {
84+
textParts.push({ type: 'html', content: remainingText });
85+
}
86+
}
87+
88+
// If no HTML tags found, treat entire content as text
89+
if (textParts.length === 0) {
90+
const trimmedContent = textContent.trim();
91+
if (trimmedContent) {
92+
textParts.push({ type: 'text', content: textContent });
93+
} else {
94+
textParts.push({ type: 'html', content: textContent });
95+
}
96+
}
97+
98+
// Process text parts
99+
for (const textPart of textParts) {
100+
if (textPart.type === 'text') {
101+
const trimmedContent = textPart.content.trim();
102+
if (trimmedContent) {
103+
const key = `text_${counter++}`;
104+
translatable[key] = trimmedContent;
105+
template += textPart.content.replace(trimmedContent, `__LINGO_PLACEHOLDER_${key}__`);
106+
} else {
107+
template += textPart.content;
108+
}
109+
} else {
110+
template += textPart.content;
111+
}
112+
}
113+
}
114+
}
115+
116+
return { content: template, translatable };
117+
}
118+
119+
function reconstructEjsWithTranslation(template: string, translatable: Record<string, string>): string {
120+
let result = template;
121+
122+
// Replace placeholders with translated content
123+
for (const [key, value] of Object.entries(translatable)) {
124+
const placeholder = `__LINGO_PLACEHOLDER_${key}__`;
125+
result = result.replace(new RegExp(placeholder, 'g'), value);
126+
}
127+
128+
return result;
129+
}
130+
131+
export default function createEjsLoader(): ILoader<string, Record<string, any>> {
132+
return createLoader({
133+
async pull(locale, input) {
134+
if (!input || input.trim() === '') {
135+
return {};
136+
}
137+
138+
try {
139+
const parseResult = parseEjsForTranslation(input);
140+
return parseResult.translatable;
141+
} catch (error) {
142+
console.warn('Warning: Could not parse EJS template, treating as plain text');
143+
// Fallback: treat entire input as translatable content
144+
return { content: input.trim() };
145+
}
146+
},
147+
148+
async push(locale, data, originalInput) {
149+
if (!originalInput) {
150+
// If no original input, reconstruct from data
151+
return Object.values(data).join('\n');
152+
}
153+
154+
try {
155+
const parseResult = parseEjsForTranslation(originalInput);
156+
157+
// Merge original translatable content with new translations
158+
const mergedTranslatable = { ...parseResult.translatable, ...data };
159+
160+
return reconstructEjsWithTranslation(parseResult.content, mergedTranslatable);
161+
} catch (error) {
162+
console.warn('Warning: Could not reconstruct EJS template, returning translated data');
163+
return Object.values(data).join('\n');
164+
}
165+
},
166+
});
167+
}

0 commit comments

Comments
 (0)