Skip to content

Commit

Permalink
feat: create single output file out of input
Browse files Browse the repository at this point in the history
Instead of creating separate labels.json files for each component,
this will merge them together to one output.json file with the correct namespaces intact.
  • Loading branch information
blurrah committed May 18, 2024
1 parent af71a1a commit ac8e6ba
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 21 deletions.
5 changes: 5 additions & 0 deletions examples/client.labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"foobar": "foobar",
"title": "title",
"foodiebar": "foodiebar"
}
31 changes: 31 additions & 0 deletions examples/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { useTranslations } from "next-intl";


export const MyComponent = () => {
const t = useTranslations("MyComponent");

const foobar = t("foobar");

return (
<div>
<h1>{t("title")}</h1>
</div>
)
}

// Nested scope
export function MyOtherComponent = () => {
const t = useTranslations("MyComponent");

const content () => {
const foobar = t("foodiebar");
return (
<div>
<h1>{t("title")}</h1>
</div>
)
}
return content()
}
9 changes: 9 additions & 0 deletions examples/output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Test": {
"Test": "Test"
},
"ProductListing": {
"foobar2": "foobar2",
"foobar": "Original text that should not be removed."
}
}
5 changes: 5 additions & 0 deletions examples/server.labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"foobar": "foobar",
"title": "title",
"results": "results"
}
22 changes: 22 additions & 0 deletions examples/server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getTranslations } from "next-intl/server";

export const MyComponent = async () => {
const t = await getTranslations({ namespace: "ProductListing", locale });
const t2 = await getTranslations({
namespace: "ProductListing.Second",
locale,
});

const foobar = t("foobar");

return (
<div>
<h1>{t("title")}</h1>
<div>
{t2("results", {
total: products.total,
})}
</div>
</div>
);
};
51 changes: 51 additions & 0 deletions src/bin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { writeTranslations } from "../write";

// Setup yargs to use the command modules from the commands directory

async function main() {
yargs(hideBin(process.argv))
.usage("$0 --source [path] --output [json file]")
.options({
source: {
type: "array",
alias: "s",
describe: "Source directories to process",
demandOption: true, // Require at least one source path
coerce: (arg: string | string[]) => {
// Ensure that the input is always an array of strings
if (typeof arg === "string") {
return [arg];
}
return arg;
},
},
output: {
type: "string",
alias: "o",
describe: "Output file",
demandOption: true,
},
})
.command(
"$0",
"Default command",
() => {},
async (argv) => {
for (const source of argv.source) {
await writeTranslations(source, argv.output);
}
// Process the source directories
}
)
.help()
.alias("help", "h")
.parse();
}

// Run the application
main().catch((err) => {
console.error(err);
process.exit(1);
});
6 changes: 3 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as fs from "fs";
import { promises as fsPromises } from "fs";
import * as path from "path";
import * as glob from "glob";
import * as fs from "fs";
import { findTranslationsUsage } from "./parse";
import * as path from "path";
import { findTranslationsUsage } from "./parse-new";

// Function to find and process all *.ts files synchronously
export const processTypescriptFilesSync = async (
Expand Down
62 changes: 53 additions & 9 deletions src/parse.ts → src/parse-new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promises as fsPromises } from "fs";
import ts from "typescript";

interface Scope {
variables: Set<string>;
variables: Map<string, string>;
parentScope: Scope | null;
}

Expand All @@ -19,10 +19,11 @@ export async function parseSource(filename: string, source: string) {
true
);

const result: Record<string, any> = {};
// Since we use the namespace string we also allow nested namespaces out of the box
const result: Record<string, Set<string>> = {};

// Create a scope dictionary to track variables assigned from useTranslations
const scopes: Record<string, Set<string>> = {};
// const scopes: Record<string, Set<string>> = {};

// Visitor function that traverses the AST and logs calls to t()
function visit(node: ts.Node, currentScope: Scope) {
Expand All @@ -46,7 +47,11 @@ export async function parseSource(filename: string, source: string) {
callExpr.expression.text === "useTranslations"
) {
if (node.name && ts.isIdentifier(node.name)) {
currentScope.variables.add(node.name.text);
currentScope.variables.set(
node.name.text,
// Remove the surrounding quotes
callExpr.arguments[0].getText().slice(1, -1)
);
}
}
}
Expand All @@ -66,22 +71,50 @@ export async function parseSource(filename: string, source: string) {
callExpr.expression.text === "getTranslations"
) {
if (node.name && ts.isIdentifier(node.name)) {
currentScope.variables.add(node.name.text);
const arg = callExpr.arguments[0];
if (ts.isObjectLiteralExpression(arg)) {
// Iterate over the object properties
for (const prop of arg.properties) {
if (
ts.isPropertyAssignment(prop) &&
ts.isIdentifier(prop.name) &&
prop.name.text === "namespace"
) {
// Get the namespace value
const namespaceValue = prop.initializer;
if (ts.isStringLiteral(namespaceValue)) {
const namespace = namespaceValue.text;
// Do something with the namespace
currentScope.variables.set(node.name.text, namespace);
}
}
}
}
}
}
}

