@arcote.tech/arc-cli 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * PO-like catalog format for Arc i18n.
3
+ *
4
+ * Format:
5
+ * #: relative/path/to/file.tsx:42
6
+ * #. hash:a1b2c3d4
7
+ * msgid "Original text"
8
+ * msgstr "Translation"
9
+ *
10
+ * Obsolete entries (no longer in source):
11
+ * #~ msgid "Removed text"
12
+ * #~ msgstr "Old translation"
13
+ */
14
+
15
+ export interface CatalogEntry {
16
+ msgid: string;
17
+ msgstr: string;
18
+ locations: string[];
19
+ hash: string;
20
+ obsolete: boolean;
21
+ }
22
+
23
+ /** Short hash from msgid for change tracking */
24
+ export function hashMsgid(msgid: string): string {
25
+ const hasher = new Bun.CryptoHasher("md5");
26
+ hasher.update(msgid);
27
+ return hasher.digest("hex").slice(0, 8);
28
+ }
29
+
30
+ /** Parse a .po file into catalog entries */
31
+ export function parsePo(content: string): CatalogEntry[] {
32
+ const entries: CatalogEntry[] = [];
33
+ const lines = content.split("\n");
34
+
35
+ let locations: string[] = [];
36
+ let hash = "";
37
+ let msgid = "";
38
+ let msgstr = "";
39
+ let obsolete = false;
40
+
41
+ const flush = () => {
42
+ if (msgid) {
43
+ entries.push({
44
+ msgid,
45
+ msgstr,
46
+ locations,
47
+ hash: hash || hashMsgid(msgid),
48
+ obsolete,
49
+ });
50
+ }
51
+ locations = [];
52
+ hash = "";
53
+ msgid = "";
54
+ msgstr = "";
55
+ obsolete = false;
56
+ };
57
+
58
+ for (const line of lines) {
59
+ const trimmed = line.trim();
60
+
61
+ if (trimmed === "" || trimmed.startsWith("#,")) {
62
+ // Empty line or flags — boundary between entries
63
+ if (msgid) flush();
64
+ continue;
65
+ }
66
+
67
+ // Location comment
68
+ if (trimmed.startsWith("#:")) {
69
+ if (msgid) flush(); // new entry starting
70
+ locations.push(trimmed.slice(3).trim());
71
+ continue;
72
+ }
73
+
74
+ // Hash comment
75
+ if (trimmed.startsWith("#.")) {
76
+ const hashMatch = trimmed.match(/hash:(\w+)/);
77
+ if (hashMatch) hash = hashMatch[1];
78
+ continue;
79
+ }
80
+
81
+ // Obsolete entry
82
+ if (trimmed.startsWith("#~")) {
83
+ const rest = trimmed.slice(3).trim();
84
+ if (rest.startsWith("msgid")) {
85
+ if (msgid) flush();
86
+ obsolete = true;
87
+ msgid = extractQuoted(rest.slice(5));
88
+ } else if (rest.startsWith("msgstr")) {
89
+ msgstr = extractQuoted(rest.slice(6));
90
+ }
91
+ continue;
92
+ }
93
+
94
+ // Regular msgid/msgstr
95
+ if (trimmed.startsWith("msgid")) {
96
+ if (msgid) flush();
97
+ msgid = extractQuoted(trimmed.slice(5));
98
+ } else if (trimmed.startsWith("msgstr")) {
99
+ msgstr = extractQuoted(trimmed.slice(6));
100
+ }
101
+ }
102
+
103
+ // Flush last entry
104
+ flush();
105
+
106
+ return entries;
107
+ }
108
+
109
+ /** Write catalog entries to .po format */
110
+ export function writePo(entries: CatalogEntry[]): string {
111
+ const lines: string[] = [];
112
+
113
+ // Active entries first, then obsolete
114
+ const active = entries.filter((e) => !e.obsolete);
115
+ const obsolete = entries.filter((e) => e.obsolete);
116
+
117
+ for (const entry of active) {
118
+ for (const loc of entry.locations) {
119
+ lines.push(`#: ${loc}`);
120
+ }
121
+ lines.push(`#. hash:${entry.hash}`);
122
+ lines.push(`msgid ${quoteString(entry.msgid)}`);
123
+ lines.push(`msgstr ${quoteString(entry.msgstr)}`);
124
+ lines.push("");
125
+ }
126
+
127
+ if (obsolete.length > 0) {
128
+ lines.push("# Obsolete entries");
129
+ lines.push("");
130
+ for (const entry of obsolete) {
131
+ lines.push(`#~ msgid ${quoteString(entry.msgid)}`);
132
+ lines.push(`#~ msgstr ${quoteString(entry.msgstr)}`);
133
+ lines.push("");
134
+ }
135
+ }
136
+
137
+ return lines.join("\n");
138
+ }
139
+
140
+ /**
141
+ * Merge extracted messages with existing catalog.
142
+ * - New msgid → add with empty msgstr
143
+ * - Existing msgid → keep msgstr, update locations
144
+ * - Removed msgid → mark obsolete (don't delete)
145
+ */
146
+ export function mergeCatalog(
147
+ existing: CatalogEntry[],
148
+ extracted: Map<string, Set<string>>,
149
+ ): CatalogEntry[] {
150
+ const existingMap = new Map<string, CatalogEntry>();
151
+ for (const entry of existing) {
152
+ existingMap.set(entry.msgid, entry);
153
+ }
154
+
155
+ const result: CatalogEntry[] = [];
156
+ const seen = new Set<string>();
157
+
158
+ // Process all extracted messages
159
+ for (const [msgid, locations] of extracted) {
160
+ seen.add(msgid);
161
+ const prev = existingMap.get(msgid);
162
+
163
+ result.push({
164
+ msgid,
165
+ msgstr: prev?.msgstr ?? "",
166
+ locations: [...locations].sort(),
167
+ hash: hashMsgid(msgid),
168
+ obsolete: false,
169
+ });
170
+ }
171
+
172
+ // Mark removed entries as obsolete (keep their translations)
173
+ for (const entry of existing) {
174
+ if (!seen.has(entry.msgid) && !entry.obsolete && entry.msgstr) {
175
+ result.push({
176
+ ...entry,
177
+ obsolete: true,
178
+ locations: [],
179
+ });
180
+ }
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ function extractQuoted(s: string): string {
187
+ const trimmed = s.trim();
188
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
189
+ return trimmed
190
+ .slice(1, -1)
191
+ .replace(/\\n/g, "\n")
192
+ .replace(/\\"/g, '"')
193
+ .replace(/\\\\/g, "\\");
194
+ }
195
+ return trimmed;
196
+ }
197
+
198
+ function quoteString(s: string): string {
199
+ const escaped = s
200
+ .replace(/\\/g, "\\\\")
201
+ .replace(/"/g, '\\"')
202
+ .replace(/\n/g, "\\n");
203
+ return `"${escaped}"`;
204
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { parsePo } from "./catalog";
4
+
5
+ /**
6
+ * Compile a .po catalog to a runtime JSON object.
7
+ * Only includes non-obsolete entries with non-empty msgstr.
8
+ * Output: { "Original text": "Translation", ... }
9
+ */
10
+ export function compileCatalog(poPath: string): Record<string, string> {
11
+ const content = readFileSync(poPath, "utf-8");
12
+ const entries = parsePo(content);
13
+ const result: Record<string, string> = {};
14
+
15
+ for (const entry of entries) {
16
+ if (!entry.obsolete && entry.msgstr) {
17
+ result[entry.msgid] = entry.msgstr;
18
+ }
19
+ }
20
+
21
+ return result;
22
+ }
23
+
24
+ /**
25
+ * Compile all .po files in localesDir to .json files in outDir.
26
+ * Used by dev watch when .po files change.
27
+ */
28
+ export function compileAllCatalogs(localesDir: string, outDir: string): void {
29
+ mkdirSync(outDir, { recursive: true });
30
+
31
+ for (const file of readdirSync(localesDir)) {
32
+ if (!file.endsWith(".po")) continue;
33
+ const locale = file.replace(".po", "");
34
+ const compiled = compileCatalog(join(localesDir, file));
35
+ writeFileSync(join(outDir, `${locale}.json`), JSON.stringify(compiled));
36
+ }
37
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { mergeCatalog, parsePo, writePo } from "./catalog";
4
+ import { compileCatalog } from "./compile";
5
+
6
+ export { i18nExtractPlugin } from "./plugin";
7
+
8
+ export interface TranslationsConfig {
9
+ locales: string[];
10
+ sourceLocale: string;
11
+ }
12
+
13
+ /**
14
+ * Read translations config from package.json "arc.translations" field.
15
+ * Returns null if no config found.
16
+ */
17
+ export function readTranslationsConfig(
18
+ rootDir: string,
19
+ ): TranslationsConfig | null {
20
+ const pkgPath = join(rootDir, "package.json");
21
+ if (!existsSync(pkgPath)) return null;
22
+
23
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
24
+ const config = pkg.arc?.translations;
25
+ if (!config?.locales?.length) return null;
26
+
27
+ return {
28
+ locales: config.locales,
29
+ sourceLocale: config.sourceLocale ?? config.locales[0],
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Finalize translations after Bun.build completes.
35
+ * Takes the collector populated by the i18n extract plugin,
36
+ * merges with existing .po catalogs, and compiles to .json.
37
+ *
38
+ * @param rootDir - project root (where locales/ lives)
39
+ * @param outDir - build output dir (where .json goes, e.g. .arc/platform)
40
+ * @param collector - Map<msgid, Set<location>> from plugin
41
+ */
42
+ export async function finalizeTranslations(
43
+ rootDir: string,
44
+ outDir: string,
45
+ collector: Map<string, Set<string>>,
46
+ ) {
47
+ const config = readTranslationsConfig(rootDir);
48
+ if (!config || collector.size === 0) return;
49
+
50
+ const localesJsonDir = join(outDir, "locales");
51
+ mkdirSync(localesJsonDir, { recursive: true });
52
+
53
+ console.log(
54
+ ` Extracted ${collector.size} translatable string(s) for ${config.locales.length} locale(s)`,
55
+ );
56
+
57
+ for (const locale of config.locales) {
58
+ const poPath = join(rootDir, "locales", `${locale}.po`);
59
+
60
+ // Ensure locales dir exists
61
+ mkdirSync(dirname(poPath), { recursive: true });
62
+
63
+ // Merge extracted messages with existing catalog
64
+ const existing = existsSync(poPath)
65
+ ? parsePo(readFileSync(poPath, "utf-8"))
66
+ : [];
67
+ const merged = mergeCatalog(existing, collector);
68
+ writeFileSync(poPath, writePo(merged));
69
+
70
+ // Compile .po → .json for runtime
71
+ const compiled = compileCatalog(poPath);
72
+ writeFileSync(
73
+ join(localesJsonDir, `${locale}.json`),
74
+ JSON.stringify(compiled),
75
+ );
76
+ }
77
+ }
@@ -0,0 +1,55 @@
1
+ import type { BunPlugin } from "bun";
2
+
3
+ /**
4
+ * Bun plugin that extracts translatable strings from <Trans> and t`` during bundling.
5
+ * Does NOT transform code — returns undefined so Bun processes files normally.
6
+ * Collects messages as a side-effect into the provided collector map.
7
+ *
8
+ * collector: Map<msgid, Set<location>>
9
+ * where location = "relative/path/to/file.tsx:lineNumber"
10
+ */
11
+ export function i18nExtractPlugin(
12
+ collector: Map<string, Set<string>>,
13
+ rootDir: string,
14
+ ): BunPlugin {
15
+ return {
16
+ name: "arc-i18n-extract",
17
+ setup(build) {
18
+ build.onLoad({ filter: /\.tsx?$/ }, async (args) => {
19
+ const source = await Bun.file(args.path).text();
20
+
21
+ // Compute relative path for location comments
22
+ const relPath = args.path.startsWith(rootDir)
23
+ ? args.path.slice(rootDir.length + 1)
24
+ : args.path;
25
+
26
+ // <Trans>static text</Trans> — single-line and multi-line
27
+ const transRegex = /<Trans>\s*([^<{]+?)\s*<\/Trans>/g;
28
+ for (const match of source.matchAll(transRegex)) {
29
+ const msgid = match[1].trim();
30
+ if (!msgid) continue;
31
+ const line =
32
+ source.substring(0, match.index).split("\n").length;
33
+ const loc = `${relPath}:${line}`;
34
+ if (!collector.has(msgid)) collector.set(msgid, new Set());
35
+ collector.get(msgid)!.add(loc);
36
+ }
37
+
38
+ // t`text` — tagged template literal (no interpolation)
39
+ const tRegex = /\bt`([^`]+)`/g;
40
+ for (const match of source.matchAll(tRegex)) {
41
+ const msgid = match[1];
42
+ if (!msgid) continue;
43
+ const line =
44
+ source.substring(0, match.index).split("\n").length;
45
+ const loc = `${relPath}:${line}`;
46
+ if (!collector.has(msgid)) collector.set(msgid, new Set());
47
+ collector.get(msgid)!.add(loc);
48
+ }
49
+
50
+ // Don't transform — let Bun process the file normally
51
+ return undefined;
52
+ });
53
+ },
54
+ };
55
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { build } from "./commands/build";
5
5
  import { dev } from "./commands/dev";
6
+ import { platformBuild } from "./commands/platform-build";
7
+ import { platformDev } from "./commands/platform-dev";
8
+ import { platformStart } from "./commands/platform-start";
6
9
 
7
10
  // Create the program
8
11
  const program = new Command();
@@ -21,6 +24,26 @@ program
21
24
  .description("Build all clients and declarations")
22
25
  .action(build);
23
26
 
27
+ // Platform subcommands
28
+ const platform = program
29
+ .command("platform")
30
+ .description("Platform commands — run full stack (server + UI)");
31
+
32
+ platform
33
+ .command("dev")
34
+ .description("Start platform in dev mode (Bun server + Vite HMR)")
35
+ .action(platformDev);
36
+
37
+ platform
38
+ .command("build")
39
+ .description("Build platform for production")
40
+ .action(platformBuild);
41
+
42
+ platform
43
+ .command("start")
44
+ .description("Start platform in production mode (requires prior build)")
45
+ .action(platformStart);
46
+
24
47
  // Parse command line arguments
25
48
  program.parse(process.argv);
26
49