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