// console.log(currentScope.variables);

// Check for calls using the translation function variable
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
findVariableInScopes(node.expression.text, currentScope)
) {
const item = parseText(node);

if (item) {
const [key, value] = item;
if (!result[key]) {
result[key] = value;
// Get caller name for node
const namespace = findNamespaceForExpression(
node.expression.text,
currentScope
);
if (namespace) {
if (!result[namespace]) {
result[namespace] = new Set();
}
result[namespace].add(item[1]);
}
}
}
Expand All @@ -91,16 +124,27 @@ export async function parseSource(filename: string, source: string) {

const globalScope = createScope();
visit(sourceFile, globalScope);
console.log(result);

Check failure on line 127 in src/parse-new.ts

View workflow job for this annotation

GitHub Actions / Lint codebase

Unexpected console statement
return result;
}

function createScope(parentScope: Scope | null = null): Scope {
return {
variables: new Set<string>(),
variables: new Map<string, string>(),
parentScope,
};
}

function findNamespaceForExpression(variableName: string, scope: Scope | null) {
while (scope !== null) {
if (scope.variables.has(variableName)) {
return scope.variables.get(variableName);
}
scope = scope.parentScope;
}
return null;
}

function findVariableInScopes(
variableName: string,
scope: Scope | null
Expand Down
11 changes: 3 additions & 8 deletions src/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { parseSource } from "./parse";
import { parseSource } from "./parse-new";

describe("Test parseSource", () => {
test("should parse source", async () => {
Expand Down Expand Up @@ -46,14 +46,9 @@ describe("Test parseSource", () => {
expect(result).toEqual(expected);
});

test("should parse source", async () => {
test("should parse source from server component using getTranslations", async () => {
const source = `
"use client";
import { useTranslations } from "next-intl";
export const MyComponent = () => {
export const MyComponent = async () => {
const t = await getTranslations({ namespace: "ProductListing", locale });
const foobar = t("foobar");
Expand Down
112 changes: 112 additions & 0 deletions src/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
type WriteCacheObject = { [key: string]: WriteCacheObject | string };

import * as fs from "fs";
import * as glob from "glob";
import { findTranslationsUsage } from "./parse-new";

export async function writeTranslations(
rootPath: string,
output: string
): Promise<void> {
const cache: WriteCacheObject = {};
const pattern = "**/*.{ts,tsx}";

const options = {
cwd: rootPath,
absolute: true,
};

const files = glob.sync(pattern, options);
for (const file of files) {
const data = await findTranslationsUsage(file);
updateCache(cache, data);
}

updateOutputFile(output, cache);
}

function updateCache(
cache: WriteCacheObject,
data: Record<string, Set<string>>
) {
for (const key of Object.keys(data)) {
const keys = key.split(".");
let currentCache = cache;
for (let i = 0; i < keys.length; i++) {
const currentKey = keys[i];
if (i === keys.length - 1) {
if (currentCache[currentKey] === undefined) {
currentCache[currentKey] = {};
}

for (const value of data[key]) {
currentCache[currentKey][value] = value;
}
} else {
if (currentCache[currentKey] === undefined) {
currentCache[currentKey] = {};
}

currentCache = currentCache[currentKey];
}
}
}
}

async function updateOutputFile(file: string, cache: WriteCacheObject) {
// Compare the output file with the cache
let existingData = {};

if (fs.existsSync(file)) {
// Read the existing data
const fileContent = await fs.promises.readFile(file, "utf8");
existingData = JSON.parse(fileContent);
}

// Recursively delete keys from existing data if they don't exist
// in the cache
removeKeysFromObject(existingData, cache);

// Recursively copy new items
copyKeysToObject(existingData, cache);

console.log(existingData);

Check failure on line 73 in src/write.ts

View workflow job for this annotation

GitHub Actions / Lint codebase

Unexpected console statement
}

function removeKeysFromObject(data: WriteCacheObject, cache: WriteCacheObject) {
const dataKeys = Object.keys(data);
const keys = Object.keys(cache);
for (const key of dataKeys) {
if (!keys.includes(key)) {
// Key doesn't exist in the cache, no need to check further
delete data[key];
continue;
}

if (typeof data[key] === "object") {
if (typeof cache[key] !== "object") {
// This is not an object in the cache, delete the object
delete data[key];
continue;
} else {
// Both are objects, we go deeper
removeKeysFromObject(data[key], cache[key]);
}
}
}
}

function copyKeysToObject(data: WriteCacheObject, cache: WriteCacheObject) {
const keys = Object.keys(cache);
for (const key of keys) {
if (data[key] === undefined) {
// Key doesn't exist in the data, copy it from the cache
data[key] = cache[key];
} else {
if (typeof data[key] === "object" && typeof cache[key] === "object") {
// Both are objects, recurse into deeper object
copyKeysToObject(data[key], cache[key]);
}
}
}
}
Loading

0 comments on commit ac8e6ba

Please sign in to comment.