Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parsing legacy YAML structures (with new pluggable adapters) #14

Merged
merged 19 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions webapp/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module.exports = {
'error',
{
default: { memberTypes: 'never', order: 'alphabetically' },
classes: ['field', 'constructor', 'method'],
interfaces: ['signature', 'method', 'constructor', 'field'],
},
],
Expand Down
5 changes: 5 additions & 0 deletions webapp/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
10 changes: 8 additions & 2 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"start": "next start",
"lint:eslint": "eslint .",
"lint:eslint-fix": "eslint . --fix",
"lint": "next lint"
"lint": "next lint",
"test": "jest"
},
"dependencies": {
"@emotion/react": "^11.11.1",
Expand All @@ -20,9 +21,11 @@
"react": "^18",
"react-dom": "^18",
"simple-git": "^3.20.0",
"yaml": "^2.3.3"
"yaml": "^2.3.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/mock-fs": "^4.13.4",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand All @@ -38,7 +41,10 @@
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-yml": "^0.13.0",
"jest": "^29.7.0",
"mock-fs": "^5.2.0",
"prettier": "^2.5.1",
"ts-jest": "^29.1.1",
"typescript": "^5"
}
}
21 changes: 12 additions & 9 deletions webapp/src/Store.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/* global globalThis */

import { flatten } from 'flat';
import fs from 'fs/promises';
import { LanguageNotFound } from '@/errors';
import { parse } from 'yaml';
import LyraConfig from './utils/config';
import YAMLTranslationAdapter from './utils/adapters/YAMLTranslationAdapter';
import { envVarNotFound, logDebug } from '@/utils/util';
import { simpleGit, SimpleGit, SimpleGitOptions } from 'simple-git';

const REPO_PATH = process.env.REPO_PATH ?? envVarNotFound('REPO_PATH');
const MAIN_BRANCH = 'main';
const MAIN_BRANCH = process.env.MAIN_BRANCH ?? envVarNotFound('MAIN_BRANCH');

