@linguanahq/vite 0.1.0 → 0.1.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/adapters.test.d.ts +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +622 -0
- package/dist/preview-client.d.ts +2 -0
- package/dist/preview.d.ts +41 -0
- package/dist/preview.test.d.ts +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +19 -0
- package/dist/shared.d.ts +34 -0
- package/dist/solid.d.ts +2 -0
- package/dist/solid.js +21 -0
- package/dist/svelte.d.ts +2 -0
- package/dist/svelte.js +282 -0
- package/dist/vue.d.ts +2 -0
- package/dist/vue.js +241 -0
- package/package.json +64 -49
- package/src/index.ts +0 -173
- package/src/react.ts +0 -19
- package/src/shared.ts +0 -145
- package/src/solid.ts +0 -21
- package/src/svelte.ts +0 -226
- package/src/vue.ts +0 -191
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SourceAdapter, type StrictPolicy } from "@linguanahq/build-core";
|
|
2
|
+
import { type Plugin } from "vite";
|
|
3
|
+
export type LinguanaViteAdapter = SourceAdapter;
|
|
4
|
+
export type LinguanaViteOptions = {
|
|
5
|
+
/** Live development preview. The dashboard can disable it; false always opts out. */
|
|
6
|
+
hotReload?: boolean;
|
|
7
|
+
/** Per-preview request ceiling in USD micros, additionally limited by the project and organization. */
|
|
8
|
+
previewMaxChargeMicros?: number;
|
|
9
|
+
adapter: LinguanaViteAdapter;
|
|
10
|
+
projectId: string;
|
|
11
|
+
environment?: string;
|
|
12
|
+
sourceLocale?: string;
|
|
13
|
+
apiUrl?: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
include?: string | string[];
|
|
16
|
+
exclude?: string | string[];
|
|
17
|
+
output?: string;
|
|
18
|
+
upload?: boolean;
|
|
19
|
+
strict?: StrictPolicy;
|
|
20
|
+
translation?: false | {
|
|
21
|
+
enabled: true;
|
|
22
|
+
maxChargeMicros: number;
|
|
23
|
+
waitTimeoutMs?: number;
|
|
24
|
+
publish: "development";
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export declare function linguana(options: LinguanaViteOptions): Plugin;
|
|
28
|
+
export default linguana;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
BuildSession as BuildSession2
|
|
6
|
+
} from "@linguanahq/build-core";
|
|
7
|
+
import {
|
|
8
|
+
createFilter,
|
|
9
|
+
normalizePath,
|
|
10
|
+
version as viteVersion
|
|
11
|
+
} from "vite";
|
|
12
|
+
|
|
13
|
+
// src/preview.ts
|
|
14
|
+
import {
|
|
15
|
+
BuildSession
|
|
16
|
+
} from "@linguanahq/build-core";
|
|
17
|
+
import { contentHash } from "@linguanahq/catalog";
|
|
18
|
+
var messageKey = (message) => `${message.id}:${message.contentHash}`;
|
|
19
|
+
var terminal = /* @__PURE__ */ new Set([
|
|
20
|
+
"succeeded",
|
|
21
|
+
"failed",
|
|
22
|
+
"canceled",
|
|
23
|
+
"review_required"
|
|
24
|
+
]);
|
|
25
|
+
var LocalPreview = class {
|
|
26
|
+
files = /* @__PURE__ */ new Map();
|
|
27
|
+
locales = /* @__PURE__ */ new Set();
|
|
28
|
+
states = /* @__PURE__ */ new Map();
|
|
29
|
+
jobs = /* @__PURE__ */ new Map();
|
|
30
|
+
config;
|
|
31
|
+
configAt = 0;
|
|
32
|
+
timer;
|
|
33
|
+
running = false;
|
|
34
|
+
dirty = false;
|
|
35
|
+
disposed = false;
|
|
36
|
+
generation = 0;
|
|
37
|
+
retrySequence = 0;
|
|
38
|
+
controllers = /* @__PURE__ */ new Set();
|
|
39
|
+
options;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
}
|
|
43
|
+
collect(file, result) {
|
|
44
|
+
if (result) this.files.set(file, structuredClone(result));
|
|
45
|
+
else this.files.delete(file);
|
|
46
|
+
this.prune();
|
|
47
|
+
this.schedule(250);
|
|
48
|
+
}
|
|
49
|
+
watch(locales) {
|
|
50
|
+
this.locales = new Set(
|
|
51
|
+
locales.filter((locale) => locale !== this.options.session.sourceLocale).slice(0, 10)
|
|
52
|
+
);
|
|
53
|
+
for (const locale of this.locales) this.state(locale);
|
|
54
|
+
this.schedule(0);
|
|
55
|
+
}
|
|
56
|
+
refresh() {
|
|
57
|
+
this.retrySequence += 1;
|
|
58
|
+
for (const job of this.jobs.values())
|
|
59
|
+
if (["failed", "canceled", "review_required"].includes(job.status)) {
|
|
60
|
+
for (const key of job.keys)
|
|
61
|
+
this.state(job.locale).submitted.delete(key);
|
|
62
|
+
this.jobs.delete(job.id);
|
|
63
|
+
}
|
|
64
|
+
for (const state of this.states.values()) {
|
|
65
|
+
state.status = "ready";
|
|
66
|
+
state.message = void 0;
|
|
67
|
+
}
|
|
68
|
+
this.configAt = 0;
|
|
69
|
+
this.schedule(0);
|
|
70
|
+
}
|
|
71
|
+
dispose() {
|
|
72
|
+
this.disposed = true;
|
|
73
|
+
clearTimeout(this.timer);
|
|
74
|
+
for (const controller of this.controllers) controller.abort();
|
|
75
|
+
this.controllers.clear();
|
|
76
|
+
this.files.clear();
|
|
77
|
+
this.jobs.clear();
|
|
78
|
+
this.states.clear();
|
|
79
|
+
}
|
|
80
|
+
state(locale) {
|
|
81
|
+
let state = this.states.get(locale);
|
|
82
|
+
if (!state) {
|
|
83
|
+
state = { submitted: /* @__PURE__ */ new Set(), entries: /* @__PURE__ */ new Map(), status: "ready" };
|
|
84
|
+
this.states.set(locale, state);
|
|
85
|
+
}
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
messages() {
|
|
89
|
+
const messages = /* @__PURE__ */ new Map();
|
|
90
|
+
const conflicts = /* @__PURE__ */ new Set();
|
|
91
|
+
for (const file of this.files.values())
|
|
92
|
+
for (const message of file.messages) {
|
|
93
|
+
if (messages.has(message.id) && messages.get(message.id)?.contentHash !== message.contentHash)
|
|
94
|
+
conflicts.add(message.id);
|
|
95
|
+
messages.set(message.id, message);
|
|
96
|
+
}
|
|
97
|
+
for (const id of conflicts) messages.delete(id);
|
|
98
|
+
return messages;
|
|
99
|
+
}
|
|
100
|
+
prune() {
|
|
101
|
+
const current = this.messages();
|
|
102
|
+
const keys = new Set([...current.values()].map(messageKey));
|
|
103
|
+
for (const [locale, state] of this.states) {
|
|
104
|
+
for (const [id, entry] of state.entries)
|
|
105
|
+
if (current.get(id)?.contentHash !== entry.contentHash)
|
|
106
|
+
state.entries.delete(id);
|
|
107
|
+
for (const key of state.submitted)
|
|
108
|
+
if (!keys.has(key)) state.submitted.delete(key);
|
|
109
|
+
this.emit(locale);
|
|
110
|
+
}
|
|
111
|
+
for (const [id, job] of this.jobs)
|
|
112
|
+
if (![...job.keys].some((key) => keys.has(key))) this.jobs.delete(id);
|
|
113
|
+
}
|
|
114
|
+
emit(locale) {
|
|
115
|
+
if (this.disposed || !this.locales.has(locale)) return;
|
|
116
|
+
const state = this.state(locale);
|
|
117
|
+
this.options.emit({
|
|
118
|
+
projectId: this.options.session.projectId,
|
|
119
|
+
environment: this.options.session.environment,
|
|
120
|
+
locale,
|
|
121
|
+
entries: [...state.entries.values()],
|
|
122
|
+
sources: [...this.messages().values()].map(({ id, source }) => ({
|
|
123
|
+
id,
|
|
124
|
+
source
|
|
125
|
+
})),
|
|
126
|
+
status: state.status,
|
|
127
|
+
total: this.messages().size,
|
|
128
|
+
message: state.message
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
schedule(ms) {
|
|
132
|
+
if (this.disposed) return;
|
|
133
|
+
if (this.running) {
|
|
134
|
+
this.dirty = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
clearTimeout(this.timer);
|
|
138
|
+
this.timer = setTimeout(() => {
|
|
139
|
+
void this.tick();
|
|
140
|
+
}, ms);
|
|
141
|
+
this.timer.unref?.();
|
|
142
|
+
}
|
|
143
|
+
async request(path, init = {}) {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
this.controllers.add(controller);
|
|
146
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
147
|
+
try {
|
|
148
|
+
const response = await (this.options.fetch ?? fetch)(
|
|
149
|
+
`${this.options.apiUrl.replace(/\/$/, "")}${path}`,
|
|
150
|
+
{
|
|
151
|
+
...init,
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
headers: {
|
|
154
|
+
"content-type": "application/json",
|
|
155
|
+
authorization: `Bearer ${this.options.token}`,
|
|
156
|
+
...init.headers
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
const body = await response.json();
|
|
161
|
+
if (!response.ok)
|
|
162
|
+
throw Object.assign(
|
|
163
|
+
new Error(body.message ?? "Preview request failed."),
|
|
164
|
+
{ code: body.code ?? `HTTP_${response.status}` }
|
|
165
|
+
);
|
|
166
|
+
return body;
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
this.controllers.delete(controller);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async tick() {
|
|
173
|
+
if (this.running || this.disposed || !this.locales.size) return;
|
|
174
|
+
this.running = true;
|
|
175
|
+
this.dirty = false;
|
|
176
|
+
try {
|
|
177
|
+
if (Date.now() - this.configAt >= 5e3) {
|
|
178
|
+
const config2 = await this.request(
|
|
179
|
+
"/api/v1/preview/config"
|
|
180
|
+
);
|
|
181
|
+
if (config2.projectId !== this.options.session.projectId || config2.environment !== "development" || config2.sourceLocale !== this.options.session.sourceLocale)
|
|
182
|
+
throw new Error("Preview configuration does not match this project.");
|
|
183
|
+
if (this.config && this.config.policyKey !== config2.policyKey) {
|
|
184
|
+
this.generation += 1;
|
|
185
|
+
this.jobs.clear();
|
|
186
|
+
for (const state of this.states.values()) {
|
|
187
|
+
state.submitted.clear();
|
|
188
|
+
state.entries.clear();
|
|
189
|
+
state.status = "ready";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.config = config2;
|
|
193
|
+
this.configAt = Date.now();
|
|
194
|
+
}
|
|
195
|
+
if (!this.config?.hotReload) {
|
|
196
|
+
for (const locale of this.locales) {
|
|
197
|
+
const state = this.state(locale);
|
|
198
|
+
state.entries.clear();
|
|
199
|
+
state.status = "disabled";
|
|
200
|
+
this.emit(locale);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const config = this.config;
|
|
205
|
+
for (const locale of this.locales) {
|
|
206
|
+
const state = this.state(locale);
|
|
207
|
+
if (!config.locales.includes(locale)) {
|
|
208
|
+
state.status = "disabled";
|
|
209
|
+
state.message = "Enable this language in the dashboard.";
|
|
210
|
+
this.emit(locale);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (state.status === "disabled") state.status = "ready";
|
|
214
|
+
const missing = [...this.messages().values()].filter(
|
|
215
|
+
(message) => !state.submitted.has(messageKey(message))
|
|
216
|
+
);
|
|
217
|
+
const outstanding = [...this.jobs.values()].filter(
|
|
218
|
+
(job) => !terminal.has(job.status)
|
|
219
|
+
).length;
|
|
220
|
+
if (missing.length && outstanding < 2 && !["budget_blocked", "failed"].includes(state.status)) {
|
|
221
|
+
const batch = [];
|
|
222
|
+
let characters = 0;
|
|
223
|
+
for (const message of missing) {
|
|
224
|
+
const size = [...message.source].length;
|
|
225
|
+
if (batch.length && (batch.length >= 20 || characters + size > 2500))
|
|
226
|
+
break;
|
|
227
|
+
if (size > 12e3) {
|
|
228
|
+
state.status = "failed";
|
|
229
|
+
state.message = "A source string exceeds the 12,000-character preview limit.";
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
batch.push(message);
|
|
233
|
+
characters += size;
|
|
234
|
+
}
|
|
235
|
+
if (batch.length) await this.enqueue(locale, batch, config);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const due = [...this.jobs.values()].filter(
|
|
240
|
+
(job) => this.locales.has(job.locale) && now - job.lastPoll >= (terminal.has(job.status) ? 5e3 : 600)
|
|
241
|
+
);
|
|
242
|
+
await Promise.all(due.slice(0, 8).map((job) => this.poll(job)));
|
|
243
|
+
for (const locale of this.locales) this.emit(locale);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (!this.disposed)
|
|
246
|
+
for (const locale of this.locales) {
|
|
247
|
+
const state = this.state(locale);
|
|
248
|
+
const code = error.code ?? "PREVIEW_UNAVAILABLE";
|
|
249
|
+
state.status = /CAP|BILLING|BUDGET/.test(code) ? "budget_blocked" : "failed";
|
|
250
|
+
state.message = `${code}. Check the dashboard, then retry preview.`;
|
|
251
|
+
this.emit(locale);
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
this.running = false;
|
|
255
|
+
this.schedule(this.dirty ? 250 : 750);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async enqueue(locale, batch, config) {
|
|
259
|
+
const session = new BuildSession(this.options.session);
|
|
260
|
+
session.collect({ code: "", map: null, messages: batch, diagnostics: [] });
|
|
261
|
+
const manifest = session.manifest();
|
|
262
|
+
manifest.sourceRevision = "linguana-local-preview-v1";
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
this.controllers.add(controller);
|
|
265
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
266
|
+
let registration;
|
|
267
|
+
try {
|
|
268
|
+
registration = await session.upload(manifest, {
|
|
269
|
+
apiUrl: this.options.apiUrl,
|
|
270
|
+
token: this.options.token,
|
|
271
|
+
preview: true,
|
|
272
|
+
signal: controller.signal
|
|
273
|
+
});
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
this.controllers.delete(controller);
|
|
277
|
+
}
|
|
278
|
+
if (this.disposed || !this.locales.has(locale)) return;
|
|
279
|
+
const created = await this.request(`/api/v1/manifests/${registration.manifestId}/translations`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: {
|
|
282
|
+
"idempotency-key": contentHash({
|
|
283
|
+
manifest: registration.manifestId,
|
|
284
|
+
locale,
|
|
285
|
+
policy: config.policyKey,
|
|
286
|
+
preview: true,
|
|
287
|
+
retry: this.retrySequence
|
|
288
|
+
})
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
preview: true,
|
|
292
|
+
previewRetry: this.retrySequence > 0,
|
|
293
|
+
targetLocales: [locale],
|
|
294
|
+
maxChargeMicros: Math.min(
|
|
295
|
+
config.maxChargeMicros,
|
|
296
|
+
this.options.maxChargeMicros ?? config.maxChargeMicros
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
});
|
|
300
|
+
const keys = new Set(batch.map(messageKey));
|
|
301
|
+
for (const job of created.jobs)
|
|
302
|
+
this.jobs.set(job.id, {
|
|
303
|
+
id: job.id,
|
|
304
|
+
locale,
|
|
305
|
+
keys,
|
|
306
|
+
status: "queued",
|
|
307
|
+
lastPoll: 0
|
|
308
|
+
});
|
|
309
|
+
const state = this.state(locale);
|
|
310
|
+
for (const key of keys) state.submitted.add(key);
|
|
311
|
+
state.status = "translating";
|
|
312
|
+
state.message = void 0;
|
|
313
|
+
this.emit(locale);
|
|
314
|
+
}
|
|
315
|
+
async poll(job) {
|
|
316
|
+
job.lastPoll = Date.now();
|
|
317
|
+
const generation = this.generation;
|
|
318
|
+
const update = await this.request(`/api/v1/preview/jobs/${job.id}`);
|
|
319
|
+
if (generation !== this.generation || this.disposed || !this.locales.has(job.locale))
|
|
320
|
+
return;
|
|
321
|
+
if (update.projectId !== this.options.session.projectId || update.environment !== this.options.session.environment || update.locale !== job.locale)
|
|
322
|
+
throw new Error("Invalid preview response scope.");
|
|
323
|
+
job.status = update.status;
|
|
324
|
+
const state = this.state(job.locale);
|
|
325
|
+
const current = this.messages();
|
|
326
|
+
for (const entry of update.entries)
|
|
327
|
+
if (current.get(entry.id)?.contentHash === entry.contentHash && current.get(entry.id)?.source === entry.source)
|
|
328
|
+
state.entries.set(entry.id, entry);
|
|
329
|
+
if (update.status === "disabled") {
|
|
330
|
+
state.entries.clear();
|
|
331
|
+
state.status = "disabled";
|
|
332
|
+
} else if (["failed", "canceled"].includes(update.status)) {
|
|
333
|
+
state.status = /CAP|BILLING/.test(update.errorCode ?? "") ? "budget_blocked" : "failed";
|
|
334
|
+
state.message = update.errorCode ?? "Translation failed. Retry from the preview bar.";
|
|
335
|
+
} else if (update.status === "review_required")
|
|
336
|
+
state.status = "review_required";
|
|
337
|
+
else if (!["failed", "budget_blocked", "review_required"].includes(state.status))
|
|
338
|
+
state.status = [...this.jobs.values()].some(
|
|
339
|
+
(other) => other.locale === job.locale && !terminal.has(other.status)
|
|
340
|
+
) ? "translating" : "ready";
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// src/preview-client.ts
|
|
345
|
+
function previewClientSource(coreModule, projectId, sourceLocale) {
|
|
346
|
+
return `
|
|
347
|
+
import { installDevelopmentPreview } from ${JSON.stringify(coreModule)};
|
|
348
|
+
const hot = import.meta.hot;
|
|
349
|
+
if (hot && typeof document !== "undefined") {
|
|
350
|
+
let scopes = [], original = false, hidden = false, host;
|
|
351
|
+
const bridge = installDevelopmentPreview({ watch(next) {
|
|
352
|
+
scopes = next.filter(scope => scope.projectId === ${JSON.stringify(projectId)} && scope.environment === "development" && scope.locale !== ${JSON.stringify(sourceLocale)});
|
|
353
|
+
hot.send("linguana:preview:watch", { locales: scopes.map(scope => scope.locale) });
|
|
354
|
+
if (host) host.hidden = hidden || scopes.length === 0;
|
|
355
|
+
} });
|
|
356
|
+
let disposed = false, configTimer;
|
|
357
|
+
const configScope = { projectId: ${JSON.stringify(projectId)}, environment: "development" };
|
|
358
|
+
const refreshConfig = async () => {
|
|
359
|
+
try { await bridge.refreshConfig(configScope); }
|
|
360
|
+
finally { if (!disposed) configTimer = setTimeout(refreshConfig, 5000); }
|
|
361
|
+
};
|
|
362
|
+
void refreshConfig();
|
|
363
|
+
const mount = () => {
|
|
364
|
+
host = document.createElement("div");
|
|
365
|
+
host.setAttribute("data-linguana-preview", "");
|
|
366
|
+
host.style.cssText = "position:fixed;inset:auto 12px 12px auto;z-index:2147483000;max-width:calc(100vw - 24px)";
|
|
367
|
+
const root = host.attachShadow({ mode: "open" });
|
|
368
|
+
root.innerHTML = '<style>:host{color-scheme:light dark}aside{display:flex;flex-wrap:wrap;gap:9px;align-items:center;max-width:520px;padding:10px 12px;border:1px solid #b7c8bc;border-radius:10px;background:#f7fbf8;color:#193327;box-shadow:0 3px 18px #0002;font:12px/1.4 system-ui,sans-serif;text-align:left;direction:ltr}strong{font-weight:650}span{flex:1;min-width:90px}button{cursor:pointer;border:1px solid #b7c8bc;border-radius:5px;background:transparent;color:inherit;font:inherit;padding:4px 7px}button:focus-visible{outline:2px solid #19653b;outline-offset:2px}@media(prefers-color-scheme:dark){aside{background:#17271e;color:#eaf5ed;border-color:#4e6858}button{border-color:#4e6858}}</style><aside aria-label="Linguana local preview"><strong>Local preview</strong><span role="status" aria-live="polite">Connecting\u2026</span><button type="button" data-original aria-pressed="false">Show original</button><button type="button" data-retry>Refresh</button><button type="button" data-hide aria-label="Hide preview controls">\xD7</button></aside>';
|
|
369
|
+
root.querySelector('[data-original]').onclick = (event) => {
|
|
370
|
+
original = !original; bridge.original(original);
|
|
371
|
+
event.currentTarget.textContent = original ? 'Show translation' : 'Show original';
|
|
372
|
+
event.currentTarget.setAttribute('aria-pressed', String(original));
|
|
373
|
+
};
|
|
374
|
+
root.querySelector('[data-retry]').onclick = () => { void bridge.refreshConfig(configScope); hot.send('linguana:preview:refresh', {}); };
|
|
375
|
+
root.querySelector('[data-hide]').onclick = () => { hidden = true; host.hidden = true; };
|
|
376
|
+
document.body.append(host); host.hidden = hidden || scopes.length === 0;
|
|
377
|
+
};
|
|
378
|
+
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
379
|
+
const onUpdate = (update) => {
|
|
380
|
+
if (update.projectId !== ${JSON.stringify(projectId)} || update.environment !== 'development') return;
|
|
381
|
+
bridge.update(update);
|
|
382
|
+
const label = host?.shadowRoot?.querySelector('[role="status"]');
|
|
383
|
+
const statuses = { ready: 'Up to date', translating: 'Translating', disabled: 'Off in dashboard', failed: 'Preview unavailable', budget_blocked: 'Spending limit reached', review_required: 'Needs review' };
|
|
384
|
+
if (label) {
|
|
385
|
+
label.textContent = update.locale + ' \xB7 ' + (statuses[update.status] || 'Waiting') + ' \xB7 ' + update.entries.length + '/' + update.total;
|
|
386
|
+
label.title = update.message || 'Validated draft wording. Publishing is a separate action.';
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
const reconnect = () => { void bridge.refreshConfig(configScope); hot.send('linguana:preview:watch', { locales: scopes.map(scope => scope.locale) }); };
|
|
390
|
+
hot.on('linguana:preview:update', onUpdate);
|
|
391
|
+
hot.on('vite:ws:connect', reconnect);
|
|
392
|
+
hot.dispose(() => { disposed = true; clearTimeout(configTimer); bridge.dispose(); host?.remove(); document.removeEventListener('DOMContentLoaded', mount); hot.off('linguana:preview:update', onUpdate); hot.off('vite:ws:connect', reconnect); });
|
|
393
|
+
}
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/index.ts
|
|
398
|
+
var VERSION = "0.1.2";
|
|
399
|
+
var OFFICIAL_PLUGINS = {
|
|
400
|
+
react: [
|
|
401
|
+
"vite:react-babel",
|
|
402
|
+
"vite:react-refresh",
|
|
403
|
+
"react-refresh/babel",
|
|
404
|
+
"tanstack-start"
|
|
405
|
+
],
|
|
406
|
+
solid: ["solid"],
|
|
407
|
+
vue: ["vite:vue"],
|
|
408
|
+
svelte: ["vite-plugin-svelte"]
|
|
409
|
+
};
|
|
410
|
+
function linguana(options) {
|
|
411
|
+
if (!options.adapter)
|
|
412
|
+
throw new Error("Linguana requires exactly one framework adapter.");
|
|
413
|
+
const environment = options.environment ?? "development";
|
|
414
|
+
const sourceLocale = options.sourceLocale ?? "en";
|
|
415
|
+
if (options.translation && (!options.token || environment !== "development"))
|
|
416
|
+
throw new Error(
|
|
417
|
+
"Build translation requires a token and the development environment."
|
|
418
|
+
);
|
|
419
|
+
if (options.translation && (!Number.isSafeInteger(options.translation.maxChargeMicros) || options.translation.maxChargeMicros <= 0))
|
|
420
|
+
throw new Error(
|
|
421
|
+
"translation.maxChargeMicros must be a positive safe integer."
|
|
422
|
+
);
|
|
423
|
+
if (options.previewMaxChargeMicros !== void 0 && (!Number.isSafeInteger(options.previewMaxChargeMicros) || options.previewMaxChargeMicros <= 0))
|
|
424
|
+
throw new Error("previewMaxChargeMicros must be a positive safe integer.");
|
|
425
|
+
const previewEnabled = options.hotReload !== false && environment === "development" && Boolean(options.token) && options.upload !== false;
|
|
426
|
+
const virtualPreview = "virtual:linguana-preview";
|
|
427
|
+
const virtualId = `\0${virtualPreview}`;
|
|
428
|
+
let preview;
|
|
429
|
+
let isServe = false;
|
|
430
|
+
let base = "/";
|
|
431
|
+
const coreEntry = normalizePath(
|
|
432
|
+
fileURLToPath(import.meta.resolve("@linguanahq/core"))
|
|
433
|
+
);
|
|
434
|
+
const previewEntry = normalizePath(
|
|
435
|
+
fileURLToPath(import.meta.resolve("@linguanahq/core/preview"))
|
|
436
|
+
);
|
|
437
|
+
let root = process.cwd();
|
|
438
|
+
let session;
|
|
439
|
+
let filter;
|
|
440
|
+
let sawSsr = false;
|
|
441
|
+
let processSource;
|
|
442
|
+
const plugin = {
|
|
443
|
+
name: "linguana:vite",
|
|
444
|
+
enforce: "pre",
|
|
445
|
+
config(_, context) {
|
|
446
|
+
if (previewEnabled && context.command === "serve")
|
|
447
|
+
return {
|
|
448
|
+
optimizeDeps: {
|
|
449
|
+
exclude: [
|
|
450
|
+
"@linguanahq/core",
|
|
451
|
+
"@linguanahq/react",
|
|
452
|
+
"@linguanahq/vue",
|
|
453
|
+
"@linguanahq/solid",
|
|
454
|
+
"@linguanahq/svelte"
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
resolveId(id) {
|
|
460
|
+
if (isServe && previewEnabled && id === virtualPreview) return virtualId;
|
|
461
|
+
},
|
|
462
|
+
load(id) {
|
|
463
|
+
if (isServe && previewEnabled && id === virtualId)
|
|
464
|
+
return previewClientSource(
|
|
465
|
+
`${base}@fs/${previewEntry}`,
|
|
466
|
+
options.projectId,
|
|
467
|
+
sourceLocale
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
transformIndexHtml() {
|
|
471
|
+
if (isServe && previewEnabled)
|
|
472
|
+
return [
|
|
473
|
+
{
|
|
474
|
+
tag: "script",
|
|
475
|
+
attrs: {
|
|
476
|
+
type: "module",
|
|
477
|
+
src: `${base}@id/__x00__${virtualPreview}`
|
|
478
|
+
},
|
|
479
|
+
injectTo: "head"
|
|
480
|
+
}
|
|
481
|
+
];
|
|
482
|
+
},
|
|
483
|
+
configureServer(server) {
|
|
484
|
+
if (!previewEnabled || !options.token) return;
|
|
485
|
+
const clients = /* @__PURE__ */ new Map();
|
|
486
|
+
preview = new LocalPreview({
|
|
487
|
+
session: session.options,
|
|
488
|
+
apiUrl: options.apiUrl ?? "https://api.linguana.dev",
|
|
489
|
+
token: options.token,
|
|
490
|
+
maxChargeMicros: options.previewMaxChargeMicros,
|
|
491
|
+
emit(update) {
|
|
492
|
+
for (const [client, locales] of clients)
|
|
493
|
+
if (locales.has(update.locale))
|
|
494
|
+
client.send("linguana:preview:update", update);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
const watch = (data, client) => {
|
|
498
|
+
if (!Array.isArray(data?.locales)) return;
|
|
499
|
+
const locales = data.locales.filter(
|
|
500
|
+
(locale) => typeof locale === "string" && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(locale)
|
|
501
|
+
).slice(0, 10);
|
|
502
|
+
if (!clients.has(client))
|
|
503
|
+
client.socket.once("close", () => {
|
|
504
|
+
clients.delete(client);
|
|
505
|
+
preview?.watch([
|
|
506
|
+
...new Set([...clients.values()].flatMap((value) => [...value]))
|
|
507
|
+
]);
|
|
508
|
+
});
|
|
509
|
+
clients.set(client, new Set(locales));
|
|
510
|
+
preview?.watch([
|
|
511
|
+
...new Set([...clients.values()].flatMap((value) => [...value]))
|
|
512
|
+
]);
|
|
513
|
+
};
|
|
514
|
+
const refresh = () => preview?.refresh();
|
|
515
|
+
const remove = (file) => preview?.collect(normalizePath(file), null);
|
|
516
|
+
server.ws.on("linguana:preview:watch", watch);
|
|
517
|
+
server.ws.on("linguana:preview:refresh", refresh);
|
|
518
|
+
server.watcher.on("unlink", remove);
|
|
519
|
+
server.httpServer?.once("close", () => {
|
|
520
|
+
preview?.dispose();
|
|
521
|
+
server.ws.off("linguana:preview:watch", watch);
|
|
522
|
+
server.ws.off("linguana:preview:refresh", refresh);
|
|
523
|
+
server.watcher.off("unlink", remove);
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
closeBundle() {
|
|
527
|
+
preview?.dispose();
|
|
528
|
+
},
|
|
529
|
+
configResolved(config) {
|
|
530
|
+
root = config.root;
|
|
531
|
+
isServe = config.command === "serve";
|
|
532
|
+
base = config.base;
|
|
533
|
+
filter = createFilter(
|
|
534
|
+
options.include ?? options.adapter.extensions.map((extension) => `**/*${extension}`),
|
|
535
|
+
options.exclude ?? ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"]
|
|
536
|
+
);
|
|
537
|
+
session = new BuildSession2({
|
|
538
|
+
projectId: options.projectId,
|
|
539
|
+
environment,
|
|
540
|
+
sourceLocale,
|
|
541
|
+
compilerVersion: VERSION,
|
|
542
|
+
strict: options.strict,
|
|
543
|
+
integration: {
|
|
544
|
+
tool: "vite",
|
|
545
|
+
toolVersion: viteVersion,
|
|
546
|
+
framework: options.adapter.id,
|
|
547
|
+
adapterVersion: options.adapter.version,
|
|
548
|
+
ssr: false
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
const ownIndex = config.plugins.findIndex(
|
|
552
|
+
(item) => item.name === "linguana:vite"
|
|
553
|
+
);
|
|
554
|
+
const frameworkIndex = config.plugins.findIndex(
|
|
555
|
+
(item) => OFFICIAL_PLUGINS[options.adapter.id].some(
|
|
556
|
+
(name) => item.name.includes(name)
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
if (frameworkIndex < 0)
|
|
560
|
+
throw new Error(
|
|
561
|
+
`Linguana ${options.adapter.id} adapter requires the official ${options.adapter.id} Vite plugin.`
|
|
562
|
+
);
|
|
563
|
+
if (ownIndex > frameworkIndex)
|
|
564
|
+
throw new Error(
|
|
565
|
+
`Place linguana() before the official ${options.adapter.id} plugin in vite.config.ts.`
|
|
566
|
+
);
|
|
567
|
+
processSource = async (code, filename, ssr = false) => {
|
|
568
|
+
const cleanId = normalizePath(filename.split("?", 1)[0] ?? filename);
|
|
569
|
+
const projectRelativeId = relative(root, cleanId);
|
|
570
|
+
if (options.include === void 0 && (projectRelativeId === ".." || projectRelativeId.startsWith("../") || projectRelativeId.startsWith("..\\") || isAbsolute(projectRelativeId)))
|
|
571
|
+
return null;
|
|
572
|
+
if (!filter(cleanId)) return null;
|
|
573
|
+
if (ssr) {
|
|
574
|
+
sawSsr = true;
|
|
575
|
+
session.options.integration.ssr = true;
|
|
576
|
+
}
|
|
577
|
+
const result = await options.adapter.transform({
|
|
578
|
+
code,
|
|
579
|
+
filename: cleanId,
|
|
580
|
+
root,
|
|
581
|
+
sourceLocale
|
|
582
|
+
});
|
|
583
|
+
preview?.collect(cleanId, result);
|
|
584
|
+
if (!result) return null;
|
|
585
|
+
if (!isServe) session.collect(result);
|
|
586
|
+
return {
|
|
587
|
+
code: result.code,
|
|
588
|
+
map: result.map
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
},
|
|
592
|
+
async transform(code, id, transformOptions) {
|
|
593
|
+
if (isServe && previewEnabled && !transformOptions?.ssr && normalizePath(id.split("?")[0] ?? id) === coreEntry)
|
|
594
|
+
return { code: `import "${virtualPreview}";
|
|
595
|
+
${code}`, map: null };
|
|
596
|
+
return processSource(code, id, Boolean(transformOptions?.ssr));
|
|
597
|
+
},
|
|
598
|
+
async writeBundle() {
|
|
599
|
+
const manifest = await session.write(
|
|
600
|
+
resolve(root, options.output ?? ".linguana/manifest.json")
|
|
601
|
+
);
|
|
602
|
+
for (const item of session.diagnostics)
|
|
603
|
+
this[item.severity === "error" ? "error" : "warn"](
|
|
604
|
+
`${item.code}: ${item.message}`
|
|
605
|
+
);
|
|
606
|
+
if ((options.upload ?? Boolean(options.token)) && options.token)
|
|
607
|
+
await session.upload(manifest, {
|
|
608
|
+
apiUrl: options.apiUrl ?? "https://api.linguana.dev",
|
|
609
|
+
token: options.token,
|
|
610
|
+
translation: options.translation,
|
|
611
|
+
onProgress: (message) => this.info(`[linguana] ${message}`)
|
|
612
|
+
});
|
|
613
|
+
void sawSsr;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
return plugin;
|
|
617
|
+
}
|
|
618
|
+
var index_default = linguana;
|
|
619
|
+
export {
|
|
620
|
+
index_default as default,
|
|
621
|
+
linguana
|
|
622
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type SourceTransformResult } from "@linguanahq/build-core";
|
|
2
|
+
export type PreviewConfig = {
|
|
3
|
+
projectId: string;
|
|
4
|
+
environment: string;
|
|
5
|
+
sourceLocale: string;
|
|
6
|
+
hotReload: boolean;
|
|
7
|
+
maxChargeMicros: number;
|
|
8
|
+
locales: string[];
|
|
9
|
+
policyKey: string;
|
|
10
|
+
};
|
|
11
|
+
/** Owns only dev-server memory. Every paid task is durably enqueued by the authenticated API. */
|
|
12
|
+
export declare class LocalPreview {
|
|
13
|
+
private files;
|
|
14
|
+
private locales;
|
|
15
|
+
private states;
|
|
16
|
+
private jobs;
|
|
17
|
+
private config?;
|
|
18
|
+
private configAt;
|
|
19
|
+
private timer?;
|
|
20
|
+
private running;
|
|
21
|
+
private dirty;
|
|
22
|
+
private disposed;
|
|
23
|
+
private generation;
|
|
24
|
+
private retrySequence;
|
|
25
|
+
private controllers;
|
|
26
|
+
private options;
|
|
27
|
+
constructor(options: LocalPreview["options"]);
|
|
28
|
+
collect(file: string, result: SourceTransformResult | null): void;
|
|
29
|
+
watch(locales: string[]): void;
|
|
30
|
+
refresh(): void;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
private state;
|
|
33
|
+
private messages;
|
|
34
|
+
private prune;
|
|
35
|
+
private emit;
|
|
36
|
+
private schedule;
|
|
37
|
+
private request;
|
|
38
|
+
private tick;
|
|
39
|
+
private enqueue;
|
|
40
|
+
private poll;
|
|
41
|
+
}
|