@arcote.tech/arc-cli 0.3.1 → 0.4.2
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.
- package/dist/index.js +18071 -7175
- package/package.json +2 -2
- package/src/builder/module-builder.ts +348 -0
- package/src/commands/platform-build.ts +7 -0
- package/src/commands/platform-dev.ts +116 -0
- package/src/commands/platform-start.ts +56 -0
- package/src/i18n/catalog.ts +204 -0
- package/src/i18n/compile.ts +37 -0
- package/src/i18n/index.ts +77 -0
- package/src/i18n/plugin.ts +55 -0
- package/src/index.ts +24 -1
- package/src/platform/server.ts +353 -0
- package/src/platform/shared.ts +280 -0
- package/src/utils/build.ts +53 -7
|
@@ -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
|
|
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
|
|