export class Store {
public static async getLanguage(lang: string) {
Expand All @@ -34,14 +33,18 @@ export class Store {
languages = globalThis.languages;
}

let translation: Record<string, string>;
let translation: Record<string, string> = {};

if (!languages.has(lang)) {
logDebug('read language[' + lang + '] from file');
// TODO: read this from .lyra.yml setting file in client repo
const yamlPath = REPO_PATH + `/src/locale/${lang}.yml`;
const config = await LyraConfig.readFromDir(REPO_PATH);
const adapter = new YAMLTranslationAdapter(config.translationsPath);
const translationsForAllLanguages = await adapter.getTranslations();

Object.entries(translationsForAllLanguages[lang]).forEach(([id, obj]) => {
translation[id] = obj.text;
});

const yamlBuf = await fs.readFile(yamlPath);
translation = flatten(parse(yamlBuf.toString()));
languages.set(lang, translation);
} else {
logDebug('read language [' + lang + '] from Memory');
Expand Down
28 changes: 5 additions & 23 deletions webapp/src/app/api/messages/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { envVarNotFound } from '@/utils/util';
import fs from 'fs/promises';
import LyraConfig from '@/utils/config';
import MessageAdapterFactory from '@/utils/adapters/MessageAdapterFactory';
import { NextResponse } from 'next/server';
import path from 'path';
import readTypedMessages, {
MessageData,
} from '@/utils/readTypedMessages';

const REPO_PATH = process.env.REPO_PATH ?? envVarNotFound('REPO_PATH');

export async function GET() {
const messages: MessageData[] = [];
// TODO: read path or src from .lyra.yml setting file in client repo
for await (const item of getMessageFiles(REPO_PATH + '/src')) {
messages.push(...readTypedMessages(item));
}
const config = await LyraConfig.readFromDir(REPO_PATH);
const msgAdapter = MessageAdapterFactory.createAdapter(config);
const messages = await msgAdapter.getMessages();

// TODO: change data instruction to be a map of key to value, instead of object
// message id is the key, and value is an object with default and params
Expand All @@ -22,16 +17,3 @@ export async function GET() {
data: messages,
});
}

async function* getMessageFiles(dirPath: string): AsyncGenerator<string> {
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = await fs.stat(itemPath);
if (stats.isDirectory()) {
yield* getMessageFiles(itemPath);
} else if (itemPath.endsWith('messageIds.ts')) {
yield itemPath;
}
}
}
2 changes: 1 addition & 1 deletion webapp/src/app/api/translations/[lang]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';

export async function GET(
req: NextRequest, // keep this here even if unused
context: { params: { lang: string; msgId: string } },
context: { params: { lang: string; msgId: string } }
) {
const lang = context.params.lang;
try {
Expand Down
13 changes: 13 additions & 0 deletions webapp/src/utils/adapters/MessageAdapterFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import TSMessageAdapter from './TSMessageAdapter';
import YAMLMessageAdapter from './YAMLMessageAdapter';
import LyraConfig, { MessageKind } from '../config';

export default class MessageAdapterFactory {
static createAdapter(config: LyraConfig) {
if (config.messageKind == MessageKind.TS) {
return new TSMessageAdapter(config.messagesPath);
} else {
return new YAMLMessageAdapter(config.messagesPath);
}
}
}
95 changes: 95 additions & 0 deletions webapp/src/utils/adapters/TSMessageAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { MessageData } from '.';
import mock from 'mock-fs';
import TSMessageAdapter from './TSMessageAdapter';
import { describe, expect, it } from '@jest/globals';

describe('TSMessageAdapter', () => {
describe('getMessages()', () => {
it('Finds messageIds.ts file and parses it', async () => {
mock({
'src/features/something/messageIds.ts': [
`import { m, makeMessages } from 'core/i18n';`,
`export default makeMessages('feat.something', {`,
` label: m<{ adjective: string }>('My {adjective} feature'),`,
`});`,
].join('\n'),
});

const msgAdapter = new TSMessageAdapter();
const messages = await msgAdapter.getMessages();

expect(messages).toEqual(<MessageData[]>[
{
defaultMessage: 'My {adjective} feature',
id: 'feat.something.label',
params: [
{
name: 'adjective',
types: ['string'],
},
],
},
]);
});

it('Finds multiple messageIds files', async () => {
mock({
'src/features/other/messageIds.ts': [
`import { m, makeMessages } from 'core/i18n';`,
`export default makeMessages('feat.other', {`,
` label: m('My other feature'),`,
`});`,
].join('\n'),
'src/features/something/messageIds.ts': [
`import { m, makeMessages } from 'core/i18n';`,
`export default makeMessages('feat.something', {`,
` label: m<{ adjective: string }>('My {adjective} feature'),`,
`});`,
].join('\n'),
});

const msgAdapter = new TSMessageAdapter();
const messages = await msgAdapter.getMessages();

expect(messages).toEqual(<MessageData[]>[
{
defaultMessage: 'My other feature',
id: 'feat.other.label',
params: [],
},
{
defaultMessage: 'My {adjective} feature',
id: 'feat.something.label',
params: [{ name: 'adjective', types: ['string'] }],
},
]);
});

it('Finds messageIds files using absolute paths', async () => {
mock({
'/path/to/src/features/something/messageIds.ts': [
`import { m, makeMessages } from 'core/i18n';`,
`export default makeMessages('feat.something', {`,
` label: m<{ adjective: string }>('My {adjective} feature'),`,
`});`,
].join('\n'),
});

const msgAdapter = new TSMessageAdapter('/path/to/src');
const messages = await msgAdapter.getMessages();

expect(messages).toEqual(<MessageData[]>[
{
defaultMessage: 'My {adjective} feature',
id: 'feat.something.label',
params: [
{
name: 'adjective',
types: ['string'],
},
],
},
]);
});
});
});
34 changes: 34 additions & 0 deletions webapp/src/utils/adapters/TSMessageAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import fs from 'fs/promises';
import path from 'path';
import readTypedMessages from '../readTypedMessages';
import { IMessageAdapter, MessageData } from '.';

export default class TSMessageAdapter implements IMessageAdapter {
private basePath: string;

constructor(basePath: string = 'src') {
this.basePath = basePath;
}

async getMessages(): Promise<MessageData[]> {
const messages: MessageData[] = [];
for await (const item of getMessageFiles(this.basePath)) {
messages.push(...readTypedMessages(item));
}

return messages;
}
}

async function* getMessageFiles(dirPath: string): AsyncGenerator<string> {
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const stats = await fs.stat(itemPath);
if (stats.isDirectory()) {
yield* getMessageFiles(itemPath);
} else if (itemPath.endsWith('messageIds.ts')) {
yield itemPath;
}
}
}
Loading