diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index ad80295..efcd91e 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -6,6 +6,7 @@
+
@@ -13,5 +14,6 @@
+
diff --git a/src/ClaudeDo.Localization/ClaudeDo.Localization.csproj b/src/ClaudeDo.Localization/ClaudeDo.Localization.csproj
new file mode 100644
index 0000000..3b41c04
--- /dev/null
+++ b/src/ClaudeDo.Localization/ClaudeDo.Localization.csproj
@@ -0,0 +1,10 @@
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/src/ClaudeDo.Localization/LocaleFile.cs b/src/ClaudeDo.Localization/LocaleFile.cs
new file mode 100644
index 0000000..18ca7c0
--- /dev/null
+++ b/src/ClaudeDo.Localization/LocaleFile.cs
@@ -0,0 +1,15 @@
+namespace ClaudeDo.Localization;
+
+public sealed class LocaleFile
+{
+ public LocaleFile(string code, string name, IReadOnlyDictionary strings)
+ {
+ Code = code;
+ Name = name;
+ Strings = strings;
+ }
+
+ public string Code { get; }
+ public string Name { get; }
+ public IReadOnlyDictionary Strings { get; }
+}
diff --git a/src/ClaudeDo.Localization/LocaleJson.cs b/src/ClaudeDo.Localization/LocaleJson.cs
new file mode 100644
index 0000000..0a3802e
--- /dev/null
+++ b/src/ClaudeDo.Localization/LocaleJson.cs
@@ -0,0 +1,46 @@
+using System.Text.Json;
+
+namespace ClaudeDo.Localization;
+
+public static class LocaleJson
+{
+ public static LocaleFile Parse(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var code = "";
+ var name = "";
+ if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
+ {
+ if (meta.TryGetProperty("code", out var c)) code = c.GetString() ?? "";
+ if (meta.TryGetProperty("name", out var n)) name = n.GetString() ?? "";
+ }
+
+ var strings = new Dictionary(StringComparer.Ordinal);
+ foreach (var prop in root.EnumerateObject())
+ {
+ if (prop.NameEquals("metadata")) continue;
+ Flatten(prop.Name, prop.Value, strings);
+ }
+
+ return new LocaleFile(code, name, strings);
+ }
+
+ private static void Flatten(string prefix, JsonElement el, IDictionary into)
+ {
+ switch (el.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var p in el.EnumerateObject())
+ Flatten($"{prefix}.{p.Name}", p.Value, into);
+ break;
+ case JsonValueKind.String:
+ into[prefix] = el.GetString() ?? "";
+ break;
+ default:
+ into[prefix] = el.ToString();
+ break;
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj b/tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj
new file mode 100644
index 0000000..66838f4
--- /dev/null
+++ b/tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs b/tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs
new file mode 100644
index 0000000..d906948
--- /dev/null
+++ b/tests/ClaudeDo.Localization.Tests/LocaleJsonTests.cs
@@ -0,0 +1,38 @@
+using ClaudeDo.Localization;
+
+namespace ClaudeDo.Localization.Tests;
+
+public class LocaleJsonTests
+{
+ private const string Sample = """
+ {
+ "metadata": { "code": "en", "name": "English" },
+ "settings": { "save": "Save", "general": { "model": "Model" } },
+ "tasks": { "addPlaceholder": "Add a task" }
+ }
+ """;
+
+ [Fact]
+ public void Parse_reads_metadata()
+ {
+ var f = LocaleJson.Parse(Sample);
+ Assert.Equal("en", f.Code);
+ Assert.Equal("English", f.Name);
+ }
+
+ [Fact]
+ public void Parse_flattens_nested_keys_to_dot_paths()
+ {
+ var f = LocaleJson.Parse(Sample);
+ Assert.Equal("Save", f.Strings["settings.save"]);
+ Assert.Equal("Model", f.Strings["settings.general.model"]);
+ Assert.Equal("Add a task", f.Strings["tasks.addPlaceholder"]);
+ }
+
+ [Fact]
+ public void Parse_excludes_metadata_from_strings()
+ {
+ var f = LocaleJson.Parse(Sample);
+ Assert.False(f.Strings.ContainsKey("metadata.code"));
+ }
+}