Skip to content

Commit

Permalink
Merge pull request #3099 from opral/mesdk-217-fix-cwd-bug-with-plugin…
Browse files Browse the repository at this point in the history
…-imports-by-making-paths-absolute

Add: Import messages from local files
  • Loading branch information
samuelstroschein authored Sep 7, 2024
2 parents a0be4cc + a59f6ef commit 02458ff
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
],
"plugin.inlang.i18next": {
"pathPattern": {
"client-page": "./../../app/i18n/locales/{languageTag}/client-page.json",
"footer": "./../../app/i18n/locales/{languageTag}/footer.json",
"second-page": "./../../app/i18n/locales/{languageTag}/second-page.json",
"translation": "./../../app/i18n/locales/{languageTag}/translation.json"
"client-page": "./../app/i18n/locales/{languageTag}/client-page.json",
"footer": "./../app/i18n/locales/{languageTag}/footer.json",
"second-page": "./../app/i18n/locales/{languageTag}/second-page.json",
"translation": "./../app/i18n/locales/{languageTag}/translation.json"
}
},
"experimental": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type BundleNested,
type IdeExtensionConfig,
type InlangProject,
pollQuery,
} from "@inlang/sdk2"

export function createMessageWebviewProvider(args: {
Expand All @@ -37,6 +38,13 @@ export function createMessageWebviewProvider(args: {
if (subscribedToProjectPath !== state().selectedProjectPath) {
subscribedToProjectPath = state().selectedProjectPath
// TODO: Uncomment when bundle subscribe is implemented
// TODO unsubscribe
pollQuery(() => selectBundleNested(project.db).execute()).subscribe((newBundles) => {
bundles = newBundles
isLoading = false
updateWebviewContent()
// throttledUpdateWebviewContent()
})
// project.query.messages.getAll.subscribe((fetchedMessages: BundleNested[]) => {
// bundles = fetchedMessages ? [...fetchedMessages] : []
// isLoading = false
Expand Down
3 changes: 2 additions & 1 deletion inlang/source-code/sdk2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"./src"
],
"scripts": {
"build": "npm run env-variables && tsc --build && npm run sentry:sourcemaps",
"prepublish": "npm run sentry:sourcemaps",
"build": "npm run env-variables && tsc --build",
"dev": "npm run env-variables && tsc --watch",
"env-variables": "node ./src/services/env-variables/createIndexFile.js",
"test": "npm run env-variables && tsc --noEmit && vitest run --passWithNoTests --coverage",
Expand Down
2 changes: 1 addition & 1 deletion inlang/source-code/sdk2/src/import-export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function importFiles(opts: {
});
}

const { bundles } = plugin.importFiles({
const { bundles } = await plugin.importFiles({
files: opts.files,
settings: structuredClone(opts.settings),
});
Expand Down
22 changes: 13 additions & 9 deletions inlang/source-code/sdk2/src/plugin/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,17 @@ export type InlangPlugin<
toBeImportedFiles?: (args: {
settings: ProjectSettings & ExternalSettings;
nodeFs: NodeFsPromisesSubset;
}) => Promise<Array<ResourceFile>> | Array<ResourceFile>;
}) => MaybePromise<Array<Omit<ResourceFile, "pluginKey">>>;
importFiles?: (args: {
files: Array<ResourceFile>;
settings: ProjectSettings & ExternalSettings; // we expose the settings in case the importFunction needs to access the plugin config
}) => {
}) => MaybePromise<{
bundles: NewBundleNested[];
};
}>;
exportFiles?: (args: {
bundles: BundleNested[];
settings: ProjectSettings & ExternalSettings;
}) => Array<ResourceFile>;
}) => MaybePromise<Array<ResourceFile>>;
/**
* @deprecated Use the `meta` field instead.
*/
Expand All @@ -73,10 +73,12 @@ export type InlangPlugin<
*
* https://github.com/opral/inlang-sdk/issues/136
*/
type NodeFsPromisesSubsetLegacy = {
readFile: (path: string) => Promise<Buffer>;
export type NodeFsPromisesSubsetLegacy = {
readFile:
| ((path: string) => Promise<ArrayBuffer>)
| ((path: string, options?: { encoding: "utf-8" }) => Promise<string>);
readdir: (path: string) => Promise<string[]>;
writeFile: (path: string, data: Buffer) => Promise<void>;
writeFile: (path: string, data: ArrayBuffer | string) => Promise<void>;
mkdir: (path: string) => Promise<void>;
};

Expand All @@ -85,7 +87,9 @@ type NodeFsPromisesSubsetLegacy = {
*
* https://github.com/opral/inlang-sdk/issues/136
*/
type NodeFsPromisesSubset = {
readFile: (path: string) => Promise<Buffer>;
export type NodeFsPromisesSubset = {
readFile: (path: string) => Promise<ArrayBuffer>;
readdir: (path: string) => Promise<string[]>;
};

type MaybePromise<T> = T | Promise<T>;
113 changes: 112 additions & 1 deletion inlang/source-code/sdk2/src/project/loadProjectFromDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { expect, test } from "vitest";
import { expect, test, vi } from "vitest";
import { ProjectSettings } from "../json-schema/settings.js";
import { Volume } from "memfs";
import {
Expand Down Expand Up @@ -268,3 +269,113 @@ test("it should provide plugins from disk for backwards compatibility but warn t
// else roundtrips would not work
expect(settings.modules?.[0]).toBe("../local-plugins/mock-plugin.js");
});

// https://github.com/opral/inlang-sdk/issues/174
test("plugin calls that use fs should be intercepted to use an absolute path", async () => {
process.cwd = () => "/";

const mockRepo = {
"/messages/en.json": JSON.stringify({
key1: "value1",
key2: "value2",
}),
"/project.inlang/settings.json": JSON.stringify({
baseLocale: "en",
locales: ["en", "de"],
"plugin.mock-plugin": {
pathPattern: "./messages/{locale}.json",
},
} satisfies ProjectSettings),
};

const mockPlugin: InlangPlugin = {
key: "mock-plugin",
loadMessages: async ({ nodeishFs, settings }) => {
const pathPattern = settings["plugin.mock-plugin"]?.pathPattern.replace(
"{locale}",
"en"
) as string;
const file = await nodeishFs.readFile(pathPattern);
// reading the file should be possible without an error
expect(file.toString()).toBe(
JSON.stringify({
key1: "value1",
key2: "value2",
})
);
return [];
},
saveMessages: async ({ nodeishFs, settings }) => {
const pathPattern = settings["plugin.mock-plugin"]?.pathPattern.replace(
"{locale}",
"en"
) as string;
const file = new TextEncoder().encode(
JSON.stringify({
key1: "value1",
key2: "value2",
key3: "value3",
})
);
await nodeishFs.writeFile(pathPattern, file);
},
toBeImportedFiles: async ({ settings, nodeFs }) => {
const pathPattern = settings["plugin.mock-plugin"]?.pathPattern.replace(
"{locale}",
"en"
) as string;
const file = await nodeFs.readFile(pathPattern);
// reading the file should be possible without an error
expect(file.toString()).toBe(
JSON.stringify({
key1: "value1",
key2: "value2",
})
);
return [];
},
};

const fs = Volume.fromJSON(mockRepo).promises;

const loadMessagesSpy = vi.spyOn(mockPlugin, "loadMessages");
const saveMessagesSpy = vi.spyOn(mockPlugin, "saveMessages");
const toBeImportedFilesSpy = vi.spyOn(mockPlugin, "toBeImportedFiles");
const fsReadFileSpy = vi.spyOn(fs, "readFile");
const fsWriteFileSpy = vi.spyOn(fs, "writeFile");

const project = await loadProjectFromDirectoryInMemory({
fs: fs as any,
path: "/project.inlang",
providePlugins: [mockPlugin],
});

expect(loadMessagesSpy).toHaveBeenCalled();
expect(fsReadFileSpy).toHaveBeenCalledWith("/messages/en.json", undefined);

// todo test that saveMessages works too.
// await project.db.insertInto("bundle").defaultValues().execute();

// const translationFile = await fs.readFile("/messages/en.json", "utf-8");

// expect(translationFile).toBe(
// JSON.stringify({
// key1: "value1",
// key2: "value2",
// key3: "value3",
// })
// );

// expect(fsWriteFileSpy).toHaveBeenCalledWith(
// "/messages/en.json",
// JSON.stringify({
// key1: "value1",
// key2: "value2",
// key3: "value3",
// }),
// "utf-8"
// );

// expect(saveMessagesSpy).toHaveBeenCalled();
// expect(toBeImportedFilesSpy).toHaveBeenCalled();
});
78 changes: 67 additions & 11 deletions inlang/source-code/sdk2/src/project/loadProjectFromDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { uuidv4, type Lix } from "@lix-js/sdk";
import type fs from "node:fs/promises";
// eslint-disable-next-line no-restricted-imports
import nodePath from "node:path";
import type { InlangPlugin } from "../plugin/schema.js";
import type {
InlangPlugin,
NodeFsPromisesSubsetLegacy,
} from "../plugin/schema.js";
import { insertBundleNested } from "../query-utilities/insertBundleNested.js";
import { fromMessageV1 } from "../json-schema/old-v1-message/fromMessageV1.js";
import type { ProjectSettings } from "../json-schema/settings.js";
Expand Down Expand Up @@ -102,7 +105,7 @@ export async function loadProjectFromDirectoryInMemory(

await project.importFiles({
pluginKey: importer.key,
files,
files: files.map((file) => ({ ...file, pluginKey: importer.key })),
});

// TODO check user id and description (where will this one appear?)
Expand All @@ -116,6 +119,7 @@ export async function loadProjectFromDirectoryInMemory(
if (chosenLegacyPlugin) {
await loadLegacyMessages({
project,
projectPath: args.path,
fs: args.fs,
pluginKey: chosenLegacyPlugin.key ?? chosenLegacyPlugin.id,
loadMessagesFn: chosenLegacyPlugin.loadMessages,
Expand Down Expand Up @@ -154,11 +158,13 @@ async function loadLegacyMessages(args: {
project: Awaited<ReturnType<typeof loadProjectInMemory>>;
pluginKey: NonNullable<InlangPlugin["key"] | InlangPlugin["id"]>;
loadMessagesFn: Required<InlangPlugin>["loadMessages"];
projectPath: string;
fs: typeof fs;
}) {
const loadedLegacyMessages = await args.loadMessagesFn({
settings: await args.project.settings.get(),
nodeishFs: args.fs,
// @ts-ignore
nodeishFs: withAbsolutePaths(args.fs, args.projectPath),
});
const insertQueries = [];

Expand Down Expand Up @@ -283,13 +289,7 @@ async function importLocalPlugins(args: {
await args.fs.readFile(settingsPath, "utf8")
) as ProjectSettings;
for (const module of settings.modules ?? []) {
// need to remove the project path from the module path for legacy reasons
// "/project.inlang/local-plugins/mock-plugin.js" -> "/local-plugins/mock-plugin.js"
const pathWithoutProject = args.path
.split(nodePath.sep)
.slice(0, -1)
.join(nodePath.sep);
const modulePath = nodePath.join(pathWithoutProject, module);
const modulePath = absolutePathFromProject(args.path, module);
try {
let moduleAsText = await args.fs.readFile(modulePath, "utf8");
if (moduleAsText.includes("messageLintRule")) {
Expand Down Expand Up @@ -344,4 +344,60 @@ export class WarningDeprecatedLintRule extends Error {
);
this.name = "WarningDeprecatedLintRule";
}
}
}

/**
* Resolving absolute paths for fs functions.
*
* This mapping is required for backwards compatibility.
* Relative paths in the project.inlang/settings.json
* file are resolved to absolute paths with `*.inlang`
* being pruned.
*
* @example
* "/website/project.inlang"
* "./local-plugins/mock-plugin.js"
* -> "/website/local-plugins/mock-plugin.js"
*
*/
function withAbsolutePaths(
fs: NodeFsPromisesSubsetLegacy,
projectPath: string
): NodeFsPromisesSubsetLegacy {
return {
// @ts-expect-error
readFile: (path, options) => {
return fs.readFile(absolutePathFromProject(projectPath, path), options);
},
writeFile: (path, data) => {
return fs.writeFile(absolutePathFromProject(projectPath, path), data);
},
mkdir: (path) => {
return fs.mkdir(absolutePathFromProject(projectPath, path));
},
readdir: (path) => {
return fs.readdir(absolutePathFromProject(projectPath, path));
},
};
}

/**
* Joins a path from a project path.
*
* @example
* joinPathFromProject("/project.inlang", "./local-plugins/mock-plugin.js") -> "/local-plugins/mock-plugin.js"
*
* joinPathFromProject("/website/project.inlang", "./mock-plugin.js") -> "/website/mock-plugin.js"
*/
function absolutePathFromProject(projectPath: string, path: string) {
// need to remove the project path from the module path for legacy reasons
// "/project.inlang/local-plugins/mock-plugin.js" -> "/local-plugins/mock-plugin.js"
const pathWithoutProject = projectPath
.split(nodePath.sep)
.slice(0, -1)
.join(nodePath.sep);

const resolvedPath = nodePath.resolve(pathWithoutProject, path);

return resolvedPath;
}

0 comments on commit 02458ff

Please sign in to comment.