@linguanahq/build-core 0.1.0 → 0.1.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.
- package/dist/index.d.ts +142 -0
- package/dist/index.js +215 -0
- package/dist/index.test.d.ts +1 -0
- package/package.json +23 -21
- package/src/index.ts +0 -319
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type Diagnostic, type Manifest, type ManifestMessage } from "@linguanahq/catalog";
|
|
2
|
+
export type FrameworkId = "react" | "vue" | "svelte" | "solid";
|
|
3
|
+
export type SourceMap = Record<string, unknown>;
|
|
4
|
+
export type SourceTransformInput = {
|
|
5
|
+
code: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
root: string;
|
|
8
|
+
sourceLocale: string;
|
|
9
|
+
};
|
|
10
|
+
export type SourceTransformResult = {
|
|
11
|
+
code: string;
|
|
12
|
+
map: SourceMap | null;
|
|
13
|
+
messages: ManifestMessage[];
|
|
14
|
+
diagnostics: Diagnostic[];
|
|
15
|
+
};
|
|
16
|
+
export interface SourceAdapter {
|
|
17
|
+
id: FrameworkId;
|
|
18
|
+
version: string;
|
|
19
|
+
extensions: readonly string[];
|
|
20
|
+
runtimeModule: string;
|
|
21
|
+
transform(input: SourceTransformInput): Promise<SourceTransformResult | null>;
|
|
22
|
+
}
|
|
23
|
+
export type StrictPolicy = boolean | {
|
|
24
|
+
ambiguous?: boolean;
|
|
25
|
+
unsafe?: boolean;
|
|
26
|
+
duplicate?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type IntegrationMetadata = {
|
|
29
|
+
tool: "vite";
|
|
30
|
+
toolVersion: string;
|
|
31
|
+
framework: FrameworkId;
|
|
32
|
+
adapterVersion: string;
|
|
33
|
+
ssr: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type BuildSessionOptions = {
|
|
36
|
+
projectId: string;
|
|
37
|
+
environment: string;
|
|
38
|
+
sourceLocale: string;
|
|
39
|
+
compilerVersion: string;
|
|
40
|
+
integration: IntegrationMetadata;
|
|
41
|
+
strict?: StrictPolicy;
|
|
42
|
+
};
|
|
43
|
+
export type RemoteBuildOptions = {
|
|
44
|
+
apiUrl: string;
|
|
45
|
+
token: string;
|
|
46
|
+
translation?: false | {
|
|
47
|
+
enabled: true;
|
|
48
|
+
maxChargeMicros: number;
|
|
49
|
+
waitTimeoutMs?: number;
|
|
50
|
+
publish: "development";
|
|
51
|
+
};
|
|
52
|
+
onProgress?: (message: string) => void;
|
|
53
|
+
};
|
|
54
|
+
export declare class BuildSession {
|
|
55
|
+
#private;
|
|
56
|
+
readonly messages: Map<string, {
|
|
57
|
+
id: string;
|
|
58
|
+
source: string;
|
|
59
|
+
normalized: string;
|
|
60
|
+
contentHash: string;
|
|
61
|
+
context: {
|
|
62
|
+
kind: "text" | "attribute" | "explicit";
|
|
63
|
+
element?: string | undefined;
|
|
64
|
+
attribute?: string | undefined;
|
|
65
|
+
developer?: string | undefined;
|
|
66
|
+
};
|
|
67
|
+
placeholders: {
|
|
68
|
+
name: string;
|
|
69
|
+
kind: "number" | "value" | "component" | "plural" | "select" | "date" | "time";
|
|
70
|
+
}[];
|
|
71
|
+
confidence: "explicit" | "high";
|
|
72
|
+
locations: {
|
|
73
|
+
file: string;
|
|
74
|
+
line: number;
|
|
75
|
+
column: number;
|
|
76
|
+
}[];
|
|
77
|
+
}>;
|
|
78
|
+
readonly diagnostics: Diagnostic[];
|
|
79
|
+
readonly options: BuildSessionOptions;
|
|
80
|
+
constructor(options: BuildSessionOptions);
|
|
81
|
+
collect(result: SourceTransformResult): void;
|
|
82
|
+
private addDiagnostic;
|
|
83
|
+
manifest(): Manifest;
|
|
84
|
+
write(output: string): Promise<{
|
|
85
|
+
schemaVersion: "1.0" | "2.0";
|
|
86
|
+
compilerVersion: string;
|
|
87
|
+
projectId: string;
|
|
88
|
+
environment: string;
|
|
89
|
+
sourceLocale: string;
|
|
90
|
+
messages: {
|
|
91
|
+
id: string;
|
|
92
|
+
source: string;
|
|
93
|
+
normalized: string;
|
|
94
|
+
contentHash: string;
|
|
95
|
+
context: {
|
|
96
|
+
kind: "text" | "attribute" | "explicit";
|
|
97
|
+
element?: string | undefined;
|
|
98
|
+
attribute?: string | undefined;
|
|
99
|
+
developer?: string | undefined;
|
|
100
|
+
};
|
|
101
|
+
placeholders: {
|
|
102
|
+
name: string;
|
|
103
|
+
kind: "number" | "value" | "component" | "plural" | "select" | "date" | "time";
|
|
104
|
+
}[];
|
|
105
|
+
confidence: "explicit" | "high";
|
|
106
|
+
locations: {
|
|
107
|
+
file: string;
|
|
108
|
+
line: number;
|
|
109
|
+
column: number;
|
|
110
|
+
}[];
|
|
111
|
+
}[];
|
|
112
|
+
diagnostics: {
|
|
113
|
+
code: string;
|
|
114
|
+
severity: "error" | "info" | "warning";
|
|
115
|
+
message: string;
|
|
116
|
+
location?: {
|
|
117
|
+
file: string;
|
|
118
|
+
line: number;
|
|
119
|
+
column: number;
|
|
120
|
+
} | undefined;
|
|
121
|
+
remediation?: string | undefined;
|
|
122
|
+
}[];
|
|
123
|
+
sourceRevision?: string | undefined;
|
|
124
|
+
integration?: {
|
|
125
|
+
tool: "vite";
|
|
126
|
+
toolVersion: string;
|
|
127
|
+
framework: "react" | "vue" | "svelte" | "solid";
|
|
128
|
+
adapterVersion: string;
|
|
129
|
+
ssr: boolean;
|
|
130
|
+
} | undefined;
|
|
131
|
+
}>;
|
|
132
|
+
upload(manifest: Manifest, remote: RemoteBuildOptions): Promise<{
|
|
133
|
+
manifestId: string;
|
|
134
|
+
status: string;
|
|
135
|
+
enabledTargetLocales: string[];
|
|
136
|
+
upload?: {
|
|
137
|
+
url: string;
|
|
138
|
+
headers: Record<string, string>;
|
|
139
|
+
};
|
|
140
|
+
}>;
|
|
141
|
+
private translate;
|
|
142
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
contentHash,
|
|
6
|
+
manifestHash,
|
|
7
|
+
manifestSchema
|
|
8
|
+
} from "@linguanahq/catalog";
|
|
9
|
+
function strictFailure(policy, diagnostics) {
|
|
10
|
+
if (!policy) return false;
|
|
11
|
+
if (policy === true)
|
|
12
|
+
return diagnostics.some((item) => item.severity !== "info");
|
|
13
|
+
return diagnostics.some((item) => {
|
|
14
|
+
if (item.code === "duplicate-id") return policy.duplicate ?? true;
|
|
15
|
+
if (item.code.startsWith("unsafe")) return policy.unsafe ?? false;
|
|
16
|
+
return policy.ambiguous ?? false;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function apiJson(url, init) {
|
|
20
|
+
const response = await fetch(url, init);
|
|
21
|
+
const body = await response.json().catch(() => ({}));
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw new Error(
|
|
24
|
+
[
|
|
25
|
+
body.code ?? `HTTP_${response.status}`,
|
|
26
|
+
body.message,
|
|
27
|
+
body.requestId ? `request ${body.requestId}` : void 0
|
|
28
|
+
].filter(Boolean).join(": ")
|
|
29
|
+
);
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
var BuildSession = class {
|
|
33
|
+
messages = /* @__PURE__ */ new Map();
|
|
34
|
+
diagnostics = [];
|
|
35
|
+
#diagnosticKeys = /* @__PURE__ */ new Set();
|
|
36
|
+
options;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
}
|
|
40
|
+
collect(result) {
|
|
41
|
+
for (const message of result.messages) {
|
|
42
|
+
const existing = this.messages.get(message.id);
|
|
43
|
+
if (existing?.contentHash === message.contentHash) {
|
|
44
|
+
for (const location of message.locations) {
|
|
45
|
+
const key = `${location.file}:${location.line}:${location.column}`;
|
|
46
|
+
if (!existing.locations.some(
|
|
47
|
+
(item) => `${item.file}:${item.line}:${item.column}` === key
|
|
48
|
+
))
|
|
49
|
+
existing.locations.push(location);
|
|
50
|
+
}
|
|
51
|
+
} else if (existing) {
|
|
52
|
+
this.addDiagnostic({
|
|
53
|
+
code: "duplicate-id",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: `Message ID ${message.id} refers to incompatible source content across modules.`,
|
|
56
|
+
location: message.locations[0],
|
|
57
|
+
remediation: "Choose a unique explicit ID or make the source messages identical."
|
|
58
|
+
});
|
|
59
|
+
} else this.messages.set(message.id, structuredClone(message));
|
|
60
|
+
}
|
|
61
|
+
for (const diagnostic of result.diagnostics) this.addDiagnostic(diagnostic);
|
|
62
|
+
}
|
|
63
|
+
addDiagnostic(diagnostic) {
|
|
64
|
+
const location = diagnostic.location;
|
|
65
|
+
const key = `${diagnostic.code}:${location?.file ?? ""}:${location?.line ?? 0}:${location?.column ?? 0}`;
|
|
66
|
+
if (this.#diagnosticKeys.has(key)) return;
|
|
67
|
+
this.#diagnosticKeys.add(key);
|
|
68
|
+
this.diagnostics.push(diagnostic);
|
|
69
|
+
}
|
|
70
|
+
manifest() {
|
|
71
|
+
return manifestSchema.parse({
|
|
72
|
+
schemaVersion: "2.0",
|
|
73
|
+
compilerVersion: this.options.compilerVersion,
|
|
74
|
+
projectId: this.options.projectId,
|
|
75
|
+
environment: this.options.environment,
|
|
76
|
+
sourceLocale: this.options.sourceLocale,
|
|
77
|
+
sourceRevision: process.env.GIT_COMMIT_SHA,
|
|
78
|
+
integration: this.options.integration,
|
|
79
|
+
messages: [...this.messages.values()].sort(
|
|
80
|
+
(left, right) => left.id.localeCompare(right.id)
|
|
81
|
+
),
|
|
82
|
+
diagnostics: this.diagnostics
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async write(output) {
|
|
86
|
+
const manifest = this.manifest();
|
|
87
|
+
if (strictFailure(this.options.strict, this.diagnostics))
|
|
88
|
+
throw new Error("Linguana strict policy rejected compiler diagnostics.");
|
|
89
|
+
await mkdir(dirname(output), { recursive: true });
|
|
90
|
+
await writeFile(output, `${JSON.stringify(manifest, null, 2)}
|
|
91
|
+
`, "utf8");
|
|
92
|
+
return manifest;
|
|
93
|
+
}
|
|
94
|
+
async upload(manifest, remote) {
|
|
95
|
+
const endpoint = remote.apiUrl.replace(/\/$/, "");
|
|
96
|
+
const hash = manifestHash(manifest);
|
|
97
|
+
const body = JSON.stringify(manifest);
|
|
98
|
+
const registration = await apiJson(`${endpoint}/api/v1/manifests`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "application/json",
|
|
102
|
+
authorization: `Bearer ${remote.token}`,
|
|
103
|
+
"idempotency-key": hash
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
schemaVersion: manifest.schemaVersion,
|
|
107
|
+
contentHash: hash,
|
|
108
|
+
byteLength: new TextEncoder().encode(body).byteLength,
|
|
109
|
+
messageCount: manifest.messages.length,
|
|
110
|
+
sourceRevision: manifest.sourceRevision,
|
|
111
|
+
integration: manifest.integration
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
if (registration.upload) {
|
|
115
|
+
const upload = await fetch(registration.upload.url, {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
headers: registration.upload.headers,
|
|
118
|
+
body
|
|
119
|
+
});
|
|
120
|
+
if (!upload.ok)
|
|
121
|
+
throw new Error(`Manifest artifact upload failed (${upload.status})`);
|
|
122
|
+
await apiJson(
|
|
123
|
+
`${endpoint}/api/v1/manifests/${registration.manifestId}/complete`,
|
|
124
|
+
{
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: `Bearer ${remote.token}`,
|
|
128
|
+
"idempotency-key": `${hash}:complete`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (remote.translation && registration.enabledTargetLocales.length)
|
|
134
|
+
await this.translate(
|
|
135
|
+
registration.manifestId,
|
|
136
|
+
hash,
|
|
137
|
+
registration.enabledTargetLocales,
|
|
138
|
+
remote
|
|
139
|
+
);
|
|
140
|
+
else if (remote.translation)
|
|
141
|
+
remote.onProgress?.(
|
|
142
|
+
"Manifest uploaded; enable a target language in the dashboard to translate it."
|
|
143
|
+
);
|
|
144
|
+
return registration;
|
|
145
|
+
}
|
|
146
|
+
async translate(manifestId, hash, targetLocales, remote) {
|
|
147
|
+
if (!remote.translation) return;
|
|
148
|
+
const endpoint = remote.apiUrl.replace(/\/$/, "");
|
|
149
|
+
const buildKey = contentHash({
|
|
150
|
+
manifestHash: hash,
|
|
151
|
+
targetLocales: [...targetLocales].sort(),
|
|
152
|
+
publish: remote.translation.publish
|
|
153
|
+
});
|
|
154
|
+
const created = await apiJson(`${endpoint}/api/v1/manifests/${manifestId}/translations`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
authorization: `Bearer ${remote.token}`,
|
|
158
|
+
"content-type": "application/json",
|
|
159
|
+
"idempotency-key": buildKey
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
targetLocales,
|
|
163
|
+
maxChargeMicros: remote.translation.maxChargeMicros
|
|
164
|
+
})
|
|
165
|
+
});
|
|
166
|
+
remote.onProgress?.(
|
|
167
|
+
`Translation jobs accepted for ${targetLocales.join(", ")} (maximum ${created.maximumChargeMicros} micros).`
|
|
168
|
+
);
|
|
169
|
+
const completed = /* @__PURE__ */ new Map();
|
|
170
|
+
const deadline = Date.now() + (remote.translation.waitTimeoutMs ?? 3e5);
|
|
171
|
+
while (completed.size < created.jobs.length && Date.now() < deadline) {
|
|
172
|
+
for (const initial of created.jobs) {
|
|
173
|
+
if (completed.has(initial.id)) continue;
|
|
174
|
+
const job = await apiJson(`${endpoint}/api/v1/jobs/${initial.id}`, {
|
|
175
|
+
headers: { authorization: `Bearer ${remote.token}` }
|
|
176
|
+
});
|
|
177
|
+
if (job.status === "SUCCEEDED" && job.catalogVersionId)
|
|
178
|
+
completed.set(job.id, {
|
|
179
|
+
id: job.id,
|
|
180
|
+
catalogVersionId: job.catalogVersionId
|
|
181
|
+
});
|
|
182
|
+
else if (["REVIEW_REQUIRED", "FAILED", "CANCELED"].includes(job.status) || job.dispatchStatus === "FAILED")
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${job.targetLocale}: ${job.errorCode ?? job.status}: job ${job.id}. Inspect the translation job in the dashboard.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (completed.size < created.jobs.length)
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
189
|
+
}
|
|
190
|
+
if (completed.size !== created.jobs.length)
|
|
191
|
+
throw new Error(
|
|
192
|
+
`TRANSLATION_BUILD_TIMEOUT: translation did not finish within ${remote.translation.waitTimeoutMs ?? 3e5}ms.`
|
|
193
|
+
);
|
|
194
|
+
await apiJson(
|
|
195
|
+
`${endpoint}/api/v1/manifests/${manifestId}/development-release`,
|
|
196
|
+
{
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
authorization: `Bearer ${remote.token}`,
|
|
200
|
+
"content-type": "application/json",
|
|
201
|
+
"idempotency-key": `${buildKey}:release`
|
|
202
|
+
},
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
catalogVersionIds: [...completed.values()].map(
|
|
205
|
+
(job) => job.catalogVersionId
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
remote.onProgress?.("Published the translated development catalog.");
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
export {
|
|
214
|
+
BuildSession
|
|
215
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
2
|
+
"name": "@linguanahq/build-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Linguana build-core integration",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.12.0"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@linguanahq/catalog": "0.1.1"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
}
|
|
23
25
|
}
|
package/src/index.ts
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
contentHash,
|
|
6
|
-
type Diagnostic,
|
|
7
|
-
type Manifest,
|
|
8
|
-
type ManifestMessage,
|
|
9
|
-
manifestHash,
|
|
10
|
-
manifestSchema,
|
|
11
|
-
} from "@linguanahq/catalog";
|
|
12
|
-
|
|
13
|
-
export type FrameworkId = "react" | "vue" | "svelte" | "solid";
|
|
14
|
-
export type SourceMap = Record<string, unknown>;
|
|
15
|
-
export type SourceTransformInput = {
|
|
16
|
-
code: string;
|
|
17
|
-
filename: string;
|
|
18
|
-
root: string;
|
|
19
|
-
sourceLocale: string;
|
|
20
|
-
};
|
|
21
|
-
export type SourceTransformResult = {
|
|
22
|
-
code: string;
|
|
23
|
-
map: SourceMap | null;
|
|
24
|
-
messages: ManifestMessage[];
|
|
25
|
-
diagnostics: Diagnostic[];
|
|
26
|
-
};
|
|
27
|
-
export interface SourceAdapter {
|
|
28
|
-
id: FrameworkId;
|
|
29
|
-
version: string;
|
|
30
|
-
extensions: readonly string[];
|
|
31
|
-
runtimeModule: string;
|
|
32
|
-
transform(input: SourceTransformInput): Promise<SourceTransformResult | null>;
|
|
33
|
-
}
|
|
34
|
-
export type StrictPolicy =
|
|
35
|
-
| boolean
|
|
36
|
-
| {
|
|
37
|
-
ambiguous?: boolean;
|
|
38
|
-
unsafe?: boolean;
|
|
39
|
-
duplicate?: boolean;
|
|
40
|
-
};
|
|
41
|
-
export type IntegrationMetadata = {
|
|
42
|
-
tool: "vite";
|
|
43
|
-
toolVersion: string;
|
|
44
|
-
framework: FrameworkId;
|
|
45
|
-
adapterVersion: string;
|
|
46
|
-
ssr: boolean;
|
|
47
|
-
};
|
|
48
|
-
export type BuildSessionOptions = {
|
|
49
|
-
projectId: string;
|
|
50
|
-
environment: string;
|
|
51
|
-
sourceLocale: string;
|
|
52
|
-
compilerVersion: string;
|
|
53
|
-
integration: IntegrationMetadata;
|
|
54
|
-
strict?: StrictPolicy;
|
|
55
|
-
};
|
|
56
|
-
export type RemoteBuildOptions = {
|
|
57
|
-
apiUrl: string;
|
|
58
|
-
token: string;
|
|
59
|
-
translation?:
|
|
60
|
-
| false
|
|
61
|
-
| {
|
|
62
|
-
enabled: true;
|
|
63
|
-
maxChargeMicros: number;
|
|
64
|
-
waitTimeoutMs?: number;
|
|
65
|
-
publish: "development";
|
|
66
|
-
};
|
|
67
|
-
onProgress?: (message: string) => void;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
function strictFailure(
|
|
71
|
-
policy: StrictPolicy | undefined,
|
|
72
|
-
diagnostics: Diagnostic[],
|
|
73
|
-
) {
|
|
74
|
-
if (!policy) return false;
|
|
75
|
-
if (policy === true)
|
|
76
|
-
return diagnostics.some((item) => item.severity !== "info");
|
|
77
|
-
return diagnostics.some((item) => {
|
|
78
|
-
if (item.code === "duplicate-id") return policy.duplicate ?? true;
|
|
79
|
-
if (item.code.startsWith("unsafe")) return policy.unsafe ?? false;
|
|
80
|
-
return policy.ambiguous ?? false;
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function apiJson<T>(url: string, init: RequestInit): Promise<T> {
|
|
85
|
-
const response = await fetch(url, init);
|
|
86
|
-
const body = (await response.json().catch(() => ({}))) as T & {
|
|
87
|
-
code?: string;
|
|
88
|
-
message?: string;
|
|
89
|
-
requestId?: string;
|
|
90
|
-
};
|
|
91
|
-
if (!response.ok)
|
|
92
|
-
throw new Error(
|
|
93
|
-
[
|
|
94
|
-
body.code ?? `HTTP_${response.status}`,
|
|
95
|
-
body.message,
|
|
96
|
-
body.requestId ? `request ${body.requestId}` : undefined,
|
|
97
|
-
]
|
|
98
|
-
.filter(Boolean)
|
|
99
|
-
.join(": "),
|
|
100
|
-
);
|
|
101
|
-
return body;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export class BuildSession {
|
|
105
|
-
readonly messages = new Map<string, ManifestMessage>();
|
|
106
|
-
readonly diagnostics: Diagnostic[] = [];
|
|
107
|
-
readonly #diagnosticKeys = new Set<string>();
|
|
108
|
-
readonly options: BuildSessionOptions;
|
|
109
|
-
|
|
110
|
-
constructor(options: BuildSessionOptions) {
|
|
111
|
-
this.options = options;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
collect(result: SourceTransformResult) {
|
|
115
|
-
for (const message of result.messages) {
|
|
116
|
-
const existing = this.messages.get(message.id);
|
|
117
|
-
if (existing?.contentHash === message.contentHash) {
|
|
118
|
-
for (const location of message.locations) {
|
|
119
|
-
const key = `${location.file}:${location.line}:${location.column}`;
|
|
120
|
-
if (
|
|
121
|
-
!existing.locations.some(
|
|
122
|
-
(item) => `${item.file}:${item.line}:${item.column}` === key,
|
|
123
|
-
)
|
|
124
|
-
)
|
|
125
|
-
existing.locations.push(location);
|
|
126
|
-
}
|
|
127
|
-
} else if (existing) {
|
|
128
|
-
this.addDiagnostic({
|
|
129
|
-
code: "duplicate-id",
|
|
130
|
-
severity: "error",
|
|
131
|
-
message: `Message ID ${message.id} refers to incompatible source content across modules.`,
|
|
132
|
-
location: message.locations[0],
|
|
133
|
-
remediation:
|
|
134
|
-
"Choose a unique explicit ID or make the source messages identical.",
|
|
135
|
-
});
|
|
136
|
-
} else this.messages.set(message.id, structuredClone(message));
|
|
137
|
-
}
|
|
138
|
-
for (const diagnostic of result.diagnostics) this.addDiagnostic(diagnostic);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private addDiagnostic(diagnostic: Diagnostic) {
|
|
142
|
-
const location = diagnostic.location;
|
|
143
|
-
const key = `${diagnostic.code}:${location?.file ?? ""}:${location?.line ?? 0}:${location?.column ?? 0}`;
|
|
144
|
-
if (this.#diagnosticKeys.has(key)) return;
|
|
145
|
-
this.#diagnosticKeys.add(key);
|
|
146
|
-
this.diagnostics.push(diagnostic);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
manifest(): Manifest {
|
|
150
|
-
return manifestSchema.parse({
|
|
151
|
-
schemaVersion: "2.0",
|
|
152
|
-
compilerVersion: this.options.compilerVersion,
|
|
153
|
-
projectId: this.options.projectId,
|
|
154
|
-
environment: this.options.environment,
|
|
155
|
-
sourceLocale: this.options.sourceLocale,
|
|
156
|
-
sourceRevision: process.env.GIT_COMMIT_SHA,
|
|
157
|
-
integration: this.options.integration,
|
|
158
|
-
messages: [...this.messages.values()].sort((left, right) =>
|
|
159
|
-
left.id.localeCompare(right.id),
|
|
160
|
-
),
|
|
161
|
-
diagnostics: this.diagnostics,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async write(output: string) {
|
|
166
|
-
const manifest = this.manifest();
|
|
167
|
-
if (strictFailure(this.options.strict, this.diagnostics))
|
|
168
|
-
throw new Error("Linguana strict policy rejected compiler diagnostics.");
|
|
169
|
-
await mkdir(dirname(output), { recursive: true });
|
|
170
|
-
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
171
|
-
return manifest;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async upload(manifest: Manifest, remote: RemoteBuildOptions) {
|
|
175
|
-
const endpoint = remote.apiUrl.replace(/\/$/, "");
|
|
176
|
-
const hash = manifestHash(manifest);
|
|
177
|
-
const body = JSON.stringify(manifest);
|
|
178
|
-
const registration = await apiJson<{
|
|
179
|
-
manifestId: string;
|
|
180
|
-
status: string;
|
|
181
|
-
enabledTargetLocales: string[];
|
|
182
|
-
upload?: { url: string; headers: Record<string, string> };
|
|
183
|
-
}>(`${endpoint}/api/v1/manifests`, {
|
|
184
|
-
method: "POST",
|
|
185
|
-
headers: {
|
|
186
|
-
"content-type": "application/json",
|
|
187
|
-
authorization: `Bearer ${remote.token}`,
|
|
188
|
-
"idempotency-key": hash,
|
|
189
|
-
},
|
|
190
|
-
body: JSON.stringify({
|
|
191
|
-
schemaVersion: manifest.schemaVersion,
|
|
192
|
-
contentHash: hash,
|
|
193
|
-
byteLength: new TextEncoder().encode(body).byteLength,
|
|
194
|
-
messageCount: manifest.messages.length,
|
|
195
|
-
sourceRevision: manifest.sourceRevision,
|
|
196
|
-
integration: manifest.integration,
|
|
197
|
-
}),
|
|
198
|
-
});
|
|
199
|
-
if (registration.upload) {
|
|
200
|
-
const upload = await fetch(registration.upload.url, {
|
|
201
|
-
method: "PUT",
|
|
202
|
-
headers: registration.upload.headers,
|
|
203
|
-
body,
|
|
204
|
-
});
|
|
205
|
-
if (!upload.ok)
|
|
206
|
-
throw new Error(`Manifest artifact upload failed (${upload.status})`);
|
|
207
|
-
await apiJson(
|
|
208
|
-
`${endpoint}/api/v1/manifests/${registration.manifestId}/complete`,
|
|
209
|
-
{
|
|
210
|
-
method: "POST",
|
|
211
|
-
headers: {
|
|
212
|
-
authorization: `Bearer ${remote.token}`,
|
|
213
|
-
"idempotency-key": `${hash}:complete`,
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
if (remote.translation && registration.enabledTargetLocales.length)
|
|
219
|
-
await this.translate(
|
|
220
|
-
registration.manifestId,
|
|
221
|
-
hash,
|
|
222
|
-
registration.enabledTargetLocales,
|
|
223
|
-
remote,
|
|
224
|
-
);
|
|
225
|
-
else if (remote.translation)
|
|
226
|
-
remote.onProgress?.(
|
|
227
|
-
"Manifest uploaded; enable a target language in the dashboard to translate it.",
|
|
228
|
-
);
|
|
229
|
-
return registration;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private async translate(
|
|
233
|
-
manifestId: string,
|
|
234
|
-
hash: string,
|
|
235
|
-
targetLocales: string[],
|
|
236
|
-
remote: RemoteBuildOptions,
|
|
237
|
-
) {
|
|
238
|
-
if (!remote.translation) return;
|
|
239
|
-
const endpoint = remote.apiUrl.replace(/\/$/, "");
|
|
240
|
-
const buildKey = contentHash({
|
|
241
|
-
manifestHash: hash,
|
|
242
|
-
targetLocales: [...targetLocales].sort(),
|
|
243
|
-
publish: remote.translation.publish,
|
|
244
|
-
});
|
|
245
|
-
const created = await apiJson<{
|
|
246
|
-
jobs: Array<{ id: string; targetLocale: string }>;
|
|
247
|
-
maximumChargeMicros: number;
|
|
248
|
-
}>(`${endpoint}/api/v1/manifests/${manifestId}/translations`, {
|
|
249
|
-
method: "POST",
|
|
250
|
-
headers: {
|
|
251
|
-
authorization: `Bearer ${remote.token}`,
|
|
252
|
-
"content-type": "application/json",
|
|
253
|
-
"idempotency-key": buildKey,
|
|
254
|
-
},
|
|
255
|
-
body: JSON.stringify({
|
|
256
|
-
targetLocales,
|
|
257
|
-
maxChargeMicros: remote.translation.maxChargeMicros,
|
|
258
|
-
}),
|
|
259
|
-
});
|
|
260
|
-
remote.onProgress?.(
|
|
261
|
-
`Translation jobs accepted for ${targetLocales.join(", ")} (maximum ${created.maximumChargeMicros} micros).`,
|
|
262
|
-
);
|
|
263
|
-
const completed = new Map<
|
|
264
|
-
string,
|
|
265
|
-
{ id: string; catalogVersionId: string }
|
|
266
|
-
>();
|
|
267
|
-
const deadline = Date.now() + (remote.translation.waitTimeoutMs ?? 300_000);
|
|
268
|
-
while (completed.size < created.jobs.length && Date.now() < deadline) {
|
|
269
|
-
for (const initial of created.jobs) {
|
|
270
|
-
if (completed.has(initial.id)) continue;
|
|
271
|
-
const job = await apiJson<{
|
|
272
|
-
id: string;
|
|
273
|
-
targetLocale: string;
|
|
274
|
-
status: string;
|
|
275
|
-
dispatchStatus: string;
|
|
276
|
-
errorCode?: string;
|
|
277
|
-
catalogVersionId?: string;
|
|
278
|
-
}>(`${endpoint}/api/v1/jobs/${initial.id}`, {
|
|
279
|
-
headers: { authorization: `Bearer ${remote.token}` },
|
|
280
|
-
});
|
|
281
|
-
if (job.status === "SUCCEEDED" && job.catalogVersionId)
|
|
282
|
-
completed.set(job.id, {
|
|
283
|
-
id: job.id,
|
|
284
|
-
catalogVersionId: job.catalogVersionId,
|
|
285
|
-
});
|
|
286
|
-
else if (
|
|
287
|
-
["REVIEW_REQUIRED", "FAILED", "CANCELED"].includes(job.status) ||
|
|
288
|
-
job.dispatchStatus === "FAILED"
|
|
289
|
-
)
|
|
290
|
-
throw new Error(
|
|
291
|
-
`${job.targetLocale}: ${job.errorCode ?? job.status}: job ${job.id}. Inspect the translation job in the dashboard.`,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
if (completed.size < created.jobs.length)
|
|
295
|
-
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
296
|
-
}
|
|
297
|
-
if (completed.size !== created.jobs.length)
|
|
298
|
-
throw new Error(
|
|
299
|
-
`TRANSLATION_BUILD_TIMEOUT: translation did not finish within ${remote.translation.waitTimeoutMs ?? 300_000}ms.`,
|
|
300
|
-
);
|
|
301
|
-
await apiJson(
|
|
302
|
-
`${endpoint}/api/v1/manifests/${manifestId}/development-release`,
|
|
303
|
-
{
|
|
304
|
-
method: "POST",
|
|
305
|
-
headers: {
|
|
306
|
-
authorization: `Bearer ${remote.token}`,
|
|
307
|
-
"content-type": "application/json",
|
|
308
|
-
"idempotency-key": `${buildKey}:release`,
|
|
309
|
-
},
|
|
310
|
-
body: JSON.stringify({
|
|
311
|
-
catalogVersionIds: [...completed.values()].map(
|
|
312
|
-
(job) => job.catalogVersionId,
|
|
313
|
-
),
|
|
314
|
-
}),
|
|
315
|
-
},
|
|
316
|
-
);
|
|
317
|
-
remote.onProgress?.("Published the translated development catalog.");
|
|
318
|
-
}
|
|
319
|
-
}
|