livelocale 0.8.0 → 0.9.0
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/README.md +230 -5
- package/dist/client/index.cjs +178 -70
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +36 -22
- package/dist/client/index.d.ts +36 -22
- package/dist/client/index.js +177 -70
- package/dist/client/index.js.map +1 -1
- package/dist/codemod/index.cjs +3057 -152
- package/dist/codemod/index.cjs.map +1 -1
- package/dist/codemod/index.js +3058 -153
- package/dist/codemod/index.js.map +1 -1
- package/dist/react/index.cjs +185 -77
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.js +185 -77
- package/dist/react/index.js.map +1 -1
- package/dist/server/index.cjs +108 -66
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +13 -3
- package/dist/server/index.d.ts +13 -3
- package/dist/server/index.js +107 -66
- package/dist/server/index.js.map +1 -1
- package/dist/studio/assets/index-Bvbtkry-.css +1 -0
- package/dist/studio/assets/index-DC_jRsox.js +13 -0
- package/dist/studio/assets/inter-latin-300-normal-BVlfKGgI.woff2 +0 -0
- package/dist/studio/assets/inter-latin-300-normal-i8F0SvXL.woff +0 -0
- package/dist/studio/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/studio/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/studio/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/studio/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/studio/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/studio/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/studio/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/studio/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/studio/assets/outfit-latin-400-normal-BGsTXAXT.woff2 +0 -0
- package/dist/studio/assets/outfit-latin-400-normal-DMwTpYkH.woff +0 -0
- package/dist/studio/assets/outfit-latin-500-normal-ClnHRwRh.woff +0 -0
- package/dist/studio/assets/outfit-latin-500-normal-DKnIMDSk.woff2 +0 -0
- package/dist/studio/assets/outfit-latin-600-normal-B7SfZ07L.woff2 +0 -0
- package/dist/studio/assets/outfit-latin-600-normal-BEfTtDA7.woff +0 -0
- package/dist/studio/assets/outfit-latin-700-normal-Cu9v6i1X.woff2 +0 -0
- package/dist/studio/assets/outfit-latin-700-normal-D4itBLBr.woff +0 -0
- package/dist/studio/index.html +3 -6
- package/dist/vite/index.cjs +739 -77
- package/dist/vite/index.cjs.map +1 -1
- package/dist/vite/index.d.cts +22 -2
- package/dist/vite/index.d.ts +22 -2
- package/dist/vite/index.js +738 -77
- package/dist/vite/index.js.map +1 -1
- package/dist/{wrap-CsOtHp8r.d.cts → wrap-2qVxLN9Q.d.cts} +10 -18
- package/dist/{wrap-CsOtHp8r.d.ts → wrap-2qVxLN9Q.d.ts} +10 -18
- package/package.json +4 -1
- package/dist/studio/assets/index-D7ifnqy5.js +0 -10
- package/dist/studio/assets/index-Zh0KtFUH.css +0 -2
package/dist/codemod/index.js
CHANGED
|
@@ -292,14 +292,23 @@ var init_transform = __esm({
|
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
// src/codemod/index.ts
|
|
295
|
-
import * as
|
|
296
|
-
import * as
|
|
295
|
+
import * as fs10 from "fs";
|
|
296
|
+
import * as path15 from "path";
|
|
297
297
|
import * as net from "net";
|
|
298
|
+
|
|
299
|
+
// src/server/module-dir.ts
|
|
300
|
+
import * as path from "path";
|
|
298
301
|
import { fileURLToPath } from "url";
|
|
302
|
+
function moduleDir() {
|
|
303
|
+
if (typeof __dirname !== "undefined") {
|
|
304
|
+
return __dirname;
|
|
305
|
+
}
|
|
306
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
307
|
+
}
|
|
299
308
|
|
|
300
309
|
// src/codemod/studio-server.ts
|
|
301
310
|
import * as fs from "fs";
|
|
302
|
-
import * as
|
|
311
|
+
import * as path2 from "path";
|
|
303
312
|
import * as http from "http";
|
|
304
313
|
var MIME_TYPES = {
|
|
305
314
|
".html": "text/html; charset=utf-8",
|
|
@@ -328,8 +337,8 @@ function injectStudioConfig(html, config) {
|
|
|
328
337
|
${html}`;
|
|
329
338
|
}
|
|
330
339
|
function createStudioServer(options) {
|
|
331
|
-
const studioDir =
|
|
332
|
-
const indexPath =
|
|
340
|
+
const studioDir = path2.resolve(options.studioDir);
|
|
341
|
+
const indexPath = path2.join(studioDir, "index.html");
|
|
333
342
|
const config = { appOrigin: options.appOrigin };
|
|
334
343
|
const sendIndex = (res) => {
|
|
335
344
|
fs.readFile(indexPath, "utf-8", (err, html) => {
|
|
@@ -358,13 +367,13 @@ function createStudioServer(options) {
|
|
|
358
367
|
const urlPath = req.url?.split("?")[0] || "/";
|
|
359
368
|
let filePath;
|
|
360
369
|
try {
|
|
361
|
-
filePath =
|
|
370
|
+
filePath = path2.resolve(studioDir, `.${decodeURIComponent(urlPath)}`);
|
|
362
371
|
} catch {
|
|
363
372
|
res.statusCode = 400;
|
|
364
373
|
res.end("Bad Request");
|
|
365
374
|
return;
|
|
366
375
|
}
|
|
367
|
-
if (filePath !== studioDir && !filePath.startsWith(studioDir +
|
|
376
|
+
if (filePath !== studioDir && !filePath.startsWith(studioDir + path2.sep)) {
|
|
368
377
|
res.statusCode = 403;
|
|
369
378
|
res.end("Forbidden");
|
|
370
379
|
return;
|
|
@@ -373,7 +382,7 @@ function createStudioServer(options) {
|
|
|
373
382
|
sendIndex(res);
|
|
374
383
|
return;
|
|
375
384
|
}
|
|
376
|
-
const ext =
|
|
385
|
+
const ext = path2.extname(filePath);
|
|
377
386
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
378
387
|
fs.readFile(filePath, (err, content) => {
|
|
379
388
|
if (err) {
|
|
@@ -389,14 +398,340 @@ function createStudioServer(options) {
|
|
|
389
398
|
|
|
390
399
|
// src/codemod/doctor.ts
|
|
391
400
|
import * as fs2 from "fs";
|
|
392
|
-
import * as
|
|
401
|
+
import * as path3 from "path";
|
|
393
402
|
|
|
394
403
|
// src/core/validate.ts
|
|
395
404
|
var LOCALE_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?$/;
|
|
405
|
+
var KEY_RE = /^[a-zA-Z0-9._-]+$/;
|
|
406
|
+
var MAX_LOCALE_LENGTH = 10;
|
|
407
|
+
var MAX_KEY_LENGTH = 200;
|
|
408
|
+
var MAX_VALUE_LENGTH = 1e4;
|
|
409
|
+
var DANGEROUS_KEY_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
410
|
+
function isSafeKeyPath(key) {
|
|
411
|
+
if (typeof key !== "string") return false;
|
|
412
|
+
return !key.split(".").some((segment) => DANGEROUS_KEY_SEGMENTS.has(segment));
|
|
413
|
+
}
|
|
414
|
+
function isValidLocaleFormat(locale) {
|
|
415
|
+
return typeof locale === "string" && locale.length >= 2 && locale.length <= MAX_LOCALE_LENGTH && LOCALE_RE.test(locale);
|
|
416
|
+
}
|
|
417
|
+
function isValidKeyFormat(key) {
|
|
418
|
+
return typeof key === "string" && key.length >= 1 && key.length <= MAX_KEY_LENGTH && KEY_RE.test(key);
|
|
419
|
+
}
|
|
420
|
+
function isValidValueFormat(value) {
|
|
421
|
+
return typeof value === "string" && value.length <= MAX_VALUE_LENGTH;
|
|
422
|
+
}
|
|
396
423
|
function isSafeLocaleSegment(locale) {
|
|
397
424
|
return !(locale.includes("..") || locale.includes("/") || locale.includes("\\") || locale.includes("%") || locale.includes("\0"));
|
|
398
425
|
}
|
|
399
426
|
|
|
427
|
+
// src/core/flag-script.ts
|
|
428
|
+
var FLAG_SCRIPT_SIGNATURE = "/*! livelocale flag v1 */";
|
|
429
|
+
var FLAG_SCRIPT = `${FLAG_SCRIPT_SIGNATURE}
|
|
430
|
+
(function () {
|
|
431
|
+
try {
|
|
432
|
+
var me = document.currentScript;
|
|
433
|
+
if (!me || !me.src || window.parent === window || window.__LIVELOCALE_FLAG__ !== undefined)
|
|
434
|
+
return;
|
|
435
|
+
var studio = new URL('.', me.src);
|
|
436
|
+
var host = window.parent.location;
|
|
437
|
+
if (
|
|
438
|
+
host.origin === 'null' ||
|
|
439
|
+
host.origin !== window.location.origin ||
|
|
440
|
+
host.origin !== studio.origin
|
|
441
|
+
)
|
|
442
|
+
return;
|
|
443
|
+
if (host.pathname.indexOf(studio.pathname) !== 0) return;
|
|
444
|
+
window.__LIVELOCALE_FLAG__ = 'preview';
|
|
445
|
+
} catch (e) {}
|
|
446
|
+
})();
|
|
447
|
+
`;
|
|
448
|
+
|
|
449
|
+
// src/core/change-link.ts
|
|
450
|
+
var CHANGE_LINK_PREFIX = "lc1.";
|
|
451
|
+
var CHANGE_BLOCK_OPEN = "```livelocale";
|
|
452
|
+
var CHANGE_BLOCK_CLOSE = "```";
|
|
453
|
+
var ChangeLinkError = class extends Error {
|
|
454
|
+
code;
|
|
455
|
+
constructor(code, message) {
|
|
456
|
+
super(message);
|
|
457
|
+
this.name = "ChangeLinkError";
|
|
458
|
+
this.code = code;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
var CORRUPT_MESSAGE = "This change link is damaged or incomplete. Ask the sender to send the file instead.";
|
|
462
|
+
var UNSUPPORTED_VERSION_MESSAGE = "This change link was made by a newer LiveLocale. Update LiveLocale and try again.";
|
|
463
|
+
var BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
464
|
+
function fromBase64Url(text) {
|
|
465
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) {
|
|
466
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE);
|
|
467
|
+
}
|
|
468
|
+
const tail = text.length % 4;
|
|
469
|
+
if (tail !== 0) {
|
|
470
|
+
const unusedBits = tail === 2 ? 15 : 3;
|
|
471
|
+
if ((BASE64URL_ALPHABET.indexOf(text[text.length - 1]) & unusedBits) !== 0) {
|
|
472
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const binary = atob(
|
|
476
|
+
text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(tail === 0 ? 0 : 4 - tail)
|
|
477
|
+
);
|
|
478
|
+
const bytes = new Uint8Array(binary.length);
|
|
479
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
480
|
+
return bytes;
|
|
481
|
+
}
|
|
482
|
+
function fencedBlockContent(text) {
|
|
483
|
+
const open = new RegExp(`${CHANGE_BLOCK_OPEN}(?:\\r\\n|\\r|\\n)`).exec(text);
|
|
484
|
+
if (!open) return null;
|
|
485
|
+
const lines = text.slice(open.index + open[0].length).split(/\r\n|\r|\n/);
|
|
486
|
+
const close = lines.indexOf(CHANGE_BLOCK_CLOSE);
|
|
487
|
+
return (close < 0 ? lines : lines.slice(0, close)).join("").replace(/\s+/g, "");
|
|
488
|
+
}
|
|
489
|
+
function findChangeToken(text) {
|
|
490
|
+
const scope = (fencedBlockContent(text) ?? text).replace(/%23/gi, "#");
|
|
491
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
492
|
+
for (const match of scope.matchAll(/(?:^|[^A-Za-z0-9_-])lc1\.([A-Za-z0-9_-]+)/g)) {
|
|
493
|
+
tokens.add(match[1]);
|
|
494
|
+
}
|
|
495
|
+
if (tokens.size === 1) return CHANGE_LINK_PREFIX + [...tokens][0];
|
|
496
|
+
if (tokens.size > 1) {
|
|
497
|
+
throw new ChangeLinkError(
|
|
498
|
+
"AMBIGUOUS",
|
|
499
|
+
"This text holds more than one change link. Paste one link at a time."
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (/(?:^|[^A-Za-z0-9_-])lc(?:[2-9]|[1-9]\d+)\.[A-Za-z0-9_-]/.test(scope)) {
|
|
503
|
+
throw new ChangeLinkError("UNSUPPORTED_VERSION", UNSUPPORTED_VERSION_MESSAGE);
|
|
504
|
+
}
|
|
505
|
+
throw new ChangeLinkError(
|
|
506
|
+
"NOT_FOUND",
|
|
507
|
+
'This is not a LiveLocale change link. A change link contains "lc1.".'
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
function isValidRepoSlug(slug) {
|
|
511
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(slug)) return false;
|
|
512
|
+
const repo = slug.slice(slug.indexOf("/") + 1);
|
|
513
|
+
return repo !== "." && repo !== "..";
|
|
514
|
+
}
|
|
515
|
+
var BRANCH_NAME_RE = /^(?!-)(?!.*\.\.)[^\s\x00-\x1f\x7f]{1,255}$/;
|
|
516
|
+
function isValidBranchName(name) {
|
|
517
|
+
return BRANCH_NAME_RE.test(name) && !hasLoneSurrogate(name);
|
|
518
|
+
}
|
|
519
|
+
var CHANGE_LINK_VERSION = 1;
|
|
520
|
+
var MAX_CHANGE_EDITS = 500;
|
|
521
|
+
var MAX_BY_CHARS = 100;
|
|
522
|
+
var MAX_NOTE_CHARS = 2e3;
|
|
523
|
+
var BASE_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})?$/;
|
|
524
|
+
var BY_FORBIDDEN_RE = /[\x00-\x1f\x7f]/;
|
|
525
|
+
var NOTE_FORBIDDEN_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
526
|
+
var LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
527
|
+
function isPlainObject(value) {
|
|
528
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
529
|
+
}
|
|
530
|
+
function own(obj, key) {
|
|
531
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
|
|
532
|
+
}
|
|
533
|
+
function invalid(message) {
|
|
534
|
+
return new ChangeLinkError("INVALID", message);
|
|
535
|
+
}
|
|
536
|
+
function hasLoneSurrogate(text) {
|
|
537
|
+
return typeof text === "string" && LONE_SURROGATE_RE.test(text);
|
|
538
|
+
}
|
|
539
|
+
function parseEdit(entry, n) {
|
|
540
|
+
const fail = (problem) => invalid(`edit ${n}: ${problem}`);
|
|
541
|
+
if (!Array.isArray(entry) || entry.length !== 4 && entry.length !== 5) {
|
|
542
|
+
throw fail("is not a [locale, key, from, to] entry.");
|
|
543
|
+
}
|
|
544
|
+
const [locale, key, from, to, force] = entry;
|
|
545
|
+
if (typeof locale !== "string" || !isValidLocaleFormat(locale)) {
|
|
546
|
+
throw fail("the locale is not a locale code.");
|
|
547
|
+
}
|
|
548
|
+
if (typeof key !== "string" || !isValidKeyFormat(key) || !isSafeKeyPath(key)) {
|
|
549
|
+
throw fail("the key is not a valid translation key.");
|
|
550
|
+
}
|
|
551
|
+
if (from !== null && (typeof from !== "string" || !isValidValueFormat(from))) {
|
|
552
|
+
throw fail(`the current text is not text of at most ${MAX_VALUE_LENGTH} characters.`);
|
|
553
|
+
}
|
|
554
|
+
if (typeof to !== "string" || !isValidValueFormat(to)) {
|
|
555
|
+
throw fail(`the new text is not text of at most ${MAX_VALUE_LENGTH} characters.`);
|
|
556
|
+
}
|
|
557
|
+
if (entry.length === 5 && force !== 1) throw fail("the force flag must be 1.");
|
|
558
|
+
return { locale, key, from, to, force: entry.length === 5 };
|
|
559
|
+
}
|
|
560
|
+
function checkKeysDistinct(edits) {
|
|
561
|
+
const keysByLocale = /* @__PURE__ */ new Map();
|
|
562
|
+
edits.forEach(({ locale, key }, i) => {
|
|
563
|
+
let keys = keysByLocale.get(locale);
|
|
564
|
+
if (!keys) keysByLocale.set(locale, keys = /* @__PURE__ */ new Set());
|
|
565
|
+
if (keys.has(key)) throw invalid(`edit ${i + 1}: an earlier edit changes the same key.`);
|
|
566
|
+
keys.add(key);
|
|
567
|
+
});
|
|
568
|
+
edits.forEach(({ locale, key }, i) => {
|
|
569
|
+
const keys = keysByLocale.get(locale);
|
|
570
|
+
for (let dot = key.indexOf("."); dot >= 0; dot = key.indexOf(".", dot + 1)) {
|
|
571
|
+
if (keys.has(key.slice(0, dot))) {
|
|
572
|
+
throw invalid(`edit ${i + 1}: another edit changes a key that contains this one.`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function parseChangeSet(value) {
|
|
578
|
+
if (!isPlainObject(value)) throw invalid("This change link does not hold a change set.");
|
|
579
|
+
if (own(value, "v") !== CHANGE_LINK_VERSION) {
|
|
580
|
+
throw new ChangeLinkError("UNSUPPORTED_VERSION", UNSUPPORTED_VERSION_MESSAGE);
|
|
581
|
+
}
|
|
582
|
+
const repo = own(value, "repo");
|
|
583
|
+
if (repo !== void 0 && (typeof repo !== "string" || !isValidRepoSlug(repo))) {
|
|
584
|
+
throw invalid("The repository is not an owner/name slug.");
|
|
585
|
+
}
|
|
586
|
+
const branch = own(value, "branch");
|
|
587
|
+
if (typeof branch !== "string" || branch !== "" && !isValidBranchName(branch)) {
|
|
588
|
+
throw invalid("The branch is not a valid branch name.");
|
|
589
|
+
}
|
|
590
|
+
const base = own(value, "base");
|
|
591
|
+
if (typeof base !== "string" || !BASE_SHA_RE.test(base)) {
|
|
592
|
+
throw invalid("The base is not a full commit id.");
|
|
593
|
+
}
|
|
594
|
+
const src = own(value, "src");
|
|
595
|
+
if (typeof src !== "string" || !isValidLocaleFormat(src)) {
|
|
596
|
+
throw invalid("The source locale is not a locale code.");
|
|
597
|
+
}
|
|
598
|
+
const by = own(value, "by");
|
|
599
|
+
if (typeof by !== "string" || by.length > MAX_BY_CHARS || BY_FORBIDDEN_RE.test(by)) {
|
|
600
|
+
throw invalid(`The name is not one line of at most ${MAX_BY_CHARS} characters.`);
|
|
601
|
+
}
|
|
602
|
+
const note = own(value, "note");
|
|
603
|
+
if (typeof note !== "string" || note.length > MAX_NOTE_CHARS || NOTE_FORBIDDEN_RE.test(note)) {
|
|
604
|
+
throw invalid(`The note is not plain text of at most ${MAX_NOTE_CHARS} characters.`);
|
|
605
|
+
}
|
|
606
|
+
const entries = own(value, "e");
|
|
607
|
+
if (!Array.isArray(entries)) throw invalid("This change link holds no list of edits.");
|
|
608
|
+
if (entries.length > MAX_CHANGE_EDITS) {
|
|
609
|
+
throw new ChangeLinkError(
|
|
610
|
+
"TOO_MANY_EDITS",
|
|
611
|
+
`A change link holds at most ${MAX_CHANGE_EDITS} edits. Send the rest in another link.`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (entries.length === 0) throw invalid("This change link holds no edits.");
|
|
615
|
+
const edits = Array.from(entries, (entry, i) => parseEdit(entry, i + 1));
|
|
616
|
+
checkKeysDistinct(edits);
|
|
617
|
+
if ([repo, branch, base, src, by, note].some(hasLoneSurrogate)) {
|
|
618
|
+
throw invalid("This change link holds broken text.");
|
|
619
|
+
}
|
|
620
|
+
edits.forEach(({ locale, key, from, to }, i) => {
|
|
621
|
+
if ([locale, key, from, to].some(hasLoneSurrogate)) {
|
|
622
|
+
throw invalid(`edit ${i + 1}: holds broken text.`);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
return { ...repo === void 0 ? {} : { repo }, branch, base, src, by, note, edits };
|
|
626
|
+
}
|
|
627
|
+
var MAX_INFLATED_BYTES = 512 * 1024;
|
|
628
|
+
var MAX_TOKEN_CHARS = 1e6;
|
|
629
|
+
var TOO_LARGE_MESSAGE = `A change link holds at most ${MAX_INFLATED_BYTES / 1024} KB of text. Send the changes in smaller links.`;
|
|
630
|
+
var UNSUPPORTED_ENVIRONMENT_MESSAGE = "This browser cannot create or read change links. Use a current Chrome, Edge or Firefox, or Safari 16.4 or later.";
|
|
631
|
+
var FEED_SLICE_BYTES = 4096;
|
|
632
|
+
async function pipeBytes(stream, bytes, maxOutputLength) {
|
|
633
|
+
const input = new Uint8Array(bytes);
|
|
634
|
+
const writer = stream.writable.getWriter();
|
|
635
|
+
const reader = stream.readable.getReader();
|
|
636
|
+
let stopped = false;
|
|
637
|
+
void (async () => {
|
|
638
|
+
for (let at = 0; at < input.length; at += FEED_SLICE_BYTES) {
|
|
639
|
+
if (stopped) return;
|
|
640
|
+
await writer.write(input.subarray(at, at + FEED_SLICE_BYTES));
|
|
641
|
+
}
|
|
642
|
+
if (!stopped) await writer.close();
|
|
643
|
+
})().catch(() => {
|
|
644
|
+
});
|
|
645
|
+
const chunks = [];
|
|
646
|
+
let total = 0;
|
|
647
|
+
try {
|
|
648
|
+
for (; ; ) {
|
|
649
|
+
const { done, value } = await reader.read();
|
|
650
|
+
if (done) break;
|
|
651
|
+
total += value.length;
|
|
652
|
+
if (total > maxOutputLength) throw new ChangeLinkError("TOO_LARGE", TOO_LARGE_MESSAGE);
|
|
653
|
+
chunks.push(value);
|
|
654
|
+
}
|
|
655
|
+
} catch (err) {
|
|
656
|
+
stopped = true;
|
|
657
|
+
void writer.abort(err).catch(() => {
|
|
658
|
+
});
|
|
659
|
+
await reader.cancel(err).catch(() => {
|
|
660
|
+
});
|
|
661
|
+
throw err;
|
|
662
|
+
}
|
|
663
|
+
const out = new Uint8Array(total);
|
|
664
|
+
let offset = 0;
|
|
665
|
+
for (const chunk of chunks) {
|
|
666
|
+
out.set(chunk, offset);
|
|
667
|
+
offset += chunk.length;
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
var streamInflate = async (bytes, maxOutputLength) => {
|
|
672
|
+
if (typeof globalThis.DecompressionStream !== "function") {
|
|
673
|
+
throw new ChangeLinkError("UNSUPPORTED_ENVIRONMENT", UNSUPPORTED_ENVIRONMENT_MESSAGE);
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
return await pipeBytes(new DecompressionStream("deflate"), bytes, maxOutputLength);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (err instanceof ChangeLinkError) throw err;
|
|
679
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
async function decodeChangeLink(text, inflate = streamInflate) {
|
|
683
|
+
const token = findChangeToken(text);
|
|
684
|
+
if (token.length > MAX_TOKEN_CHARS) {
|
|
685
|
+
throw new ChangeLinkError("TOO_LARGE", TOO_LARGE_MESSAGE);
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
const bytes = await inflate(
|
|
689
|
+
fromBase64Url(token.slice(CHANGE_LINK_PREFIX.length)),
|
|
690
|
+
MAX_INFLATED_BYTES
|
|
691
|
+
);
|
|
692
|
+
const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
693
|
+
return parseChangeSet(JSON.parse(json));
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (err instanceof ChangeLinkError) throw err;
|
|
696
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/core/share-manifest.ts
|
|
701
|
+
var SHARE_DIR = "livelocale";
|
|
702
|
+
var SHARE_MANIFEST_FILE = "manifest.json";
|
|
703
|
+
var SHARE_FLAG_FILE = "flag.js";
|
|
704
|
+
var SHARE_LOCALES_DIR = "locales";
|
|
705
|
+
var SHARE_MANIFEST_FORMAT = 1;
|
|
706
|
+
var BASE_SHA_RE2 = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
707
|
+
function own2(obj, key) {
|
|
708
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
|
|
709
|
+
}
|
|
710
|
+
function isArrayOf(value, test) {
|
|
711
|
+
if (!Array.isArray(value)) return false;
|
|
712
|
+
for (let i = 0; i < value.length; i++) {
|
|
713
|
+
if (!test(value[i])) return false;
|
|
714
|
+
}
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
function isString(value) {
|
|
718
|
+
return typeof value === "string";
|
|
719
|
+
}
|
|
720
|
+
function isLocaleCode(value) {
|
|
721
|
+
return typeof value === "string" && LOCALE_RE.test(value);
|
|
722
|
+
}
|
|
723
|
+
function isNullOr(value, test) {
|
|
724
|
+
return value === null || typeof value === "string" && test(value);
|
|
725
|
+
}
|
|
726
|
+
function isShareManifest(value) {
|
|
727
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
728
|
+
const repo = own2(value, "repo");
|
|
729
|
+
const layout = own2(value, "layout");
|
|
730
|
+
const locales = own2(value, "locales");
|
|
731
|
+
const sourceLocale = own2(value, "sourceLocale");
|
|
732
|
+
return own2(value, "format") === SHARE_MANIFEST_FORMAT && (repo === void 0 || typeof repo === "string" && isValidRepoSlug(repo)) && isNullOr(own2(value, "defaultBranch"), isValidBranchName) && isNullOr(own2(value, "branch"), isValidBranchName) && isNullOr(own2(value, "base"), (base) => BASE_SHA_RE2.test(base)) && typeof own2(value, "localesPath") === "string" && (layout === "flat" || layout === "namespaced") && isArrayOf(locales, isLocaleCode) && typeof sourceLocale === "string" && locales.includes(sourceLocale) && isNullOr(own2(value, "basePath"), (path16) => path16.startsWith("/") && path16.endsWith("/")) && isArrayOf(own2(value, "codeKeys"), isString);
|
|
733
|
+
}
|
|
734
|
+
|
|
400
735
|
// src/codemod/doctor.ts
|
|
401
736
|
var ROUTE_CANDIDATES = [
|
|
402
737
|
"app/api/livelocale/route.ts",
|
|
@@ -439,10 +774,32 @@ var NEXT_CONFIG_CANDIDATES = [
|
|
|
439
774
|
"next.config.ts",
|
|
440
775
|
"next.config.cjs"
|
|
441
776
|
];
|
|
777
|
+
var VITE_CONFIG_CANDIDATES = [
|
|
778
|
+
"vite.config.ts",
|
|
779
|
+
"vite.config.mts",
|
|
780
|
+
"vite.config.js",
|
|
781
|
+
"vite.config.mjs",
|
|
782
|
+
"vite.config.cjs",
|
|
783
|
+
"vite.config.cts"
|
|
784
|
+
];
|
|
785
|
+
var SERVICE_WORKER_CONFIG_CANDIDATES = [
|
|
786
|
+
...VITE_CONFIG_CANDIDATES,
|
|
787
|
+
"webpack.config.js",
|
|
788
|
+
"webpack.config.cjs",
|
|
789
|
+
"webpack.config.mjs",
|
|
790
|
+
"webpack.config.ts",
|
|
791
|
+
"craco.config.js",
|
|
792
|
+
"craco.config.ts",
|
|
793
|
+
"vue.config.js",
|
|
794
|
+
"config-overrides.js",
|
|
795
|
+
...NEXT_CONFIG_CANDIDATES,
|
|
796
|
+
"package.json"
|
|
797
|
+
];
|
|
442
798
|
var API_ROUTE = "/api/livelocale";
|
|
799
|
+
var DENYLIST_LINE = "navigateFallbackDenylist: [/^\\/livelocale\\//]";
|
|
443
800
|
function firstExisting(root, candidates) {
|
|
444
801
|
for (const rel of candidates) {
|
|
445
|
-
if (fs2.existsSync(
|
|
802
|
+
if (fs2.existsSync(path3.join(root, rel))) return rel;
|
|
446
803
|
}
|
|
447
804
|
return null;
|
|
448
805
|
}
|
|
@@ -453,9 +810,31 @@ function readText(file) {
|
|
|
453
810
|
return "";
|
|
454
811
|
}
|
|
455
812
|
}
|
|
813
|
+
function buildsStaticShare(root) {
|
|
814
|
+
const vite = firstExisting(root, VITE_CONFIG_CANDIDATES);
|
|
815
|
+
const viteText = vite ? readText(path3.join(root, vite)) : "";
|
|
816
|
+
if (/liveLocaleVite\s*\(\s*\{[^)]*\bshare\b(?!\s*:\s*false)/.test(viteText)) return true;
|
|
817
|
+
let scripts;
|
|
818
|
+
try {
|
|
819
|
+
scripts = JSON.parse(readText(path3.join(root, "package.json")) || "{}")?.scripts;
|
|
820
|
+
} catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
if (typeof scripts !== "object" || scripts === null) return false;
|
|
824
|
+
return Object.values(scripts).some(
|
|
825
|
+
(cmd) => typeof cmd === "string" && /\blivelocale\s+(?:pack|build)\b/.test(cmd)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
456
828
|
function checkRouteHandler(root) {
|
|
457
829
|
const found = firstExisting(root, ROUTE_CANDIDATES);
|
|
458
830
|
if (found) return { level: "ok", title: "Mutation route handler", detail: found };
|
|
831
|
+
if (buildsStaticShare(root)) {
|
|
832
|
+
return {
|
|
833
|
+
level: "ok",
|
|
834
|
+
title: "Mutation route handler",
|
|
835
|
+
detail: "not needed for the share link (static build)"
|
|
836
|
+
};
|
|
837
|
+
}
|
|
459
838
|
return {
|
|
460
839
|
level: "fail",
|
|
461
840
|
title: "Mutation route handler",
|
|
@@ -463,7 +842,7 @@ function checkRouteHandler(root) {
|
|
|
463
842
|
};
|
|
464
843
|
}
|
|
465
844
|
function checkProvider(root) {
|
|
466
|
-
const existing = LAYOUT_CANDIDATES.filter((rel) => fs2.existsSync(
|
|
845
|
+
const existing = LAYOUT_CANDIDATES.filter((rel) => fs2.existsSync(path3.join(root, rel)));
|
|
467
846
|
if (existing.length === 0) {
|
|
468
847
|
return {
|
|
469
848
|
level: "warn",
|
|
@@ -471,7 +850,7 @@ function checkProvider(root) {
|
|
|
471
850
|
detail: "No root layout (app/layout.*) or pages/_app.* found, so the provider could not be verified."
|
|
472
851
|
};
|
|
473
852
|
}
|
|
474
|
-
const mounted = existing.find((rel) => /LiveLocaleProvider/.test(readText(
|
|
853
|
+
const mounted = existing.find((rel) => /LiveLocaleProvider/.test(readText(path3.join(root, rel))));
|
|
475
854
|
if (mounted) return { level: "ok", title: "LiveLocaleProvider mounted", detail: mounted };
|
|
476
855
|
return {
|
|
477
856
|
level: "warn",
|
|
@@ -482,7 +861,7 @@ function checkProvider(root) {
|
|
|
482
861
|
function checkMiddleware(root) {
|
|
483
862
|
const found = firstExisting(root, MIDDLEWARE_CANDIDATES);
|
|
484
863
|
if (!found) return { level: "ok", title: "Middleware matcher", detail: "no middleware file" };
|
|
485
|
-
const text = readText(
|
|
864
|
+
const text = readText(path3.join(root, found));
|
|
486
865
|
if (text.includes("api/livelocale")) {
|
|
487
866
|
return { level: "ok", title: "Middleware matcher", detail: `${found} excludes ${API_ROUTE}` };
|
|
488
867
|
}
|
|
@@ -501,7 +880,7 @@ function checkFrameHeaders(root) {
|
|
|
501
880
|
const found = firstExisting(root, NEXT_CONFIG_CANDIDATES);
|
|
502
881
|
if (!found)
|
|
503
882
|
return { level: "ok", title: "Frame-blocking headers in next.config", detail: "none" };
|
|
504
|
-
const text = readText(
|
|
883
|
+
const text = readText(path3.join(root, found));
|
|
505
884
|
if (STUDIO_ORIGIN_RE.test(text)) {
|
|
506
885
|
return {
|
|
507
886
|
level: "ok",
|
|
@@ -522,8 +901,8 @@ function checkFrameHeaders(root) {
|
|
|
522
901
|
};
|
|
523
902
|
}
|
|
524
903
|
function checkLocales(root, localesPath) {
|
|
525
|
-
const dir =
|
|
526
|
-
const rel =
|
|
904
|
+
const dir = path3.resolve(root, localesPath ?? "locales");
|
|
905
|
+
const rel = path3.relative(root, dir) || ".";
|
|
527
906
|
if (!fs2.existsSync(dir) || !fs2.statSync(dir).isDirectory()) {
|
|
528
907
|
return {
|
|
529
908
|
level: "fail",
|
|
@@ -534,27 +913,27 @@ function checkLocales(root, localesPath) {
|
|
|
534
913
|
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
535
914
|
const flat = entries.filter((e) => e.isFile() && e.name.endsWith(".json"));
|
|
536
915
|
const dirs = entries.filter((e) => e.isDirectory() && LOCALE_RE.test(e.name));
|
|
537
|
-
const
|
|
916
|
+
const invalid2 = [];
|
|
538
917
|
let fileCount = 0;
|
|
539
918
|
const validate = (file, label) => {
|
|
540
919
|
fileCount++;
|
|
541
920
|
try {
|
|
542
921
|
const parsed = JSON.parse(fs2.readFileSync(file, "utf-8"));
|
|
543
922
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
544
|
-
|
|
923
|
+
invalid2.push(`${label} (root must be an object)`);
|
|
545
924
|
}
|
|
546
925
|
} catch (err) {
|
|
547
|
-
|
|
926
|
+
invalid2.push(`${label} (${err.message})`);
|
|
548
927
|
}
|
|
549
928
|
};
|
|
550
|
-
for (const f of flat) validate(
|
|
929
|
+
for (const f of flat) validate(path3.join(dir, f.name), f.name);
|
|
551
930
|
for (const d of dirs) {
|
|
552
|
-
for (const f of fs2.readdirSync(
|
|
553
|
-
if (f.endsWith(".json")) validate(
|
|
931
|
+
for (const f of fs2.readdirSync(path3.join(dir, d.name))) {
|
|
932
|
+
if (f.endsWith(".json")) validate(path3.join(dir, d.name, f), `${d.name}/${f}`);
|
|
554
933
|
}
|
|
555
934
|
}
|
|
556
|
-
if (
|
|
557
|
-
return { level: "fail", title: "Locale files", detail: `Invalid JSON: ${
|
|
935
|
+
if (invalid2.length > 0) {
|
|
936
|
+
return { level: "fail", title: "Locale files", detail: `Invalid JSON: ${invalid2.join("; ")}` };
|
|
558
937
|
}
|
|
559
938
|
if (fileCount === 0) {
|
|
560
939
|
return {
|
|
@@ -577,6 +956,22 @@ function checkLocales(root, localesPath) {
|
|
|
577
956
|
detail: `${rel}: ${layout}, ${fileCount} file(s) parse`
|
|
578
957
|
};
|
|
579
958
|
}
|
|
959
|
+
function checkServiceWorkerConfig(root) {
|
|
960
|
+
const files = SERVICE_WORKER_CONFIG_CANDIDATES.filter(
|
|
961
|
+
(rel) => fs2.existsSync(path3.join(root, rel))
|
|
962
|
+
).map((rel) => ({ rel, text: readText(path3.join(root, rel)) }));
|
|
963
|
+
const pwa = files.find(({ text }) => /VitePWA\s*\(|workbox-webpack-plugin/.test(text));
|
|
964
|
+
if (!pwa) return null;
|
|
965
|
+
if (files.some(({ text }) => /navigateFallbackDenylist[\s\S]{0,300}?livelocale/.test(text))) {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
const plugin = /VitePWA\s*\(/.test(pwa.text) ? "VitePWA" : "workbox-webpack-plugin";
|
|
969
|
+
return {
|
|
970
|
+
level: "warn",
|
|
971
|
+
title: "Service worker config",
|
|
972
|
+
detail: `${pwa.rel} uses ${plugin}, and its navigation fallback would answer livelocale/index.html with your app's page. Add to its Workbox options: ${DENYLIST_LINE}`
|
|
973
|
+
};
|
|
974
|
+
}
|
|
580
975
|
async function probe(fetchImpl, url, token) {
|
|
581
976
|
try {
|
|
582
977
|
const signal = typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(15e3) : void 0;
|
|
@@ -709,8 +1104,265 @@ async function checkLive(appOrigin, fetchImpl, token) {
|
|
|
709
1104
|
}
|
|
710
1105
|
return findings;
|
|
711
1106
|
}
|
|
1107
|
+
function shareLinkFor(appBase) {
|
|
1108
|
+
return `${appBase}${SHARE_DIR}/index.html`;
|
|
1109
|
+
}
|
|
1110
|
+
function normalizeAppUrl(value) {
|
|
1111
|
+
const url = new URL(value);
|
|
1112
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1113
|
+
throw new Error(`Not an http(s) URL: ${value}`);
|
|
1114
|
+
}
|
|
1115
|
+
let pathname = url.pathname.replace(/index\.html$/, "");
|
|
1116
|
+
if (!pathname.endsWith("/")) pathname += "/";
|
|
1117
|
+
return { origin: url.origin, base: `${url.origin}${pathname}` };
|
|
1118
|
+
}
|
|
1119
|
+
var STUDIO_TITLE = "LiveLocale \xB7 Studio";
|
|
1120
|
+
var UPLOAD_HINT = "Upload the livelocale/ folder from your build output.";
|
|
1121
|
+
function isAnswer(page) {
|
|
1122
|
+
return !("error" in page);
|
|
1123
|
+
}
|
|
1124
|
+
async function fetchPage(fetchImpl, url, origin) {
|
|
1125
|
+
const res = await probe(fetchImpl, url);
|
|
1126
|
+
if (!res.ok) return { url, error: res.error };
|
|
1127
|
+
let body = "";
|
|
1128
|
+
try {
|
|
1129
|
+
body = await res.res.text();
|
|
1130
|
+
} catch {
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
url,
|
|
1134
|
+
status: res.res.status,
|
|
1135
|
+
headers: res.res.headers,
|
|
1136
|
+
body,
|
|
1137
|
+
wall: loginWall(res.res, origin)
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
function answered(title, page) {
|
|
1141
|
+
if (!isAnswer(page)) {
|
|
1142
|
+
return { level: "fail", title, detail: `${page.url} is not reachable (${page.error}).` };
|
|
1143
|
+
}
|
|
1144
|
+
if (page.wall) return { level: "fail", title, detail: page.wall };
|
|
1145
|
+
return page;
|
|
1146
|
+
}
|
|
1147
|
+
function parseJson(text) {
|
|
1148
|
+
try {
|
|
1149
|
+
return { ok: true, value: JSON.parse(text) };
|
|
1150
|
+
} catch {
|
|
1151
|
+
return { ok: false };
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function checkStudioPage(studioPage, root) {
|
|
1155
|
+
const title = "Share Studio page";
|
|
1156
|
+
const studio = answered(title, studioPage);
|
|
1157
|
+
if ("level" in studio) return studio;
|
|
1158
|
+
if (studio.status === 200 && isAnswer(root) && root.status === 200 && studio.body === root.body) {
|
|
1159
|
+
return {
|
|
1160
|
+
level: "fail",
|
|
1161
|
+
title,
|
|
1162
|
+
detail: `The host answered livelocale/index.html with your app's page (SPA fallback or service worker). ${UPLOAD_HINT}`
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (studio.status === 200 && studio.body.includes(STUDIO_TITLE)) {
|
|
1166
|
+
return { level: "ok", title, detail: studio.url };
|
|
1167
|
+
}
|
|
1168
|
+
return {
|
|
1169
|
+
level: "fail",
|
|
1170
|
+
title,
|
|
1171
|
+
detail: `${studio.url} answered HTTP ${studio.status} without the LiveLocale Studio. ${UPLOAD_HINT}`
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
function checkManifest(manifestPage) {
|
|
1175
|
+
const title = "Share manifest";
|
|
1176
|
+
const manifest = answered(title, manifestPage);
|
|
1177
|
+
if ("level" in manifest) return manifest;
|
|
1178
|
+
if (manifest.status === 404) {
|
|
1179
|
+
return {
|
|
1180
|
+
level: "fail",
|
|
1181
|
+
title,
|
|
1182
|
+
detail: `${manifest.url} answered HTTP 404; did the build run with share: true or livelocale pack?`
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
const parsed = manifest.status === 200 ? parseJson(manifest.body) : { ok: false };
|
|
1186
|
+
if (parsed.ok && isShareManifest(parsed.value)) {
|
|
1187
|
+
const m = parsed.value;
|
|
1188
|
+
return {
|
|
1189
|
+
level: "ok",
|
|
1190
|
+
title,
|
|
1191
|
+
detail: `${m.locales.length} locale(s), source ${m.sourceLocale}, built from ${m.branch ?? "an unknown branch"} @ ${m.base?.slice(0, 7) ?? "an unknown commit"}`
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
return {
|
|
1195
|
+
level: "fail",
|
|
1196
|
+
title,
|
|
1197
|
+
detail: `${manifest.url} answered HTTP ${manifest.status} without a LiveLocale share manifest (format 1). Rebuild with share: true or run livelocale pack.`
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
var HEAD_OPEN = /<head(?=[\s>])[^>]*>/i;
|
|
1201
|
+
var HTML_OPEN = /<html(?=[\s>])[^>]*>/i;
|
|
1202
|
+
var DOCTYPE = /<!doctype\b[^>]*>/i;
|
|
1203
|
+
function firstHeadScript(html) {
|
|
1204
|
+
let start = 0;
|
|
1205
|
+
for (const pattern of [HEAD_OPEN, HTML_OPEN, DOCTYPE]) {
|
|
1206
|
+
const match = pattern.exec(html);
|
|
1207
|
+
if (match) {
|
|
1208
|
+
start = match.index + match[0].length;
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const rest = html.slice(start).replace(/^(?:\s|<!--[\s\S]*?-->)*/, "");
|
|
1213
|
+
return /^<script\b([^>]*)>/i.exec(rest)?.[1] ?? null;
|
|
1214
|
+
}
|
|
1215
|
+
function flagTagProblem(root, base, flagUrl) {
|
|
1216
|
+
const expected = `The first element in <head> of ${base} must be <script src="${flagUrl}"></script> without async. Run livelocale pack on the build output again.`;
|
|
1217
|
+
if (!isAnswer(root) || root.status !== 200) return expected;
|
|
1218
|
+
const attrs = firstHeadScript(root.body);
|
|
1219
|
+
if (attrs === null || /(?:^|\s)async(?=[\s=]|$)/i.test(attrs)) return expected;
|
|
1220
|
+
const src = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i.exec(attrs);
|
|
1221
|
+
const value = src ? src[1] ?? src[2] ?? src[3] ?? "" : "";
|
|
1222
|
+
try {
|
|
1223
|
+
if (src && new URL(value, base).href === flagUrl) return null;
|
|
1224
|
+
} catch {
|
|
1225
|
+
}
|
|
1226
|
+
return expected;
|
|
1227
|
+
}
|
|
1228
|
+
function checkFlagScript(flagPage, root, base) {
|
|
1229
|
+
const title = "Share flag script";
|
|
1230
|
+
const flag = answered(title, flagPage);
|
|
1231
|
+
if ("level" in flag) return flag;
|
|
1232
|
+
const problems = [];
|
|
1233
|
+
const type = flag.headers.get("content-type") || "";
|
|
1234
|
+
if (flag.status !== 200 || !/javascript|ecmascript/i.test(type) || !flag.body.startsWith(FLAG_SCRIPT_SIGNATURE)) {
|
|
1235
|
+
problems.push(
|
|
1236
|
+
`${flag.url} answered HTTP ${flag.status} (${type || "no content type"}) without the LiveLocale flag script. ${UPLOAD_HINT}`
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
const tag = flagTagProblem(root, base, flag.url);
|
|
1240
|
+
if (tag) problems.push(tag);
|
|
1241
|
+
if (problems.length > 0) return { level: "fail", title, detail: problems.join(" ") };
|
|
1242
|
+
return { level: "ok", title, detail: `${flag.url} loads first in <head>` };
|
|
1243
|
+
}
|
|
1244
|
+
function cspSources(csp, directive) {
|
|
1245
|
+
for (const part of csp.split(";")) {
|
|
1246
|
+
const [name, ...sources] = part.trim().split(/\s+/);
|
|
1247
|
+
if (name && name.toLowerCase() === directive) return sources;
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
function allowsSelf(sources, origin) {
|
|
1252
|
+
return sources.some((s) => s === "'self'" || s === "*" || s.replace(/\/+$/, "") === origin);
|
|
1253
|
+
}
|
|
1254
|
+
function checkShareFrameHeaders(pages, origin) {
|
|
1255
|
+
const title = "Share frame headers";
|
|
1256
|
+
const blockers = [];
|
|
1257
|
+
for (const page of pages) {
|
|
1258
|
+
const answer = answered(title, page);
|
|
1259
|
+
if ("level" in answer) return answer;
|
|
1260
|
+
const xfo = (answer.headers.get("x-frame-options") || "").trim();
|
|
1261
|
+
if (/^deny$/i.test(xfo)) blockers.push(`${page.url} sends X-Frame-Options: ${xfo}`);
|
|
1262
|
+
const ancestors = cspSources(
|
|
1263
|
+
answer.headers.get("content-security-policy") || "",
|
|
1264
|
+
"frame-ancestors"
|
|
1265
|
+
);
|
|
1266
|
+
if (ancestors && (ancestors.includes("'none'") || !allowsSelf(ancestors, origin))) {
|
|
1267
|
+
blockers.push(`${page.url} sends frame-ancestors ${ancestors.join(" ") || "'none'"}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (blockers.length > 0) {
|
|
1271
|
+
return {
|
|
1272
|
+
level: "fail",
|
|
1273
|
+
title,
|
|
1274
|
+
detail: `${blockers.join("; ")}. The share Studio frames your app from the same origin; use X-Frame-Options: SAMEORIGIN or frame-ancestors 'self'.`
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
return { level: "ok", title, detail: "both pages can be framed by the same origin" };
|
|
1278
|
+
}
|
|
1279
|
+
function checkShareCsp(pages, origin) {
|
|
1280
|
+
const title = "Share CSP";
|
|
1281
|
+
const blocked = [];
|
|
1282
|
+
for (const page of pages) {
|
|
1283
|
+
const answer = answered(title, page);
|
|
1284
|
+
if ("level" in answer) return answer;
|
|
1285
|
+
const csp = answer.headers.get("content-security-policy") || "";
|
|
1286
|
+
const scriptSrc = cspSources(csp, "script-src");
|
|
1287
|
+
const sources = scriptSrc ?? cspSources(csp, "default-src");
|
|
1288
|
+
if (sources && !allowsSelf(sources, origin)) {
|
|
1289
|
+
blocked.push(`${page.url} (${scriptSrc ? "script-src" : "default-src"})`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (blocked.length > 0) {
|
|
1293
|
+
return {
|
|
1294
|
+
level: "warn",
|
|
1295
|
+
title,
|
|
1296
|
+
detail: `${blocked.join(", ")} leaves out 'self', so the browser may block livelocale/flag.js and the Studio's scripts. Add 'self' to script-src.`
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
return { level: "ok", title, detail: "scripts from 'self' are allowed" };
|
|
1300
|
+
}
|
|
1301
|
+
function workerCandidates(root, base) {
|
|
1302
|
+
const urls = [];
|
|
1303
|
+
if (isAnswer(root)) {
|
|
1304
|
+
const registered = /serviceWorker\s*\.\s*register\s*\(\s*["'`]([^"'`]+)["'`]/.exec(root.body);
|
|
1305
|
+
if (registered) {
|
|
1306
|
+
try {
|
|
1307
|
+
urls.push(new URL(registered[1], base).href);
|
|
1308
|
+
} catch {
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const names = ["sw.js", "service-worker.js"];
|
|
1313
|
+
for (const url of [
|
|
1314
|
+
...names.map((name) => new URL(name, base).href),
|
|
1315
|
+
...names.map((name) => new URL(`/${name}`, base).href)
|
|
1316
|
+
]) {
|
|
1317
|
+
if (!urls.includes(url)) urls.push(url);
|
|
1318
|
+
}
|
|
1319
|
+
return urls;
|
|
1320
|
+
}
|
|
1321
|
+
async function checkServiceWorker(fetchImpl, root, base, origin) {
|
|
1322
|
+
const title = "Service worker";
|
|
1323
|
+
const found = [];
|
|
1324
|
+
for (const url of workerCandidates(root, base)) {
|
|
1325
|
+
const worker = await fetchPage(fetchImpl, url, origin);
|
|
1326
|
+
if (!isAnswer(worker)) continue;
|
|
1327
|
+
if (worker.wall) return { level: "fail", title, detail: worker.wall };
|
|
1328
|
+
if (worker.status !== 200 || /html/i.test(worker.headers.get("content-type") || "")) continue;
|
|
1329
|
+
found.push(url);
|
|
1330
|
+
if (/createHandlerBoundToURL|navigateFallback/.test(worker.body) && !worker.body.includes("livelocale")) {
|
|
1331
|
+
return {
|
|
1332
|
+
level: "warn",
|
|
1333
|
+
title,
|
|
1334
|
+
detail: `${url} answers navigations with the app shell, so livelocale/index.html would open your app. Add to your Workbox config: ${DENYLIST_LINE}`
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return {
|
|
1339
|
+
level: "ok",
|
|
1340
|
+
title,
|
|
1341
|
+
detail: found.length > 0 ? `${found.join(", ")} leaves livelocale/ alone` : "none found"
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
async function checkShare(origin, base, fetchImpl, manifest) {
|
|
1345
|
+
const shareUrl = (file) => `${base}${SHARE_DIR}/${file}`;
|
|
1346
|
+
const [root, studio, flag] = await Promise.all([
|
|
1347
|
+
fetchPage(fetchImpl, base, origin),
|
|
1348
|
+
fetchPage(fetchImpl, shareLinkFor(base), origin),
|
|
1349
|
+
fetchPage(fetchImpl, shareUrl(SHARE_FLAG_FILE), origin)
|
|
1350
|
+
]);
|
|
1351
|
+
return [
|
|
1352
|
+
checkStudioPage(studio, root),
|
|
1353
|
+
checkManifest(manifest),
|
|
1354
|
+
checkFlagScript(flag, root, base),
|
|
1355
|
+
checkShareFrameHeaders([root, studio], origin),
|
|
1356
|
+
checkShareCsp([root, studio], origin),
|
|
1357
|
+
await checkServiceWorker(fetchImpl, root, base, origin),
|
|
1358
|
+
{ level: "ok", title: "Share link", detail: shareLinkFor(base) }
|
|
1359
|
+
];
|
|
1360
|
+
}
|
|
1361
|
+
function looksLikeShareBuild(manifest) {
|
|
1362
|
+
return isAnswer(manifest) && manifest.status === 200 && parseJson(manifest.body).ok;
|
|
1363
|
+
}
|
|
712
1364
|
async function runDoctor(options) {
|
|
713
|
-
const root =
|
|
1365
|
+
const root = path3.resolve(options.projectDir);
|
|
714
1366
|
const findings = [
|
|
715
1367
|
checkRouteHandler(root),
|
|
716
1368
|
checkProvider(root),
|
|
@@ -718,7 +1370,10 @@ async function runDoctor(options) {
|
|
|
718
1370
|
checkFrameHeaders(root),
|
|
719
1371
|
checkLocales(root, options.localesPath)
|
|
720
1372
|
];
|
|
721
|
-
|
|
1373
|
+
const workerConfig = checkServiceWorkerConfig(root);
|
|
1374
|
+
if (workerConfig) findings.push(workerConfig);
|
|
1375
|
+
const appOrigin = options.appOrigin ?? (options.appBase && new URL(options.appBase).origin);
|
|
1376
|
+
if (appOrigin) {
|
|
722
1377
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
723
1378
|
if (typeof fetchImpl !== "function") {
|
|
724
1379
|
findings.push({
|
|
@@ -727,8 +1382,19 @@ async function runDoctor(options) {
|
|
|
727
1382
|
detail: "Live checks need Node 18+ (global fetch)."
|
|
728
1383
|
});
|
|
729
1384
|
} else {
|
|
730
|
-
const
|
|
731
|
-
|
|
1385
|
+
const origin = appOrigin.replace(/\/+$/, "");
|
|
1386
|
+
const base = options.appBase ?? `${origin}/`;
|
|
1387
|
+
const manifest = await fetchPage(
|
|
1388
|
+
fetchImpl,
|
|
1389
|
+
`${base}${SHARE_DIR}/${SHARE_MANIFEST_FILE}`,
|
|
1390
|
+
origin
|
|
1391
|
+
);
|
|
1392
|
+
if (options.share || looksLikeShareBuild(manifest)) {
|
|
1393
|
+
findings.push(...await checkShare(origin, base, fetchImpl, manifest));
|
|
1394
|
+
} else {
|
|
1395
|
+
const token = options.token ?? process.env["LIVELOCALE_TOKEN"] ?? void 0;
|
|
1396
|
+
findings.push(...await checkLive(origin, fetchImpl, token || void 0));
|
|
1397
|
+
}
|
|
732
1398
|
}
|
|
733
1399
|
}
|
|
734
1400
|
return findings;
|
|
@@ -748,7 +1414,7 @@ function formatFindings(findings) {
|
|
|
748
1414
|
|
|
749
1415
|
// src/codemod/check.ts
|
|
750
1416
|
import * as fs4 from "fs";
|
|
751
|
-
import * as
|
|
1417
|
+
import * as path5 from "path";
|
|
752
1418
|
|
|
753
1419
|
// src/core/placeholders.ts
|
|
754
1420
|
var PLURAL_TYPES = /* @__PURE__ */ new Set(["plural", "select", "selectordinal"]);
|
|
@@ -913,7 +1579,7 @@ function validateMessageSyntax(message) {
|
|
|
913
1579
|
|
|
914
1580
|
// src/server/key-scanner.ts
|
|
915
1581
|
import { promises as fs3 } from "fs";
|
|
916
|
-
import * as
|
|
1582
|
+
import * as path4 from "path";
|
|
917
1583
|
|
|
918
1584
|
// src/core/env.ts
|
|
919
1585
|
var OFF_VALUES = /* @__PURE__ */ new Set(["off", "0", "false"]);
|
|
@@ -1194,7 +1860,7 @@ var KeyScanner = class {
|
|
|
1194
1860
|
}
|
|
1195
1861
|
}
|
|
1196
1862
|
async loadGitignore(projectRoot) {
|
|
1197
|
-
const gitignorePath =
|
|
1863
|
+
const gitignorePath = path4.join(projectRoot, ".gitignore");
|
|
1198
1864
|
let stat;
|
|
1199
1865
|
try {
|
|
1200
1866
|
stat = await fs3.stat(gitignorePath);
|
|
@@ -1219,16 +1885,16 @@ var KeyScanner = class {
|
|
|
1219
1885
|
* Scans the project (the parent directory of `basePath`) for translation key references.
|
|
1220
1886
|
*/
|
|
1221
1887
|
async scanCodebaseKeys(basePath) {
|
|
1222
|
-
return this.scanSourceKeys(
|
|
1888
|
+
return this.scanSourceKeys(path4.dirname(path4.resolve(basePath)));
|
|
1223
1889
|
}
|
|
1224
1890
|
/** Scans every source file under `projectRoot` for translation key references. */
|
|
1225
1891
|
async scanSourceKeys(projectRoot) {
|
|
1226
1892
|
this.assertEnabled();
|
|
1227
|
-
projectRoot =
|
|
1893
|
+
projectRoot = path4.resolve(projectRoot);
|
|
1228
1894
|
const gitignore = await this.loadGitignore(projectRoot);
|
|
1229
1895
|
const keysSet = /* @__PURE__ */ new Set();
|
|
1230
1896
|
const seen = /* @__PURE__ */ new Set();
|
|
1231
|
-
const toRel = (fullPath) =>
|
|
1897
|
+
const toRel = (fullPath) => path4.relative(projectRoot, fullPath).split(path4.sep).join("/");
|
|
1232
1898
|
const scanDir = async (dir) => {
|
|
1233
1899
|
let items;
|
|
1234
1900
|
try {
|
|
@@ -1237,7 +1903,7 @@ var KeyScanner = class {
|
|
|
1237
1903
|
return;
|
|
1238
1904
|
}
|
|
1239
1905
|
for (const item of items) {
|
|
1240
|
-
const fullPath =
|
|
1906
|
+
const fullPath = path4.join(dir, item.name);
|
|
1241
1907
|
if (item.isDirectory()) {
|
|
1242
1908
|
if (IGNORE_DIRS.has(item.name)) continue;
|
|
1243
1909
|
if (gitignore.ignores(toRel(fullPath), true)) continue;
|
|
@@ -1245,7 +1911,7 @@ var KeyScanner = class {
|
|
|
1245
1911
|
continue;
|
|
1246
1912
|
}
|
|
1247
1913
|
if (!item.isFile()) continue;
|
|
1248
|
-
if (!SOURCE_EXTENSIONS.has(
|
|
1914
|
+
if (!SOURCE_EXTENSIONS.has(path4.extname(item.name))) continue;
|
|
1249
1915
|
if (item.name.endsWith(".d.ts")) continue;
|
|
1250
1916
|
if (gitignore.ignores(toRel(fullPath), false)) continue;
|
|
1251
1917
|
let stat;
|
|
@@ -1285,8 +1951,8 @@ var KeyScanner = class {
|
|
|
1285
1951
|
const keysSet = /* @__PURE__ */ new Set();
|
|
1286
1952
|
for (const locale of locales) {
|
|
1287
1953
|
try {
|
|
1288
|
-
const resolvedBasePath =
|
|
1289
|
-
const localePath =
|
|
1954
|
+
const resolvedBasePath = path4.resolve(basePath);
|
|
1955
|
+
const localePath = path4.resolve(resolvedBasePath, locale);
|
|
1290
1956
|
let isDir = false;
|
|
1291
1957
|
try {
|
|
1292
1958
|
const stats = await fs3.stat(localePath);
|
|
@@ -1298,9 +1964,9 @@ var KeyScanner = class {
|
|
|
1298
1964
|
const files = await fs3.readdir(localePath);
|
|
1299
1965
|
for (const file of files) {
|
|
1300
1966
|
if (file.endsWith(".json")) {
|
|
1301
|
-
const ns =
|
|
1967
|
+
const ns = path4.basename(file, ".json");
|
|
1302
1968
|
try {
|
|
1303
|
-
const content = await fs3.readFile(
|
|
1969
|
+
const content = await fs3.readFile(path4.join(localePath, file), "utf-8");
|
|
1304
1970
|
data[ns] = JSON.parse(content);
|
|
1305
1971
|
} catch {
|
|
1306
1972
|
}
|
|
@@ -1311,7 +1977,7 @@ var KeyScanner = class {
|
|
|
1311
1977
|
if (!isSafeLocaleSegment(locale)) {
|
|
1312
1978
|
continue;
|
|
1313
1979
|
}
|
|
1314
|
-
const filePath =
|
|
1980
|
+
const filePath = path4.resolve(resolvedBasePath, `${locale}.json`);
|
|
1315
1981
|
if (filePath.startsWith(resolvedBasePath)) {
|
|
1316
1982
|
const content = await fs3.readFile(filePath, "utf-8");
|
|
1317
1983
|
data = JSON.parse(content);
|
|
@@ -1377,7 +2043,7 @@ function listLocales(localesDir) {
|
|
|
1377
2043
|
for (const item of fs4.readdirSync(localesDir, { withFileTypes: true })) {
|
|
1378
2044
|
if (item.isDirectory() && LOCALE_RE.test(item.name)) locales.add(item.name);
|
|
1379
2045
|
else if (item.isFile() && item.name.endsWith(".json")) {
|
|
1380
|
-
const name =
|
|
2046
|
+
const name = path5.basename(item.name, ".json");
|
|
1381
2047
|
if (LOCALE_RE.test(name)) locales.add(name);
|
|
1382
2048
|
}
|
|
1383
2049
|
}
|
|
@@ -1397,7 +2063,7 @@ function flatten(obj, prefix, data) {
|
|
|
1397
2063
|
}
|
|
1398
2064
|
function readLocale(localesDir, locale, projectDir, issues) {
|
|
1399
2065
|
const data = { leaves: /* @__PURE__ */ new Map(), branches: /* @__PURE__ */ new Set() };
|
|
1400
|
-
const rel = (p) =>
|
|
2066
|
+
const rel = (p) => path5.relative(projectDir, p).split(path5.sep).join("/");
|
|
1401
2067
|
const parse = (file) => {
|
|
1402
2068
|
try {
|
|
1403
2069
|
return JSON.parse(fs4.readFileSync(file, "utf-8"));
|
|
@@ -1412,18 +2078,18 @@ function readLocale(localesDir, locale, projectDir, issues) {
|
|
|
1412
2078
|
return void 0;
|
|
1413
2079
|
}
|
|
1414
2080
|
};
|
|
1415
|
-
const dir =
|
|
2081
|
+
const dir = path5.join(localesDir, locale);
|
|
1416
2082
|
if (fs4.existsSync(dir) && fs4.statSync(dir).isDirectory()) {
|
|
1417
2083
|
for (const file of fs4.readdirSync(dir).sort()) {
|
|
1418
2084
|
if (!file.endsWith(".json")) continue;
|
|
1419
|
-
const ns =
|
|
1420
|
-
const parsed = parse(
|
|
2085
|
+
const ns = path5.basename(file, ".json");
|
|
2086
|
+
const parsed = parse(path5.join(dir, file));
|
|
1421
2087
|
if (parsed === void 0) continue;
|
|
1422
2088
|
data.branches.add(ns);
|
|
1423
2089
|
flatten(parsed, ns, data);
|
|
1424
2090
|
}
|
|
1425
2091
|
} else {
|
|
1426
|
-
flatten(parse(
|
|
2092
|
+
flatten(parse(path5.join(localesDir, `${locale}.json`)), "", data);
|
|
1427
2093
|
}
|
|
1428
2094
|
return data;
|
|
1429
2095
|
}
|
|
@@ -1447,8 +2113,8 @@ function isReferenced(key, codeKeys) {
|
|
|
1447
2113
|
return false;
|
|
1448
2114
|
}
|
|
1449
2115
|
async function runCheck(options) {
|
|
1450
|
-
const projectDir =
|
|
1451
|
-
const localesDir =
|
|
2116
|
+
const projectDir = path5.resolve(options.projectDir);
|
|
2117
|
+
const localesDir = path5.resolve(projectDir, options.localesPath ?? "locales");
|
|
1452
2118
|
const issues = [];
|
|
1453
2119
|
const strict = options.strict ?? false;
|
|
1454
2120
|
const warn = strict ? "fail" : "warn";
|
|
@@ -1565,64 +2231,2208 @@ function formatCheckReport(report, limit = 25) {
|
|
|
1565
2231
|
return lines.join("\n");
|
|
1566
2232
|
}
|
|
1567
2233
|
|
|
1568
|
-
// src/
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
}
|
|
1573
|
-
return path5.dirname(fileURLToPath(import.meta.url));
|
|
1574
|
-
};
|
|
1575
|
-
var DEFAULT_STUDIO_PORT = 3010;
|
|
1576
|
-
var DEFAULT_APP_ORIGIN = "http://localhost:3000";
|
|
1577
|
-
function printHelp() {
|
|
1578
|
-
console.log(`
|
|
1579
|
-
LiveLocale (livelocale) CLI
|
|
1580
|
-
|
|
1581
|
-
Usage:
|
|
1582
|
-
npx livelocale <command> [options]
|
|
1583
|
-
|
|
1584
|
-
Commands:
|
|
1585
|
-
migrate Bulk migrate existing react-i18next translation usages to wrap them with wrapTranslationEngine automatically.
|
|
1586
|
-
init Initialize the API route handler inside a Next.js project automatically.
|
|
1587
|
-
studio Start the visual editing studio local web server.
|
|
1588
|
-
doctor Check the project for common integration problems (route, provider, middleware, CSP, locales).
|
|
1589
|
-
check Check translations for missing keys, placeholder drift, broken ICU syntax and unused keys (CI-friendly).
|
|
1590
|
-
|
|
1591
|
-
Options for migrate:
|
|
1592
|
-
--dir <path> Directory to scan (default: current directory)
|
|
1593
|
-
--exclude <list> Comma-separated list of directories to exclude (default: node_modules,.next,dist,.git)
|
|
1594
|
-
--dry-run Preview changes without writing them to disk
|
|
1595
|
-
|
|
1596
|
-
Options for init:
|
|
1597
|
-
--dir <path> Target project directory (default: current directory)
|
|
1598
|
-
|
|
1599
|
-
Options for studio:
|
|
1600
|
-
--port <number> Port to run the studio server on (default: ${DEFAULT_STUDIO_PORT})
|
|
1601
|
-
--app <origin> Origin of the app being edited (default: ${DEFAULT_APP_ORIGIN})
|
|
1602
|
-
Can also be changed at run time with ?app=<origin> on the Studio URL.
|
|
1603
|
-
|
|
1604
|
-
Options for doctor:
|
|
1605
|
-
--dir <path> Project directory to inspect (default: current directory)
|
|
1606
|
-
--locales <path> Locales directory relative to the project (default: locales)
|
|
1607
|
-
--app <origin> Also probe the running app, e.g. http://localhost:3000
|
|
1608
|
-
--token <token> Access token for a preview deployment (default: LIVELOCALE_TOKEN)
|
|
1609
|
-
|
|
1610
|
-
Options for check:
|
|
1611
|
-
--dir <path> Project directory to scan (default: current directory)
|
|
1612
|
-
--locales <path> Locales directory relative to the project (default: locales)
|
|
1613
|
-
--source <locale> Locale the others are compared against (default: en, else the first locale)
|
|
1614
|
-
--no-unused Do not report keys that code never references
|
|
1615
|
-
--strict Treat warnings (unused keys, empty values) as failures
|
|
1616
|
-
--json Print the full report as JSON
|
|
2234
|
+
// src/server/pack.ts
|
|
2235
|
+
import { spawn } from "child_process";
|
|
2236
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, statSync as statSync4, promises as fs5 } from "fs";
|
|
2237
|
+
import * as path8 from "path";
|
|
1617
2238
|
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2239
|
+
// src/server/build-info.ts
|
|
2240
|
+
import { execFile } from "child_process";
|
|
2241
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2242
|
+
import * as path6 from "path";
|
|
2243
|
+
var runGit = (args, cwd) => new Promise((resolve14) => {
|
|
1623
2244
|
try {
|
|
1624
|
-
|
|
1625
|
-
|
|
2245
|
+
execFile(
|
|
2246
|
+
"git",
|
|
2247
|
+
args,
|
|
2248
|
+
{ cwd, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
|
2249
|
+
(error, stdout) => resolve14(error ? null : String(stdout).trim())
|
|
2250
|
+
);
|
|
2251
|
+
} catch {
|
|
2252
|
+
resolve14(null);
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
var SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
2256
|
+
async function firstValid(candidates, accept) {
|
|
2257
|
+
for (const candidate of candidates) {
|
|
2258
|
+
const value = typeof candidate === "function" ? await candidate() : candidate;
|
|
2259
|
+
if (typeof value !== "string" || value === "") continue;
|
|
2260
|
+
const accepted = accept(value);
|
|
2261
|
+
if (accepted !== null) return accepted;
|
|
2262
|
+
}
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
var asBranch = (value) => isValidBranchName(value) ? value : null;
|
|
2266
|
+
var asSha = (value) => SHA_RE.test(value) ? value.toLowerCase() : null;
|
|
2267
|
+
var asSlug = (value) => isValidRepoSlug(value) ? value : null;
|
|
2268
|
+
function readEvent(eventPath) {
|
|
2269
|
+
if (!eventPath) return {};
|
|
2270
|
+
try {
|
|
2271
|
+
const parsed = JSON.parse(readFileSync3(eventPath, "utf-8"));
|
|
2272
|
+
return isRecord(parsed) ? parsed : {};
|
|
2273
|
+
} catch {
|
|
2274
|
+
return {};
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
function isRecord(value) {
|
|
2278
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2279
|
+
}
|
|
2280
|
+
function pick(obj, ...keys) {
|
|
2281
|
+
let current = obj;
|
|
2282
|
+
for (const key of keys) {
|
|
2283
|
+
if (!isRecord(current)) return void 0;
|
|
2284
|
+
current = current[key];
|
|
2285
|
+
}
|
|
2286
|
+
return typeof current === "string" ? current : void 0;
|
|
2287
|
+
}
|
|
2288
|
+
async function resolveBuildInfo(options) {
|
|
2289
|
+
const { cwd, env = process.env, git = runGit, includeRepo = false } = options;
|
|
2290
|
+
const event = readEvent(env.GITHUB_EVENT_PATH);
|
|
2291
|
+
const isPullRequest = env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target";
|
|
2292
|
+
const netlify = env.NETLIFY === "true";
|
|
2293
|
+
const gitlab = env.GITLAB_CI === "true";
|
|
2294
|
+
const branch = await firstValid(
|
|
2295
|
+
[
|
|
2296
|
+
env.GITHUB_HEAD_REF,
|
|
2297
|
+
env.GITHUB_REF_TYPE === "branch" ? env.GITHUB_REF_NAME : null,
|
|
2298
|
+
env.VERCEL_GIT_COMMIT_REF,
|
|
2299
|
+
env.CF_PAGES_BRANCH,
|
|
2300
|
+
netlify ? env.BRANCH : null,
|
|
2301
|
+
gitlab ? env.CI_COMMIT_REF_NAME : null,
|
|
2302
|
+
async () => {
|
|
2303
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
2304
|
+
return head === "HEAD" ? null : head;
|
|
2305
|
+
}
|
|
2306
|
+
],
|
|
2307
|
+
asBranch
|
|
2308
|
+
);
|
|
2309
|
+
const base = await firstValid(
|
|
2310
|
+
[
|
|
2311
|
+
isPullRequest ? pick(event, "pull_request", "head", "sha") : null,
|
|
2312
|
+
env.GITHUB_SHA,
|
|
2313
|
+
env.VERCEL_GIT_COMMIT_SHA,
|
|
2314
|
+
env.CF_PAGES_COMMIT_SHA,
|
|
2315
|
+
netlify ? env.COMMIT_REF : null,
|
|
2316
|
+
gitlab ? env.CI_COMMIT_SHA : null,
|
|
2317
|
+
() => git(["rev-parse", "HEAD"], cwd)
|
|
2318
|
+
],
|
|
2319
|
+
asSha
|
|
2320
|
+
);
|
|
2321
|
+
const defaultBranch = await firstValid(
|
|
2322
|
+
[
|
|
2323
|
+
pick(event, "repository", "default_branch"),
|
|
2324
|
+
async () => {
|
|
2325
|
+
const ref = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
|
|
2326
|
+
return ref?.startsWith("origin/") ? ref.slice("origin/".length) : null;
|
|
2327
|
+
}
|
|
2328
|
+
],
|
|
2329
|
+
asBranch
|
|
2330
|
+
);
|
|
2331
|
+
const githubCom = env.GITHUB_SERVER_URL === void 0 || env.GITHUB_SERVER_URL === "" || env.GITHUB_SERVER_URL === "https://github.com";
|
|
2332
|
+
const repo = includeRepo ? await firstValid(
|
|
2333
|
+
[
|
|
2334
|
+
githubCom ? env.GITHUB_REPOSITORY : null,
|
|
2335
|
+
env.VERCEL_GIT_PROVIDER === "github" && env.VERCEL_GIT_REPO_OWNER && env.VERCEL_GIT_REPO_SLUG ? `${env.VERCEL_GIT_REPO_OWNER}/${env.VERCEL_GIT_REPO_SLUG}` : null,
|
|
2336
|
+
netlify && env.REPOSITORY_URL ? parseGitHubSlug(env.REPOSITORY_URL) : null,
|
|
2337
|
+
async () => {
|
|
2338
|
+
const remote = await git(["remote", "get-url", "origin"], cwd);
|
|
2339
|
+
return remote ? parseGitHubSlug(remote) : null;
|
|
2340
|
+
}
|
|
2341
|
+
],
|
|
2342
|
+
asSlug
|
|
2343
|
+
) : null;
|
|
2344
|
+
const top = await git(["rev-parse", "--show-toplevel"], cwd);
|
|
2345
|
+
const gitRoot = top ? path6.resolve(top) : null;
|
|
2346
|
+
return { repo, defaultBranch, branch, base, gitRoot };
|
|
2347
|
+
}
|
|
2348
|
+
function parseGitHubSlug(remote) {
|
|
2349
|
+
const text = remote.trim();
|
|
2350
|
+
let repoPath;
|
|
2351
|
+
const scp = /^[A-Za-z0-9._-]+@github\.com:(.*)$/i.exec(text);
|
|
2352
|
+
if (scp) {
|
|
2353
|
+
repoPath = scp[1] ?? "";
|
|
2354
|
+
} else {
|
|
2355
|
+
let url;
|
|
2356
|
+
try {
|
|
2357
|
+
url = new URL(text);
|
|
2358
|
+
} catch {
|
|
2359
|
+
return null;
|
|
2360
|
+
}
|
|
2361
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
|
|
2362
|
+
if (url.hostname.toLowerCase() !== "github.com") return null;
|
|
2363
|
+
repoPath = url.pathname;
|
|
2364
|
+
}
|
|
2365
|
+
const trimmed = repoPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
2366
|
+
return isValidRepoSlug(trimmed) ? trimmed : null;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// src/server/build-target.ts
|
|
2370
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
2371
|
+
import * as path7 from "path";
|
|
2372
|
+
var NEXT_CONFIG_FILES = [
|
|
2373
|
+
"next.config.js",
|
|
2374
|
+
"next.config.mjs",
|
|
2375
|
+
"next.config.cjs",
|
|
2376
|
+
"next.config.ts",
|
|
2377
|
+
"next.config.mts"
|
|
2378
|
+
];
|
|
2379
|
+
var VITE_CONFIG_FILES = [
|
|
2380
|
+
"vite.config.js",
|
|
2381
|
+
"vite.config.mjs",
|
|
2382
|
+
"vite.config.cjs",
|
|
2383
|
+
"vite.config.ts",
|
|
2384
|
+
"vite.config.mts"
|
|
2385
|
+
];
|
|
2386
|
+
var OUTPUT_EXPORT = /\boutput\s*:\s*['"`]export['"`]/;
|
|
2387
|
+
var BASE_PATH = /\bbasePath\s*:\s*['"`](\/[^'"`]*)['"`]/;
|
|
2388
|
+
var VITE_BASE = /\bbase\s*:\s*['"`](\/[^'"`]*|\.\/?|)['"`]/;
|
|
2389
|
+
var EXPORTS_POST = /export\s+(?:const|let|var|async\s+function|function)\s+POST\b|export\s*\{[^}]*\bPOST\b[^}]*\}/;
|
|
2390
|
+
function readText2(file) {
|
|
2391
|
+
try {
|
|
2392
|
+
return readFileSync4(file, "utf-8");
|
|
2393
|
+
} catch {
|
|
2394
|
+
return null;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
function readPackageJson(projectDir) {
|
|
2398
|
+
const text = readText2(path7.join(projectDir, "package.json"));
|
|
2399
|
+
if (text === null) return {};
|
|
2400
|
+
try {
|
|
2401
|
+
const parsed = JSON.parse(text);
|
|
2402
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
2403
|
+
} catch {
|
|
2404
|
+
return {};
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
function hasDependency(pkg, name) {
|
|
2408
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
|
|
2409
|
+
}
|
|
2410
|
+
function firstExisting2(projectDir, names) {
|
|
2411
|
+
for (const name of names) {
|
|
2412
|
+
const file = path7.join(projectDir, name);
|
|
2413
|
+
if (existsSync4(file)) return file;
|
|
2414
|
+
}
|
|
2415
|
+
return null;
|
|
2416
|
+
}
|
|
2417
|
+
function stripComments(source) {
|
|
2418
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").split("\n").filter((line) => !line.trimStart().startsWith("//")).join("\n");
|
|
2419
|
+
}
|
|
2420
|
+
function craBase(homepage) {
|
|
2421
|
+
if (typeof homepage !== "string") return null;
|
|
2422
|
+
const value = homepage.trim();
|
|
2423
|
+
if (value === "." || value === "./") return "./";
|
|
2424
|
+
if (value.startsWith("/")) return value;
|
|
2425
|
+
try {
|
|
2426
|
+
return new URL(value).pathname;
|
|
2427
|
+
} catch {
|
|
2428
|
+
return null;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
function detectBuildTarget(projectDir) {
|
|
2432
|
+
const pkg = readPackageJson(projectDir);
|
|
2433
|
+
const nextConfig = firstExisting2(projectDir, NEXT_CONFIG_FILES);
|
|
2434
|
+
if (nextConfig || hasDependency(pkg, "next")) {
|
|
2435
|
+
const source = stripComments((nextConfig && readText2(nextConfig)) ?? "");
|
|
2436
|
+
return {
|
|
2437
|
+
kind: "next",
|
|
2438
|
+
staticExport: OUTPUT_EXPORT.test(source),
|
|
2439
|
+
base: BASE_PATH.exec(source)?.[1] ?? null
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
if (hasDependency(pkg, "react-scripts")) return { kind: "cra", base: craBase(pkg.homepage) };
|
|
2443
|
+
if (hasDependency(pkg, "vite") || firstExisting2(projectDir, VITE_CONFIG_FILES)) {
|
|
2444
|
+
const viteConfig = firstExisting2(projectDir, VITE_CONFIG_FILES);
|
|
2445
|
+
const source = stripComments((viteConfig && readText2(viteConfig)) ?? "");
|
|
2446
|
+
return { kind: "vite", base: VITE_BASE.exec(source)?.[1] ?? null };
|
|
2447
|
+
}
|
|
2448
|
+
return { kind: "other", base: null };
|
|
2449
|
+
}
|
|
2450
|
+
var ROUTE_DIRS = ["app", "src/app"];
|
|
2451
|
+
var ROUTE_EXTENSIONS = ["ts", "js", "mjs", "tsx", "jsx"];
|
|
2452
|
+
function findExportBlockingRoute(projectDir) {
|
|
2453
|
+
for (const dir of ROUTE_DIRS) {
|
|
2454
|
+
for (const ext of ROUTE_EXTENSIONS) {
|
|
2455
|
+
const rel = `${dir}/api/livelocale/route.${ext}`;
|
|
2456
|
+
const source = readText2(path7.join(projectDir, rel));
|
|
2457
|
+
if (source !== null && EXPORTS_POST.test(source)) return rel;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
function outputCandidates(target) {
|
|
2463
|
+
return target.kind === "cra" ? ["build", "dist", "out"] : ["dist", "build", "out"];
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// src/server/pack-error.ts
|
|
2467
|
+
var PackError = class extends Error {
|
|
2468
|
+
code;
|
|
2469
|
+
constructor(code, message) {
|
|
2470
|
+
super(message);
|
|
2471
|
+
this.name = "PackError";
|
|
2472
|
+
this.code = code;
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
|
|
2476
|
+
// src/server/share-html.ts
|
|
2477
|
+
var FULL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
2478
|
+
var WINDOWS_PATH = /^[A-Za-z]:[\\/]/;
|
|
2479
|
+
var SEGMENT = /^(?:[A-Za-z0-9._~@+-]|%[0-9A-Fa-f]{2})+$/;
|
|
2480
|
+
function normalizeBase(value) {
|
|
2481
|
+
if (value === "" || value === "." || value === "./") return { kind: "relative" };
|
|
2482
|
+
if (WINDOWS_PATH.test(value)) {
|
|
2483
|
+
throw new PackError(
|
|
2484
|
+
"BAD_BASE",
|
|
2485
|
+
`The app base "${value}" is a Windows path. Your shell (Git Bash/MSYS) probably converted a site path such as /repo/ into it. Run the command with MSYS_NO_PATHCONV=1, or from an npm script, and pass the site path, for example --base /app/.`
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
if (FULL_URL.test(value)) {
|
|
2489
|
+
throw new PackError(
|
|
2490
|
+
"BAD_BASE",
|
|
2491
|
+
`The app base "${value}" is a full URL. Pass the path your HTML is served from, for example --base /app/ (shareBase in the Vite plugin).`
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
const notASitePath = () => new PackError(
|
|
2495
|
+
"BAD_BASE",
|
|
2496
|
+
`The app base "${value}" is not a site path. Use "/", "/app/" or "./".`
|
|
2497
|
+
);
|
|
2498
|
+
if (!value.startsWith("/")) throw notASitePath();
|
|
2499
|
+
const segments = value.slice(1).split("/");
|
|
2500
|
+
if (segments[segments.length - 1] === "") segments.pop();
|
|
2501
|
+
for (const segment of segments) {
|
|
2502
|
+
if (segment === "." || segment === ".." || !SEGMENT.test(segment)) throw notASitePath();
|
|
2503
|
+
}
|
|
2504
|
+
return { kind: "absolute", path: segments.length === 0 ? "/" : `/${segments.join("/")}/` };
|
|
2505
|
+
}
|
|
2506
|
+
function basePathOf(base) {
|
|
2507
|
+
return base.kind === "absolute" ? base.path : null;
|
|
2508
|
+
}
|
|
2509
|
+
function flagSrcFor(base, htmlRelPath) {
|
|
2510
|
+
const file = `${SHARE_DIR}/${SHARE_FLAG_FILE}`;
|
|
2511
|
+
if (base.kind === "absolute") return `${base.path}${file}`;
|
|
2512
|
+
const depth = htmlRelPath.split("/").length - 1;
|
|
2513
|
+
return depth === 0 ? `./${file}` : `${"../".repeat(depth)}${file}`;
|
|
2514
|
+
}
|
|
2515
|
+
function shareUrlPathFor(base) {
|
|
2516
|
+
const page = `${SHARE_DIR}/index.html`;
|
|
2517
|
+
return base.kind === "absolute" ? `${base.path}${page}` : page;
|
|
2518
|
+
}
|
|
2519
|
+
var EXISTING_FLAG_TAG = /<script\b[^>]*\bsrc="[^"]*livelocale\/flag\.js"[^>]*>\s*<\/script>/gi;
|
|
2520
|
+
var HEAD_OPEN2 = /<head(?=[\s>])[^>]*>/i;
|
|
2521
|
+
var HTML_OPEN2 = /<html(?=[\s>])[^>]*>/i;
|
|
2522
|
+
var DOCTYPE2 = /<!doctype\b[^>]*>/i;
|
|
2523
|
+
function escapeAttribute(value) {
|
|
2524
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
2525
|
+
}
|
|
2526
|
+
function injectFlagScript(html, src) {
|
|
2527
|
+
const tag = `<script src="${escapeAttribute(src)}"></script>`;
|
|
2528
|
+
const cleaned = html.replace(EXISTING_FLAG_TAG, "");
|
|
2529
|
+
for (const pattern of [HEAD_OPEN2, HTML_OPEN2, DOCTYPE2]) {
|
|
2530
|
+
const match = pattern.exec(cleaned);
|
|
2531
|
+
if (match) {
|
|
2532
|
+
const end = match.index + match[0].length;
|
|
2533
|
+
return cleaned.slice(0, end) + tag + cleaned.slice(end);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return tag + cleaned;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// src/server/pack.ts
|
|
2540
|
+
var NO_REPO_WARNING = "exposeRepo is on but no GitHub repository was found; the manifest has no repo.";
|
|
2541
|
+
function isPlainObject2(value) {
|
|
2542
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2543
|
+
}
|
|
2544
|
+
function toPosix(relative8) {
|
|
2545
|
+
return relative8.split(path8.sep).join("/") || ".";
|
|
2546
|
+
}
|
|
2547
|
+
function isInside(parent, child) {
|
|
2548
|
+
const rel = path8.relative(parent, child);
|
|
2549
|
+
return rel === "" || !rel.startsWith("..") && !path8.isAbsolute(rel);
|
|
2550
|
+
}
|
|
2551
|
+
function defineOwn(target, key, value) {
|
|
2552
|
+
Object.defineProperty(target, key, {
|
|
2553
|
+
value,
|
|
2554
|
+
enumerable: true,
|
|
2555
|
+
writable: true,
|
|
2556
|
+
configurable: true
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
async function readJsonObject(file, projectDir) {
|
|
2560
|
+
const shown = toPosix(path8.relative(projectDir, file));
|
|
2561
|
+
let parsed;
|
|
2562
|
+
try {
|
|
2563
|
+
parsed = JSON.parse(await fs5.readFile(file, "utf-8"));
|
|
2564
|
+
} catch (error) {
|
|
2565
|
+
throw new PackError(
|
|
2566
|
+
"INVALID_LOCALE_FILE",
|
|
2567
|
+
`${shown} is not a JSON object: ${error instanceof Error ? error.message : String(error)}`
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
if (!isPlainObject2(parsed)) {
|
|
2571
|
+
const kind = Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed;
|
|
2572
|
+
throw new PackError(
|
|
2573
|
+
"INVALID_LOCALE_FILE",
|
|
2574
|
+
`${shown} is not a JSON object: the root is ${kind}`
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
return parsed;
|
|
2578
|
+
}
|
|
2579
|
+
async function collectShareData(options) {
|
|
2580
|
+
const projectDir = path8.resolve(options.projectDir);
|
|
2581
|
+
const localesDir = path8.resolve(projectDir, options.localesPath ?? "locales");
|
|
2582
|
+
const warnings = [];
|
|
2583
|
+
let entries;
|
|
2584
|
+
try {
|
|
2585
|
+
if (!(await fs5.stat(localesDir)).isDirectory()) throw new Error("not a directory");
|
|
2586
|
+
entries = await fs5.readdir(localesDir, { withFileTypes: true });
|
|
2587
|
+
} catch {
|
|
2588
|
+
throw new PackError(
|
|
2589
|
+
"LOCALES_NOT_FOUND",
|
|
2590
|
+
`Locales directory not found: ${localesDir}. Pass --locales <path>.`
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
const dirLocales = /* @__PURE__ */ new Set();
|
|
2594
|
+
const fileLocales = /* @__PURE__ */ new Set();
|
|
2595
|
+
for (const entry of entries) {
|
|
2596
|
+
if (entry.isDirectory() && LOCALE_RE.test(entry.name)) {
|
|
2597
|
+
dirLocales.add(entry.name);
|
|
2598
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
2599
|
+
const code = entry.name.slice(0, -".json".length);
|
|
2600
|
+
if (LOCALE_RE.test(code)) fileLocales.add(code);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
const locales = [.../* @__PURE__ */ new Set([...dirLocales, ...fileLocales])].sort();
|
|
2604
|
+
if (locales.length === 0) {
|
|
2605
|
+
throw new PackError(
|
|
2606
|
+
"NO_LOCALES",
|
|
2607
|
+
`No locale files in ${localesDir} (expected en.json or en/*.json).`
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
const layout = dirLocales.size > 0 ? "namespaced" : "flat";
|
|
2611
|
+
const sourceLocale = options.sourceLocale ?? (locales.includes("en") ? "en" : locales[0]);
|
|
2612
|
+
if (!locales.includes(sourceLocale)) {
|
|
2613
|
+
throw new PackError(
|
|
2614
|
+
"SOURCE_LOCALE_NOT_FOUND",
|
|
2615
|
+
`Source locale "${sourceLocale}" not found in ${localesDir} (found: ${locales.join(", ")}).`
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
const dictionaries = {};
|
|
2619
|
+
for (const locale of locales) {
|
|
2620
|
+
let dictionary;
|
|
2621
|
+
if (dirLocales.has(locale)) {
|
|
2622
|
+
dictionary = {};
|
|
2623
|
+
const folder = path8.join(localesDir, locale);
|
|
2624
|
+
const files = (await fs5.readdir(folder, { withFileTypes: true })).filter((file) => file.isFile() && file.name.endsWith(".json")).map((file) => file.name).sort();
|
|
2625
|
+
for (const file of files) {
|
|
2626
|
+
const namespace = file.slice(0, -".json".length);
|
|
2627
|
+
defineOwn(dictionary, namespace, await readJsonObject(path8.join(folder, file), projectDir));
|
|
2628
|
+
}
|
|
2629
|
+
} else {
|
|
2630
|
+
dictionary = await readJsonObject(path8.join(localesDir, `${locale}.json`), projectDir);
|
|
2631
|
+
}
|
|
2632
|
+
defineOwn(dictionaries, locale, dictionary);
|
|
2633
|
+
}
|
|
2634
|
+
const info = await resolveBuildInfo({
|
|
2635
|
+
cwd: projectDir,
|
|
2636
|
+
env: options.env,
|
|
2637
|
+
git: options.git,
|
|
2638
|
+
includeRepo: options.exposeRepo === true
|
|
2639
|
+
});
|
|
2640
|
+
const root = info.gitRoot !== null && isInside(info.gitRoot, localesDir) ? info.gitRoot : projectDir;
|
|
2641
|
+
const codeKeys = await new KeyScanner({ requireEnabled: false }).scanSourceKeys(projectDir);
|
|
2642
|
+
if (options.exposeRepo === true && info.repo === null) warnings.push(NO_REPO_WARNING);
|
|
2643
|
+
const manifest = {
|
|
2644
|
+
format: SHARE_MANIFEST_FORMAT,
|
|
2645
|
+
...options.exposeRepo === true && info.repo !== null ? { repo: info.repo } : {},
|
|
2646
|
+
defaultBranch: info.defaultBranch,
|
|
2647
|
+
branch: info.branch,
|
|
2648
|
+
base: info.base,
|
|
2649
|
+
localesPath: toPosix(path8.relative(root, localesDir)),
|
|
2650
|
+
layout,
|
|
2651
|
+
sourceLocale,
|
|
2652
|
+
locales,
|
|
2653
|
+
basePath: options.basePath,
|
|
2654
|
+
codeKeys: [...codeKeys].sort()
|
|
2655
|
+
};
|
|
2656
|
+
return { manifest, dictionaries, warnings };
|
|
2657
|
+
}
|
|
2658
|
+
var RELATIVE_BASE_WARNING = "The app base is relative (./), so flag.js is linked per HTML file. Pages served by a client-side route fallback cannot load it; build with an absolute base (--base /path/) if the app has such routes.";
|
|
2659
|
+
function defaultStudioCandidates() {
|
|
2660
|
+
const here = moduleDir();
|
|
2661
|
+
return [path8.resolve(here, "../studio"), path8.resolve(here, "../../dist/studio")];
|
|
2662
|
+
}
|
|
2663
|
+
function isDirectory(target) {
|
|
2664
|
+
try {
|
|
2665
|
+
return statSync4(target).isDirectory();
|
|
2666
|
+
} catch {
|
|
2667
|
+
return false;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
function resolveStudioDir(candidates = defaultStudioCandidates()) {
|
|
2671
|
+
for (const dir of candidates) {
|
|
2672
|
+
if (existsSync5(path8.join(dir, "index.html")) && isDirectory(path8.join(dir, "assets"))) {
|
|
2673
|
+
return dir;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
async function ownsShareDir(shareDir) {
|
|
2679
|
+
try {
|
|
2680
|
+
await fs5.lstat(shareDir);
|
|
2681
|
+
} catch {
|
|
2682
|
+
return true;
|
|
2683
|
+
}
|
|
2684
|
+
try {
|
|
2685
|
+
const manifest = JSON.parse(
|
|
2686
|
+
await fs5.readFile(path8.join(shareDir, SHARE_MANIFEST_FILE), "utf-8")
|
|
2687
|
+
);
|
|
2688
|
+
return isPlainObject2(manifest) && typeof manifest["format"] === "number";
|
|
2689
|
+
} catch {
|
|
2690
|
+
return false;
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
async function findHtmlFiles(outDir, shareDir) {
|
|
2694
|
+
const found = [];
|
|
2695
|
+
const walk = async (dir) => {
|
|
2696
|
+
for (const entry of await fs5.readdir(dir, { withFileTypes: true })) {
|
|
2697
|
+
if (entry.isSymbolicLink()) continue;
|
|
2698
|
+
const full = path8.join(dir, entry.name);
|
|
2699
|
+
if (entry.isDirectory()) {
|
|
2700
|
+
if (full !== shareDir) await walk(full);
|
|
2701
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
|
|
2702
|
+
found.push(toPosix(path8.relative(outDir, full)));
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
await walk(outDir);
|
|
2707
|
+
return found.sort();
|
|
2708
|
+
}
|
|
2709
|
+
async function packOutput(options) {
|
|
2710
|
+
const {
|
|
2711
|
+
outDir: outOption,
|
|
2712
|
+
base: baseOption,
|
|
2713
|
+
injectHtml = "auto",
|
|
2714
|
+
studioDir: studioOption,
|
|
2715
|
+
...shareOptions
|
|
2716
|
+
} = options;
|
|
2717
|
+
const projectDir = path8.resolve(options.projectDir);
|
|
2718
|
+
const outDir = path8.resolve(projectDir, outOption);
|
|
2719
|
+
if (!isDirectory(outDir)) {
|
|
2720
|
+
throw new PackError(
|
|
2721
|
+
"OUT_DIR_NOT_FOUND",
|
|
2722
|
+
`Build output not found: ${outDir}. Run your build first.`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
const target = detectBuildTarget(projectDir);
|
|
2726
|
+
const base = normalizeBase(baseOption ?? target.base ?? "/");
|
|
2727
|
+
const candidates = studioOption !== void 0 ? [path8.resolve(projectDir, studioOption)] : defaultStudioCandidates();
|
|
2728
|
+
const studioDir = resolveStudioDir(candidates);
|
|
2729
|
+
if (studioDir === null) {
|
|
2730
|
+
throw new PackError(
|
|
2731
|
+
"STUDIO_NOT_FOUND",
|
|
2732
|
+
`LiveLocale Studio files are missing from the installed package (looked in ${candidates.join(", ")}). Reinstall livelocale.`
|
|
2733
|
+
);
|
|
2734
|
+
}
|
|
2735
|
+
const data = await collectShareData({ ...shareOptions, projectDir, basePath: basePathOf(base) });
|
|
2736
|
+
const shareDir = path8.join(outDir, SHARE_DIR);
|
|
2737
|
+
if (!await ownsShareDir(shareDir)) {
|
|
2738
|
+
throw new PackError(
|
|
2739
|
+
"SHARE_DIR_TAKEN",
|
|
2740
|
+
`${toPosix(path8.relative(projectDir, outDir))}/${SHARE_DIR}/ already exists and was not written by LiveLocale. Move it away and run again.`
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
await fs5.rm(shareDir, { recursive: true, force: true });
|
|
2744
|
+
await fs5.cp(studioDir, shareDir, { recursive: true });
|
|
2745
|
+
await fs5.writeFile(
|
|
2746
|
+
path8.join(shareDir, SHARE_MANIFEST_FILE),
|
|
2747
|
+
JSON.stringify(data.manifest, null, 2) + "\n",
|
|
2748
|
+
"utf-8"
|
|
2749
|
+
);
|
|
2750
|
+
const localesDir = path8.join(shareDir, SHARE_LOCALES_DIR);
|
|
2751
|
+
await fs5.mkdir(localesDir, { recursive: true });
|
|
2752
|
+
for (const locale of data.manifest.locales) {
|
|
2753
|
+
await fs5.writeFile(
|
|
2754
|
+
path8.join(localesDir, `${locale}.json`),
|
|
2755
|
+
JSON.stringify(data.dictionaries[locale]),
|
|
2756
|
+
"utf-8"
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
await fs5.writeFile(path8.join(shareDir, SHARE_FLAG_FILE), FLAG_SCRIPT, "utf-8");
|
|
2760
|
+
const inject = injectHtml === "auto" ? target.kind !== "next" && !existsSync5(path8.join(outDir, "_next")) : injectHtml;
|
|
2761
|
+
const htmlInjected = inject ? await findHtmlFiles(outDir, shareDir) : [];
|
|
2762
|
+
for (const rel of htmlInjected) {
|
|
2763
|
+
const file = path8.join(outDir, ...rel.split("/"));
|
|
2764
|
+
const html = await fs5.readFile(file, "utf-8");
|
|
2765
|
+
const injected = injectFlagScript(html, flagSrcFor(base, rel));
|
|
2766
|
+
if (injected !== html) await fs5.writeFile(file, injected, "utf-8");
|
|
2767
|
+
}
|
|
2768
|
+
const warnings = [...data.warnings];
|
|
2769
|
+
if (base.kind === "relative") warnings.push(RELATIVE_BASE_WARNING);
|
|
2770
|
+
return {
|
|
2771
|
+
shareDir,
|
|
2772
|
+
manifest: data.manifest,
|
|
2773
|
+
htmlInjected,
|
|
2774
|
+
shareUrlPath: shareUrlPathFor(base),
|
|
2775
|
+
warnings
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
var VALUE_OPTIONS = {
|
|
2779
|
+
"--dir": "dir",
|
|
2780
|
+
"--locales": "locales",
|
|
2781
|
+
"--source": "source",
|
|
2782
|
+
"--base": "base",
|
|
2783
|
+
"--out": "out"
|
|
2784
|
+
};
|
|
2785
|
+
function parseShareArgs(argv) {
|
|
2786
|
+
const args = {
|
|
2787
|
+
positional: [],
|
|
2788
|
+
dir: ".",
|
|
2789
|
+
locales: void 0,
|
|
2790
|
+
source: void 0,
|
|
2791
|
+
base: void 0,
|
|
2792
|
+
out: void 0,
|
|
2793
|
+
exposeRepo: false,
|
|
2794
|
+
inject: true,
|
|
2795
|
+
preview: false,
|
|
2796
|
+
command: []
|
|
2797
|
+
};
|
|
2798
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2799
|
+
const arg = argv[i];
|
|
2800
|
+
if (arg === "--") {
|
|
2801
|
+
args.command = argv.slice(i + 1);
|
|
2802
|
+
break;
|
|
2803
|
+
}
|
|
2804
|
+
if (Object.prototype.hasOwnProperty.call(VALUE_OPTIONS, arg)) {
|
|
2805
|
+
const value = argv[i + 1];
|
|
2806
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
2807
|
+
throw new PackError("USAGE", `${arg} needs a value.`);
|
|
2808
|
+
}
|
|
2809
|
+
args[VALUE_OPTIONS[arg]] = value;
|
|
2810
|
+
i++;
|
|
2811
|
+
} else if (arg === "--expose-repo") {
|
|
2812
|
+
args.exposeRepo = true;
|
|
2813
|
+
} else if (arg === "--no-inject") {
|
|
2814
|
+
args.inject = false;
|
|
2815
|
+
} else if (arg === "--preview") {
|
|
2816
|
+
args.preview = true;
|
|
2817
|
+
} else if (arg.startsWith("-") && arg !== "-") {
|
|
2818
|
+
throw new PackError("USAGE", `Unknown option "${arg}".`);
|
|
2819
|
+
} else {
|
|
2820
|
+
args.positional.push(arg);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
return args;
|
|
2824
|
+
}
|
|
2825
|
+
var WINDOWS_NEEDS_QUOTES = /[\s"&|<>^()]/;
|
|
2826
|
+
function quoteWindowsArg(arg) {
|
|
2827
|
+
if (arg !== "" && !WINDOWS_NEEDS_QUOTES.test(arg)) return arg;
|
|
2828
|
+
let out = '"';
|
|
2829
|
+
let backslashes = 0;
|
|
2830
|
+
for (const ch of arg) {
|
|
2831
|
+
if (ch === "\\") {
|
|
2832
|
+
backslashes++;
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (ch === '"') {
|
|
2836
|
+
out += "\\".repeat(backslashes * 2 + 1) + '"';
|
|
2837
|
+
} else {
|
|
2838
|
+
out += "\\".repeat(backslashes) + ch;
|
|
2839
|
+
}
|
|
2840
|
+
backslashes = 0;
|
|
2841
|
+
}
|
|
2842
|
+
return out + "\\".repeat(backslashes * 2) + '"';
|
|
2843
|
+
}
|
|
2844
|
+
var spawnInherit = (command, args, { cwd, env }) => new Promise((resolve14, reject2) => {
|
|
2845
|
+
const child = process.platform === "win32" ? spawn([command, ...args].map(quoteWindowsArg).join(" "), {
|
|
2846
|
+
cwd,
|
|
2847
|
+
env,
|
|
2848
|
+
stdio: "inherit",
|
|
2849
|
+
shell: true
|
|
2850
|
+
}) : spawn(command, args, { cwd, env, stdio: "inherit" });
|
|
2851
|
+
child.on("error", (error) => {
|
|
2852
|
+
if (error.code === "ENOENT") resolve14(127);
|
|
2853
|
+
else reject2(error);
|
|
2854
|
+
});
|
|
2855
|
+
child.on("close", (code) => resolve14(code ?? 1));
|
|
2856
|
+
});
|
|
2857
|
+
var BUILD_USAGE = "Usage: livelocale build [--preview] -- <your build command>";
|
|
2858
|
+
var NEXT_PREVIEW_WARNING = "Next.js pages have no watermarks without --preview; the Studio will open but nothing is editable.";
|
|
2859
|
+
var NEXT_GITIGNORE_WARNING = "Add public/livelocale/ to .gitignore; it is generated on every build.";
|
|
2860
|
+
var FRESH_SLACK_MS = 2e3;
|
|
2861
|
+
function gitignoresShareDir(projectDir) {
|
|
2862
|
+
let content;
|
|
2863
|
+
try {
|
|
2864
|
+
content = readFileSync5(path8.join(projectDir, ".gitignore"), "utf-8");
|
|
2865
|
+
} catch {
|
|
2866
|
+
return false;
|
|
2867
|
+
}
|
|
2868
|
+
return parseGitignore(content).ignores(`public/${SHARE_DIR}`, true);
|
|
2869
|
+
}
|
|
2870
|
+
function freshOutput(projectDir, candidates, startMs) {
|
|
2871
|
+
for (const candidate of candidates) {
|
|
2872
|
+
try {
|
|
2873
|
+
const stat = statSync4(path8.join(projectDir, candidate, "index.html"));
|
|
2874
|
+
if (stat.isFile() && stat.mtimeMs >= startMs - FRESH_SLACK_MS) return candidate;
|
|
2875
|
+
} catch {
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return null;
|
|
2879
|
+
}
|
|
2880
|
+
async function runBuildCommand(options) {
|
|
2881
|
+
const {
|
|
2882
|
+
command,
|
|
2883
|
+
preview = false,
|
|
2884
|
+
outDir,
|
|
2885
|
+
run: run3 = spawnInherit,
|
|
2886
|
+
now = Date.now,
|
|
2887
|
+
...packOptions
|
|
2888
|
+
} = options;
|
|
2889
|
+
if (command.length === 0) throw new PackError("USAGE", BUILD_USAGE);
|
|
2890
|
+
const projectDir = path8.resolve(options.projectDir);
|
|
2891
|
+
const target = detectBuildTarget(projectDir);
|
|
2892
|
+
const env = options.env ?? process.env;
|
|
2893
|
+
const childEnv = preview ? { ...env, LIVELOCALE: "preview", NEXT_PUBLIC_LIVELOCALE: "preview" } : env;
|
|
2894
|
+
const runBuild = () => run3(command[0], command.slice(1), { cwd: projectDir, env: childEnv });
|
|
2895
|
+
if (target.kind === "next") {
|
|
2896
|
+
const blocking = target.staticExport ? findExportBlockingRoute(projectDir) : null;
|
|
2897
|
+
if (blocking !== null) {
|
|
2898
|
+
throw new PackError(
|
|
2899
|
+
"NEXT_EXPORT_POST_ROUTE",
|
|
2900
|
+
`Next.js static export (output: 'export') cannot build ${blocking}: it exports POST, and export mode only allows GET route handlers. The share link does not need this route. Delete ${blocking} (or move it out of this build) and run again.`
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
await fs5.mkdir(path8.join(projectDir, "public"), { recursive: true });
|
|
2904
|
+
const pack2 = await packOutput({
|
|
2905
|
+
...packOptions,
|
|
2906
|
+
projectDir,
|
|
2907
|
+
outDir: "public",
|
|
2908
|
+
injectHtml: false
|
|
2909
|
+
});
|
|
2910
|
+
const warnings = [];
|
|
2911
|
+
if (!preview) warnings.push(NEXT_PREVIEW_WARNING);
|
|
2912
|
+
if (!gitignoresShareDir(projectDir)) warnings.push(NEXT_GITIGNORE_WARNING);
|
|
2913
|
+
const exitCode2 = await runBuild();
|
|
2914
|
+
return { exitCode: exitCode2, pack: pack2, warnings: [...warnings, ...pack2.warnings] };
|
|
2915
|
+
}
|
|
2916
|
+
const startMs = now();
|
|
2917
|
+
const exitCode = await runBuild();
|
|
2918
|
+
if (exitCode !== 0) return { exitCode, pack: null, warnings: [] };
|
|
2919
|
+
const candidates = outputCandidates(target);
|
|
2920
|
+
const output = outDir ?? freshOutput(projectDir, candidates, startMs);
|
|
2921
|
+
if (output === null) {
|
|
2922
|
+
throw new PackError(
|
|
2923
|
+
"OUTPUT_NOT_FOUND",
|
|
2924
|
+
`Could not find the build output (looked for a fresh index.html in ${candidates.map((c) => `${c}/`).join(", ")}). Pass --out <dir>.`
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
const pack = await packOutput({ ...packOptions, projectDir, outDir: output, injectHtml: "auto" });
|
|
2928
|
+
return { exitCode, pack, warnings: [...pack.warnings] };
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/codemod/apply/cli.ts
|
|
2932
|
+
import * as path14 from "path";
|
|
2933
|
+
|
|
2934
|
+
// src/codemod/apply/link-input.ts
|
|
2935
|
+
import { execFile as execFile2 } from "child_process";
|
|
2936
|
+
import * as fs6 from "fs";
|
|
2937
|
+
import * as path9 from "path";
|
|
2938
|
+
var MAX_APPLY_INPUT_BYTES = 1048576;
|
|
2939
|
+
var TOO_LARGE_MESSAGE2 = "The input is larger than 1 MB.";
|
|
2940
|
+
var NO_INPUT_MESSAGE = 'Pass the change link, a file that holds it, "-" to read stdin, or --clipboard. (cmd.exe cuts arguments at 8,191 characters; use --clipboard or a file for long links.)';
|
|
2941
|
+
var NO_CLIPBOARD_MESSAGE = "Could not read the clipboard. Save the change link to a file and pass the file instead.";
|
|
2942
|
+
var ApplyInputError = class extends Error {
|
|
2943
|
+
constructor(message) {
|
|
2944
|
+
super(message);
|
|
2945
|
+
this.name = "ApplyInputError";
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
function checkSize(text) {
|
|
2949
|
+
if (Buffer.byteLength(text, "utf8") > MAX_APPLY_INPUT_BYTES) {
|
|
2950
|
+
throw new ApplyInputError(TOO_LARGE_MESSAGE2);
|
|
2951
|
+
}
|
|
2952
|
+
return text;
|
|
2953
|
+
}
|
|
2954
|
+
async function readStream(stream) {
|
|
2955
|
+
const chunks = [];
|
|
2956
|
+
let size = 0;
|
|
2957
|
+
for await (const chunk of stream) {
|
|
2958
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
2959
|
+
size += bytes.length;
|
|
2960
|
+
if (size > MAX_APPLY_INPUT_BYTES) throw new ApplyInputError(TOO_LARGE_MESSAGE2);
|
|
2961
|
+
chunks.push(bytes);
|
|
2962
|
+
}
|
|
2963
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2964
|
+
}
|
|
2965
|
+
function existingFile(arg, cwd) {
|
|
2966
|
+
try {
|
|
2967
|
+
const resolved = path9.resolve(cwd, arg);
|
|
2968
|
+
return fs6.statSync(resolved, { throwIfNoEntry: false })?.isFile() ? resolved : null;
|
|
2969
|
+
} catch {
|
|
2970
|
+
return null;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
async function readApplyInput(arg, options) {
|
|
2974
|
+
if (options.clipboard) return checkSize(await (options.readClipboard ?? readClipboard)());
|
|
2975
|
+
if (arg === void 0 || arg === "") throw new ApplyInputError(NO_INPUT_MESSAGE);
|
|
2976
|
+
if (arg === "-") return readStream(options.stdin ?? process.stdin);
|
|
2977
|
+
const file = existingFile(arg, options.cwd);
|
|
2978
|
+
if (file === null) return checkSize(arg);
|
|
2979
|
+
if (fs6.statSync(file).size > MAX_APPLY_INPUT_BYTES) throw new ApplyInputError(TOO_LARGE_MESSAGE2);
|
|
2980
|
+
return checkSize(fs6.readFileSync(file, "utf8"));
|
|
2981
|
+
}
|
|
2982
|
+
function clipboardCommands(platform) {
|
|
2983
|
+
if (platform === "win32") {
|
|
2984
|
+
return [
|
|
2985
|
+
["powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"]]
|
|
2986
|
+
];
|
|
2987
|
+
}
|
|
2988
|
+
if (platform === "darwin") return [["pbpaste", []]];
|
|
2989
|
+
return [
|
|
2990
|
+
["wl-paste", ["--no-newline"]],
|
|
2991
|
+
["xclip", ["-selection", "clipboard", "-o"]],
|
|
2992
|
+
["xsel", ["--clipboard", "--output"]]
|
|
2993
|
+
];
|
|
2994
|
+
}
|
|
2995
|
+
var TOO_LARGE = /* @__PURE__ */ Symbol("too large");
|
|
2996
|
+
function run(execFileImpl, [command, args]) {
|
|
2997
|
+
return new Promise((resolve14) => {
|
|
2998
|
+
try {
|
|
2999
|
+
execFileImpl(
|
|
3000
|
+
command,
|
|
3001
|
+
args,
|
|
3002
|
+
{ encoding: "utf8", windowsHide: true, maxBuffer: MAX_APPLY_INPUT_BYTES + 1 },
|
|
3003
|
+
(error, stdout) => {
|
|
3004
|
+
const code = error?.code;
|
|
3005
|
+
if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") resolve14(TOO_LARGE);
|
|
3006
|
+
else resolve14(error ? null : String(stdout));
|
|
3007
|
+
}
|
|
3008
|
+
);
|
|
3009
|
+
} catch {
|
|
3010
|
+
resolve14(null);
|
|
3011
|
+
}
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
async function readClipboard(execFileImpl = execFile2, platform = process.platform) {
|
|
3015
|
+
for (const command of clipboardCommands(platform)) {
|
|
3016
|
+
const text = await run(execFileImpl, command);
|
|
3017
|
+
if (text === TOO_LARGE) throw new ApplyInputError(TOO_LARGE_MESSAGE2);
|
|
3018
|
+
if (text !== null) return checkSize(text);
|
|
3019
|
+
}
|
|
3020
|
+
throw new ApplyInputError(NO_CLIPBOARD_MESSAGE);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
// src/codemod/apply/local.ts
|
|
3024
|
+
import { execFile as execFile4, spawn as spawn2 } from "child_process";
|
|
3025
|
+
import * as fs9 from "fs";
|
|
3026
|
+
import * as os from "os";
|
|
3027
|
+
import * as path13 from "path";
|
|
3028
|
+
|
|
3029
|
+
// src/core/change-link-node.ts
|
|
3030
|
+
import { inflateSync } from "zlib";
|
|
3031
|
+
var CORRUPT_MESSAGE2 = "This change link is damaged or incomplete. Ask the sender to send the file instead.";
|
|
3032
|
+
var TOO_LARGE_MESSAGE3 = `A change link holds at most ${MAX_INFLATED_BYTES / 1024} KB of text. Send the changes in smaller links.`;
|
|
3033
|
+
var nodeInflate = async (bytes, maxOutputLength) => {
|
|
3034
|
+
let result;
|
|
3035
|
+
try {
|
|
3036
|
+
result = inflateSync(bytes, { maxOutputLength, info: true });
|
|
3037
|
+
} catch (err) {
|
|
3038
|
+
if (err instanceof Error && "code" in err && err.code === "ERR_BUFFER_TOO_LARGE") {
|
|
3039
|
+
throw new ChangeLinkError("TOO_LARGE", TOO_LARGE_MESSAGE3);
|
|
3040
|
+
}
|
|
3041
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE2);
|
|
3042
|
+
}
|
|
3043
|
+
if (result.engine.bytesWritten !== bytes.length) {
|
|
3044
|
+
throw new ChangeLinkError("CORRUPT", CORRUPT_MESSAGE2);
|
|
3045
|
+
}
|
|
3046
|
+
return result.buffer;
|
|
3047
|
+
};
|
|
3048
|
+
|
|
3049
|
+
// src/client/watermark.ts
|
|
3050
|
+
var WATERMARK_RE = /\u200C(?:[\u200B\u200D]{8})+\u200C/g;
|
|
3051
|
+
function containsWatermark(text) {
|
|
3052
|
+
WATERMARK_RE.lastIndex = 0;
|
|
3053
|
+
const found = WATERMARK_RE.test(text);
|
|
3054
|
+
WATERMARK_RE.lastIndex = 0;
|
|
3055
|
+
return found;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// src/core/json-path.ts
|
|
3059
|
+
var MAX_KEY_DEPTH = 30;
|
|
3060
|
+
function setNestedProperty(obj, keyPath, value) {
|
|
3061
|
+
const keys = keyPath.split(".");
|
|
3062
|
+
if (keys.length > MAX_KEY_DEPTH) {
|
|
3063
|
+
throw new Error(
|
|
3064
|
+
`[livelocale] Key depth limit exceeded. Maximum depth is ${MAX_KEY_DEPTH} levels.`
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
if (keys.some((k) => DANGEROUS_KEY_SEGMENTS.has(k))) {
|
|
3068
|
+
throw new Error(
|
|
3069
|
+
`[livelocale] Forbidden key segment in path: "${keyPath}". Segment names like __proto__, constructor, and prototype are not allowed.`
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
let current = obj;
|
|
3073
|
+
for (let i = 0; i < keys.length; i++) {
|
|
3074
|
+
const key = keys[i];
|
|
3075
|
+
if (i === keys.length - 1) {
|
|
3076
|
+
current[key] = value;
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
if (!(key in current) || current[key] === null || typeof current[key] !== "object" || Array.isArray(current[key])) {
|
|
3080
|
+
current[key] = {};
|
|
3081
|
+
}
|
|
3082
|
+
current = current[key];
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
function isPlainObject3(value) {
|
|
3086
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3087
|
+
}
|
|
3088
|
+
function hasOwn(obj, key) {
|
|
3089
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
3090
|
+
}
|
|
3091
|
+
function lookupKey(dict, keyPath) {
|
|
3092
|
+
if (!isPlainObject3(dict)) return { state: "missing" };
|
|
3093
|
+
const segments = keyPath.split(".");
|
|
3094
|
+
const leaf = segments.length - 1;
|
|
3095
|
+
let current = dict;
|
|
3096
|
+
for (let i = 0; i < leaf; i++) {
|
|
3097
|
+
if (hasOwn(current, segments.slice(i).join("."))) return { state: "occupied" };
|
|
3098
|
+
const segment = segments[i];
|
|
3099
|
+
if (!hasOwn(current, segment)) return { state: "missing" };
|
|
3100
|
+
const next = current[segment];
|
|
3101
|
+
if (!isPlainObject3(next)) return { state: "occupied" };
|
|
3102
|
+
current = next;
|
|
3103
|
+
}
|
|
3104
|
+
const key = segments[leaf];
|
|
3105
|
+
if (!hasOwn(current, key)) return { state: "missing" };
|
|
3106
|
+
const value = current[key];
|
|
3107
|
+
return typeof value === "string" ? { state: "string", value } : { state: "occupied" };
|
|
3108
|
+
}
|
|
3109
|
+
function detectJsonIndent(content) {
|
|
3110
|
+
const match = /^([ \t]+)"/m.exec(content);
|
|
3111
|
+
if (!match) return " ";
|
|
3112
|
+
const ws = match[1];
|
|
3113
|
+
return ws.includes(" ") ? " " : ws;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// src/core/save-rules.ts
|
|
3117
|
+
var reject = (status, code, error) => ({
|
|
3118
|
+
ok: false,
|
|
3119
|
+
status,
|
|
3120
|
+
code,
|
|
3121
|
+
error
|
|
3122
|
+
});
|
|
3123
|
+
function validateWrite(input, options = {}) {
|
|
3124
|
+
const { locale, key, value } = input;
|
|
3125
|
+
if (!isValidLocaleFormat(locale)) {
|
|
3126
|
+
return reject(400, "INVALID_LOCALE_FORMAT", "Invalid locale string parameter.");
|
|
3127
|
+
}
|
|
3128
|
+
if (!isValidKeyFormat(key)) {
|
|
3129
|
+
return reject(
|
|
3130
|
+
400,
|
|
3131
|
+
"INVALID_KEY_FORMAT",
|
|
3132
|
+
"Invalid key path. Must be 1-200 characters containing alphanumeric, dots, underscores, or hyphens."
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
if (!isSafeKeyPath(key)) {
|
|
3136
|
+
return reject(
|
|
3137
|
+
400,
|
|
3138
|
+
"INVALID_KEY_FORMAT",
|
|
3139
|
+
"Forbidden key path: segments like __proto__, constructor, or prototype are not allowed."
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
if (!isValidValueFormat(value)) {
|
|
3143
|
+
return reject(
|
|
3144
|
+
400,
|
|
3145
|
+
"INVALID_VALUE_FORMAT",
|
|
3146
|
+
"Invalid value. Must be a string less than 10000 characters."
|
|
3147
|
+
);
|
|
3148
|
+
}
|
|
3149
|
+
const accepted = {
|
|
3150
|
+
ok: true,
|
|
3151
|
+
locale,
|
|
3152
|
+
key,
|
|
3153
|
+
value
|
|
3154
|
+
};
|
|
3155
|
+
if (options.force === true) return accepted;
|
|
3156
|
+
const syntaxError = validateMessageSyntax(accepted.value);
|
|
3157
|
+
if (syntaxError) {
|
|
3158
|
+
return reject(422, "MESSAGE_SYNTAX", `Message syntax: ${syntaxError}.`);
|
|
3159
|
+
}
|
|
3160
|
+
const comparison = comparePlaceholders(options.reference, accepted.value);
|
|
3161
|
+
if (!comparison.ok) {
|
|
3162
|
+
const parts = [];
|
|
3163
|
+
if (comparison.missing.length) parts.push(`missing ${comparison.missing.join(", ")}`);
|
|
3164
|
+
if (comparison.extra.length) parts.push(`unexpected ${comparison.extra.join(", ")}`);
|
|
3165
|
+
return {
|
|
3166
|
+
...reject(
|
|
3167
|
+
422,
|
|
3168
|
+
"PLACEHOLDER_MISMATCH",
|
|
3169
|
+
`Placeholders differ from the reference: ${parts.join("; ")}.`
|
|
3170
|
+
),
|
|
3171
|
+
details: { missing: comparison.missing, extra: comparison.extra }
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
return accepted;
|
|
3175
|
+
}
|
|
3176
|
+
function sourceReference(key, ctx) {
|
|
3177
|
+
const found = lookupKey(ctx.source, key);
|
|
3178
|
+
return found.state === "string" ? found.value : null;
|
|
3179
|
+
}
|
|
3180
|
+
function validateEdit(edit, ctx, options) {
|
|
3181
|
+
const { locale, key, value } = edit;
|
|
3182
|
+
const input = { locale, key, value };
|
|
3183
|
+
const format = validateWrite(input, { force: true });
|
|
3184
|
+
if (!format.ok) return format;
|
|
3185
|
+
const dict = locale === ctx.sourceLocale ? ctx.source : ctx.target ?? {};
|
|
3186
|
+
if (lookupKey(dict, key).state === "occupied") {
|
|
3187
|
+
return reject(
|
|
3188
|
+
409,
|
|
3189
|
+
"KEY_NOT_A_STRING",
|
|
3190
|
+
`Cannot save "${key}" in ${locale}: it would replace translations that are not a single text.`
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
return validateWrite(input, {
|
|
3194
|
+
reference: sourceReference(key, ctx),
|
|
3195
|
+
force: options?.force === true
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
var RISK_FLAG_MESSAGES = {
|
|
3199
|
+
markup: "Adds markup that the source text does not have.",
|
|
3200
|
+
"javascript-url": "Adds a javascript:, vbscript: or data: link.",
|
|
3201
|
+
"new-url": "Adds a web address that the source text does not have.",
|
|
3202
|
+
"bidi-control": "Contains invisible text-direction control characters."
|
|
3203
|
+
};
|
|
3204
|
+
var TAG_PATTERN = /<\/?([A-Za-z][\w-]*)\b[^<>]*>/g;
|
|
3205
|
+
var SCRIPT_LINK_PATTERNS = [/\b(?:javascript|vbscript)\s*:/i, /\bdata:[a-z]+\/[\w.+-]+/i];
|
|
3206
|
+
var URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s<>"')]+/gi;
|
|
3207
|
+
var BIDI_CONTROL_PATTERN = /[\u202A-\u202E\u2066-\u2069]/g;
|
|
3208
|
+
var tagNames = (text) => Array.from(text.matchAll(TAG_PATTERN), (m) => m[1].toLowerCase());
|
|
3209
|
+
var allMatches = (pattern, text) => text.match(pattern) ?? [];
|
|
3210
|
+
var hasNew = (known, found) => {
|
|
3211
|
+
const set = new Set(known);
|
|
3212
|
+
return found.some((item) => !set.has(item));
|
|
3213
|
+
};
|
|
3214
|
+
function detectRiskFlags(reference, value) {
|
|
3215
|
+
const ref = reference ?? "";
|
|
3216
|
+
const flags = [];
|
|
3217
|
+
if (hasNew(tagNames(ref), tagNames(value))) flags.push("markup");
|
|
3218
|
+
if (SCRIPT_LINK_PATTERNS.some((pattern) => pattern.test(value) && !pattern.test(ref))) {
|
|
3219
|
+
flags.push("javascript-url");
|
|
3220
|
+
}
|
|
3221
|
+
if (hasNew(allMatches(URL_PATTERN, ref), allMatches(URL_PATTERN, value))) flags.push("new-url");
|
|
3222
|
+
if (hasNew(allMatches(BIDI_CONTROL_PATTERN, ref), allMatches(BIDI_CONTROL_PATTERN, value))) {
|
|
3223
|
+
flags.push("bidi-control");
|
|
3224
|
+
}
|
|
3225
|
+
return flags;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// src/core/apply-plan.ts
|
|
3229
|
+
var FLAG_MESSAGES = {
|
|
3230
|
+
...RISK_FLAG_MESSAGES,
|
|
3231
|
+
forced: "Saved with \u201CSave anyway\u201D.",
|
|
3232
|
+
"new-key": "Adds a key the source locale does not have."
|
|
3233
|
+
};
|
|
3234
|
+
function inExistingNamespace(dict, key) {
|
|
3235
|
+
const dot = key.indexOf(".");
|
|
3236
|
+
if (dot === -1) return false;
|
|
3237
|
+
const namespace = key.slice(0, dot);
|
|
3238
|
+
if (!Object.prototype.hasOwnProperty.call(dict, namespace)) return false;
|
|
3239
|
+
const value = dict[namespace];
|
|
3240
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3241
|
+
}
|
|
3242
|
+
function planEdit(edit, input, source) {
|
|
3243
|
+
const { locale, key, from, to, force } = edit;
|
|
3244
|
+
const base = { locale, key, from, to, force };
|
|
3245
|
+
const invalid2 = (code, message) => ({
|
|
3246
|
+
...base,
|
|
3247
|
+
status: "invalid",
|
|
3248
|
+
code,
|
|
3249
|
+
message,
|
|
3250
|
+
flags: []
|
|
3251
|
+
});
|
|
3252
|
+
const target = input.targets.get(locale);
|
|
3253
|
+
if (!target) return invalid2("UNKNOWN_LOCALE", `No "${locale}" locale file on the target.`);
|
|
3254
|
+
if (target.layout === "namespaced" && !inExistingNamespace(target.dict, key)) {
|
|
3255
|
+
return invalid2(
|
|
3256
|
+
"UNKNOWN_NAMESPACE",
|
|
3257
|
+
`"${key}" is not in an existing namespace file of ${locale}.`
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
if (containsWatermark(to)) {
|
|
3261
|
+
return invalid2("CONTAINS_WATERMARK", "The new text contains LiveLocale watermark characters.");
|
|
3262
|
+
}
|
|
3263
|
+
const ctx = { sourceLocale: input.sourceLocale, source, target: target.dict };
|
|
3264
|
+
const reference = sourceReference(key, ctx);
|
|
3265
|
+
const flags = [];
|
|
3266
|
+
const planned = (outcome) => ({
|
|
3267
|
+
...base,
|
|
3268
|
+
...outcome,
|
|
3269
|
+
flags: [...flags, ...detectRiskFlags(reference ?? from ?? void 0, to)]
|
|
3270
|
+
});
|
|
3271
|
+
const found = lookupKey(target.dict, key);
|
|
3272
|
+
if (found.state === "string" && found.value === to) {
|
|
3273
|
+
return planned({ status: "already", current: found.value });
|
|
3274
|
+
}
|
|
3275
|
+
const verdict = validateEdit({ locale, key, value: to }, ctx, { force: false });
|
|
3276
|
+
if (!verdict.ok) {
|
|
3277
|
+
if (verdict.status !== 422 || !force) return invalid2(verdict.code, verdict.error);
|
|
3278
|
+
if (!input.allowForce) {
|
|
3279
|
+
return planned({ status: "needs-force", code: verdict.code, message: verdict.error });
|
|
3280
|
+
}
|
|
3281
|
+
flags.push("forced");
|
|
3282
|
+
}
|
|
3283
|
+
const current = found.state === "string" ? found.value : void 0;
|
|
3284
|
+
if (current === void 0) {
|
|
3285
|
+
if (from !== null) {
|
|
3286
|
+
return planned({
|
|
3287
|
+
status: "conflict",
|
|
3288
|
+
code: "REMOVED_ON_TARGET",
|
|
3289
|
+
message: "Removed on the target since the link was made."
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
if (reference === null) flags.push("new-key");
|
|
3293
|
+
return planned({ status: "apply" });
|
|
3294
|
+
}
|
|
3295
|
+
if (current === from) return planned({ status: "apply" });
|
|
3296
|
+
return planned({
|
|
3297
|
+
status: "conflict",
|
|
3298
|
+
code: "CHANGED_ON_TARGET",
|
|
3299
|
+
message: "Changed on the target since the link was made.",
|
|
3300
|
+
current
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
function planApply(input) {
|
|
3304
|
+
const source = input.targets.get(input.sourceLocale);
|
|
3305
|
+
if (!source) {
|
|
3306
|
+
throw new Error(`Source locale "${input.sourceLocale}" has no locale file on the target.`);
|
|
3307
|
+
}
|
|
3308
|
+
const edits = input.edits.map((edit) => planEdit(edit, input, source.dict));
|
|
3309
|
+
const counts = {
|
|
3310
|
+
apply: 0,
|
|
3311
|
+
already: 0,
|
|
3312
|
+
conflict: 0,
|
|
3313
|
+
invalid: 0,
|
|
3314
|
+
"needs-force": 0
|
|
3315
|
+
};
|
|
3316
|
+
for (const edit of edits) counts[edit.status] += 1;
|
|
3317
|
+
return { edits, counts };
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
// src/server/file-mutator.ts
|
|
3321
|
+
import { promises as fs7 } from "fs";
|
|
3322
|
+
import * as path11 from "path";
|
|
3323
|
+
|
|
3324
|
+
// src/core/safe-path.ts
|
|
3325
|
+
import * as path10 from "path";
|
|
3326
|
+
function resolveSafeLocalePath(basePath, locale) {
|
|
3327
|
+
if (!isSafeLocaleSegment(locale)) {
|
|
3328
|
+
throw new Error("Path traversal attempt");
|
|
3329
|
+
}
|
|
3330
|
+
const resolvedBasePath = path10.resolve(basePath);
|
|
3331
|
+
const filePath = path10.resolve(resolvedBasePath, `${locale}.json`);
|
|
3332
|
+
if (!filePath.startsWith(resolvedBasePath)) {
|
|
3333
|
+
throw new Error("Path traversal attempt");
|
|
3334
|
+
}
|
|
3335
|
+
return filePath;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
// src/server/file-mutator.ts
|
|
3339
|
+
var FileMutex = class {
|
|
3340
|
+
queue = Promise.resolve();
|
|
3341
|
+
async acquire(fn) {
|
|
3342
|
+
const next = this.queue.then(fn);
|
|
3343
|
+
this.queue = next.then(
|
|
3344
|
+
() => {
|
|
3345
|
+
},
|
|
3346
|
+
() => {
|
|
3347
|
+
}
|
|
3348
|
+
);
|
|
3349
|
+
return next;
|
|
3350
|
+
}
|
|
3351
|
+
};
|
|
3352
|
+
var FileMutator = class {
|
|
3353
|
+
fileLocks = /* @__PURE__ */ new Map();
|
|
3354
|
+
requireEnabled;
|
|
3355
|
+
quiet;
|
|
3356
|
+
scanner;
|
|
3357
|
+
constructor(options = {}) {
|
|
3358
|
+
this.requireEnabled = options.requireEnabled ?? true;
|
|
3359
|
+
this.quiet = options.quiet ?? false;
|
|
3360
|
+
this.scanner = new KeyScanner({ requireEnabled: this.requireEnabled });
|
|
3361
|
+
}
|
|
3362
|
+
assertEnabled() {
|
|
3363
|
+
if (!this.requireEnabled) return;
|
|
3364
|
+
const reason = getDisabledReason();
|
|
3365
|
+
if (reason !== null) {
|
|
3366
|
+
throw new Error(
|
|
3367
|
+
`[livelocale] FileMutator can only be executed in development mode (${reason}).`
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Helper to serialize execution per file path
|
|
3373
|
+
*/
|
|
3374
|
+
async withLock(filePath, fn) {
|
|
3375
|
+
let lock = this.fileLocks.get(filePath);
|
|
3376
|
+
if (!lock) {
|
|
3377
|
+
lock = new FileMutex();
|
|
3378
|
+
this.fileLocks.set(filePath, lock);
|
|
3379
|
+
}
|
|
3380
|
+
return lock.acquire(fn);
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Updates a locale JSON file on disk atomically and safely.
|
|
3384
|
+
* Enforces development mode checks, path traversal mitigation, key depth restrictions,
|
|
3385
|
+
* structural JSON validation, and concurrency serialization.
|
|
3386
|
+
*/
|
|
3387
|
+
async updateLocaleKey(basePath, locale, keyPath, newValue) {
|
|
3388
|
+
this.assertEnabled();
|
|
3389
|
+
if (!isSafeLocaleSegment(locale)) {
|
|
3390
|
+
throw new Error(
|
|
3391
|
+
`[livelocale] Path traversal attempt detected in locale identifier: "${locale}"`
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
const resolvedBasePath = path11.resolve(basePath);
|
|
3395
|
+
const localeDir = path11.resolve(resolvedBasePath, locale);
|
|
3396
|
+
let filePath = "";
|
|
3397
|
+
let mutationKeyPath = keyPath;
|
|
3398
|
+
let isNamespaceDir = false;
|
|
3399
|
+
try {
|
|
3400
|
+
const stats = await fs7.stat(localeDir);
|
|
3401
|
+
if (stats.isDirectory()) {
|
|
3402
|
+
isNamespaceDir = true;
|
|
3403
|
+
}
|
|
3404
|
+
} catch {
|
|
3405
|
+
}
|
|
3406
|
+
if (isNamespaceDir) {
|
|
3407
|
+
const firstDot = keyPath.indexOf(".");
|
|
3408
|
+
if (firstDot !== -1) {
|
|
3409
|
+
const namespace = keyPath.substring(0, firstDot);
|
|
3410
|
+
if (DANGEROUS_KEY_SEGMENTS.has(namespace)) {
|
|
3411
|
+
throw new Error(
|
|
3412
|
+
`[livelocale] Forbidden namespace segment in path: "${namespace}". Segment names like __proto__, constructor, and prototype are not allowed.`
|
|
3413
|
+
);
|
|
3414
|
+
}
|
|
3415
|
+
mutationKeyPath = keyPath.substring(firstDot + 1);
|
|
3416
|
+
filePath = path11.resolve(localeDir, `${namespace}.json`);
|
|
3417
|
+
} else {
|
|
3418
|
+
filePath = path11.resolve(localeDir, `${keyPath}.json`);
|
|
3419
|
+
mutationKeyPath = "";
|
|
3420
|
+
}
|
|
3421
|
+
} else {
|
|
3422
|
+
filePath = path11.resolve(resolvedBasePath, `${locale}.json`);
|
|
3423
|
+
}
|
|
3424
|
+
if (!filePath.startsWith(resolvedBasePath)) {
|
|
3425
|
+
throw new Error(
|
|
3426
|
+
`[livelocale] Path traversal attempt: Resolved file path must start with basePath.`
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
await this.withLock(filePath, async () => {
|
|
3430
|
+
let currentData = {};
|
|
3431
|
+
let indent = " ";
|
|
3432
|
+
let trailingNewline = "\n";
|
|
3433
|
+
try {
|
|
3434
|
+
const fileContent = await fs7.readFile(filePath, "utf-8");
|
|
3435
|
+
indent = detectJsonIndent(fileContent);
|
|
3436
|
+
trailingNewline = fileContent.endsWith("\n") ? "\n" : "";
|
|
3437
|
+
try {
|
|
3438
|
+
const parsed = JSON.parse(fileContent);
|
|
3439
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3440
|
+
throw new Error("Root must be an object");
|
|
3441
|
+
}
|
|
3442
|
+
currentData = parsed;
|
|
3443
|
+
} catch (parseError) {
|
|
3444
|
+
throw new Error(
|
|
3445
|
+
`[livelocale] Invalid locale file: root must be an object. Code: INVALID_LOCALE. Original error: ${parseError.message}`
|
|
3446
|
+
);
|
|
3447
|
+
}
|
|
3448
|
+
} catch (err) {
|
|
3449
|
+
if (err.code !== "ENOENT") {
|
|
3450
|
+
throw new Error(
|
|
3451
|
+
`[livelocale] Failed to read locale file at ${filePath}. Operation: read. Original error: ${err.message}`
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
if (mutationKeyPath) {
|
|
3456
|
+
setNestedProperty(currentData, mutationKeyPath, newValue);
|
|
3457
|
+
} else {
|
|
3458
|
+
throw new Error(
|
|
3459
|
+
"[livelocale] Invalid namespace key path: must contain at least one nested key."
|
|
3460
|
+
);
|
|
3461
|
+
}
|
|
3462
|
+
let stringified;
|
|
3463
|
+
try {
|
|
3464
|
+
stringified = JSON.stringify(currentData, null, indent);
|
|
3465
|
+
JSON.parse(stringified);
|
|
3466
|
+
stringified += trailingNewline;
|
|
3467
|
+
} catch (err) {
|
|
3468
|
+
throw new Error(
|
|
3469
|
+
`[livelocale] Produced JSON is invalid or has circular references. Original error: ${err.message}`
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
const tempPath = `${filePath}.tmp`;
|
|
3473
|
+
try {
|
|
3474
|
+
await fs7.writeFile(tempPath, stringified, "utf-8");
|
|
3475
|
+
} catch (err) {
|
|
3476
|
+
throw new Error(
|
|
3477
|
+
`[livelocale] Failed to write temp file at ${tempPath}. Operation: write. Original error: ${err.message}`
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
try {
|
|
3481
|
+
await fs7.rename(tempPath, filePath);
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
try {
|
|
3484
|
+
await fs7.unlink(tempPath);
|
|
3485
|
+
} catch {
|
|
3486
|
+
}
|
|
3487
|
+
throw new Error(
|
|
3488
|
+
`[livelocale] Failed to rename temp file to target at ${filePath}. Operation: rename. Original error: ${err.message}`
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
if (!this.quiet) {
|
|
3492
|
+
console.log(`[livelocale] Updated key '${keyPath}' in locale '${locale}'`);
|
|
3493
|
+
}
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* Returns the full dictionary for a locale: a flat `{locale}.json`, or every
|
|
3498
|
+
* `*.json` inside the `{locale}/` folder merged under its namespace.
|
|
3499
|
+
*/
|
|
3500
|
+
async readLocale(basePath, locale) {
|
|
3501
|
+
this.assertEnabled();
|
|
3502
|
+
const localePath = path11.resolve(basePath, locale);
|
|
3503
|
+
let isDir = false;
|
|
3504
|
+
try {
|
|
3505
|
+
isDir = (await fs7.stat(localePath)).isDirectory();
|
|
3506
|
+
} catch {
|
|
3507
|
+
}
|
|
3508
|
+
if (isDir) {
|
|
3509
|
+
const merged = {};
|
|
3510
|
+
for (const file of await fs7.readdir(localePath)) {
|
|
3511
|
+
if (!file.endsWith(".json")) continue;
|
|
3512
|
+
try {
|
|
3513
|
+
merged[path11.basename(file, ".json")] = JSON.parse(
|
|
3514
|
+
await fs7.readFile(path11.join(localePath, file), "utf-8")
|
|
3515
|
+
);
|
|
3516
|
+
} catch {
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
return merged;
|
|
3520
|
+
}
|
|
3521
|
+
const filePath = resolveSafeLocalePath(basePath, locale);
|
|
3522
|
+
return JSON.parse(await fs7.readFile(filePath, "utf-8"));
|
|
3523
|
+
}
|
|
3524
|
+
/**
|
|
3525
|
+
* Lists all locale codes dynamically scanned from the locales directory.
|
|
3526
|
+
*/
|
|
3527
|
+
async listLocales(basePath) {
|
|
3528
|
+
this.assertEnabled();
|
|
3529
|
+
const resolvedBasePath = path11.resolve(basePath);
|
|
3530
|
+
const items = await fs7.readdir(resolvedBasePath, { withFileTypes: true });
|
|
3531
|
+
const localesSet = /* @__PURE__ */ new Set();
|
|
3532
|
+
for (const item of items) {
|
|
3533
|
+
if (item.isDirectory()) {
|
|
3534
|
+
if (LOCALE_RE.test(item.name)) {
|
|
3535
|
+
localesSet.add(item.name);
|
|
3536
|
+
}
|
|
3537
|
+
} else if (item.isFile() && item.name.endsWith(".json")) {
|
|
3538
|
+
const name = path11.basename(item.name, ".json");
|
|
3539
|
+
if (LOCALE_RE.test(name)) {
|
|
3540
|
+
localesSet.add(name);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
return Array.from(localesSet).sort();
|
|
3545
|
+
}
|
|
3546
|
+
/**
|
|
3547
|
+
* Adds a new locale directory/file based on current repository structure.
|
|
3548
|
+
*/
|
|
3549
|
+
async addLocale(basePath, locale) {
|
|
3550
|
+
this.assertEnabled();
|
|
3551
|
+
if (!isSafeLocaleSegment(locale)) {
|
|
3552
|
+
throw new Error(
|
|
3553
|
+
`[livelocale] Path traversal attempt detected in locale identifier: "${locale}"`
|
|
3554
|
+
);
|
|
3555
|
+
}
|
|
3556
|
+
if (!LOCALE_RE.test(locale)) {
|
|
3557
|
+
throw new Error(`[livelocale] Invalid locale format: "${locale}"`);
|
|
3558
|
+
}
|
|
3559
|
+
const resolvedBasePath = path11.resolve(basePath);
|
|
3560
|
+
const localePath = path11.resolve(resolvedBasePath, locale);
|
|
3561
|
+
if (!localePath.startsWith(resolvedBasePath)) {
|
|
3562
|
+
throw new Error(
|
|
3563
|
+
`[livelocale] Path traversal attempt: Resolved file path must start with basePath.`
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
let exists = false;
|
|
3567
|
+
try {
|
|
3568
|
+
await fs7.stat(localePath);
|
|
3569
|
+
exists = true;
|
|
3570
|
+
} catch {
|
|
3571
|
+
}
|
|
3572
|
+
try {
|
|
3573
|
+
await fs7.stat(`${localePath}.json`);
|
|
3574
|
+
exists = true;
|
|
3575
|
+
} catch {
|
|
3576
|
+
}
|
|
3577
|
+
if (exists) {
|
|
3578
|
+
throw new Error(`[livelocale] Locale "${locale}" already exists.`);
|
|
3579
|
+
}
|
|
3580
|
+
const items = await fs7.readdir(resolvedBasePath, { withFileTypes: true });
|
|
3581
|
+
let referenceDir = null;
|
|
3582
|
+
for (const item of items) {
|
|
3583
|
+
if (item.isDirectory() && LOCALE_RE.test(item.name)) {
|
|
3584
|
+
referenceDir = item.name;
|
|
3585
|
+
break;
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
if (referenceDir) {
|
|
3589
|
+
await fs7.mkdir(localePath, { recursive: true });
|
|
3590
|
+
const refPath = path11.resolve(resolvedBasePath, referenceDir);
|
|
3591
|
+
const files = await fs7.readdir(refPath);
|
|
3592
|
+
for (const file of files) {
|
|
3593
|
+
if (file.endsWith(".json")) {
|
|
3594
|
+
await fs7.writeFile(path11.join(localePath, file), "{}", "utf-8");
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
} else {
|
|
3598
|
+
await fs7.writeFile(`${localePath}.json`, "{}", "utf-8");
|
|
3599
|
+
}
|
|
3600
|
+
console.log(`[livelocale] Added locale '${locale}'`);
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* Renames an existing locale (directory or file) to a new name.
|
|
3604
|
+
*/
|
|
3605
|
+
async renameLocale(basePath, oldLocale, newLocale) {
|
|
3606
|
+
this.assertEnabled();
|
|
3607
|
+
if (!isSafeLocaleSegment(oldLocale) || !isSafeLocaleSegment(newLocale)) {
|
|
3608
|
+
throw new Error("[livelocale] Path traversal attempt detected");
|
|
3609
|
+
}
|
|
3610
|
+
if (!LOCALE_RE.test(oldLocale) || !LOCALE_RE.test(newLocale)) {
|
|
3611
|
+
throw new Error(`[livelocale] Invalid locale format`);
|
|
3612
|
+
}
|
|
3613
|
+
const resolvedBasePath = path11.resolve(basePath);
|
|
3614
|
+
const oldPath = path11.resolve(resolvedBasePath, oldLocale);
|
|
3615
|
+
const newPath = path11.resolve(resolvedBasePath, newLocale);
|
|
3616
|
+
if (!oldPath.startsWith(resolvedBasePath) || !newPath.startsWith(resolvedBasePath)) {
|
|
3617
|
+
throw new Error("[livelocale] Path traversal attempt");
|
|
3618
|
+
}
|
|
3619
|
+
let isDir = false;
|
|
3620
|
+
let exists = false;
|
|
3621
|
+
try {
|
|
3622
|
+
const stats = await fs7.stat(oldPath);
|
|
3623
|
+
isDir = stats.isDirectory();
|
|
3624
|
+
exists = true;
|
|
3625
|
+
} catch {
|
|
3626
|
+
}
|
|
3627
|
+
let isFile = false;
|
|
3628
|
+
if (!exists) {
|
|
3629
|
+
try {
|
|
3630
|
+
await fs7.stat(`${oldPath}.json`);
|
|
3631
|
+
isFile = true;
|
|
3632
|
+
exists = true;
|
|
3633
|
+
} catch {
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
if (!exists) {
|
|
3637
|
+
throw new Error(`[livelocale] Locale "${oldLocale}" does not exist.`);
|
|
3638
|
+
}
|
|
3639
|
+
let newExists = false;
|
|
3640
|
+
try {
|
|
3641
|
+
await fs7.stat(newPath);
|
|
3642
|
+
newExists = true;
|
|
3643
|
+
} catch {
|
|
3644
|
+
}
|
|
3645
|
+
try {
|
|
3646
|
+
await fs7.stat(`${newPath}.json`);
|
|
3647
|
+
newExists = true;
|
|
3648
|
+
} catch {
|
|
3649
|
+
}
|
|
3650
|
+
if (newExists) {
|
|
3651
|
+
throw new Error(`[livelocale] Target locale "${newLocale}" already exists.`);
|
|
3652
|
+
}
|
|
3653
|
+
if (isDir) {
|
|
3654
|
+
await fs7.rename(oldPath, newPath);
|
|
3655
|
+
} else if (isFile) {
|
|
3656
|
+
await fs7.rename(`${oldPath}.json`, `${newPath}.json`);
|
|
3657
|
+
}
|
|
3658
|
+
console.log(`[livelocale] Renamed locale '${oldLocale}' to '${newLocale}'`);
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Deletes an existing locale (directory or file).
|
|
3662
|
+
*/
|
|
3663
|
+
async deleteLocale(basePath, locale) {
|
|
3664
|
+
this.assertEnabled();
|
|
3665
|
+
if (!isSafeLocaleSegment(locale)) {
|
|
3666
|
+
throw new Error("[livelocale] Path traversal attempt detected");
|
|
3667
|
+
}
|
|
3668
|
+
if (!LOCALE_RE.test(locale)) {
|
|
3669
|
+
throw new Error(`[livelocale] Invalid locale format`);
|
|
3670
|
+
}
|
|
3671
|
+
const resolvedBasePath = path11.resolve(basePath);
|
|
3672
|
+
const localePath = path11.resolve(resolvedBasePath, locale);
|
|
3673
|
+
if (!localePath.startsWith(resolvedBasePath)) {
|
|
3674
|
+
throw new Error("[livelocale] Path traversal attempt");
|
|
3675
|
+
}
|
|
3676
|
+
let isDir = false;
|
|
3677
|
+
let exists = false;
|
|
3678
|
+
try {
|
|
3679
|
+
const stats = await fs7.stat(localePath);
|
|
3680
|
+
isDir = stats.isDirectory();
|
|
3681
|
+
exists = true;
|
|
3682
|
+
} catch {
|
|
3683
|
+
}
|
|
3684
|
+
let isFile = false;
|
|
3685
|
+
if (!exists) {
|
|
3686
|
+
try {
|
|
3687
|
+
await fs7.stat(`${localePath}.json`);
|
|
3688
|
+
isFile = true;
|
|
3689
|
+
exists = true;
|
|
3690
|
+
} catch {
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3693
|
+
if (!exists) {
|
|
3694
|
+
throw new Error(`[livelocale] Locale "${locale}" does not exist.`);
|
|
3695
|
+
}
|
|
3696
|
+
if (isDir) {
|
|
3697
|
+
await fs7.rm(localePath, { recursive: true, force: true });
|
|
3698
|
+
} else if (isFile) {
|
|
3699
|
+
await fs7.unlink(`${localePath}.json`);
|
|
3700
|
+
}
|
|
3701
|
+
console.log(`[livelocale] Deleted locale '${locale}'`);
|
|
3702
|
+
}
|
|
3703
|
+
/**
|
|
3704
|
+
* Scans codebase directory recursively for references to translation keys.
|
|
3705
|
+
* Delegates to the composed KeyScanner (SRP).
|
|
3706
|
+
*/
|
|
3707
|
+
async scanCodebaseKeys(basePath) {
|
|
3708
|
+
return this.scanner.scanCodebaseKeys(basePath);
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Scans all locale files/folders to compile the set of all keys currently in locales.
|
|
3712
|
+
*/
|
|
3713
|
+
async scanLocalesKeys(basePath) {
|
|
3714
|
+
return this.scanner.scanLocalesKeys(basePath, this.listLocales.bind(this));
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* Returns merged lists of keys from codebase and existing locale files.
|
|
3718
|
+
*/
|
|
3719
|
+
async getKeysMetadata(basePath) {
|
|
3720
|
+
return this.scanner.getKeysMetadata(basePath, this.listLocales.bind(this));
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
|
|
3724
|
+
// src/server/git-client.ts
|
|
3725
|
+
import { execFile as execFile3 } from "child_process";
|
|
3726
|
+
import { promisify } from "util";
|
|
3727
|
+
import * as fs8 from "fs";
|
|
3728
|
+
import * as path12 from "path";
|
|
3729
|
+
var execFileAsync = promisify(execFile3);
|
|
3730
|
+
var MAX_DIFF_BYTES = 2e5;
|
|
3731
|
+
function mapStatus(xy) {
|
|
3732
|
+
if (xy === "??") return "added";
|
|
3733
|
+
const code = xy.trim()[0] ?? "";
|
|
3734
|
+
switch (code) {
|
|
3735
|
+
case "M":
|
|
3736
|
+
return "modified";
|
|
3737
|
+
case "A":
|
|
3738
|
+
return "added";
|
|
3739
|
+
case "D":
|
|
3740
|
+
return "deleted";
|
|
3741
|
+
case "R":
|
|
3742
|
+
return "renamed";
|
|
3743
|
+
default:
|
|
3744
|
+
return "modified";
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
var GitClient = class {
|
|
3748
|
+
async git(cwd, args) {
|
|
3749
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
3750
|
+
cwd,
|
|
3751
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
3752
|
+
windowsHide: true,
|
|
3753
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
3754
|
+
});
|
|
3755
|
+
return stdout;
|
|
3756
|
+
}
|
|
3757
|
+
/** Resolves the repository root and the locales path relative to it (forward slashes). */
|
|
3758
|
+
async locate(localesPath) {
|
|
3759
|
+
const absolute = path12.resolve(localesPath);
|
|
3760
|
+
const cwd = fs8.existsSync(absolute) ? absolute : path12.dirname(absolute);
|
|
3761
|
+
if (!fs8.existsSync(cwd)) return null;
|
|
3762
|
+
try {
|
|
3763
|
+
const root = (await this.git(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
3764
|
+
if (!root) return null;
|
|
3765
|
+
const rel = path12.relative(root, absolute).split(path12.sep).join("/");
|
|
3766
|
+
if (rel.startsWith("..")) return null;
|
|
3767
|
+
return { root: path12.resolve(root), rel: rel || ".", absolute };
|
|
3768
|
+
} catch {
|
|
3769
|
+
return null;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
async status(localesPath) {
|
|
3773
|
+
const loc = await this.locate(localesPath);
|
|
3774
|
+
if (!loc) return { isRepo: false, branch: null, changes: [] };
|
|
3775
|
+
let branch = null;
|
|
3776
|
+
try {
|
|
3777
|
+
branch = (await this.git(loc.root, ["rev-parse", "--abbrev-ref", "HEAD"])).trim() || null;
|
|
3778
|
+
} catch {
|
|
3779
|
+
branch = null;
|
|
3780
|
+
}
|
|
3781
|
+
const changes = [];
|
|
3782
|
+
try {
|
|
3783
|
+
const out = await this.git(loc.root, [
|
|
3784
|
+
"status",
|
|
3785
|
+
"--porcelain=v1",
|
|
3786
|
+
"--untracked-files=all",
|
|
3787
|
+
"--",
|
|
3788
|
+
loc.rel
|
|
3789
|
+
]);
|
|
3790
|
+
for (const line of out.split("\n")) {
|
|
3791
|
+
if (line.length < 4) continue;
|
|
3792
|
+
const xy = line.slice(0, 2);
|
|
3793
|
+
let file = line.slice(3).trim();
|
|
3794
|
+
const arrow = file.indexOf(" -> ");
|
|
3795
|
+
if (arrow !== -1) file = file.slice(arrow + 4);
|
|
3796
|
+
if (file.startsWith('"') && file.endsWith('"')) file = file.slice(1, -1);
|
|
3797
|
+
changes.push({ path: file, status: mapStatus(xy) });
|
|
3798
|
+
}
|
|
3799
|
+
} catch {
|
|
3800
|
+
}
|
|
3801
|
+
return { isRepo: true, branch, changes, mode: "local", pullRequestUrl: null };
|
|
3802
|
+
}
|
|
3803
|
+
async diff(localesPath) {
|
|
3804
|
+
const loc = await this.locate(localesPath);
|
|
3805
|
+
if (!loc) return "";
|
|
3806
|
+
let out = "";
|
|
3807
|
+
try {
|
|
3808
|
+
out = await this.git(loc.root, ["diff", "--no-color", "--", loc.rel]);
|
|
3809
|
+
} catch {
|
|
3810
|
+
}
|
|
3811
|
+
try {
|
|
3812
|
+
const untracked = await this.git(loc.root, [
|
|
3813
|
+
"ls-files",
|
|
3814
|
+
"--others",
|
|
3815
|
+
"--exclude-standard",
|
|
3816
|
+
"--",
|
|
3817
|
+
loc.rel
|
|
3818
|
+
]);
|
|
3819
|
+
for (const file of untracked.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
3820
|
+
const full = path12.join(loc.root, file);
|
|
3821
|
+
let content = "";
|
|
3822
|
+
try {
|
|
3823
|
+
content = fs8.readFileSync(full, "utf-8");
|
|
3824
|
+
} catch {
|
|
3825
|
+
continue;
|
|
3826
|
+
}
|
|
3827
|
+
const lines = content.split("\n");
|
|
3828
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
3829
|
+
out += `diff --git a/${file} b/${file}
|
|
3830
|
+
new file mode 100644
|
|
3831
|
+
--- /dev/null
|
|
3832
|
+
+++ b/${file}
|
|
3833
|
+
@@ -0,0 +1,${lines.length} @@
|
|
3834
|
+
` + lines.map((l) => `+${l}`).join("\n") + "\n";
|
|
3835
|
+
}
|
|
3836
|
+
} catch {
|
|
3837
|
+
}
|
|
3838
|
+
if (Buffer.byteLength(out, "utf-8") > MAX_DIFF_BYTES) {
|
|
3839
|
+
out = out.slice(0, MAX_DIFF_BYTES) + "\n\u2026 diff truncated \u2026\n";
|
|
3840
|
+
}
|
|
3841
|
+
return out;
|
|
3842
|
+
}
|
|
3843
|
+
async commit(localesPath, message) {
|
|
3844
|
+
const loc = await this.locate(localesPath);
|
|
3845
|
+
if (!loc) throw new Error("Not a git repository.");
|
|
3846
|
+
const status = await this.status(localesPath);
|
|
3847
|
+
if (status.changes.length === 0) {
|
|
3848
|
+
throw new Error(`No changes to commit under ${loc.rel}.`);
|
|
3849
|
+
}
|
|
3850
|
+
await this.git(loc.root, ["add", "-A", "--", loc.rel]);
|
|
3851
|
+
await this.git(loc.root, ["commit", "-q", "-m", message, "--", loc.rel]);
|
|
3852
|
+
const hash = (await this.git(loc.root, ["rev-parse", "--short", "HEAD"])).trim();
|
|
3853
|
+
return { hash };
|
|
3854
|
+
}
|
|
3855
|
+
async discard(localesPath) {
|
|
3856
|
+
const loc = await this.locate(localesPath);
|
|
3857
|
+
if (!loc) throw new Error("Not a git repository.");
|
|
3858
|
+
try {
|
|
3859
|
+
await this.git(loc.root, ["checkout", "--", loc.rel]);
|
|
3860
|
+
} catch {
|
|
3861
|
+
}
|
|
3862
|
+
await this.git(loc.root, ["clean", "-fd", "--", loc.rel]);
|
|
3863
|
+
}
|
|
3864
|
+
};
|
|
3865
|
+
|
|
3866
|
+
// src/codemod/apply/report.ts
|
|
3867
|
+
var DEFAULT_SPAN_LENGTH = 120;
|
|
3868
|
+
var DEFAULT_TABLE_ROWS = 100;
|
|
3869
|
+
var MAX_BODY_CHARS = 6e4;
|
|
3870
|
+
var MAX_NOTE_CHARS2 = 2e3;
|
|
3871
|
+
var MAX_NAME_CHARS = 64;
|
|
3872
|
+
var MAX_CHECK_LINES = 50;
|
|
3873
|
+
var MAX_CHECK_SUMMARY_CHARS = 1e4;
|
|
3874
|
+
var BODY_MARGIN = 200;
|
|
3875
|
+
var STATUS_LABELS = {
|
|
3876
|
+
apply: "applied",
|
|
3877
|
+
already: "already done",
|
|
3878
|
+
conflict: "conflict",
|
|
3879
|
+
"needs-force": "needs --force",
|
|
3880
|
+
invalid: "rejected"
|
|
3881
|
+
};
|
|
3882
|
+
var BIDI_CONTROL_RE = /[--]/g;
|
|
3883
|
+
var NEWLINE_RE = /\r\n|\r|\n/g;
|
|
3884
|
+
var SPAN_CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
3885
|
+
var CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
3886
|
+
function cut(text, max) {
|
|
3887
|
+
if (text.length <= max) return text;
|
|
3888
|
+
let out = text.slice(0, max);
|
|
3889
|
+
const last = out.charCodeAt(out.length - 1);
|
|
3890
|
+
if (last >= 55296 && last <= 56319) out = out.slice(0, -1);
|
|
3891
|
+
return out;
|
|
3892
|
+
}
|
|
3893
|
+
function cutLines(text, max) {
|
|
3894
|
+
if (text.length <= max) return text;
|
|
3895
|
+
const kept = [];
|
|
3896
|
+
let length = 0;
|
|
3897
|
+
for (const line of text.split("\n")) {
|
|
3898
|
+
const added = (kept.length > 0 ? 1 : 0) + line.length;
|
|
3899
|
+
if (length + added + 2 > max) break;
|
|
3900
|
+
kept.push(line);
|
|
3901
|
+
length += added;
|
|
3902
|
+
}
|
|
3903
|
+
return [...kept, "\u2026"].join("\n");
|
|
3904
|
+
}
|
|
3905
|
+
function longestBacktickRun(text) {
|
|
3906
|
+
let longest = 0;
|
|
3907
|
+
for (const run3 of text.match(/`+/g) ?? []) longest = Math.max(longest, run3.length);
|
|
3908
|
+
return longest;
|
|
3909
|
+
}
|
|
3910
|
+
function codePointLabel(ch) {
|
|
3911
|
+
return `\u27E8U+${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}\u27E9`;
|
|
3912
|
+
}
|
|
3913
|
+
function codeSpan(text, maxLength = DEFAULT_SPAN_LENGTH) {
|
|
3914
|
+
const truncated = text.length > maxLength ? `${cut(text, maxLength)}\u2026` : text;
|
|
3915
|
+
const content = truncated.replace(NEWLINE_RE, "\u23CE").replace(BIDI_CONTROL_RE, codePointLabel).replace(SPAN_CONTROL_RE, codePointLabel).replace(/\|/g, "\\|");
|
|
3916
|
+
if (content === "") return "` `";
|
|
3917
|
+
const fence = "`".repeat(longestBacktickRun(content) + 1);
|
|
3918
|
+
const pad = /^[` ]|[` ]$/.test(content) ? " " : "";
|
|
3919
|
+
return `${fence}${pad}${content}${pad}${fence}`;
|
|
3920
|
+
}
|
|
3921
|
+
function sanitizeName(name) {
|
|
3922
|
+
if (name === void 0) return void 0;
|
|
3923
|
+
const cleaned = name.replace(NEWLINE_RE, " ").replace(CONTROL_RE, "").replace(/[@#<>]/g, "").trim();
|
|
3924
|
+
const result = cut(cleaned, MAX_NAME_CHARS).trim();
|
|
3925
|
+
return result === "" ? void 0 : result;
|
|
3926
|
+
}
|
|
3927
|
+
var NO_CHECK_PROBLEMS = "\u2705 `livelocale check`: no problems in the changed keys.";
|
|
3928
|
+
function summarizeCheck(report, edited) {
|
|
3929
|
+
const changed = new Set(edited.map(([locale, key]) => `${locale}\0${key}`));
|
|
3930
|
+
const mine = report.issues.filter(
|
|
3931
|
+
(issue) => issue.locale !== void 0 && issue.key !== void 0 && changed.has(`${issue.locale}\0${issue.key}`)
|
|
3932
|
+
);
|
|
3933
|
+
if (mine.length === 0) return NO_CHECK_PROBLEMS;
|
|
3934
|
+
const lines = [`\u26A0 \`livelocale check\` found ${mine.length} problem(s) in the changed keys:`, ""];
|
|
3935
|
+
for (const issue of mine.slice(0, MAX_CHECK_LINES)) {
|
|
3936
|
+
lines.push(
|
|
3937
|
+
`- ${codeSpan(issue.locale ?? "")} ${codeSpan(issue.key ?? "")}: ${codeSpan(issue.message)}`
|
|
3938
|
+
);
|
|
3939
|
+
}
|
|
3940
|
+
if (mine.length > MAX_CHECK_LINES) lines.push(`- \u2026and ${mine.length - MAX_CHECK_LINES} more`);
|
|
3941
|
+
const others = report.issues.length - mine.length;
|
|
3942
|
+
if (others > 0) {
|
|
3943
|
+
lines.push("", `(${others} other problem(s) already in the repository are not shown.)`);
|
|
3944
|
+
}
|
|
3945
|
+
return lines.join("\n");
|
|
3946
|
+
}
|
|
3947
|
+
function checksCell(edit) {
|
|
3948
|
+
const parts = edit.flags.map((flag) => FLAG_MESSAGES[flag]);
|
|
3949
|
+
if ((edit.status === "invalid" || edit.status === "needs-force") && edit.message) {
|
|
3950
|
+
parts.push(codeSpan(edit.message));
|
|
3951
|
+
}
|
|
3952
|
+
return parts.join("; ");
|
|
3953
|
+
}
|
|
3954
|
+
function renderRow(edit) {
|
|
3955
|
+
const before = edit.from === null ? "\u2014" : codeSpan(edit.from);
|
|
3956
|
+
const after = edit.status === "conflict" ? `now: ${edit.current === void 0 ? "(removed)" : codeSpan(edit.current)}` : codeSpan(edit.to);
|
|
3957
|
+
const cells = [
|
|
3958
|
+
STATUS_LABELS[edit.status],
|
|
3959
|
+
codeSpan(edit.locale),
|
|
3960
|
+
codeSpan(edit.key),
|
|
3961
|
+
before,
|
|
3962
|
+
after,
|
|
3963
|
+
checksCell(edit)
|
|
3964
|
+
];
|
|
3965
|
+
return `| ${cells.join(" | ")} |`;
|
|
3966
|
+
}
|
|
3967
|
+
function renderTable(plan, limit, maxChars) {
|
|
3968
|
+
const lines = [
|
|
3969
|
+
"| Status | Locale | Key | Before | After | Checks |",
|
|
3970
|
+
"| --- | --- | --- | --- | --- | --- |"
|
|
3971
|
+
];
|
|
3972
|
+
const moreLine = (n) => `
|
|
3973
|
+
|
|
3974
|
+
\u2026and ${n} more`;
|
|
3975
|
+
let length = lines.join("\n").length + moreLine(plan.edits.length).length;
|
|
3976
|
+
let shown = 0;
|
|
3977
|
+
for (const edit of plan.edits) {
|
|
3978
|
+
if (shown >= limit) break;
|
|
3979
|
+
const row = renderRow(edit);
|
|
3980
|
+
if (length + row.length + 1 > maxChars) break;
|
|
3981
|
+
lines.push(row);
|
|
3982
|
+
length += row.length + 1;
|
|
3983
|
+
shown += 1;
|
|
3984
|
+
}
|
|
3985
|
+
const rest = plan.edits.length - shown;
|
|
3986
|
+
return lines.join("\n") + (rest > 0 ? moreLine(rest) : "");
|
|
3987
|
+
}
|
|
3988
|
+
function fencedNote(note) {
|
|
3989
|
+
const text = cut(note, MAX_NOTE_CHARS2);
|
|
3990
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun(text) + 1));
|
|
3991
|
+
return `${fence}text
|
|
3992
|
+
${text}
|
|
3993
|
+
${fence}`;
|
|
3994
|
+
}
|
|
3995
|
+
function closingLine(ctx) {
|
|
3996
|
+
if (ctx.issueNumber === void 0) return void 0;
|
|
3997
|
+
if (ctx.target.isDefault) return `Closes #${ctx.issueNumber}`;
|
|
3998
|
+
return `Refs #${ctx.issueNumber}. LiveLocale closes the issue when this pull request is merged or closed.`;
|
|
3999
|
+
}
|
|
4000
|
+
function renderPullRequestBody(ctx) {
|
|
4001
|
+
const head = ["Applies translations sent with a LiveLocale change link."];
|
|
4002
|
+
if (ctx.by?.trim()) head.push(`Translated by ${codeSpan(ctx.by)}`);
|
|
4003
|
+
if (ctx.target.reason === "not-allowed" && ctx.target.requested !== void 0) {
|
|
4004
|
+
head.push(
|
|
4005
|
+
`The link was made from ${codeSpan(ctx.target.requested)}, which is not in the allowed branches, so this targets ${codeSpan(ctx.target.branch)}.`
|
|
4006
|
+
);
|
|
4007
|
+
}
|
|
4008
|
+
if (ctx.note?.trim()) head.push(fencedNote(ctx.note));
|
|
4009
|
+
const tail = [];
|
|
4010
|
+
if (ctx.checkSummary) tail.push(cutLines(ctx.checkSummary, MAX_CHECK_SUMMARY_CHARS));
|
|
4011
|
+
if (ctx.runUrl) tail.push(`Run log: ${ctx.runUrl}`);
|
|
4012
|
+
const closing = closingLine(ctx);
|
|
4013
|
+
if (closing) tail.push(closing);
|
|
4014
|
+
const fixed = [...head, ...tail].join("\n\n").length;
|
|
4015
|
+
const table = renderTable(ctx.plan, DEFAULT_TABLE_ROWS, MAX_BODY_CHARS - BODY_MARGIN - fixed);
|
|
4016
|
+
return [...head, table, ...tail].join("\n\n");
|
|
4017
|
+
}
|
|
4018
|
+
function renderCommitMessage(input) {
|
|
4019
|
+
let subject = "i18n: apply LiveLocale changes";
|
|
4020
|
+
if (input.issueNumber !== void 0) subject += ` (#${input.issueNumber})`;
|
|
4021
|
+
else if (input.runId !== void 0) subject += ` (run ${input.runId})`;
|
|
4022
|
+
const applied = input.plan.edits.filter((edit) => edit.status === "apply");
|
|
4023
|
+
const locales = [...new Set(applied.map((edit) => edit.locale))].sort();
|
|
4024
|
+
const parts = [subject, `${applied.length} translation(s) in ${locales.join(", ")}.`];
|
|
4025
|
+
const name = sanitizeName(input.by);
|
|
4026
|
+
if (name) parts.push(`Translated-by: ${name}`);
|
|
4027
|
+
return parts.join("\n\n");
|
|
4028
|
+
}
|
|
4029
|
+
function renderTerminalSummary(plan) {
|
|
4030
|
+
const c = plan.counts;
|
|
4031
|
+
const lines = [
|
|
4032
|
+
`Applied ${c.apply} \xB7 already done ${c.already} \xB7 conflicts ${c.conflict} \xB7 needs --force ${c["needs-force"]} \xB7 rejected ${c.invalid}`
|
|
4033
|
+
];
|
|
4034
|
+
for (const edit of plan.edits) {
|
|
4035
|
+
lines.push(`${STATUS_LABELS[edit.status]} ${edit.locale} ${edit.key}`);
|
|
4036
|
+
}
|
|
4037
|
+
return lines.join("\n");
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
// src/codemod/apply/local.ts
|
|
4041
|
+
function isPlainObject4(value) {
|
|
4042
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4043
|
+
}
|
|
4044
|
+
async function readTarget(mutator, localesDir, locale) {
|
|
4045
|
+
if (!isValidLocaleFormat(locale)) return null;
|
|
4046
|
+
const dir = path13.join(localesDir, locale);
|
|
4047
|
+
let layout;
|
|
4048
|
+
if (fs9.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
4049
|
+
layout = "namespaced";
|
|
4050
|
+
} else if (fs9.statSync(`${dir}.json`, { throwIfNoEntry: false })?.isFile()) {
|
|
4051
|
+
layout = "flat";
|
|
4052
|
+
} else {
|
|
4053
|
+
return null;
|
|
4054
|
+
}
|
|
4055
|
+
const dict = await mutator.readLocale(localesDir, locale);
|
|
4056
|
+
if (!isPlainObject4(dict)) {
|
|
4057
|
+
throw new Error(`The "${locale}" locale file is not a JSON object.`);
|
|
4058
|
+
}
|
|
4059
|
+
return { layout, dict };
|
|
4060
|
+
}
|
|
4061
|
+
function fileFor(localesPath, layout, locale, key) {
|
|
4062
|
+
const rel = layout === "flat" ? path13.join(localesPath, `${locale}.json`) : path13.join(localesPath, locale, `${key.slice(0, key.indexOf("."))}.json`);
|
|
4063
|
+
return path13.normalize(rel).split(path13.sep).join("/");
|
|
4064
|
+
}
|
|
4065
|
+
var PR_TITLE = "i18n: LiveLocale changes";
|
|
4066
|
+
var GITHUB_REMOTE_RE = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/;
|
|
4067
|
+
var defaultGit = (args, cwd, opts) => {
|
|
4068
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
4069
|
+
if (opts?.inherit) {
|
|
4070
|
+
return new Promise((resolve14, reject2) => {
|
|
4071
|
+
const child = spawn2("git", args, { cwd, env, stdio: "inherit", windowsHide: true });
|
|
4072
|
+
child.on("error", reject2);
|
|
4073
|
+
child.on(
|
|
4074
|
+
"close",
|
|
4075
|
+
(code) => code === 0 ? resolve14("") : reject2(new Error(`git ${args[0]} failed (exit ${code}).`))
|
|
4076
|
+
);
|
|
4077
|
+
});
|
|
4078
|
+
}
|
|
4079
|
+
return new Promise((resolve14, reject2) => {
|
|
4080
|
+
execFile4(
|
|
4081
|
+
"git",
|
|
4082
|
+
args,
|
|
4083
|
+
{ cwd, env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 },
|
|
4084
|
+
(error, stdout) => error ? reject2(error) : resolve14(String(stdout))
|
|
4085
|
+
);
|
|
4086
|
+
});
|
|
4087
|
+
};
|
|
4088
|
+
var defaultGh = (args, cwd) => new Promise((resolve14, reject2) => {
|
|
4089
|
+
execFile4(
|
|
4090
|
+
"gh",
|
|
4091
|
+
args,
|
|
4092
|
+
{ cwd, windowsHide: true },
|
|
4093
|
+
(error, stdout, stderr) => error ? reject2(Object.assign(error, { stderr: String(stderr) })) : resolve14(String(stdout))
|
|
4094
|
+
);
|
|
4095
|
+
});
|
|
4096
|
+
function utcStamp(date) {
|
|
4097
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
4098
|
+
return `${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}-${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}`;
|
|
4099
|
+
}
|
|
4100
|
+
async function tryRun(run3) {
|
|
4101
|
+
try {
|
|
4102
|
+
return await run3();
|
|
4103
|
+
} catch {
|
|
4104
|
+
return null;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
async function pickBranchName(git, root, stamp) {
|
|
4108
|
+
const pushUrl = (await tryRun(() => git(["remote", "get-url", "--push", "origin"], root)))?.trim();
|
|
4109
|
+
const taken = async (name2) => {
|
|
4110
|
+
const local = await tryRun(
|
|
4111
|
+
() => git(["rev-parse", "--verify", "--quiet", `refs/heads/${name2}`], root)
|
|
4112
|
+
);
|
|
4113
|
+
if (local !== null) return true;
|
|
4114
|
+
if (!pushUrl) return false;
|
|
4115
|
+
const remote = await tryRun(
|
|
4116
|
+
() => git(["ls-remote", "--heads", pushUrl, `refs/heads/${name2}`], root)
|
|
4117
|
+
);
|
|
4118
|
+
return remote !== null && remote.trim() !== "";
|
|
4119
|
+
};
|
|
4120
|
+
const base = `livelocale/apply-${stamp}`;
|
|
4121
|
+
let name = base;
|
|
4122
|
+
for (let n = 2; await taken(name); n++) name = `${base}-${n}`;
|
|
4123
|
+
return name;
|
|
4124
|
+
}
|
|
4125
|
+
function errorMessage(err) {
|
|
4126
|
+
return err instanceof Error ? err.message : String(err);
|
|
4127
|
+
}
|
|
4128
|
+
async function writeEdits(mutator, plan, targets, localesDir, localesPath) {
|
|
4129
|
+
const written = [];
|
|
4130
|
+
for (const edit of plan.edits) {
|
|
4131
|
+
if (edit.status !== "apply") continue;
|
|
4132
|
+
await mutator.updateLocaleKey(localesDir, edit.locale, edit.key, edit.to);
|
|
4133
|
+
const file = fileFor(localesPath, targets.get(edit.locale).layout, edit.locale, edit.key);
|
|
4134
|
+
if (!written.includes(file)) written.push(file);
|
|
4135
|
+
}
|
|
4136
|
+
return written;
|
|
4137
|
+
}
|
|
4138
|
+
function exitCodeFor(plan) {
|
|
4139
|
+
const c = plan.counts;
|
|
4140
|
+
return c.conflict + c.invalid + c["needs-force"] > 0 ? 1 : 0;
|
|
4141
|
+
}
|
|
4142
|
+
async function preparePullRequest(git, projectDir, localesDir) {
|
|
4143
|
+
const top = (await tryRun(() => git(["rev-parse", "--show-toplevel"], projectDir)))?.trim();
|
|
4144
|
+
if (!top) throw new ApplyInputError("apply --pr needs a git repository.");
|
|
4145
|
+
const root = path13.resolve(top);
|
|
4146
|
+
const original = (await tryRun(() => git(["symbolic-ref", "--quiet", "--short", "HEAD"], root)))?.trim();
|
|
4147
|
+
if (!original) throw new ApplyInputError("Check out a branch first.");
|
|
4148
|
+
const status = await new GitClient().status(localesDir);
|
|
4149
|
+
if (status.changes.length > 0) {
|
|
4150
|
+
const rel = path13.relative(root, localesDir).split(path13.sep).join("/") || ".";
|
|
4151
|
+
throw new ApplyInputError(
|
|
4152
|
+
`Commit or stash the changes under ${rel} first; apply --pr commits everything in that folder.`
|
|
4153
|
+
);
|
|
4154
|
+
}
|
|
4155
|
+
return { root, original };
|
|
4156
|
+
}
|
|
4157
|
+
async function createPullRequest(log, gh, root, base, head, body) {
|
|
4158
|
+
const tmpDir = fs9.mkdtempSync(path13.join(os.tmpdir(), "livelocale-pr-"));
|
|
4159
|
+
const bodyFile = path13.join(tmpDir, "body.md");
|
|
4160
|
+
try {
|
|
4161
|
+
fs9.writeFileSync(bodyFile, body, "utf-8");
|
|
4162
|
+
const out = await gh(
|
|
4163
|
+
[
|
|
4164
|
+
"pr",
|
|
4165
|
+
"create",
|
|
4166
|
+
"--base",
|
|
4167
|
+
base,
|
|
4168
|
+
"--head",
|
|
4169
|
+
head,
|
|
4170
|
+
"--title",
|
|
4171
|
+
PR_TITLE,
|
|
4172
|
+
"--body-file",
|
|
4173
|
+
bodyFile
|
|
4174
|
+
],
|
|
4175
|
+
root
|
|
4176
|
+
);
|
|
4177
|
+
const lines = out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
4178
|
+
return lines[lines.length - 1];
|
|
4179
|
+
} catch (err) {
|
|
4180
|
+
if (err?.code !== "ENOENT") {
|
|
4181
|
+
const stderr = err?.stderr;
|
|
4182
|
+
const reason = typeof stderr === "string" && stderr.trim() ? stderr.trim() : errorMessage(err);
|
|
4183
|
+
log(`gh could not open the pull request: ${reason}`);
|
|
4184
|
+
}
|
|
4185
|
+
return void 0;
|
|
4186
|
+
} finally {
|
|
4187
|
+
fs9.rmSync(tmpDir, { recursive: true, force: true });
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
async function runLocalApply(options) {
|
|
4191
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
4192
|
+
const set = await decodeChangeLink(options.input, nodeInflate);
|
|
4193
|
+
const localesPath = options.localesPath ?? "locales";
|
|
4194
|
+
const localesDir = path13.resolve(options.projectDir, localesPath);
|
|
4195
|
+
if (!fs9.statSync(localesDir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
4196
|
+
throw new ApplyInputError(`No locales folder at ${localesPath}.`);
|
|
4197
|
+
}
|
|
4198
|
+
const pr = options.pr === true && options.dryRun !== true;
|
|
4199
|
+
const git = options.exec?.git ?? defaultGit;
|
|
4200
|
+
const repo = pr ? await preparePullRequest(git, options.projectDir, localesDir) : void 0;
|
|
4201
|
+
if (repo && set.branch !== "" && set.branch !== repo.original) {
|
|
4202
|
+
log(
|
|
4203
|
+
`The link was made from the branch \`${set.branch}\`; the pull request targets \`${repo.original}\`.`
|
|
4204
|
+
);
|
|
4205
|
+
}
|
|
4206
|
+
const mutator = new FileMutator({ requireEnabled: false, quiet: true });
|
|
4207
|
+
let sourceLocale = options.sourceLocale;
|
|
4208
|
+
if (sourceLocale === void 0) {
|
|
4209
|
+
const locales = await mutator.listLocales(localesDir);
|
|
4210
|
+
sourceLocale = locales.includes("en") ? "en" : locales[0] ?? "en";
|
|
4211
|
+
}
|
|
4212
|
+
if (set.src !== sourceLocale) {
|
|
4213
|
+
log(
|
|
4214
|
+
`The link says the source locale is \`${set.src}\`; using \`${sourceLocale}\` (pass --source to change).`
|
|
4215
|
+
);
|
|
4216
|
+
}
|
|
4217
|
+
const targets = /* @__PURE__ */ new Map();
|
|
4218
|
+
for (const locale of /* @__PURE__ */ new Set([sourceLocale, ...set.edits.map((e) => e.locale)])) {
|
|
4219
|
+
targets.set(locale, await readTarget(mutator, localesDir, locale));
|
|
4220
|
+
}
|
|
4221
|
+
const plan = planApply({
|
|
4222
|
+
edits: set.edits,
|
|
4223
|
+
sourceLocale,
|
|
4224
|
+
targets,
|
|
4225
|
+
allowForce: options.force === true
|
|
4226
|
+
});
|
|
4227
|
+
const exitCode = exitCodeFor(plan);
|
|
4228
|
+
if (!repo || plan.counts.apply === 0) {
|
|
4229
|
+
const written = repo || options.dryRun ? [] : await writeEdits(mutator, plan, targets, localesDir, localesPath);
|
|
4230
|
+
log(renderTerminalSummary(plan));
|
|
4231
|
+
return { plan, written, exitCode };
|
|
4232
|
+
}
|
|
4233
|
+
const { root, original } = repo;
|
|
4234
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
4235
|
+
const branch = await pickBranchName(git, root, utcStamp(now()));
|
|
4236
|
+
await git(["switch", "-c", branch], root);
|
|
4237
|
+
try {
|
|
4238
|
+
const written = await writeEdits(mutator, plan, targets, localesDir, localesPath);
|
|
4239
|
+
log(renderTerminalSummary(plan));
|
|
4240
|
+
let checkSummary;
|
|
4241
|
+
try {
|
|
4242
|
+
const report = await runCheck({
|
|
4243
|
+
projectDir: options.projectDir,
|
|
4244
|
+
localesPath: localesDir,
|
|
4245
|
+
sourceLocale,
|
|
4246
|
+
unused: false
|
|
4247
|
+
});
|
|
4248
|
+
const edited = plan.edits.filter((edit) => edit.status === "apply").map((edit) => [edit.locale, edit.key]);
|
|
4249
|
+
checkSummary = summarizeCheck(report, edited);
|
|
4250
|
+
} catch (err) {
|
|
4251
|
+
checkSummary = `Check could not run: ${errorMessage(err)}`;
|
|
4252
|
+
}
|
|
4253
|
+
await new GitClient().commit(localesDir, renderCommitMessage({ plan, by: set.by }));
|
|
4254
|
+
await git(["push", "-u", "origin", branch], root, { inherit: true });
|
|
4255
|
+
const remote = (await tryRun(() => git(["remote", "get-url", "origin"], root)))?.trim();
|
|
4256
|
+
const slug = remote ? GITHUB_REMOTE_RE.exec(remote)?.[1] : void 0;
|
|
4257
|
+
let prUrl;
|
|
4258
|
+
if (slug) {
|
|
4259
|
+
const body = renderPullRequestBody({
|
|
4260
|
+
plan,
|
|
4261
|
+
by: set.by,
|
|
4262
|
+
note: set.note,
|
|
4263
|
+
checkSummary,
|
|
4264
|
+
target: { branch: original, isDefault: false, reason: "local" }
|
|
4265
|
+
});
|
|
4266
|
+
prUrl = await createPullRequest(
|
|
4267
|
+
log,
|
|
4268
|
+
options.exec?.gh ?? defaultGh,
|
|
4269
|
+
root,
|
|
4270
|
+
original,
|
|
4271
|
+
branch,
|
|
4272
|
+
body
|
|
4273
|
+
);
|
|
4274
|
+
}
|
|
4275
|
+
if (prUrl) {
|
|
4276
|
+
log(`Opened ${prUrl}`);
|
|
4277
|
+
} else {
|
|
4278
|
+
log(`Pushed ${branch}. Open a pull/merge request from it into ${original}.`);
|
|
4279
|
+
if (slug) log(`https://github.com/${slug}/compare/${original}...${branch}?expand=1`);
|
|
4280
|
+
}
|
|
4281
|
+
await git(["switch", original], root);
|
|
4282
|
+
return { plan, written, branch, prUrl, exitCode };
|
|
4283
|
+
} catch (err) {
|
|
4284
|
+
log(`You are on ${branch}; your changes are committed there.`);
|
|
4285
|
+
throw err;
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
// src/codemod/apply/cli.ts
|
|
4290
|
+
var VALUE_OPTIONS2 = { "--dir": "dir", "--locales": "locales", "--source": "source" };
|
|
4291
|
+
var FLAG_OPTIONS = {
|
|
4292
|
+
"--clipboard": "clipboard",
|
|
4293
|
+
"--pr": "pr",
|
|
4294
|
+
"--force": "force",
|
|
4295
|
+
"--dry-run": "dryRun"
|
|
4296
|
+
};
|
|
4297
|
+
function parseApplyArgs(argv) {
|
|
4298
|
+
const args = {
|
|
4299
|
+
input: void 0,
|
|
4300
|
+
clipboard: false,
|
|
4301
|
+
pr: false,
|
|
4302
|
+
force: false,
|
|
4303
|
+
dryRun: false,
|
|
4304
|
+
dir: ".",
|
|
4305
|
+
locales: void 0,
|
|
4306
|
+
source: void 0
|
|
4307
|
+
};
|
|
4308
|
+
for (let i = 0; i < argv.length; i++) {
|
|
4309
|
+
const arg = argv[i];
|
|
4310
|
+
if (Object.prototype.hasOwnProperty.call(VALUE_OPTIONS2, arg)) {
|
|
4311
|
+
const value = argv[i + 1];
|
|
4312
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
4313
|
+
throw new ApplyInputError(`${arg} needs a value.`);
|
|
4314
|
+
}
|
|
4315
|
+
args[VALUE_OPTIONS2[arg]] = value;
|
|
4316
|
+
i++;
|
|
4317
|
+
} else if (Object.prototype.hasOwnProperty.call(FLAG_OPTIONS, arg)) {
|
|
4318
|
+
args[FLAG_OPTIONS[arg]] = true;
|
|
4319
|
+
} else if (arg.startsWith("-") && arg !== "-") {
|
|
4320
|
+
throw new ApplyInputError(`Unknown option "${arg}".`);
|
|
4321
|
+
} else if (args.input !== void 0) {
|
|
4322
|
+
throw new ApplyInputError('Pass one change link, file or "-".');
|
|
4323
|
+
} else {
|
|
4324
|
+
args.input = arg;
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
if (args.clipboard && args.input !== void 0) {
|
|
4328
|
+
throw new ApplyInputError("Pass the link or --clipboard, not both.");
|
|
4329
|
+
}
|
|
4330
|
+
return args;
|
|
4331
|
+
}
|
|
4332
|
+
async function runApplyCommand(argv) {
|
|
4333
|
+
try {
|
|
4334
|
+
const args = parseApplyArgs(argv);
|
|
4335
|
+
const input = await readApplyInput(args.input, {
|
|
4336
|
+
cwd: process.cwd(),
|
|
4337
|
+
clipboard: args.clipboard
|
|
4338
|
+
});
|
|
4339
|
+
const result = await runLocalApply({
|
|
4340
|
+
projectDir: path14.resolve(args.dir),
|
|
4341
|
+
localesPath: args.locales,
|
|
4342
|
+
sourceLocale: args.source,
|
|
4343
|
+
input,
|
|
4344
|
+
force: args.force,
|
|
4345
|
+
dryRun: args.dryRun,
|
|
4346
|
+
pr: args.pr
|
|
4347
|
+
});
|
|
4348
|
+
process.exitCode = result.exitCode;
|
|
4349
|
+
} catch (err) {
|
|
4350
|
+
console.error(`\u274C ${err instanceof Error ? err.message : String(err)}`);
|
|
4351
|
+
process.exitCode = 1;
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
// src/codemod/index.ts
|
|
4356
|
+
var DEFAULT_STUDIO_PORT = 3010;
|
|
4357
|
+
var DEFAULT_APP_ORIGIN = "http://localhost:3000";
|
|
4358
|
+
function printHelp() {
|
|
4359
|
+
console.log(`
|
|
4360
|
+
LiveLocale (livelocale) CLI
|
|
4361
|
+
|
|
4362
|
+
Usage:
|
|
4363
|
+
npx livelocale <command> [options]
|
|
4364
|
+
|
|
4365
|
+
Commands:
|
|
4366
|
+
migrate Bulk migrate existing react-i18next translation usages to wrap them with wrapTranslationEngine automatically.
|
|
4367
|
+
init Initialize the API route handler inside a Next.js project automatically.
|
|
4368
|
+
studio Start the visual editing studio local web server.
|
|
4369
|
+
doctor Check the project for common integration problems (route, provider, middleware, CSP, locales).
|
|
4370
|
+
check Check translations for missing keys, placeholder drift, broken ICU syntax and unused keys (CI-friendly).
|
|
4371
|
+
pack Write the share Studio (livelocale/) into a built site folder and link every HTML page to it.
|
|
4372
|
+
build Run your build command, then pack its output (Next.js: packs into public/livelocale/ first).
|
|
4373
|
+
apply Apply a LiveLocale change link to your locale files (--pr opens a pull request)
|
|
4374
|
+
|
|
4375
|
+
Options for migrate:
|
|
4376
|
+
--dir <path> Directory to scan (default: current directory)
|
|
4377
|
+
--exclude <list> Comma-separated list of directories to exclude (default: node_modules,.next,dist,.git)
|
|
4378
|
+
--dry-run Preview changes without writing them to disk
|
|
4379
|
+
|
|
4380
|
+
Options for init:
|
|
4381
|
+
--dir <path> Target project directory (default: current directory)
|
|
4382
|
+
|
|
4383
|
+
Options for studio:
|
|
4384
|
+
--port <number> Port to run the studio server on (default: ${DEFAULT_STUDIO_PORT})
|
|
4385
|
+
--app <origin> Origin of the app being edited (default: ${DEFAULT_APP_ORIGIN})
|
|
4386
|
+
Can also be changed at run time with ?app=<origin> on the Studio URL.
|
|
4387
|
+
|
|
4388
|
+
Options for doctor:
|
|
4389
|
+
--dir <path> Project directory to inspect (default: current directory)
|
|
4390
|
+
--locales <path> Locales directory relative to the project (default: locales)
|
|
4391
|
+
--app <url> Also probe the running app or deployed site, e.g. http://localhost:3000
|
|
4392
|
+
or https://user.github.io/repo/ (a path is the app base)
|
|
4393
|
+
--share Run the share link probes even when livelocale/manifest.json is missing
|
|
4394
|
+
--token <token> Access token for a preview deployment (default: LIVELOCALE_TOKEN)
|
|
4395
|
+
|
|
4396
|
+
Options for check:
|
|
4397
|
+
--dir <path> Project directory to scan (default: current directory)
|
|
4398
|
+
--locales <path> Locales directory relative to the project (default: locales)
|
|
4399
|
+
--source <locale> Locale the others are compared against (default: en, else the first locale)
|
|
4400
|
+
--no-unused Do not report keys that code never references
|
|
4401
|
+
--strict Treat warnings (unused keys, empty values) as failures
|
|
4402
|
+
--json Print the full report as JSON
|
|
4403
|
+
|
|
4404
|
+
Options for pack:
|
|
4405
|
+
pack <outDir> Built site folder, relative to --dir (e.g. dist, build, out)
|
|
4406
|
+
--dir <path> Project directory (default: current directory)
|
|
4407
|
+
--locales <path> Locales directory relative to the project (default: locales)
|
|
4408
|
+
--source <locale> Source locale (default: en, else the first locale)
|
|
4409
|
+
--base <path> Site path the app is served from, e.g. /repo/ (default: detected, else /)
|
|
4410
|
+
--expose-repo Put the GitHub owner/repo into livelocale/manifest.json
|
|
4411
|
+
--no-inject Do not add the flag.js script to HTML files
|
|
4412
|
+
|
|
4413
|
+
Options for build:
|
|
4414
|
+
build [--preview] [--out <dir>] [pack options] -- <command...>
|
|
4415
|
+
--preview Run the command with LIVELOCALE=preview and NEXT_PUBLIC_LIVELOCALE=preview
|
|
4416
|
+
--out <dir> Build output folder (default: detected from dist/, build/, out/)
|
|
4417
|
+
|
|
4418
|
+
Options for apply:
|
|
4419
|
+
apply <link|file|-> The change link, a file that holds it, or - to read stdin
|
|
4420
|
+
--clipboard Read the change link from the clipboard instead
|
|
4421
|
+
--pr Commit the changes on a new branch and open a pull request
|
|
4422
|
+
--dir <path> Project directory (default: current directory)
|
|
4423
|
+
--locales <path> Locales directory relative to the project (default: locales)
|
|
4424
|
+
--source <locale> Source locale (default: en, else the first locale)
|
|
4425
|
+
--force Apply edits the translator saved with "Save anyway"
|
|
4426
|
+
--dry-run Show what would change without writing files
|
|
4427
|
+
|
|
4428
|
+
Global Options:
|
|
4429
|
+
-h, --help Show help information
|
|
4430
|
+
`);
|
|
4431
|
+
}
|
|
4432
|
+
function normalizeOriginArg(value, flag) {
|
|
4433
|
+
try {
|
|
4434
|
+
const url = new URL(value);
|
|
4435
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("bad protocol");
|
|
1626
4436
|
return url.origin;
|
|
1627
4437
|
} catch {
|
|
1628
4438
|
console.error(
|
|
@@ -1631,12 +4441,22 @@ function normalizeOriginArg(value, flag) {
|
|
|
1631
4441
|
process.exit(1);
|
|
1632
4442
|
}
|
|
1633
4443
|
}
|
|
4444
|
+
function normalizeAppArg(value) {
|
|
4445
|
+
try {
|
|
4446
|
+
return normalizeAppUrl(value);
|
|
4447
|
+
} catch {
|
|
4448
|
+
console.error(
|
|
4449
|
+
`Error: --app must be an http(s) URL such as http://localhost:3000 or https://user.github.io/repo/ (got "${value}").`
|
|
4450
|
+
);
|
|
4451
|
+
process.exit(1);
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
1634
4454
|
function isPortAnswering(port) {
|
|
1635
|
-
return new Promise((
|
|
4455
|
+
return new Promise((resolve14) => {
|
|
1636
4456
|
const socket = net.connect({ host: "localhost", port });
|
|
1637
4457
|
const done = (answering) => {
|
|
1638
4458
|
socket.destroy();
|
|
1639
|
-
|
|
4459
|
+
resolve14(answering);
|
|
1640
4460
|
};
|
|
1641
4461
|
socket.setTimeout(1e3, () => done(false));
|
|
1642
4462
|
socket.once("connect", () => done(true));
|
|
@@ -1646,8 +4466,8 @@ function isPortAnswering(port) {
|
|
|
1646
4466
|
async function runStudio(port, appOrigin) {
|
|
1647
4467
|
console.log(`
|
|
1648
4468
|
\u{1F680} Starting LiveLocale Studio...`);
|
|
1649
|
-
const studioDir =
|
|
1650
|
-
if (!
|
|
4469
|
+
const studioDir = path15.resolve(moduleDir(), "../studio");
|
|
4470
|
+
if (!fs10.existsSync(path15.join(studioDir, "index.html"))) {
|
|
1651
4471
|
console.error(`Error: Studio assets not found at "${studioDir}".`);
|
|
1652
4472
|
console.error(`Did you build the project? Run "npm run build" first.`);
|
|
1653
4473
|
process.exit(1);
|
|
@@ -1679,48 +4499,48 @@ async function runStudio(port, appOrigin) {
|
|
|
1679
4499
|
function runInit(projectDir) {
|
|
1680
4500
|
console.log(`
|
|
1681
4501
|
\u{1F680} Initializing LiveLocale API handler...`);
|
|
1682
|
-
const absoluteDir =
|
|
1683
|
-
if (!
|
|
4502
|
+
const absoluteDir = path15.resolve(projectDir);
|
|
4503
|
+
if (!fs10.existsSync(absoluteDir)) {
|
|
1684
4504
|
console.error(`Error: Directory "${projectDir}" does not exist.`);
|
|
1685
4505
|
process.exit(1);
|
|
1686
4506
|
}
|
|
1687
|
-
const isTypeScript =
|
|
4507
|
+
const isTypeScript = fs10.existsSync(path15.join(absoluteDir, "tsconfig.json"));
|
|
1688
4508
|
const fileExt = isTypeScript ? "ts" : "js";
|
|
1689
4509
|
console.log(`\u{1F4C1} Target: ${absoluteDir}`);
|
|
1690
4510
|
console.log(`\u{1F527} Language detected: ${isTypeScript ? "TypeScript" : "JavaScript"}`);
|
|
1691
4511
|
let hasNextIntl = false;
|
|
1692
4512
|
let hasReactI18next = false;
|
|
1693
|
-
const targetPkgPath =
|
|
1694
|
-
if (
|
|
4513
|
+
const targetPkgPath = path15.join(absoluteDir, "package.json");
|
|
4514
|
+
if (fs10.existsSync(targetPkgPath)) {
|
|
1695
4515
|
try {
|
|
1696
|
-
const pkg = JSON.parse(
|
|
4516
|
+
const pkg = JSON.parse(fs10.readFileSync(targetPkgPath, "utf8"));
|
|
1697
4517
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1698
4518
|
hasNextIntl = Boolean(allDeps["next-intl"]);
|
|
1699
4519
|
hasReactI18next = Boolean(allDeps["react-i18next"]);
|
|
1700
4520
|
} catch {
|
|
1701
4521
|
}
|
|
1702
4522
|
}
|
|
1703
|
-
const appDirPath =
|
|
1704
|
-
const pagesDirPath =
|
|
1705
|
-
const srcAppDirPath =
|
|
1706
|
-
const srcPagesDirPath =
|
|
4523
|
+
const appDirPath = path15.join(absoluteDir, "app");
|
|
4524
|
+
const pagesDirPath = path15.join(absoluteDir, "pages");
|
|
4525
|
+
const srcAppDirPath = path15.join(absoluteDir, "src", "app");
|
|
4526
|
+
const srcPagesDirPath = path15.join(absoluteDir, "src", "pages");
|
|
1707
4527
|
let targetPath = "";
|
|
1708
4528
|
let routerType = "";
|
|
1709
4529
|
let content = "";
|
|
1710
|
-
if (
|
|
1711
|
-
targetPath =
|
|
4530
|
+
if (fs10.existsSync(appDirPath)) {
|
|
4531
|
+
targetPath = path15.join(appDirPath, "api", "livelocale", `route.${fileExt}`);
|
|
1712
4532
|
routerType = "Next.js App Router (root)";
|
|
1713
|
-
} else if (
|
|
1714
|
-
targetPath =
|
|
4533
|
+
} else if (fs10.existsSync(srcAppDirPath)) {
|
|
4534
|
+
targetPath = path15.join(srcAppDirPath, "api", "livelocale", `route.${fileExt}`);
|
|
1715
4535
|
routerType = "Next.js App Router (src/)";
|
|
1716
|
-
} else if (
|
|
1717
|
-
targetPath =
|
|
4536
|
+
} else if (fs10.existsSync(pagesDirPath)) {
|
|
4537
|
+
targetPath = path15.join(pagesDirPath, "api", `livelocale.${fileExt}`);
|
|
1718
4538
|
routerType = "Next.js Pages Router (root)";
|
|
1719
|
-
} else if (
|
|
1720
|
-
targetPath =
|
|
4539
|
+
} else if (fs10.existsSync(srcPagesDirPath)) {
|
|
4540
|
+
targetPath = path15.join(srcPagesDirPath, "api", `livelocale.${fileExt}`);
|
|
1721
4541
|
routerType = "Next.js Pages Router (src/)";
|
|
1722
4542
|
} else {
|
|
1723
|
-
targetPath =
|
|
4543
|
+
targetPath = path15.join(absoluteDir, "app", "api", "livelocale", `route.${fileExt}`);
|
|
1724
4544
|
routerType = "Default App Router (app/ not found)";
|
|
1725
4545
|
}
|
|
1726
4546
|
console.log(`\u{1F4CD} Project structure: ${routerType}`);
|
|
@@ -1748,14 +4568,14 @@ const handler = createLiveLocaleHandler({
|
|
|
1748
4568
|
export default handler;
|
|
1749
4569
|
`;
|
|
1750
4570
|
}
|
|
1751
|
-
const targetDir =
|
|
1752
|
-
if (!
|
|
1753
|
-
|
|
4571
|
+
const targetDir = path15.dirname(targetPath);
|
|
4572
|
+
if (!fs10.existsSync(targetDir)) {
|
|
4573
|
+
fs10.mkdirSync(targetDir, { recursive: true });
|
|
1754
4574
|
}
|
|
1755
4575
|
try {
|
|
1756
|
-
|
|
4576
|
+
fs10.writeFileSync(targetPath, content, "utf8");
|
|
1757
4577
|
console.log(`\u2705 Created API Route Handler successfully at:`);
|
|
1758
|
-
console.log(` ${
|
|
4578
|
+
console.log(` ${path15.relative(absoluteDir, targetPath)}`);
|
|
1759
4579
|
if (hasNextIntl) {
|
|
1760
4580
|
console.log(`
|
|
1761
4581
|
\u{1F4A1} Detected 'next-intl' in your project!`);
|
|
@@ -1797,8 +4617,8 @@ async function runMigrate(args) {
|
|
|
1797
4617
|
}
|
|
1798
4618
|
}
|
|
1799
4619
|
}
|
|
1800
|
-
const absoluteDir =
|
|
1801
|
-
if (!
|
|
4620
|
+
const absoluteDir = path15.resolve(dir);
|
|
4621
|
+
if (!fs10.existsSync(absoluteDir)) {
|
|
1802
4622
|
console.error(`Error: Directory "${dir}" does not exist.`);
|
|
1803
4623
|
process.exit(1);
|
|
1804
4624
|
}
|
|
@@ -1822,28 +4642,28 @@ async function runMigrate(args) {
|
|
|
1822
4642
|
let scannedCount = 0;
|
|
1823
4643
|
let migratedCount = 0;
|
|
1824
4644
|
async function walk(currentDir) {
|
|
1825
|
-
const files =
|
|
4645
|
+
const files = fs10.readdirSync(currentDir, { withFileTypes: true });
|
|
1826
4646
|
for (const file of files) {
|
|
1827
|
-
const fullPath =
|
|
4647
|
+
const fullPath = path15.join(currentDir, file.name);
|
|
1828
4648
|
if (file.isDirectory()) {
|
|
1829
4649
|
if (excludes.includes(file.name)) {
|
|
1830
4650
|
continue;
|
|
1831
4651
|
}
|
|
1832
4652
|
await walk(fullPath);
|
|
1833
4653
|
} else if (file.isFile()) {
|
|
1834
|
-
const ext =
|
|
4654
|
+
const ext = path15.extname(file.name);
|
|
1835
4655
|
if ([".js", ".jsx", ".ts", ".tsx"].includes(ext)) {
|
|
1836
4656
|
scannedCount++;
|
|
1837
4657
|
try {
|
|
1838
|
-
const content =
|
|
4658
|
+
const content = fs10.readFileSync(fullPath, "utf8");
|
|
1839
4659
|
const result = await transformReactI18next2(content, fullPath);
|
|
1840
4660
|
if (result.modified) {
|
|
1841
4661
|
migratedCount++;
|
|
1842
|
-
const relativePath =
|
|
4662
|
+
const relativePath = path15.relative(absoluteDir, fullPath);
|
|
1843
4663
|
if (dryRun) {
|
|
1844
4664
|
console.log(`[DRY RUN] Would migrate: ${relativePath}`);
|
|
1845
4665
|
} else {
|
|
1846
|
-
|
|
4666
|
+
fs10.writeFileSync(fullPath, result.code, "utf8");
|
|
1847
4667
|
console.log(`\u2705 Migrated: ${relativePath}`);
|
|
1848
4668
|
}
|
|
1849
4669
|
}
|
|
@@ -1873,7 +4693,8 @@ async function runMigrate(args) {
|
|
|
1873
4693
|
async function runDoctorCommand(args) {
|
|
1874
4694
|
let dir = ".";
|
|
1875
4695
|
let localesPath;
|
|
1876
|
-
let
|
|
4696
|
+
let app;
|
|
4697
|
+
let share = false;
|
|
1877
4698
|
let token;
|
|
1878
4699
|
for (let i = 1; i < args.length; i++) {
|
|
1879
4700
|
const arg = args[i];
|
|
@@ -1882,23 +4703,32 @@ async function runDoctorCommand(args) {
|
|
|
1882
4703
|
} else if (arg === "--locales") {
|
|
1883
4704
|
localesPath = args[++i];
|
|
1884
4705
|
} else if (arg === "--app") {
|
|
1885
|
-
|
|
4706
|
+
app = normalizeAppArg(args[++i] || "");
|
|
4707
|
+
} else if (arg === "--share") {
|
|
4708
|
+
share = true;
|
|
1886
4709
|
} else if (arg === "--token") {
|
|
1887
4710
|
token = args[++i];
|
|
1888
4711
|
} else if (arg && !arg.startsWith("--")) {
|
|
1889
4712
|
dir = arg;
|
|
1890
4713
|
}
|
|
1891
4714
|
}
|
|
1892
|
-
const projectDir =
|
|
1893
|
-
if (!
|
|
4715
|
+
const projectDir = path15.resolve(dir);
|
|
4716
|
+
if (!fs10.existsSync(projectDir)) {
|
|
1894
4717
|
console.error(`Error: Directory "${dir}" does not exist.`);
|
|
1895
4718
|
process.exit(1);
|
|
1896
4719
|
}
|
|
1897
4720
|
console.log(`
|
|
1898
4721
|
\u{1FA7A} LiveLocale doctor \u2014 ${projectDir}`);
|
|
1899
|
-
if (
|
|
4722
|
+
if (app) console.log(`\u{1F517} Probing app at ${app.base}`);
|
|
1900
4723
|
console.log("--------------------------------------------------");
|
|
1901
|
-
const findings = await runDoctor({
|
|
4724
|
+
const findings = await runDoctor({
|
|
4725
|
+
projectDir,
|
|
4726
|
+
localesPath,
|
|
4727
|
+
appOrigin: app?.origin,
|
|
4728
|
+
appBase: app?.base,
|
|
4729
|
+
share,
|
|
4730
|
+
token
|
|
4731
|
+
});
|
|
1902
4732
|
console.log(formatFindings(findings));
|
|
1903
4733
|
console.log("--------------------------------------------------");
|
|
1904
4734
|
const fails = findings.filter((f) => f.level === "fail").length;
|
|
@@ -1911,7 +4741,9 @@ async function runDoctorCommand(args) {
|
|
|
1911
4741
|
console.log(
|
|
1912
4742
|
warns > 0 ? `\u2705 No blocking problems, ${warns} warning(s) worth a look.` : `\u2705 Everything looks good.`
|
|
1913
4743
|
);
|
|
1914
|
-
|
|
4744
|
+
const shareLink = findings.find((f) => f.title === "Share link")?.detail;
|
|
4745
|
+
if (shareLink) console.log(`\u{1F517} Share link: ${shareLink}`);
|
|
4746
|
+
if (!app) {
|
|
1915
4747
|
console.log(
|
|
1916
4748
|
`\u{1F4A1} Start your dev server and re-run with --app http://localhost:3000 for live checks.`
|
|
1917
4749
|
);
|
|
@@ -1942,8 +4774,8 @@ async function runCheckCommand(args) {
|
|
|
1942
4774
|
dir = arg;
|
|
1943
4775
|
}
|
|
1944
4776
|
}
|
|
1945
|
-
const projectDir =
|
|
1946
|
-
if (!
|
|
4777
|
+
const projectDir = path15.resolve(dir);
|
|
4778
|
+
if (!fs10.existsSync(projectDir)) {
|
|
1947
4779
|
console.error(`Error: Directory "${dir}" does not exist.`);
|
|
1948
4780
|
process.exit(1);
|
|
1949
4781
|
}
|
|
@@ -1981,13 +4813,74 @@ async function runCheckCommand(args) {
|
|
|
1981
4813
|
warns > 0 ? `\u2705 No problems, ${warns} warning(s) worth a look.` : `\u2705 Translations look good.`
|
|
1982
4814
|
);
|
|
1983
4815
|
}
|
|
1984
|
-
|
|
4816
|
+
var PACK_USAGE = "Usage: livelocale pack <outDir> [options]";
|
|
4817
|
+
function printPackSummary(result, projectDir, warnings) {
|
|
4818
|
+
for (const warning of warnings) console.log(`\u26A0\uFE0F ${warning}`);
|
|
4819
|
+
const outRel = path15.relative(projectDir, path15.dirname(result.shareDir)).split(path15.sep).join("/") || ".";
|
|
4820
|
+
console.log(
|
|
4821
|
+
`\u2705 LiveLocale share folder written to ${outRel}/livelocale/ (${result.manifest.locales.length} locales, ${result.manifest.codeKeys.length} code keys, ${result.htmlInjected.length} HTML files linked).`
|
|
4822
|
+
);
|
|
4823
|
+
console.log(`\u{1F517} Open ${result.shareUrlPath} on the deployed site.`);
|
|
4824
|
+
}
|
|
4825
|
+
async function runPackCommand(args) {
|
|
4826
|
+
try {
|
|
4827
|
+
const options = parseShareArgs(args.slice(1));
|
|
4828
|
+
if (options.positional.length !== 1 || options.out !== void 0 || options.preview || options.command.length > 0) {
|
|
4829
|
+
throw new PackError("USAGE", PACK_USAGE);
|
|
4830
|
+
}
|
|
4831
|
+
const projectDir = path15.resolve(options.dir);
|
|
4832
|
+
const result = await packOutput({
|
|
4833
|
+
projectDir,
|
|
4834
|
+
outDir: options.positional[0],
|
|
4835
|
+
localesPath: options.locales,
|
|
4836
|
+
sourceLocale: options.source,
|
|
4837
|
+
base: options.base,
|
|
4838
|
+
exposeRepo: options.exposeRepo,
|
|
4839
|
+
injectHtml: options.inject ? "auto" : false
|
|
4840
|
+
});
|
|
4841
|
+
printPackSummary(result, projectDir, result.warnings);
|
|
4842
|
+
} catch (err) {
|
|
4843
|
+
if (!(err instanceof PackError)) throw err;
|
|
4844
|
+
console.error(`\u274C ${err.message}`);
|
|
4845
|
+
process.exitCode = 1;
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
async function runBuildCli(args) {
|
|
4849
|
+
try {
|
|
4850
|
+
const options = parseShareArgs(args.slice(1));
|
|
4851
|
+
if (options.positional.length > 0 || !options.inject) {
|
|
4852
|
+
throw new PackError("USAGE", BUILD_USAGE);
|
|
4853
|
+
}
|
|
4854
|
+
const projectDir = path15.resolve(options.dir);
|
|
4855
|
+
const result = await runBuildCommand({
|
|
4856
|
+
projectDir,
|
|
4857
|
+
command: options.command,
|
|
4858
|
+
preview: options.preview,
|
|
4859
|
+
outDir: options.out,
|
|
4860
|
+
localesPath: options.locales,
|
|
4861
|
+
sourceLocale: options.source,
|
|
4862
|
+
base: options.base,
|
|
4863
|
+
exposeRepo: options.exposeRepo
|
|
4864
|
+
});
|
|
4865
|
+
if (result.pack !== null) {
|
|
4866
|
+
printPackSummary(result.pack, projectDir, result.warnings);
|
|
4867
|
+
} else {
|
|
4868
|
+
for (const warning of result.warnings) console.log(`\u26A0\uFE0F ${warning}`);
|
|
4869
|
+
}
|
|
4870
|
+
process.exitCode = result.exitCode;
|
|
4871
|
+
} catch (err) {
|
|
4872
|
+
if (!(err instanceof PackError)) throw err;
|
|
4873
|
+
console.error(`\u274C ${err.message}`);
|
|
4874
|
+
process.exitCode = 1;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
async function run2() {
|
|
1985
4878
|
const args = process.argv.slice(2);
|
|
1986
4879
|
if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
|
|
1987
4880
|
printHelp();
|
|
1988
4881
|
process.exit(0);
|
|
1989
4882
|
}
|
|
1990
|
-
const COMMANDS = ["migrate", "init", "studio", "doctor", "check"];
|
|
4883
|
+
const COMMANDS = ["migrate", "init", "studio", "doctor", "check", "pack", "build", "apply"];
|
|
1991
4884
|
if (!args[0] || !COMMANDS.includes(args[0])) {
|
|
1992
4885
|
console.error(`Unknown command: "${args[0]}". Expected one of: ${COMMANDS.join(", ")}.`);
|
|
1993
4886
|
printHelp();
|
|
@@ -2001,6 +4894,18 @@ async function run() {
|
|
|
2001
4894
|
await runCheckCommand(args);
|
|
2002
4895
|
return;
|
|
2003
4896
|
}
|
|
4897
|
+
if (args[0] === "pack") {
|
|
4898
|
+
await runPackCommand(args);
|
|
4899
|
+
return;
|
|
4900
|
+
}
|
|
4901
|
+
if (args[0] === "build") {
|
|
4902
|
+
await runBuildCli(args);
|
|
4903
|
+
return;
|
|
4904
|
+
}
|
|
4905
|
+
if (args[0] === "apply") {
|
|
4906
|
+
await runApplyCommand(args.slice(1));
|
|
4907
|
+
return;
|
|
4908
|
+
}
|
|
2004
4909
|
if (args[0] === "init") {
|
|
2005
4910
|
let dir = ".";
|
|
2006
4911
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -2031,7 +4936,7 @@ async function run() {
|
|
|
2031
4936
|
}
|
|
2032
4937
|
await runMigrate(args);
|
|
2033
4938
|
}
|
|
2034
|
-
|
|
4939
|
+
run2().catch((err) => {
|
|
2035
4940
|
console.error(`\u274C ${err?.message || err}`);
|
|
2036
4941
|
process.exit(1);
|
|
2037
4942
|
});
|