Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/add-jsonc-error-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inlang/plugin-icu1": patch
"@inlang/sdk": patch
---

test: Added comprehensive error handling tests for JSONC parsing to ensure malformed JSON files are properly detected and reported with informative error messages.
9 changes: 9 additions & 0 deletions .changeset/fix-jsonc-error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@inlang/cli": minor
"@inlang/plugin-i18next": minor
"@inlang/plugin-icu1": minor
"@inlang/sdk": minor
"@inlang/rpc": minor
---

feat: Added JSONC (JSON with Comments) support across SDK and plugins. The SDK now parses JSONC files with proper error handling and informative error messages. Added jsonc-parser as direct dependency to CLI and RPC to ensure availability in production environments.
2 changes: 1 addition & 1 deletion packages/cli/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const __dirname = pathPolyfill123.dirname(__filename)
PUBLIC_POSTHOG_TOKEN: process.env.PUBLIC_POSTHOG_TOKEN,
}),
},
external: ["esbuild-wasm"],
external: ["esbuild-wasm", "jsonc-parser"],
});

if (isProduction === false) {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
},
"dependencies": {
"@inlang/sdk": "workspace:*",
"esbuild-wasm": "^0.19.2"
"esbuild-wasm": "^0.19.2",
"jsonc-parser": "^3.3.1"
},
"devDependencies": {
"@sentry/node": "^7.64.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/i18next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
},
"dependencies": {
"@inlang/sdk": "workspace:*",
"@sinclair/typebox": "0.31.28"
"@sinclair/typebox": "0.31.28",
"jsonc-parser": "^3.3.1"
},
"devDependencies": {
"@inlang/tsconfig": "workspace:*",
Expand Down
15 changes: 12 additions & 3 deletions packages/plugins/i18next/src/import-export/importFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type plugin } from "../plugin.js";
import { flatten } from "flat";
import type { BundleImport, MessageImport, VariantImport } from "@inlang/sdk";
import type { PluginSettings } from "../settings.js";
import { parse as parseJsonc, type ParseError } from "jsonc-parser";

export const importFiles: NonNullable<(typeof plugin)["importFiles"]> = async ({
files,
Expand Down Expand Up @@ -49,9 +50,17 @@ function parseFile(args: {
messages: MessageImport[];
variants: VariantImport[];
} {
const resource: Record<string, string> = flatten(
JSON.parse(new TextDecoder().decode(args.content))
);
const errors: ParseError[] = [];
const parsed = parseJsonc(new TextDecoder().decode(args.content), errors);

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse JSON file for locale "${args.locale}": ${errorDetails}`);
}

const resource: Record<string, string> = flatten(parsed);

const bundles: BundleImport[] = [];
const messages: MessageImport[] = [];
Expand Down
41 changes: 41 additions & 0 deletions packages/plugins/i18next/src/import-export/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,3 +623,44 @@ async function runExportFilesParsed(imported: any, settings?: any) {
const exported = await runExportFiles(imported, settings);
return JSON.parse(new TextDecoder().decode(exported[0]?.content));
}
test("throws error on malformed JSON", async () => {
await expect(async () => {
await importFiles({
settings: {
baseLocale: "en",
locales: ["en"],
"plugin.inlang.i18next": {
pathPattern: "./messages/{locale}.json",
},
},
files: [
{
locale: "en",
// Invalid JSON - missing closing brace
content: new TextEncoder().encode('{"key": "value"'),
},
],
});
}).rejects.toThrow(/Failed to parse JSON file for locale "en"/);
});

test("throws error on JSON with missing quote", async () => {
await expect(async () => {
await importFiles({
settings: {
baseLocale: "en",
locales: ["en", "de"],
"plugin.inlang.i18next": {
pathPattern: "./messages/{locale}.json",
},
},
files: [
{
locale: "de",
// Invalid JSON - missing quote after key
content: new TextEncoder().encode('{"key: "value"}'),
},
],
});
}).rejects.toThrow(/Failed to parse JSON file for locale "de"/);
});
3 changes: 2 additions & 1 deletion packages/plugins/icu1/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"vitest": "^4.0.6"
},
"dependencies": {
"@messageformat/parser": "^5.1.1"
"@messageformat/parser": "^5.1.1",
"jsonc-parser": "^3.3.1"
}
}
30 changes: 30 additions & 0 deletions packages/plugins/icu1/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,33 @@ function buildMessage(
},
};
}

it("throws error on malformed JSON", async () => {
await expect(async () => {
await plugin.importFiles!({
settings,
files: [
{
locale: "en",
// Invalid JSON - missing closing brace
content: new TextEncoder().encode('{"key": "value"'),
},
],
});
}).rejects.toThrow(/Failed to parse JSON file for locale "en"/);
});

it("throws error on JSON with missing quote", async () => {
await expect(async () => {
await plugin.importFiles!({
settings,
files: [
{
locale: "de",
// Invalid JSON - missing quote after key
content: new TextEncoder().encode('{"key: "value"}'),
},
],
});
}).rejects.toThrow(/Failed to parse JSON file for locale "de"/);
});
12 changes: 11 additions & 1 deletion packages/plugins/icu1/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import { PluginSettings } from "./settings.js";
import { parseMessage } from "./parse.js";
import { serializeMessage } from "./serialize.js";
import { parse as parseJsonc, type ParseError } from "jsonc-parser";

export const PLUGIN_KEY = "plugin.inlang.icu-messageformat-1";

Expand Down Expand Up @@ -55,7 +56,16 @@ export const plugin: InlangPlugin<PluginConfig> = {
const decoder = new TextDecoder("utf-8");

for (const file of files) {
const json = JSON.parse(decoder.decode(file.content));
const errors: ParseError[] = [];
const json = parseJsonc(decoder.decode(file.content), errors);

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse JSON file for locale "${file.locale}": ${errorDetails}`);
}

for (const [key, value] of Object.entries(json)) {
if (key === "$schema") continue;
if (typeof value !== "string") continue;
Expand Down
1 change: 1 addition & 0 deletions packages/rpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonc-parser": "^3.3.1",
"ts-dedent": "^2.2.0",
"typed-rpc": "3.0.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"dependencies": {
"@lix-js/sdk": "0.4.7",
"@sinclair/typebox": "^0.31.17",
"jsonc-parser": "^3.3.1",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^13.0.0"
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/src/project/loadProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,25 @@ test("project.errors.get() returns errors for modules that couldn't be imported

await project.close();
});

test("throws error on malformed settings.json", async () => {
const blob = await newProject();
const project = await loadProjectInMemory({ blob });

// Corrupt the settings.json with invalid JSON
await project.lix.db
.updateTable("file")
.where("path", "=", "/settings.json")
.set({
data: new TextEncoder().encode('{"baseLocale": "en"'),
})
.execute();

const corruptedBlob = await project.toBlob();
await project.close();

// Attempting to load a project with corrupted settings should throw
await expect(async () => {
await loadProjectInMemory({ blob: corruptedBlob });
}).rejects.toThrow(/Failed to parse settings.json/);
});
55 changes: 46 additions & 9 deletions packages/sdk/src/project/loadProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { v4 } from "uuid";
import { maybeCaptureLoadedProject } from "./maybeCaptureTelemetry.js";
import { importFiles } from "../import-export/importFiles.js";
import { exportFiles } from "../import-export/exportFiles.js";
import { parse as parseJsonc, type ParseError } from "jsonc-parser";

/**
* Common load project logic.
Expand Down Expand Up @@ -74,8 +75,18 @@ export async function loadProject(args: {
.where("path", "=", "/settings.json")
.executeTakeFirstOrThrow();

const errors: ParseError[] = [];
const parsedSettings = parseJsonc(new TextDecoder().decode(settingsFile.data), errors);

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse settings.json: ${errorDetails}`);
}

const settings = withLanguageTagToLocaleMigration(
JSON.parse(new TextDecoder().decode(settingsFile.data)) as ProjectSettings
parsedSettings as ProjectSettings
);

const importedPlugins = await importPlugins({
Expand Down Expand Up @@ -121,16 +132,24 @@ export async function loadProject(args: {
return new TextDecoder().decode(file.data);
},
},
settings: {
settings: {
get: async () => {
const file = await args.lix.db
.selectFrom("file")
.where("path", "=", "/settings.json")
.select("file.data")
.executeTakeFirstOrThrow();
return withLanguageTagToLocaleMigration(
JSON.parse(new TextDecoder().decode(file.data))
);
const errors: ParseError[] = [];
const parsedSettings = parseJsonc(new TextDecoder().decode(file.data), errors);

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse settings.json: ${errorDetails}`);
}

return withLanguageTagToLocaleMigration(parsedSettings);
},
set: async (newSettings) => {
const cloned = JSON.parse(JSON.stringify(newSettings));
Expand Down Expand Up @@ -162,9 +181,18 @@ export async function loadProject(args: {
.select("file.data")
.executeTakeFirstOrThrow();

const settings = JSON.parse(
new TextDecoder().decode(settingsFile.data)
const errors: ParseError[] = [];
const settings = parseJsonc(
new TextDecoder().decode(settingsFile.data),
errors
) as ProjectSettings;

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse settings.json in importFiles: ${errorDetails}`);
}

return await importFiles({
files,
Expand All @@ -182,9 +210,18 @@ export async function loadProject(args: {
.select("file.data")
.executeTakeFirstOrThrow();

const settings = JSON.parse(
new TextDecoder().decode(settingsFile.data)
const errors: ParseError[] = [];
const settings = parseJsonc(
new TextDecoder().decode(settingsFile.data),
errors
) as ProjectSettings;

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse settings.json in exportFiles: ${errorDetails}`);
}

return (
await exportFiles({
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk/src/project/loadProjectFromDirectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,3 +1214,16 @@ test("it imports plugins from cache if the network is not available", async () =
expect(plugins2.length).toBe(1);
expect(plugins2[0]?.key).toBe("mock-plugin");
});

test("throws error on malformed settings.json from directory", async () => {
const fs = Volume.fromJSON({
"/project.inlang/settings.json": '{"baseLocale": "en"', // Invalid JSON - missing closing brace
});

await expect(async () => {
await loadProjectFromDirectory({
fs: fs as any,
path: "/project.inlang",
});
}).rejects.toThrow(/Failed to parse settings.json/);
});
14 changes: 12 additions & 2 deletions packages/sdk/src/project/loadProjectFromDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { absolutePathFromProject, withAbsolutePaths } from "./path-helpers.js";
import { saveProjectToDirectory } from "./saveProjectToDirectory.js";
import { ENV_VARIABLES } from "../services/env-variables/index.js";
import { compareSemver, pickHighestVersion, readProjectMeta } from "./meta.js";
import { parse as parseJsonc, type ParseError } from "jsonc-parser";

/**
* Loads a project from a directory.
Expand Down Expand Up @@ -52,9 +53,18 @@ export async function loadProjectFromDirectory(
} & Omit<Parameters<typeof loadProjectInMemory>[0], "blob">
) {
const settingsPath = nodePath.join(args.path, "settings.json");
const settings = JSON.parse(
await args.fs.promises.readFile(settingsPath, "utf8")
const errors: ParseError[] = [];
const settings = parseJsonc(
await args.fs.promises.readFile(settingsPath, "utf8"),
errors
) as ProjectSettings;

if (errors.length > 0) {
const errorDetails = errors.map(e =>
`Parse error at offset ${e.offset} (error code: ${e.error})`
).join("; ");
throw new Error(`Failed to parse settings.json at ${settingsPath}: ${errorDetails}`);
}

let inlangId: string | undefined = undefined;

Expand Down
Loading