glotfile 0.1.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/LICENSE +21 -0
- package/README.md +234 -0
- package/bin/glotfile.js +7 -0
- package/dist/server/cli.js +5172 -0
- package/dist/server/server.js +3936 -0
- package/dist/ui/assets/ad-B18i8NGa.svg +150 -0
- package/dist/ui/assets/ae-CZRtWSox.svg +6 -0
- package/dist/ui/assets/af-C77Rf6cE.svg +81 -0
- package/dist/ui/assets/ag-C8MykuG2.svg +14 -0
- package/dist/ui/assets/ai-Dmedkf4v.svg +29 -0
- package/dist/ui/assets/al-10QRkakw.svg +5 -0
- package/dist/ui/assets/am-DMt4_dd4.svg +5 -0
- package/dist/ui/assets/ao-tXuRa6vm.svg +13 -0
- package/dist/ui/assets/aq-CF5jO-0h.svg +5 -0
- package/dist/ui/assets/ar-Be8Ju1cG.svg +32 -0
- package/dist/ui/assets/arab-C4CYPgyC.svg +109 -0
- package/dist/ui/assets/as-Dekqy8Of.svg +72 -0
- package/dist/ui/assets/asean-WMtZ-US_.svg +13 -0
- package/dist/ui/assets/at-DGA_6m5E.svg +4 -0
- package/dist/ui/assets/au-DAHDIuPI.svg +8 -0
- package/dist/ui/assets/aw-W0PWLK5p.svg +186 -0
- package/dist/ui/assets/ax-DvLIy84U.svg +18 -0
- package/dist/ui/assets/az-Bk-bYNxy.svg +8 -0
- package/dist/ui/assets/ba-WdDiSMvP.svg +12 -0
- package/dist/ui/assets/bb-DJxbaxmT.svg +6 -0
- package/dist/ui/assets/bd-BF9t1-60.svg +4 -0
- package/dist/ui/assets/be-CLLkK3PN.svg +7 -0
- package/dist/ui/assets/bf-YclsoDuF.svg +7 -0
- package/dist/ui/assets/bg-GUQenraa.svg +5 -0
- package/dist/ui/assets/bh-BQqEGq6F.svg +4 -0
- package/dist/ui/assets/bi-CRmKY6RQ.svg +15 -0
- package/dist/ui/assets/bj-14PhO9bM.svg +14 -0
- package/dist/ui/assets/bl-4CI2YcwX.svg +5 -0
- package/dist/ui/assets/bm-BeYgB2z9.svg +97 -0
- package/dist/ui/assets/bn-B6T3O78g.svg +36 -0
- package/dist/ui/assets/bo-CcUiMqkJ.svg +673 -0
- package/dist/ui/assets/bq-BYpdxEeT.svg +5 -0
- package/dist/ui/assets/br-Cu5YU29T.svg +45 -0
- package/dist/ui/assets/bs-7Gd_oriM.svg +13 -0
- package/dist/ui/assets/bt-BTo4qm10.svg +89 -0
- package/dist/ui/assets/bv-wM9JLv4R.svg +13 -0
- package/dist/ui/assets/bw-n5ZaAnGL.svg +7 -0
- package/dist/ui/assets/by-C621sBpd.svg +18 -0
- package/dist/ui/assets/bz-BCKHR4_q.svg +145 -0
- package/dist/ui/assets/ca-PYUrLVUV.svg +4 -0
- package/dist/ui/assets/cc-BNT6Xjzk.svg +19 -0
- package/dist/ui/assets/cd-BGclsrP6.svg +5 -0
- package/dist/ui/assets/cefta-2dDBYygd.svg +13 -0
- package/dist/ui/assets/cf-DRetLmp-.svg +15 -0
- package/dist/ui/assets/cg-CwIyG6SE.svg +12 -0
- package/dist/ui/assets/ch-sfriZoF1.svg +9 -0
- package/dist/ui/assets/ci-C8Q8IYTn.svg +7 -0
- package/dist/ui/assets/ck-DfXMUOTo.svg +9 -0
- package/dist/ui/assets/cl-BgYYb4qP.svg +13 -0
- package/dist/ui/assets/cm-D4yjdmKT.svg +15 -0
- package/dist/ui/assets/cn-DifnnI3t.svg +11 -0
- package/dist/ui/assets/co-DV591zMm.svg +7 -0
- package/dist/ui/assets/cp-K_ay05Q_.svg +7 -0
- package/dist/ui/assets/cr-BlYVN-_Q.svg +7 -0
- package/dist/ui/assets/cu-L6XVZNgo.svg +13 -0
- package/dist/ui/assets/cv-CPsfcOfk.svg +13 -0
- package/dist/ui/assets/cw-BbrnximR.svg +14 -0
- package/dist/ui/assets/cx-DpYD6n6U.svg +15 -0
- package/dist/ui/assets/cy-bZuP8hmf.svg +6 -0
- package/dist/ui/assets/cz-WWBC5Aeb.svg +5 -0
- package/dist/ui/assets/de-B-2o-4Z9.svg +5 -0
- package/dist/ui/assets/dg-CJPJrjiZ.svg +130 -0
- package/dist/ui/assets/dj-hp_BwbmO.svg +13 -0
- package/dist/ui/assets/dk-DmS9BCZB.svg +5 -0
- package/dist/ui/assets/dm-Cbhezfe1.svg +152 -0
- package/dist/ui/assets/do-B86d445t.svg +121 -0
- package/dist/ui/assets/dz-Dytc1TFu.svg +5 -0
- package/dist/ui/assets/eac-CwGQsyAM.svg +48 -0
- package/dist/ui/assets/ec-CaVOFQ3t.svg +138 -0
- package/dist/ui/assets/ee-DufrxGIR.svg +5 -0
- package/dist/ui/assets/eg-YC70hswZ.svg +38 -0
- package/dist/ui/assets/eh-0awM4TVj.svg +16 -0
- package/dist/ui/assets/er-X83uml6t.svg +8 -0
- package/dist/ui/assets/es-ct-CVyhLp7O.svg +4 -0
- package/dist/ui/assets/es-d5m8M5h8.svg +544 -0
- package/dist/ui/assets/es-ga-D9xG2hYr.svg +187 -0
- package/dist/ui/assets/es-pv-CO3NM2SE.svg +5 -0
- package/dist/ui/assets/et-DwdlzOIx.svg +14 -0
- package/dist/ui/assets/eu-Brdgz8ab.svg +28 -0
- package/dist/ui/assets/fi-DWUIkfjL.svg +5 -0
- package/dist/ui/assets/fj-DEAVMg38.svg +120 -0
- package/dist/ui/assets/fk-nuUF_Ak3.svg +90 -0
- package/dist/ui/assets/fm-B4Z83GL0.svg +11 -0
- package/dist/ui/assets/fo-DFjwyWur.svg +12 -0
- package/dist/ui/assets/fr-DVvUyOqI.svg +5 -0
- package/dist/ui/assets/ga-BklUhLH_.svg +7 -0
- package/dist/ui/assets/gb-DTXiLQoe.svg +7 -0
- package/dist/ui/assets/gb-eng-C8iDhGHN.svg +5 -0
- package/dist/ui/assets/gb-nir-D4gikpNq.svg +132 -0
- package/dist/ui/assets/gb-sct-fW5q01ek.svg +4 -0
- package/dist/ui/assets/gb-wls-Bxz9hxvX.svg +9 -0
- package/dist/ui/assets/gd-CO-whzUe.svg +27 -0
- package/dist/ui/assets/ge-B2RiL-Ih.svg +6 -0
- package/dist/ui/assets/gf-DWl5zcw0.svg +5 -0
- package/dist/ui/assets/gg-jdOQS2nU.svg +9 -0
- package/dist/ui/assets/gh-r3LP_X7q.svg +6 -0
- package/dist/ui/assets/gi-BJKE9SzW.svg +32 -0
- package/dist/ui/assets/gl-CHaBnMib.svg +4 -0
- package/dist/ui/assets/gm-Bu99atwn.svg +14 -0
- package/dist/ui/assets/gn-1dJNy9oQ.svg +7 -0
- package/dist/ui/assets/gp-DNyt_wTA.svg +5 -0
- package/dist/ui/assets/gq-Cag8QTk2.svg +23 -0
- package/dist/ui/assets/gr-C5PU0p9p.svg +16 -0
- package/dist/ui/assets/gs-DiiNa0F5.svg +133 -0
- package/dist/ui/assets/gt-CJo5DI-7.svg +204 -0
- package/dist/ui/assets/gu-Di1JYREk.svg +19 -0
- package/dist/ui/assets/gw-D249VY33.svg +13 -0
- package/dist/ui/assets/gy-CcVYUM2E.svg +9 -0
- package/dist/ui/assets/hk-CUNIaSX0.svg +8 -0
- package/dist/ui/assets/hm-Dh6t_Tj3.svg +8 -0
- package/dist/ui/assets/hn-CRjyS_bm.svg +18 -0
- package/dist/ui/assets/hr-fzLfaANM.svg +58 -0
- package/dist/ui/assets/ht-DIMg4gti.svg +116 -0
- package/dist/ui/assets/hu-7Q5wwIIi.svg +7 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/dist/ui/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- package/dist/ui/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- package/dist/ui/assets/ic-CSo4d8tH.svg +7 -0
- package/dist/ui/assets/id-DiSP6Fmm.svg +4 -0
- package/dist/ui/assets/ie-ChAXClx3.svg +7 -0
- package/dist/ui/assets/il-_56OEGLa.svg +14 -0
- package/dist/ui/assets/im--VPIqfkF.svg +36 -0
- package/dist/ui/assets/in-Cdwu6Bq7.svg +25 -0
- package/dist/ui/assets/index-BqcYDTXL.css +1 -0
- package/dist/ui/assets/index-DK-AGskd.js +1805 -0
- package/dist/ui/assets/io-13HOfeJD.svg +130 -0
- package/dist/ui/assets/iq-Dp8HDzo2.svg +10 -0
- package/dist/ui/assets/ir-cCIgaNf6.svg +219 -0
- package/dist/ui/assets/is-CZjefTNV.svg +12 -0
- package/dist/ui/assets/it-Br7q0Zh6.svg +7 -0
- package/dist/ui/assets/je-DyWbhIiC.svg +62 -0
- package/dist/ui/assets/jm-CItSr3iX.svg +8 -0
- package/dist/ui/assets/jo-BAF1FGbm.svg +16 -0
- package/dist/ui/assets/jp-BIMmfxpO.svg +11 -0
- package/dist/ui/assets/ke-C8foqndp.svg +23 -0
- package/dist/ui/assets/kg-B0FsxZiL.svg +4 -0
- package/dist/ui/assets/kh-BeWfuE30.svg +61 -0
- package/dist/ui/assets/ki-p_fAQGbS.svg +36 -0
- package/dist/ui/assets/km-B5tqtGG7.svg +16 -0
- package/dist/ui/assets/kn-DVPxDkNY.svg +14 -0
- package/dist/ui/assets/kp-CrDKzoxe.svg +15 -0
- package/dist/ui/assets/kr-BCXH1Hao.svg +24 -0
- package/dist/ui/assets/kw-fSBzmd30.svg +13 -0
- package/dist/ui/assets/ky-Dpsu1myA.svg +103 -0
- package/dist/ui/assets/kz-CwKXYZ8s.svg +36 -0
- package/dist/ui/assets/la-CuJflhIW.svg +12 -0
- package/dist/ui/assets/lb-BSjpYEoo.svg +15 -0
- package/dist/ui/assets/lc-CNvab8Ae.svg +8 -0
- package/dist/ui/assets/li-CHdhvNcr.svg +43 -0
- package/dist/ui/assets/lk-DUkgV9Tq.svg +22 -0
- package/dist/ui/assets/lr-B84vu3Ds.svg +14 -0
- package/dist/ui/assets/ls-5Xk3Mxq5.svg +8 -0
- package/dist/ui/assets/lt-DoukV-Sm.svg +7 -0
- package/dist/ui/assets/lu-DOI02Msy.svg +5 -0
- package/dist/ui/assets/lv-C-KfY8Yc.svg +6 -0
- package/dist/ui/assets/ly-BWpTK3ux.svg +13 -0
- package/dist/ui/assets/ma-BTRNTRUj.svg +4 -0
- package/dist/ui/assets/mc-PK078JHl.svg +6 -0
- package/dist/ui/assets/md-DRlxvNwm.svg +70 -0
- package/dist/ui/assets/me-Cv4Gwqah.svg +116 -0
- package/dist/ui/assets/mf-BaAGWwAq.svg +5 -0
- package/dist/ui/assets/mg-C168LHXW.svg +7 -0
- package/dist/ui/assets/mh-gxuIp6Wk.svg +7 -0
- package/dist/ui/assets/mk-D9SIMr-a.svg +5 -0
- package/dist/ui/assets/ml-DVf6ujpi.svg +7 -0
- package/dist/ui/assets/mm-lwT09MQ0.svg +12 -0
- package/dist/ui/assets/mn-CgXyw0O9.svg +14 -0
- package/dist/ui/assets/mo-BAtCjuYA.svg +9 -0
- package/dist/ui/assets/mp-CrOApEqW.svg +86 -0
- package/dist/ui/assets/mq-BFnJ22KI.svg +5 -0
- package/dist/ui/assets/mr-D6G1wpeZ.svg +6 -0
- package/dist/ui/assets/ms-DxciGbUu.svg +29 -0
- package/dist/ui/assets/mt-YqzKx9xl.svg +58 -0
- package/dist/ui/assets/mu-mcq7cUFl.svg +8 -0
- package/dist/ui/assets/mv-BynAllfM.svg +6 -0
- package/dist/ui/assets/mw-C15nc1xZ.svg +10 -0
- package/dist/ui/assets/mx-Cc8Ccfe8.svg +382 -0
- package/dist/ui/assets/my-Co4JeeyE.svg +26 -0
- package/dist/ui/assets/mz-Drlr_USV.svg +21 -0
- package/dist/ui/assets/na-D79ffb4Z.svg +16 -0
- package/dist/ui/assets/nc-5j7wEmDR.svg +13 -0
- package/dist/ui/assets/ne-B1jPOYkl.svg +6 -0
- package/dist/ui/assets/nf-Dl00mlk2.svg +9 -0
- package/dist/ui/assets/ng-su4NM9If.svg +6 -0
- package/dist/ui/assets/ni-CcFCSQxm.svg +129 -0
- package/dist/ui/assets/nl-BnOa6UiA.svg +5 -0
- package/dist/ui/assets/no-qf2JPO73.svg +7 -0
- package/dist/ui/assets/np-CIuq5GKd.svg +13 -0
- package/dist/ui/assets/nr-DERIdzkN.svg +12 -0
- package/dist/ui/assets/nu-BfgWvGcd.svg +10 -0
- package/dist/ui/assets/nz-5vODdBjz.svg +36 -0
- package/dist/ui/assets/om-DcqxRdQL.svg +115 -0
- package/dist/ui/assets/pa-BLNN9G2-.svg +14 -0
- package/dist/ui/assets/pc-BJpYiA9b.svg +33 -0
- package/dist/ui/assets/pe-BLqhuu1C.svg +4 -0
- package/dist/ui/assets/pf-C8ahG68q.svg +19 -0
- package/dist/ui/assets/pg-BAYpbp9Z.svg +9 -0
- package/dist/ui/assets/ph-BEzTn62K.svg +6 -0
- package/dist/ui/assets/pk-CWMEc3ad.svg +15 -0
- package/dist/ui/assets/pl-o38JROoc.svg +6 -0
- package/dist/ui/assets/pm-CHiP5UmZ.svg +5 -0
- package/dist/ui/assets/pn-DgxdtieE.svg +53 -0
- package/dist/ui/assets/pr-B4tMV0xm.svg +13 -0
- package/dist/ui/assets/ps-DO8YKYeS.svg +6 -0
- package/dist/ui/assets/pt-DZ2ADgIR.svg +57 -0
- package/dist/ui/assets/pw-CQP52WMX.svg +11 -0
- package/dist/ui/assets/py-mNzh0mZC.svg +157 -0
- package/dist/ui/assets/qa-Dkmpc78M.svg +4 -0
- package/dist/ui/assets/re-Bk4ipYK1.svg +5 -0
- package/dist/ui/assets/ro-CoSeqKY1.svg +7 -0
- package/dist/ui/assets/rs-BfwKwXtn.svg +292 -0
- package/dist/ui/assets/ru-D-4tNwXt.svg +5 -0
- package/dist/ui/assets/rw-D7nbSYKI.svg +13 -0
- package/dist/ui/assets/sa-Dh79zbT9.svg +25 -0
- package/dist/ui/assets/sb-BDTYjcbk.svg +13 -0
- package/dist/ui/assets/sc-CRNsSLg9.svg +7 -0
- package/dist/ui/assets/sd-ClziNjGr.svg +13 -0
- package/dist/ui/assets/se-8C923vhy.svg +4 -0
- package/dist/ui/assets/sg-DGYIMG0G.svg +13 -0
- package/dist/ui/assets/sh-DNImvbrE.svg +7 -0
- package/dist/ui/assets/sh-ac-FjwY7RYr.svg +689 -0
- package/dist/ui/assets/sh-hl-CqtQPzWZ.svg +164 -0
- package/dist/ui/assets/sh-ta-CPJublpi.svg +76 -0
- package/dist/ui/assets/si-BMKT-Tec.svg +18 -0
- package/dist/ui/assets/sj-BFFEGknm.svg +7 -0
- package/dist/ui/assets/sk-K9BNIYAO.svg +9 -0
- package/dist/ui/assets/sl-DxLJY5vJ.svg +7 -0
- package/dist/ui/assets/sm-DGBIRFB_.svg +75 -0
- package/dist/ui/assets/sn-S8ipNF1U.svg +8 -0
- package/dist/ui/assets/so-DlzA2Fco.svg +11 -0
- package/dist/ui/assets/sr-Co7OKBh3.svg +6 -0
- package/dist/ui/assets/ss-CxVEpdPD.svg +8 -0
- package/dist/ui/assets/st-C1Nd1c3V.svg +16 -0
- package/dist/ui/assets/sv-CJIHhYwF.svg +593 -0
- package/dist/ui/assets/sx-nDhIaDNb.svg +56 -0
- package/dist/ui/assets/sy-DwSud114.svg +6 -0
- package/dist/ui/assets/sz-qxMwa2gs.svg +34 -0
- package/dist/ui/assets/tc-dtelpZmc.svg +50 -0
- package/dist/ui/assets/td-BsuVhZpT.svg +7 -0
- package/dist/ui/assets/tf-Co33RhQH.svg +15 -0
- package/dist/ui/assets/tg-CP1-sc35.svg +14 -0
- package/dist/ui/assets/th-tzq84hgd.svg +7 -0
- package/dist/ui/assets/tj-b-aWfOTb.svg +22 -0
- package/dist/ui/assets/tk-CDucsEss.svg +5 -0
- package/dist/ui/assets/tl-wpo93AGk.svg +13 -0
- package/dist/ui/assets/tm-C_WSgUcv.svg +204 -0
- package/dist/ui/assets/tn-BcKCZULf.svg +4 -0
- package/dist/ui/assets/to-D8uVsoxb.svg +10 -0
- package/dist/ui/assets/tr-Cd6FO9Bg.svg +8 -0
- package/dist/ui/assets/tt-CTnr7aY5.svg +5 -0
- package/dist/ui/assets/tv-DDqkVT-n.svg +9 -0
- package/dist/ui/assets/tw-d-Mf-0VT.svg +34 -0
- package/dist/ui/assets/tz-BjLtHeil.svg +13 -0
- package/dist/ui/assets/ua-Bq0XgQqK.svg +6 -0
- package/dist/ui/assets/ug-ByL2ejGl.svg +30 -0
- package/dist/ui/assets/um-Bhke9Eic.svg +9 -0
- package/dist/ui/assets/un-Bqg4Cbbh.svg +16 -0
- package/dist/ui/assets/us-C73uVeEr.svg +9 -0
- package/dist/ui/assets/uy-DD6peej-.svg +28 -0
- package/dist/ui/assets/uz-C2f-Cubn.svg +30 -0
- package/dist/ui/assets/va-B9-hqIE-.svg +190 -0
- package/dist/ui/assets/vc-COpmFovN.svg +8 -0
- package/dist/ui/assets/ve-BSqnIB9l.svg +26 -0
- package/dist/ui/assets/vg-C7xY6pic.svg +59 -0
- package/dist/ui/assets/vi-BC_zcciE.svg +28 -0
- package/dist/ui/assets/vn-BEAEijd0.svg +11 -0
- package/dist/ui/assets/vu-D6k0NQlg.svg +21 -0
- package/dist/ui/assets/wf-DrxpOO_G.svg +5 -0
- package/dist/ui/assets/ws-vzJNwdVm.svg +7 -0
- package/dist/ui/assets/xk-Bj15g7cp.svg +5 -0
- package/dist/ui/assets/xx-zm_JmrXl.svg +4 -0
- package/dist/ui/assets/ye-BwST9gXC.svg +7 -0
- package/dist/ui/assets/yt-DIfEG0ex.svg +5 -0
- package/dist/ui/assets/za-Jz40JTrv.svg +17 -0
- package/dist/ui/assets/zm-BmsW91ne.svg +27 -0
- package/dist/ui/assets/zw-U0m7oJ5e.svg +21 -0
- package/dist/ui/index.html +13 -0
- package/package.json +68 -0
|
@@ -0,0 +1,3936 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// node_modules/dictionary-en/index.js
|
|
12
|
+
var dictionary_en_exports = {};
|
|
13
|
+
__export(dictionary_en_exports, {
|
|
14
|
+
default: () => dictionary_en_default
|
|
15
|
+
});
|
|
16
|
+
import fs from "fs/promises";
|
|
17
|
+
var aff, dic, dictionary, dictionary_en_default;
|
|
18
|
+
var init_dictionary_en = __esm({
|
|
19
|
+
async "node_modules/dictionary-en/index.js"() {
|
|
20
|
+
"use strict";
|
|
21
|
+
aff = await fs.readFile(new URL("index.aff", import.meta.url));
|
|
22
|
+
dic = await fs.readFile(new URL("index.dic", import.meta.url));
|
|
23
|
+
dictionary = { aff, dic };
|
|
24
|
+
dictionary_en_default = dictionary;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// src/server/server.ts
|
|
29
|
+
import { Hono as Hono2 } from "hono";
|
|
30
|
+
import { serve } from "@hono/node-server";
|
|
31
|
+
import { fileURLToPath } from "url";
|
|
32
|
+
import { dirname as dirname6, join as join7, resolve as resolve7, extname as extname3, sep as sep2 } from "path";
|
|
33
|
+
import { readFile, stat } from "fs/promises";
|
|
34
|
+
import open from "open";
|
|
35
|
+
|
|
36
|
+
// src/server/api.ts
|
|
37
|
+
import { Hono } from "hono";
|
|
38
|
+
import { streamSSE } from "hono/streaming";
|
|
39
|
+
|
|
40
|
+
// src/server/state.ts
|
|
41
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
|
|
42
|
+
import { dirname as dirname2 } from "path";
|
|
43
|
+
import { randomUUID } from "crypto";
|
|
44
|
+
|
|
45
|
+
// src/server/format.ts
|
|
46
|
+
function sortDeep(value) {
|
|
47
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const k of Object.keys(value).sort()) {
|
|
51
|
+
out[k] = sortDeep(value[k]);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function serializeJson(value, opts) {
|
|
58
|
+
const prepared = opts.sortKeys ? sortDeep(value) : value;
|
|
59
|
+
const body = JSON.stringify(prepared, null, opts.indent);
|
|
60
|
+
return opts.finalNewline ? body + "\n" : body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/server/lint/registry.ts
|
|
64
|
+
var RULE_IDS = [
|
|
65
|
+
"empty-source",
|
|
66
|
+
"empty-translation",
|
|
67
|
+
"placeholder-mismatch",
|
|
68
|
+
"icu-mismatch",
|
|
69
|
+
"glossary-violation",
|
|
70
|
+
"max-length",
|
|
71
|
+
"identical-to-source",
|
|
72
|
+
"whitespace",
|
|
73
|
+
"spelling"
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// src/server/schema.ts
|
|
77
|
+
var CURRENT_VERSION = 1;
|
|
78
|
+
var STATES = ["source", "machine", "reviewed", "needs-review"];
|
|
79
|
+
var PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
|
|
80
|
+
var EXACT_SELECTOR_RE = /^=\d+$/;
|
|
81
|
+
function isPluralForm(key) {
|
|
82
|
+
return PLURAL_CATEGORIES.includes(key) || EXACT_SELECTOR_RE.test(key);
|
|
83
|
+
}
|
|
84
|
+
var LOCALE_CASES = ["lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"];
|
|
85
|
+
var PROVIDERS = ["anthropic", "openai", "bedrock"];
|
|
86
|
+
var GlotfileError = class extends Error {
|
|
87
|
+
};
|
|
88
|
+
function isObject(v) {
|
|
89
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
90
|
+
}
|
|
91
|
+
function fail(msg) {
|
|
92
|
+
throw new GlotfileError(msg);
|
|
93
|
+
}
|
|
94
|
+
function validate(raw) {
|
|
95
|
+
if (!isObject(raw)) fail("glotfile.json must be a JSON object");
|
|
96
|
+
if (typeof raw.version !== "number") fail("version must be a number");
|
|
97
|
+
const config = raw.config;
|
|
98
|
+
if (!isObject(config)) fail("config must be an object");
|
|
99
|
+
if (typeof config.sourceLocale !== "string") fail("config.sourceLocale must be a string");
|
|
100
|
+
if (!Array.isArray(config.locales) || !config.locales.every((l) => typeof l === "string")) {
|
|
101
|
+
fail("config.locales must be an array of strings");
|
|
102
|
+
}
|
|
103
|
+
const locales = config.locales;
|
|
104
|
+
if (!locales.includes(config.sourceLocale)) {
|
|
105
|
+
fail(`config.sourceLocale "${config.sourceLocale}" is not in config.locales`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(config.outputs)) fail("config.outputs must be an array");
|
|
108
|
+
for (const o of config.outputs) {
|
|
109
|
+
if (!isObject(o) || typeof o.adapter !== "string" || typeof o.path !== "string") {
|
|
110
|
+
fail("each config.outputs entry needs string 'adapter' and 'path'");
|
|
111
|
+
}
|
|
112
|
+
if (o.style !== void 0 && typeof o.style !== "string") fail("config.outputs[].style must be a string");
|
|
113
|
+
if (o.emptyAs !== void 0 && !["source", "empty", "omit"].includes(o.emptyAs)) {
|
|
114
|
+
fail('config.outputs[].emptyAs must be "source", "empty", or "omit"');
|
|
115
|
+
}
|
|
116
|
+
if (o.indent !== void 0 && typeof o.indent !== "number") fail("config.outputs[].indent must be a number");
|
|
117
|
+
if (o.finalNewline !== void 0 && typeof o.finalNewline !== "boolean") fail("config.outputs[].finalNewline must be a boolean");
|
|
118
|
+
if (o.includeLocale !== void 0 && typeof o.includeLocale !== "boolean") fail("config.outputs[].includeLocale must be a boolean");
|
|
119
|
+
if (o.localeAliases !== void 0) {
|
|
120
|
+
if (!isObject(o.localeAliases)) fail("config.outputs[].localeAliases must be an object");
|
|
121
|
+
for (const [k, v] of Object.entries(o.localeAliases)) {
|
|
122
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
|
|
123
|
+
fail(`config.outputs[].localeAliases["${k}"] must be an array of strings`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (o.localeCase !== void 0 && !LOCALE_CASES.includes(o.localeCase)) {
|
|
128
|
+
fail('config.outputs[].localeCase must be one of "lower-hyphen", "lower-underscore", "bcp47-hyphen", "bcp47-underscore"');
|
|
129
|
+
}
|
|
130
|
+
if (o.localeMap !== void 0) {
|
|
131
|
+
if (!isObject(o.localeMap)) fail("config.outputs[].localeMap must be an object");
|
|
132
|
+
const canon = (s) => s.trim().toLowerCase().replace(/_/g, "-");
|
|
133
|
+
const localeSet = new Set(locales.map(canon));
|
|
134
|
+
for (const [k, v] of Object.entries(o.localeMap)) {
|
|
135
|
+
if (typeof v !== "string") fail(`config.outputs[].localeMap["${k}"] must be a string`);
|
|
136
|
+
if (!localeSet.has(canon(k))) fail(`config.outputs[].localeMap key "${k}" is not in config.locales`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!isObject(config.ai)) fail("config.ai must be an object");
|
|
141
|
+
const ai = config.ai;
|
|
142
|
+
if (typeof ai.provider !== "string" || !PROVIDERS.includes(ai.provider)) {
|
|
143
|
+
fail(`config.ai.provider must be one of: ${PROVIDERS.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof ai.model !== "string") fail("config.ai.model must be a string");
|
|
146
|
+
if (!(ai.endpoint === null || typeof ai.endpoint === "string")) fail("config.ai.endpoint must be a string or null");
|
|
147
|
+
if (!(ai.region === void 0 || ai.region === null || typeof ai.region === "string")) {
|
|
148
|
+
fail("config.ai.region must be a string or null");
|
|
149
|
+
}
|
|
150
|
+
if (typeof ai.batchSize !== "number") fail("config.ai.batchSize must be a number");
|
|
151
|
+
if (!isObject(config.format)) fail("config.format must be an object");
|
|
152
|
+
const fmt = config.format;
|
|
153
|
+
if (typeof fmt.indent !== "number") fail("config.format.indent must be a number");
|
|
154
|
+
if (typeof fmt.sortKeys !== "boolean") fail("config.format.sortKeys must be a boolean");
|
|
155
|
+
if (typeof fmt.finalNewline !== "boolean") fail("config.format.finalNewline must be a boolean");
|
|
156
|
+
if (config.autoExport !== void 0 && typeof config.autoExport !== "boolean") fail("config.autoExport must be a boolean");
|
|
157
|
+
if (config.exportLocales !== void 0 && (!Array.isArray(config.exportLocales) || !config.exportLocales.every((l) => typeof l === "string"))) {
|
|
158
|
+
fail("config.exportLocales must be an array of strings");
|
|
159
|
+
}
|
|
160
|
+
if (config.storage !== void 0 && config.storage !== "single" && config.storage !== "split") {
|
|
161
|
+
fail('config.storage must be "single" or "split"');
|
|
162
|
+
}
|
|
163
|
+
if (config.spelling !== void 0) {
|
|
164
|
+
const sp = config.spelling;
|
|
165
|
+
if (!isObject(sp) || !Array.isArray(sp.customWords) || !sp.customWords.every((w) => typeof w === "string")) {
|
|
166
|
+
fail("config.spelling.customWords must be an array of strings");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (config.lint !== void 0) {
|
|
170
|
+
const lint = config.lint;
|
|
171
|
+
if (!isObject(lint)) fail("config.lint must be an object");
|
|
172
|
+
if (lint.rules !== void 0) {
|
|
173
|
+
if (!isObject(lint.rules)) fail("config.lint.rules must be an object");
|
|
174
|
+
for (const [id, sev] of Object.entries(lint.rules)) {
|
|
175
|
+
if (!RULE_IDS.includes(id)) fail(`config.lint.rules has unknown rule id "${id}"`);
|
|
176
|
+
if (sev !== "error" && sev !== "warn" && sev !== "off") {
|
|
177
|
+
fail(`config.lint.rules.${id} must be "error", "warn", or "off"`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (lint.ignore !== void 0 && (!Array.isArray(lint.ignore) || !lint.ignore.every((g) => typeof g === "string"))) {
|
|
182
|
+
fail("config.lint.ignore must be an array of strings");
|
|
183
|
+
}
|
|
184
|
+
if (lint.dictionary !== void 0 && (!Array.isArray(lint.dictionary) || !lint.dictionary.every((w) => typeof w === "string"))) {
|
|
185
|
+
fail("config.lint.dictionary must be an array of strings");
|
|
186
|
+
}
|
|
187
|
+
if (lint.spelling !== void 0) {
|
|
188
|
+
if (!isObject(lint.spelling)) fail("config.lint.spelling must be an object");
|
|
189
|
+
if (lint.spelling.locales !== void 0 && (!isObject(lint.spelling.locales) || !Object.values(lint.spelling.locales).every((v) => typeof v === "string"))) {
|
|
190
|
+
fail("config.lint.spelling.locales must be a map of strings");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (config.scan !== void 0) {
|
|
195
|
+
const scan = config.scan;
|
|
196
|
+
if (!isObject(scan)) fail("config.scan must be an object");
|
|
197
|
+
for (const f of ["include", "exclude", "accessors", "patterns"]) {
|
|
198
|
+
const v = scan[f];
|
|
199
|
+
if (v !== void 0 && (!Array.isArray(v) || !v.every((x) => typeof x === "string"))) {
|
|
200
|
+
fail(`config.scan.${f} must be an array of strings`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const p of scan.patterns ?? []) {
|
|
204
|
+
try {
|
|
205
|
+
new RegExp(p);
|
|
206
|
+
} catch {
|
|
207
|
+
fail(`config.scan.patterns has an invalid regex: ${p}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!isObject(raw.keys)) fail("keys must be an object");
|
|
212
|
+
for (const [key, entry] of Object.entries(raw.keys)) {
|
|
213
|
+
if (!isObject(entry)) fail(`key "${key}" must be an object`);
|
|
214
|
+
if (!isObject(entry.values)) fail(`key "${key}" must have a values object`);
|
|
215
|
+
const plural = entry.plural;
|
|
216
|
+
if (plural !== void 0 && (!isObject(plural) || typeof plural.arg !== "string" || !plural.arg)) {
|
|
217
|
+
fail(`key "${key}" plural.arg must be a non-empty string`);
|
|
218
|
+
}
|
|
219
|
+
if (entry.placeholders !== void 0) {
|
|
220
|
+
if (!isObject(entry.placeholders)) fail(`key "${key}" placeholders must be an object`);
|
|
221
|
+
for (const [name, def] of Object.entries(entry.placeholders)) {
|
|
222
|
+
if (!isObject(def)) fail(`key "${key}" placeholder "${name}" must be an object`);
|
|
223
|
+
for (const f of ["type", "format", "example"]) {
|
|
224
|
+
if (def[f] !== void 0 && typeof def[f] !== "string") {
|
|
225
|
+
fail(`key "${key}" placeholder "${name}".${f} must be a string`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const [loc, lv] of Object.entries(entry.values)) {
|
|
231
|
+
if (!isObject(lv)) fail(`key "${key}" locale "${loc}" must be an object`);
|
|
232
|
+
if (!STATES.includes(lv.state)) {
|
|
233
|
+
fail(`key "${key}" locale "${loc}" has invalid state "${String(lv.state)}"`);
|
|
234
|
+
}
|
|
235
|
+
if (plural) {
|
|
236
|
+
if (!isObject(lv.forms)) fail(`key "${key}" locale "${loc}" must have a forms object (plural key)`);
|
|
237
|
+
for (const [cat, body] of Object.entries(lv.forms)) {
|
|
238
|
+
if (!isPluralForm(cat)) {
|
|
239
|
+
fail(`key "${key}" locale "${loc}" has invalid plural category "${cat}"`);
|
|
240
|
+
}
|
|
241
|
+
if (typeof body !== "string") fail(`key "${key}" locale "${loc}" form "${cat}" must be a string`);
|
|
242
|
+
}
|
|
243
|
+
if (typeof lv.forms.other !== "string") {
|
|
244
|
+
fail(`key "${key}" locale "${loc}" plural must include the "other" form`);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
if (typeof lv.value !== "string") fail(`key "${key}" locale "${loc}" value must be a string`);
|
|
248
|
+
lv.value = lv.value.trim();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (entry.notes !== void 0) {
|
|
252
|
+
if (!Array.isArray(entry.notes)) fail(`key "${key}" notes must be an array`);
|
|
253
|
+
for (const n of entry.notes) {
|
|
254
|
+
if (!isObject(n) || typeof n.id !== "string" || typeof n.text !== "string" || typeof n.at !== "string") {
|
|
255
|
+
fail(`key "${key}" has an invalid note (needs string id, text, at)`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (entry.contextSource !== void 0 && entry.contextSource !== "ai") {
|
|
260
|
+
fail(`key "${key}" contextSource must be "ai" if present`);
|
|
261
|
+
}
|
|
262
|
+
if (entry.contextAt !== void 0 && typeof entry.contextAt !== "string") {
|
|
263
|
+
fail(`key "${key}" contextAt must be a string if present`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (raw.glossary !== void 0 && !Array.isArray(raw.glossary)) fail("glossary must be an array");
|
|
267
|
+
const state = { glossary: [], ...raw };
|
|
268
|
+
return state;
|
|
269
|
+
}
|
|
270
|
+
function defaultState() {
|
|
271
|
+
return {
|
|
272
|
+
$schema: "https://glotfile.dev/schema/v1.json",
|
|
273
|
+
version: CURRENT_VERSION,
|
|
274
|
+
config: {
|
|
275
|
+
sourceLocale: "en",
|
|
276
|
+
locales: ["en"],
|
|
277
|
+
outputs: [
|
|
278
|
+
{ adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
|
|
279
|
+
{ adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" }
|
|
280
|
+
],
|
|
281
|
+
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", endpoint: null, region: null, batchSize: 25 },
|
|
282
|
+
format: { indent: 2, sortKeys: true, finalNewline: true },
|
|
283
|
+
spelling: { customWords: [] },
|
|
284
|
+
autoExport: true
|
|
285
|
+
},
|
|
286
|
+
glossary: [],
|
|
287
|
+
keys: {}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/server/plurals.ts
|
|
292
|
+
function bcp47(locale) {
|
|
293
|
+
return locale.replace(/_/g, "-");
|
|
294
|
+
}
|
|
295
|
+
function categoriesFor(locale) {
|
|
296
|
+
let reported;
|
|
297
|
+
try {
|
|
298
|
+
reported = new Intl.PluralRules(bcp47(locale), { type: "cardinal" }).resolvedOptions().pluralCategories;
|
|
299
|
+
} catch {
|
|
300
|
+
reported = ["other"];
|
|
301
|
+
}
|
|
302
|
+
const set = new Set(reported);
|
|
303
|
+
return PLURAL_CATEGORIES.filter((c) => set.has(c));
|
|
304
|
+
}
|
|
305
|
+
function parseIcuPlural(icu) {
|
|
306
|
+
const s = icu.trim();
|
|
307
|
+
if (!s.startsWith("{") || !s.endsWith("}")) return null;
|
|
308
|
+
const head = /^\{\s*(\w+)\s*,\s*plural\s*,/.exec(s);
|
|
309
|
+
if (!head) return null;
|
|
310
|
+
const arg = head[1];
|
|
311
|
+
const end = s.length - 1;
|
|
312
|
+
let i = head[0].length;
|
|
313
|
+
const forms = {};
|
|
314
|
+
while (i < end) {
|
|
315
|
+
while (i < end && /\s/.test(s[i])) i++;
|
|
316
|
+
if (i >= end) break;
|
|
317
|
+
const catStart = i;
|
|
318
|
+
while (i < end && !/\s/.test(s[i]) && s[i] !== "{") i++;
|
|
319
|
+
const cat = s.slice(catStart, i);
|
|
320
|
+
while (i < end && /\s/.test(s[i])) i++;
|
|
321
|
+
if (s[i] !== "{") return null;
|
|
322
|
+
let depth = 0;
|
|
323
|
+
const bodyStart = i + 1;
|
|
324
|
+
for (; i < s.length; i++) {
|
|
325
|
+
if (s[i] === "{") depth++;
|
|
326
|
+
else if (s[i] === "}") {
|
|
327
|
+
depth--;
|
|
328
|
+
if (depth === 0) break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (depth !== 0) return null;
|
|
332
|
+
const body = s.slice(bodyStart, i);
|
|
333
|
+
i++;
|
|
334
|
+
if (!isPluralForm(cat)) return null;
|
|
335
|
+
if (forms[cat] !== void 0) return null;
|
|
336
|
+
forms[cat] = body.replace(/#/g, `{${arg}}`);
|
|
337
|
+
}
|
|
338
|
+
if (typeof forms.other !== "string") return null;
|
|
339
|
+
return { arg, forms };
|
|
340
|
+
}
|
|
341
|
+
function formsToIcu(arg, forms) {
|
|
342
|
+
const parts = [];
|
|
343
|
+
const exact = Object.keys(forms).filter((k) => /^=\d+$/.test(k)).sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)));
|
|
344
|
+
for (const sel of exact) parts.push(`${sel} {${forms[sel]}}`);
|
|
345
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
346
|
+
const body = forms[cat];
|
|
347
|
+
if (body !== void 0) parts.push(`${cat} {${body}}`);
|
|
348
|
+
}
|
|
349
|
+
return `{${arg}, plural, ${parts.join(" ")}}`;
|
|
350
|
+
}
|
|
351
|
+
function exactFormsToCldr(locale, forms) {
|
|
352
|
+
const exactKeys = Object.keys(forms).filter((k) => /^=\d+$/.test(k));
|
|
353
|
+
if (exactKeys.length === 0) return { ...forms };
|
|
354
|
+
let pr;
|
|
355
|
+
try {
|
|
356
|
+
pr = new Intl.PluralRules(bcp47(locale), { type: "cardinal" });
|
|
357
|
+
} catch {
|
|
358
|
+
return { other: forms.other ?? "" };
|
|
359
|
+
}
|
|
360
|
+
const exactByCat = /* @__PURE__ */ new Map();
|
|
361
|
+
for (const k of exactKeys.sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)))) {
|
|
362
|
+
const cat = pr.select(Number(k.slice(1)));
|
|
363
|
+
if (cat !== "other" && !exactByCat.has(cat)) exactByCat.set(cat, forms[k]);
|
|
364
|
+
}
|
|
365
|
+
const out = {};
|
|
366
|
+
for (const c of categoriesFor(locale)) {
|
|
367
|
+
out[c] = exactByCat.get(c) ?? forms[c] ?? forms.other ?? "";
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function gettextPluralForms(locale) {
|
|
372
|
+
const cats = categoriesFor(locale);
|
|
373
|
+
const nplurals = cats.length;
|
|
374
|
+
if (nplurals === 1) return { nplurals: 1, expr: "0", sampled: false };
|
|
375
|
+
let pr;
|
|
376
|
+
try {
|
|
377
|
+
pr = new Intl.PluralRules(bcp47(locale), { type: "cardinal" });
|
|
378
|
+
} catch {
|
|
379
|
+
return { nplurals: 1, expr: "0", sampled: false };
|
|
380
|
+
}
|
|
381
|
+
if (nplurals === 2 && cats[0] === "one" && cats[1] === "other" && pr.select(0) === "other" && pr.select(1) === "one") {
|
|
382
|
+
return { nplurals: 2, expr: "(n != 1)", sampled: false };
|
|
383
|
+
}
|
|
384
|
+
const index = new Map(cats.map((c, i) => [c, i]));
|
|
385
|
+
const idxOf = (n) => index.get(pr.select(n)) ?? 0;
|
|
386
|
+
const MAX = 200;
|
|
387
|
+
const runs = [];
|
|
388
|
+
for (let n = 0; n <= MAX; n++) {
|
|
389
|
+
const i = idxOf(n);
|
|
390
|
+
const last = runs[runs.length - 1];
|
|
391
|
+
if (last && last.idx === i && last.end === n - 1) last.end = n;
|
|
392
|
+
else runs.push({ start: n, end: n, idx: i });
|
|
393
|
+
}
|
|
394
|
+
let expr = String(runs[runs.length - 1].idx);
|
|
395
|
+
for (let r = runs.length - 2; r >= 0; r--) {
|
|
396
|
+
const { start, end, idx } = runs[r];
|
|
397
|
+
const cond = start === end ? `n == ${start}` : `n >= ${start} && n <= ${end}`;
|
|
398
|
+
expr = `(${cond}) ? ${idx} : ${expr}`;
|
|
399
|
+
}
|
|
400
|
+
return { nplurals, expr, sampled: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/server/storage.ts
|
|
404
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|
405
|
+
import { join, dirname } from "path";
|
|
406
|
+
function splitDirFor(statePath) {
|
|
407
|
+
return statePath.replace(/\.json$/i, "");
|
|
408
|
+
}
|
|
409
|
+
function detectFormat(statePath) {
|
|
410
|
+
if (existsSync(join(splitDirFor(statePath), "config.json"))) return "split";
|
|
411
|
+
if (existsSync(statePath)) return "single";
|
|
412
|
+
return "none";
|
|
413
|
+
}
|
|
414
|
+
function disassemble(state) {
|
|
415
|
+
const { keys, ...manifest } = state;
|
|
416
|
+
const keyMeta = {};
|
|
417
|
+
const locales = {};
|
|
418
|
+
for (const [key, entry] of Object.entries(keys)) {
|
|
419
|
+
const { values, ...meta } = entry;
|
|
420
|
+
keyMeta[key] = meta;
|
|
421
|
+
for (const [locale, lv] of Object.entries(values)) {
|
|
422
|
+
(locales[locale] ??= {})[key] = lv;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { manifest, keys: keyMeta, locales };
|
|
426
|
+
}
|
|
427
|
+
function assemble(parts) {
|
|
428
|
+
const keys = {};
|
|
429
|
+
for (const [key, meta] of Object.entries(parts.keys)) {
|
|
430
|
+
keys[key] = { ...meta, values: {} };
|
|
431
|
+
}
|
|
432
|
+
for (const [locale, entries] of Object.entries(parts.locales)) {
|
|
433
|
+
for (const [key, lv] of Object.entries(entries)) {
|
|
434
|
+
(keys[key] ??= { values: {} }).values[locale] = lv;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { ...parts.manifest, keys };
|
|
438
|
+
}
|
|
439
|
+
function loadSplit(splitDir) {
|
|
440
|
+
const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
441
|
+
const manifest = readJson(join(splitDir, "config.json"));
|
|
442
|
+
const keysPath = join(splitDir, "keys.json");
|
|
443
|
+
const keys = existsSync(keysPath) ? readJson(keysPath) : {};
|
|
444
|
+
const localesDir = join(splitDir, "locales");
|
|
445
|
+
const locales = {};
|
|
446
|
+
if (existsSync(localesDir)) {
|
|
447
|
+
for (const name of readdirSync(localesDir)) {
|
|
448
|
+
if (name.endsWith(".json")) locales[name.slice(0, -5)] = readJson(join(localesDir, name));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return assemble({ manifest, keys, locales });
|
|
452
|
+
}
|
|
453
|
+
function writeIfChanged(path, contents) {
|
|
454
|
+
let current = null;
|
|
455
|
+
try {
|
|
456
|
+
current = readFileSync(path, "utf8");
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
if (current === contents) return false;
|
|
460
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
461
|
+
writeFileSync(path, contents, "utf8");
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
function saveSplit(splitDir, state) {
|
|
465
|
+
const fmt = state.config.format;
|
|
466
|
+
const parts = disassemble(state);
|
|
467
|
+
const localesDir = join(splitDir, "locales");
|
|
468
|
+
mkdirSync(localesDir, { recursive: true });
|
|
469
|
+
let written = 0;
|
|
470
|
+
let skipped = 0;
|
|
471
|
+
let deleted = 0;
|
|
472
|
+
const track = (changed) => {
|
|
473
|
+
if (changed) written++;
|
|
474
|
+
else skipped++;
|
|
475
|
+
};
|
|
476
|
+
track(writeIfChanged(join(splitDir, "config.json"), serializeJson(parts.manifest, fmt)));
|
|
477
|
+
track(writeIfChanged(join(splitDir, "keys.json"), serializeJson(parts.keys, fmt)));
|
|
478
|
+
for (const [locale, entries] of Object.entries(parts.locales)) {
|
|
479
|
+
track(writeIfChanged(join(localesDir, `${locale}.json`), serializeJson(entries, fmt)));
|
|
480
|
+
}
|
|
481
|
+
const live = new Set(Object.keys(parts.locales).map((l) => `${l}.json`));
|
|
482
|
+
for (const name of readdirSync(localesDir)) {
|
|
483
|
+
if (name.endsWith(".json") && !live.has(name)) {
|
|
484
|
+
rmSync(join(localesDir, name));
|
|
485
|
+
deleted++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { written, skipped, deleted };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/server/normalize.ts
|
|
492
|
+
function normalizeSource(value) {
|
|
493
|
+
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/server/state.ts
|
|
497
|
+
var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
498
|
+
function canonLocale(locale) {
|
|
499
|
+
return locale.trim().toLowerCase().replace(/_/g, "-");
|
|
500
|
+
}
|
|
501
|
+
function normalizeState(state) {
|
|
502
|
+
const src = canonLocale(state.config.sourceLocale);
|
|
503
|
+
state.config.sourceLocale = src;
|
|
504
|
+
state.version = CURRENT_VERSION;
|
|
505
|
+
const seen = /* @__PURE__ */ new Set([src]);
|
|
506
|
+
for (const l of state.config.locales) {
|
|
507
|
+
const c = canonLocale(l);
|
|
508
|
+
if (c) seen.add(c);
|
|
509
|
+
}
|
|
510
|
+
const rest = [...seen].filter((l) => l !== src).sort();
|
|
511
|
+
state.config.locales = [src, ...rest];
|
|
512
|
+
for (const entry of Object.values(state.keys)) {
|
|
513
|
+
const remapped = {};
|
|
514
|
+
for (const [loc, lv] of Object.entries(entry.values)) remapped[canonLocale(loc)] = lv;
|
|
515
|
+
entry.values = remapped;
|
|
516
|
+
}
|
|
517
|
+
for (const output of state.config.outputs) {
|
|
518
|
+
if (!output.localeMap) continue;
|
|
519
|
+
const remapped = {};
|
|
520
|
+
for (const [k, v] of Object.entries(output.localeMap)) remapped[canonLocale(k)] = v;
|
|
521
|
+
output.localeMap = remapped;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function loadState(path) {
|
|
525
|
+
const fmt = detectFormat(path);
|
|
526
|
+
if (fmt === "none") return defaultState();
|
|
527
|
+
let raw;
|
|
528
|
+
try {
|
|
529
|
+
raw = fmt === "split" ? loadSplit(splitDirFor(path)) : JSON.parse(readFileSync2(path, "utf8"));
|
|
530
|
+
} catch (e) {
|
|
531
|
+
throw new GlotfileError(`Could not load ${path}: ${e.message}`);
|
|
532
|
+
}
|
|
533
|
+
const state = validate(raw);
|
|
534
|
+
normalizeState(state);
|
|
535
|
+
return state;
|
|
536
|
+
}
|
|
537
|
+
function saveState(path, state) {
|
|
538
|
+
normalizeState(state);
|
|
539
|
+
if (state.config.storage === "split") {
|
|
540
|
+
saveSplit(splitDirFor(path), state);
|
|
541
|
+
if (existsSync2(path)) rmSync2(path);
|
|
542
|
+
} else {
|
|
543
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
544
|
+
writeFileSync2(path, serializeJson(state, state.config.format), "utf8");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function requireKey(state, key) {
|
|
548
|
+
const entry = state.keys[key];
|
|
549
|
+
if (!entry) throw new GlotfileError(`No such key: ${key}`);
|
|
550
|
+
return entry;
|
|
551
|
+
}
|
|
552
|
+
function requirePlural(state, key) {
|
|
553
|
+
const entry = requireKey(state, key);
|
|
554
|
+
if (!entry.plural) throw new GlotfileError(`Key is not a plural: ${key}`);
|
|
555
|
+
return entry;
|
|
556
|
+
}
|
|
557
|
+
function normalizeForms(forms) {
|
|
558
|
+
const out = {};
|
|
559
|
+
for (const [cat, body] of Object.entries(forms)) {
|
|
560
|
+
if (!isPluralForm(cat)) throw new GlotfileError(`Invalid plural category: ${cat}`);
|
|
561
|
+
if (typeof body !== "string") throw new GlotfileError(`Plural form "${cat}" must be a string`);
|
|
562
|
+
out[cat] = body.trim();
|
|
563
|
+
}
|
|
564
|
+
if (typeof out.other !== "string") throw new GlotfileError(`Plural forms must include the "other" form`);
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
function createKey(state, key, sourceValue, clock = systemClock, opts = {}) {
|
|
568
|
+
if (state.keys[key]) throw new GlotfileError(`Key already exists: ${key}`);
|
|
569
|
+
const sl = state.config.sourceLocale;
|
|
570
|
+
if (opts.plural) {
|
|
571
|
+
state.keys[key] = {
|
|
572
|
+
createdAt: clock(),
|
|
573
|
+
plural: { arg: opts.plural.arg },
|
|
574
|
+
// The source value seeds the required "other" form so nothing is empty.
|
|
575
|
+
values: { [sl]: { forms: { other: sourceValue.trim() }, state: "source" } }
|
|
576
|
+
};
|
|
577
|
+
} else {
|
|
578
|
+
state.keys[key] = {
|
|
579
|
+
createdAt: clock(),
|
|
580
|
+
values: { [sl]: { value: sourceValue.trim(), state: "source" } }
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function renameKey(state, from, to) {
|
|
585
|
+
if (from === to) return;
|
|
586
|
+
const entry = requireKey(state, from);
|
|
587
|
+
if (state.keys[to]) throw new GlotfileError(`Key already exists: ${to}`);
|
|
588
|
+
delete state.keys[from];
|
|
589
|
+
state.keys[to] = entry;
|
|
590
|
+
}
|
|
591
|
+
function deleteKey(state, key) {
|
|
592
|
+
requireKey(state, key);
|
|
593
|
+
delete state.keys[key];
|
|
594
|
+
}
|
|
595
|
+
function setSourceValue(state, key, value) {
|
|
596
|
+
const entry = requireKey(state, key);
|
|
597
|
+
if (entry.plural) throw new GlotfileError(`Key is a plural; use the plural setters: ${key}`);
|
|
598
|
+
const oldNorm = normalizeSource(entry.values[state.config.sourceLocale]?.value ?? "");
|
|
599
|
+
const newNorm = normalizeSource(value);
|
|
600
|
+
entry.values[state.config.sourceLocale] = { value: value.trim(), state: "source" };
|
|
601
|
+
if (oldNorm !== newNorm) {
|
|
602
|
+
for (const [locale, lv] of Object.entries(entry.values)) {
|
|
603
|
+
if (locale === state.config.sourceLocale) continue;
|
|
604
|
+
if (lv.state === "reviewed" || lv.state === "machine") {
|
|
605
|
+
lv.state = "needs-review";
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function setTargetValue(state, key, locale, value, clock = systemClock) {
|
|
611
|
+
const entry = requireKey(state, key);
|
|
612
|
+
if (entry.plural) throw new GlotfileError(`Key is a plural; use the plural setters: ${key}`);
|
|
613
|
+
entry.values[locale] = { value: value.trim(), state: "reviewed", updatedAt: clock() };
|
|
614
|
+
}
|
|
615
|
+
function formSignature(forms) {
|
|
616
|
+
return Object.entries(forms).sort(([a], [b]) => a.localeCompare(b)).map(([cat, val]) => `${cat}:${normalizeSource(val ?? "")}`).join("|");
|
|
617
|
+
}
|
|
618
|
+
function setSourcePluralForms(state, key, forms) {
|
|
619
|
+
const entry = requirePlural(state, key);
|
|
620
|
+
const normalized = normalizeForms(forms);
|
|
621
|
+
const oldSig = formSignature(entry.values[state.config.sourceLocale]?.forms ?? {});
|
|
622
|
+
const newSig = formSignature(normalized);
|
|
623
|
+
entry.values[state.config.sourceLocale] = { forms: normalized, state: "source" };
|
|
624
|
+
if (oldSig !== newSig) {
|
|
625
|
+
for (const [locale, lv] of Object.entries(entry.values)) {
|
|
626
|
+
if (locale === state.config.sourceLocale) continue;
|
|
627
|
+
if (lv.state === "reviewed" || lv.state === "machine") {
|
|
628
|
+
lv.state = "needs-review";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function setPluralForms(state, key, locale, forms, clock = systemClock) {
|
|
634
|
+
const entry = requirePlural(state, key);
|
|
635
|
+
if (locale === state.config.sourceLocale) throw new GlotfileError("Use setSourcePluralForms for the source locale");
|
|
636
|
+
entry.values[locale] = { forms: normalizeForms(forms), state: "reviewed", updatedAt: clock() };
|
|
637
|
+
}
|
|
638
|
+
function convertToPlural(state, key, arg) {
|
|
639
|
+
const entry = requireKey(state, key);
|
|
640
|
+
if (entry.plural) throw new GlotfileError(`Key is already a plural: ${key}`);
|
|
641
|
+
entry.plural = { arg };
|
|
642
|
+
for (const lv of Object.values(entry.values)) {
|
|
643
|
+
lv.forms = { other: lv.value ?? "" };
|
|
644
|
+
delete lv.value;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function setPluralArg(state, key, arg) {
|
|
648
|
+
const entry = requireKey(state, key);
|
|
649
|
+
if (!entry.plural) throw new GlotfileError(`Key is not a plural: ${key}`);
|
|
650
|
+
entry.plural = { arg };
|
|
651
|
+
}
|
|
652
|
+
function convertToScalar(state, key) {
|
|
653
|
+
const entry = requirePlural(state, key);
|
|
654
|
+
const arg = entry.plural.arg;
|
|
655
|
+
for (const lv of Object.values(entry.values)) {
|
|
656
|
+
const forms = lv.forms ?? {};
|
|
657
|
+
lv.value = Object.keys(forms).length <= 1 ? forms.other ?? "" : formsToIcu(arg, forms);
|
|
658
|
+
delete lv.forms;
|
|
659
|
+
}
|
|
660
|
+
delete entry.plural;
|
|
661
|
+
}
|
|
662
|
+
function clearValue(state, key, locale) {
|
|
663
|
+
const entry = requireKey(state, key);
|
|
664
|
+
if (locale === state.config.sourceLocale) throw new GlotfileError("Cannot clear the source value");
|
|
665
|
+
delete entry.values[locale];
|
|
666
|
+
}
|
|
667
|
+
function setKeyState(state, key, locale, next) {
|
|
668
|
+
const entry = requireKey(state, key);
|
|
669
|
+
const lv = entry.values[locale];
|
|
670
|
+
if (!lv) throw new GlotfileError(`No value for ${key} @ ${locale}`);
|
|
671
|
+
lv.state = next;
|
|
672
|
+
}
|
|
673
|
+
function setMetadata(state, key, partial) {
|
|
674
|
+
const entry = requireKey(state, key);
|
|
675
|
+
const safe = { ...partial };
|
|
676
|
+
delete safe.plural;
|
|
677
|
+
delete safe.values;
|
|
678
|
+
if ("context" in safe) {
|
|
679
|
+
delete entry.contextSource;
|
|
680
|
+
delete entry.contextAt;
|
|
681
|
+
}
|
|
682
|
+
Object.assign(entry, safe);
|
|
683
|
+
}
|
|
684
|
+
function addNote(state, key, text, clock = systemClock) {
|
|
685
|
+
const entry = requireKey(state, key);
|
|
686
|
+
const note = { id: "n_" + randomUUID(), text, at: clock() };
|
|
687
|
+
(entry.notes ??= []).push(note);
|
|
688
|
+
return note;
|
|
689
|
+
}
|
|
690
|
+
function editNote(state, key, id, text) {
|
|
691
|
+
const entry = requireKey(state, key);
|
|
692
|
+
const note = entry.notes?.find((n) => n.id === id);
|
|
693
|
+
if (!note) throw new GlotfileError(`No such note: ${id}`);
|
|
694
|
+
note.text = text;
|
|
695
|
+
}
|
|
696
|
+
function deleteNote(state, key, id) {
|
|
697
|
+
const entry = requireKey(state, key);
|
|
698
|
+
if (!entry.notes) return;
|
|
699
|
+
entry.notes = entry.notes.filter((n) => n.id !== id);
|
|
700
|
+
}
|
|
701
|
+
function upsertGlossaryEntry(state, entry) {
|
|
702
|
+
const i = state.glossary.findIndex((e) => e.term === entry.term);
|
|
703
|
+
if (i === -1) state.glossary.push(entry);
|
|
704
|
+
else state.glossary[i] = entry;
|
|
705
|
+
}
|
|
706
|
+
function deleteGlossaryEntry(state, term) {
|
|
707
|
+
state.glossary = state.glossary.filter((e) => e.term !== term);
|
|
708
|
+
}
|
|
709
|
+
function addCustomWord(state, word) {
|
|
710
|
+
const w = word.trim();
|
|
711
|
+
if (!w) return;
|
|
712
|
+
const spelling = state.config.spelling ??= { customWords: [] };
|
|
713
|
+
if (!spelling.customWords.includes(w)) {
|
|
714
|
+
spelling.customWords.push(w);
|
|
715
|
+
spelling.customWords.sort();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function removeCustomWord(state, word) {
|
|
719
|
+
const spelling = state.config.spelling;
|
|
720
|
+
if (!spelling) return;
|
|
721
|
+
spelling.customWords = spelling.customWords.filter((w) => w !== word);
|
|
722
|
+
}
|
|
723
|
+
function applyMachineTranslation(state, key, locale, value, clock = systemClock, force = false) {
|
|
724
|
+
const entry = requireKey(state, key);
|
|
725
|
+
if (entry.plural) throw new GlotfileError(`Key is a plural; use applyMachineTranslationForms: ${key}`);
|
|
726
|
+
if (!force && entry.values[locale]?.state === "reviewed") return false;
|
|
727
|
+
entry.values[locale] = { value: value.trim(), state: "machine", source: "ai", updatedAt: clock() };
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
function applyMachineTranslationForms(state, key, locale, forms, clock = systemClock, force = false) {
|
|
731
|
+
const entry = requirePlural(state, key);
|
|
732
|
+
if (!force && entry.values[locale]?.state === "reviewed") return false;
|
|
733
|
+
entry.values[locale] = { forms: normalizeForms(forms), state: "machine", source: "ai", updatedAt: clock() };
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/server/scan.ts
|
|
738
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
739
|
+
import { resolve, dirname as dirname3 } from "path";
|
|
740
|
+
function loadUsageCache(projectRoot) {
|
|
741
|
+
const path = resolve(projectRoot, ".glotfile", "usage.json");
|
|
742
|
+
if (!existsSync3(path)) return null;
|
|
743
|
+
try {
|
|
744
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function saveUsageCache(projectRoot, cache2) {
|
|
750
|
+
const path = resolve(projectRoot, ".glotfile", "usage.json");
|
|
751
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
752
|
+
writeFileSync3(path, JSON.stringify(cache2, null, 2) + "\n", "utf8");
|
|
753
|
+
}
|
|
754
|
+
function findMissing(state) {
|
|
755
|
+
const targets = state.config.locales.filter((l) => l !== state.config.sourceLocale).sort();
|
|
756
|
+
const out = [];
|
|
757
|
+
for (const key of Object.keys(state.keys).sort()) {
|
|
758
|
+
for (const locale of targets) {
|
|
759
|
+
const v = state.keys[key].values[locale]?.value;
|
|
760
|
+
if (!v) out.push({ key, locale });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
function computeUsedKeys(state, cache2) {
|
|
766
|
+
const exact = /* @__PURE__ */ new Set();
|
|
767
|
+
const prefixes = [];
|
|
768
|
+
for (const entry of Object.values(cache2.files)) {
|
|
769
|
+
for (const r of entry.refs) exact.add(r.key);
|
|
770
|
+
for (const p of entry.prefixes) {
|
|
771
|
+
if (p.prefix) prefixes.push(p.prefix);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return Object.keys(state.keys).filter((key) => exact.has(key) || prefixes.some((p) => key.startsWith(p))).sort();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/server/scanner.ts
|
|
778
|
+
import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync4 } from "fs";
|
|
779
|
+
import { join as join2, extname, relative } from "path";
|
|
780
|
+
var PATTERNS = {
|
|
781
|
+
laravel: [
|
|
782
|
+
/\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']+)'/g,
|
|
783
|
+
/\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]+)"/g,
|
|
784
|
+
/@(?:lang|choice)\s*\(\s*'([^']+)'/g,
|
|
785
|
+
/@(?:lang|choice)\s*\(\s*"([^"]+)"/g
|
|
786
|
+
],
|
|
787
|
+
"js-i18n": [
|
|
788
|
+
/\$t\s*\(\s*'([^']+)'/g,
|
|
789
|
+
/\$t\s*\(\s*"([^"]+)"/g,
|
|
790
|
+
/\$t\s*\(\s*`([^`$\n]+)`/g,
|
|
791
|
+
/\bi18n\.t\s*\(\s*'([^']+)'/g,
|
|
792
|
+
/\bi18n\.t\s*\(\s*"([^"]+)"/g,
|
|
793
|
+
/\bi18next\.t\s*\(\s*'([^']+)'/g,
|
|
794
|
+
/\bi18next\.t\s*\(\s*"([^"]+)"/g,
|
|
795
|
+
// t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
|
|
796
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
|
|
797
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
|
|
798
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
|
|
799
|
+
],
|
|
800
|
+
gettext: [
|
|
801
|
+
/\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
|
|
802
|
+
/\b(?:gettext|ngettext)\s*\(\s*"([^"]+)"/g,
|
|
803
|
+
// _() — word boundary, not preceded by alphanumeric
|
|
804
|
+
/(?<![a-zA-Z0-9_$])_\s*\(\s*'([^']+)'/g,
|
|
805
|
+
/(?<![a-zA-Z0-9_$])_\s*\(\s*"([^"]+)"/g
|
|
806
|
+
],
|
|
807
|
+
apple: [
|
|
808
|
+
/NSLocalizedString\s*\(\s*@?"([^"]+)"/g,
|
|
809
|
+
/String\s*\(\s*localized:\s*"([^"]+)"/g,
|
|
810
|
+
/localizedString\s*\(\s*forKey:\s*"([^"]+)"/g
|
|
811
|
+
]
|
|
812
|
+
};
|
|
813
|
+
var PREFIX_PATTERNS = {
|
|
814
|
+
laravel: [
|
|
815
|
+
/\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*'([^']*)'\s*\./g,
|
|
816
|
+
/\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"]*)"\s*\./g,
|
|
817
|
+
/\b(?:__|trans|trans_choice|Lang::(?:get|choice))\s*\(\s*"([^"${]*)\{?\$/g
|
|
818
|
+
],
|
|
819
|
+
"js-i18n": [
|
|
820
|
+
/(?:\$t|i18n\.t|i18next\.t)\s*\(\s*'([^']*)'\s*\+/g,
|
|
821
|
+
/(?:\$t|i18n\.t|i18next\.t)\s*\(\s*"([^"]*)"\s*\+/g,
|
|
822
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']*)'\s*\+/g,
|
|
823
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]*)"\s*\+/g,
|
|
824
|
+
/(?:\$t|i18n\.t|i18next\.t)\s*\(\s*`([^`$]*)\$\{/g,
|
|
825
|
+
/(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
|
|
826
|
+
]
|
|
827
|
+
};
|
|
828
|
+
var CACHE_VERSION = 4;
|
|
829
|
+
var EXT_SCANNER = {
|
|
830
|
+
".php": "laravel",
|
|
831
|
+
".vue": "js-i18n",
|
|
832
|
+
".js": "js-i18n",
|
|
833
|
+
".ts": "js-i18n",
|
|
834
|
+
".jsx": "js-i18n",
|
|
835
|
+
".tsx": "js-i18n",
|
|
836
|
+
".mjs": "js-i18n",
|
|
837
|
+
".cjs": "js-i18n",
|
|
838
|
+
".dart": "flutter",
|
|
839
|
+
".py": "gettext",
|
|
840
|
+
".c": "gettext",
|
|
841
|
+
".cpp": "gettext",
|
|
842
|
+
".h": "gettext",
|
|
843
|
+
".swift": "apple",
|
|
844
|
+
".m": "apple",
|
|
845
|
+
".mm": "apple"
|
|
846
|
+
};
|
|
847
|
+
var ALWAYS_EXCLUDE = /* @__PURE__ */ new Set([
|
|
848
|
+
"node_modules",
|
|
849
|
+
".git",
|
|
850
|
+
".glotfile",
|
|
851
|
+
".claude",
|
|
852
|
+
"dist",
|
|
853
|
+
"build",
|
|
854
|
+
"vendor",
|
|
855
|
+
"coverage",
|
|
856
|
+
".next",
|
|
857
|
+
".nuxt",
|
|
858
|
+
".turbo",
|
|
859
|
+
"__pycache__"
|
|
860
|
+
]);
|
|
861
|
+
function scannerForExt(ext) {
|
|
862
|
+
return EXT_SCANNER[ext] ?? null;
|
|
863
|
+
}
|
|
864
|
+
var FLUTTER_ACCESSOR_DEFAULTS = ["l10n", "loc", "localizations", "translations"];
|
|
865
|
+
function escapeRe(s) {
|
|
866
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
867
|
+
}
|
|
868
|
+
function detectFlutterAccessors(content) {
|
|
869
|
+
const names = /* @__PURE__ */ new Set();
|
|
870
|
+
const assigned = /\b([a-zA-Z_]\w*)\s*=\s*AppLocalizations\s*\.\s*of\s*\(/g;
|
|
871
|
+
const typed = /\bAppLocalizations[?!]?\s+([a-zA-Z_]\w*)\b(?!\s*\()/g;
|
|
872
|
+
let m;
|
|
873
|
+
while ((m = assigned.exec(content)) !== null) names.add(m[1]);
|
|
874
|
+
while ((m = typed.exec(content)) !== null) names.add(m[1]);
|
|
875
|
+
return [...names];
|
|
876
|
+
}
|
|
877
|
+
function flutterPatterns(content, opts) {
|
|
878
|
+
const names = [.../* @__PURE__ */ new Set([
|
|
879
|
+
...FLUTTER_ACCESSOR_DEFAULTS,
|
|
880
|
+
...detectFlutterAccessors(content),
|
|
881
|
+
...opts?.accessors ?? []
|
|
882
|
+
])].map(escapeRe);
|
|
883
|
+
return [
|
|
884
|
+
// AppLocalizations.of(context)!.key — tolerates the !/? null-assertion.
|
|
885
|
+
/AppLocalizations\.of\([^)]*\)[!?]?\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
886
|
+
// <accessor>.key — accessor is conventional, auto-detected, or configured.
|
|
887
|
+
new RegExp(`\\b(?:${names.join("|")})[!?]?\\s*\\.([a-zA-Z_][a-zA-Z0-9_]*)`, "g")
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
function customPatterns(opts) {
|
|
891
|
+
const out = [];
|
|
892
|
+
for (const p of opts?.patterns ?? []) {
|
|
893
|
+
try {
|
|
894
|
+
out.push(new RegExp(p, "g"));
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return out;
|
|
899
|
+
}
|
|
900
|
+
function lineStartOffsets(content) {
|
|
901
|
+
const starts = [0];
|
|
902
|
+
let idx = content.indexOf("\n");
|
|
903
|
+
while (idx !== -1) {
|
|
904
|
+
starts.push(idx + 1);
|
|
905
|
+
idx = content.indexOf("\n", idx + 1);
|
|
906
|
+
}
|
|
907
|
+
return starts;
|
|
908
|
+
}
|
|
909
|
+
function offsetToLineCol(starts, offset) {
|
|
910
|
+
let lo = 0;
|
|
911
|
+
let hi = starts.length - 1;
|
|
912
|
+
while (lo < hi) {
|
|
913
|
+
const mid = lo + hi + 1 >>> 1;
|
|
914
|
+
if (starts[mid] <= offset) lo = mid;
|
|
915
|
+
else hi = mid - 1;
|
|
916
|
+
}
|
|
917
|
+
return { line: lo + 1, col: offset - starts[lo] + 1 };
|
|
918
|
+
}
|
|
919
|
+
function extractRefs(content, scanner, opts) {
|
|
920
|
+
const base = scanner === "flutter" ? flutterPatterns(content, opts) : PATTERNS[scanner] ?? [];
|
|
921
|
+
const patterns = [...base, ...customPatterns(opts)];
|
|
922
|
+
if (patterns.length === 0) return [];
|
|
923
|
+
const starts = lineStartOffsets(content);
|
|
924
|
+
const result = [];
|
|
925
|
+
const seen = /* @__PURE__ */ new Set();
|
|
926
|
+
for (const pattern of patterns) {
|
|
927
|
+
const re = new RegExp(pattern.source, "g");
|
|
928
|
+
let m;
|
|
929
|
+
while ((m = re.exec(content)) !== null) {
|
|
930
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
931
|
+
const key = m[1];
|
|
932
|
+
const { line, col } = offsetToLineCol(starts, m.index);
|
|
933
|
+
const dedup = `${line}:${col}:${key}`;
|
|
934
|
+
if (!seen.has(dedup)) {
|
|
935
|
+
seen.add(dedup);
|
|
936
|
+
result.push({ key, line, col, scanner });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
result.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
941
|
+
return result;
|
|
942
|
+
}
|
|
943
|
+
function extractPrefixes(content, scanner) {
|
|
944
|
+
const patterns = PREFIX_PATTERNS[scanner];
|
|
945
|
+
if (!patterns) return [];
|
|
946
|
+
const starts = lineStartOffsets(content);
|
|
947
|
+
const result = [];
|
|
948
|
+
const seen = /* @__PURE__ */ new Set();
|
|
949
|
+
for (const pattern of patterns) {
|
|
950
|
+
const re = new RegExp(pattern.source, "g");
|
|
951
|
+
let m;
|
|
952
|
+
while ((m = re.exec(content)) !== null) {
|
|
953
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
954
|
+
const prefix = m[1];
|
|
955
|
+
if (!prefix) continue;
|
|
956
|
+
const { line, col } = offsetToLineCol(starts, m.index);
|
|
957
|
+
const dedup = `${line}:${col}:${prefix}`;
|
|
958
|
+
if (!seen.has(dedup)) {
|
|
959
|
+
seen.add(dedup);
|
|
960
|
+
result.push({ prefix, line, col, scanner });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
result.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
function matchesGlob(relPath, glob) {
|
|
968
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
|
|
969
|
+
return new RegExp(`^${escaped}$`).test(relPath);
|
|
970
|
+
}
|
|
971
|
+
function isExcluded(relPath, excludePatterns) {
|
|
972
|
+
return excludePatterns.some((p) => matchesGlob(relPath, p));
|
|
973
|
+
}
|
|
974
|
+
function isIncluded(relPath, includePatterns) {
|
|
975
|
+
if (includePatterns.length === 0) return true;
|
|
976
|
+
return includePatterns.some((p) => matchesGlob(relPath, p));
|
|
977
|
+
}
|
|
978
|
+
function* walkFiles(dir, root, exclude) {
|
|
979
|
+
let entries;
|
|
980
|
+
try {
|
|
981
|
+
entries = readdirSync2(dir);
|
|
982
|
+
} catch {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
for (const name of entries) {
|
|
986
|
+
if (ALWAYS_EXCLUDE.has(name)) continue;
|
|
987
|
+
const abs = join2(dir, name);
|
|
988
|
+
const rel = relative(root, abs);
|
|
989
|
+
let st;
|
|
990
|
+
try {
|
|
991
|
+
st = statSync(abs);
|
|
992
|
+
} catch {
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (st.isDirectory()) {
|
|
996
|
+
yield* walkFiles(abs, root, exclude);
|
|
997
|
+
} else if (st.isFile()) {
|
|
998
|
+
yield rel;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function runScan(projectRoot, opts, existing) {
|
|
1003
|
+
const exclude = opts.exclude ?? [];
|
|
1004
|
+
const include = opts.include ?? [];
|
|
1005
|
+
const cache2 = {
|
|
1006
|
+
version: CACHE_VERSION,
|
|
1007
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1008
|
+
files: {}
|
|
1009
|
+
};
|
|
1010
|
+
const reusable = existing && existing.version === CACHE_VERSION ? existing : null;
|
|
1011
|
+
for (const relPath of walkFiles(projectRoot, projectRoot, exclude)) {
|
|
1012
|
+
if (isExcluded(relPath, exclude)) continue;
|
|
1013
|
+
if (!isIncluded(relPath, include)) continue;
|
|
1014
|
+
const ext = extname(relPath);
|
|
1015
|
+
const scanner = scannerForExt(ext);
|
|
1016
|
+
if (!scanner) continue;
|
|
1017
|
+
const abs = join2(projectRoot, relPath);
|
|
1018
|
+
let st;
|
|
1019
|
+
try {
|
|
1020
|
+
st = statSync(abs);
|
|
1021
|
+
} catch {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const mtime = Math.floor(st.mtimeMs);
|
|
1025
|
+
const size = st.size;
|
|
1026
|
+
const prev = reusable?.files[relPath];
|
|
1027
|
+
if (prev && prev.mtime === mtime && prev.size === size) {
|
|
1028
|
+
cache2.files[relPath] = prev;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
let content;
|
|
1032
|
+
try {
|
|
1033
|
+
content = readFileSync4(abs, "utf8");
|
|
1034
|
+
} catch {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
cache2.files[relPath] = {
|
|
1038
|
+
mtime,
|
|
1039
|
+
size,
|
|
1040
|
+
refs: extractRefs(content, scanner, opts),
|
|
1041
|
+
prefixes: extractPrefixes(content, scanner)
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
saveUsageCache(projectRoot, cache2);
|
|
1045
|
+
return cache2;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/server/ai/context.ts
|
|
1049
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
1050
|
+
import { resolve as resolve2 } from "path";
|
|
1051
|
+
var MAX_CONTEXT_LENGTH = 500;
|
|
1052
|
+
var SNIPPET_WINDOW = 15;
|
|
1053
|
+
var MAX_SNIPPETS = 3;
|
|
1054
|
+
var EXCLUDED_DIRS = ["node_modules/", "vendor/", "dist/", ".git/", ".glotfile/"];
|
|
1055
|
+
function globToRegExp(glob) {
|
|
1056
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
1057
|
+
return new RegExp(`^${escaped}$`);
|
|
1058
|
+
}
|
|
1059
|
+
function extractSnippets(refs, projectRoot, fileCache) {
|
|
1060
|
+
const filtered = refs.filter((r) => !EXCLUDED_DIRS.some((d) => r.file.startsWith(d)));
|
|
1061
|
+
const sorted = [...filtered].sort((a, b) => a.file.length - b.file.length);
|
|
1062
|
+
const selected = sorted.slice(0, MAX_SNIPPETS);
|
|
1063
|
+
const extraRefs = filtered.length > MAX_SNIPPETS ? filtered.length - MAX_SNIPPETS : 0;
|
|
1064
|
+
const snippets = [];
|
|
1065
|
+
for (const ref of selected) {
|
|
1066
|
+
const absPath = resolve2(projectRoot, ref.file);
|
|
1067
|
+
if (!fileCache.has(ref.file)) {
|
|
1068
|
+
if (!existsSync4(absPath)) continue;
|
|
1069
|
+
const content = readFileSync5(absPath, "utf8");
|
|
1070
|
+
fileCache.set(ref.file, content.split("\n"));
|
|
1071
|
+
}
|
|
1072
|
+
const lines = fileCache.get(ref.file);
|
|
1073
|
+
const start = Math.max(0, ref.line - 1 - SNIPPET_WINDOW);
|
|
1074
|
+
const end = Math.min(lines.length, ref.line + SNIPPET_WINDOW);
|
|
1075
|
+
snippets.push({
|
|
1076
|
+
file: ref.file,
|
|
1077
|
+
startLine: start + 1,
|
|
1078
|
+
lines: lines.slice(start, end).join("\n"),
|
|
1079
|
+
scanner: ref.scanner,
|
|
1080
|
+
...snippets.length === 0 && extraRefs > 0 ? { extraRefs } : {}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
return snippets;
|
|
1084
|
+
}
|
|
1085
|
+
function buildUsageIndex(cache2) {
|
|
1086
|
+
const index = /* @__PURE__ */ new Map();
|
|
1087
|
+
for (const [file, entry] of Object.entries(cache2.files)) {
|
|
1088
|
+
for (const ref of entry.refs) {
|
|
1089
|
+
const existing = index.get(ref.key) ?? [];
|
|
1090
|
+
existing.push({ key: ref.key, file, line: ref.line, col: ref.col, scanner: ref.scanner });
|
|
1091
|
+
index.set(ref.key, existing);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return index;
|
|
1095
|
+
}
|
|
1096
|
+
function selectContextTargets(state, opts, cache2, lastRunAt) {
|
|
1097
|
+
const cutoff = opts.all ? void 0 : opts.since ?? lastRunAt;
|
|
1098
|
+
const keyRe = opts.keyGlob ? globToRegExp(opts.keyGlob) : null;
|
|
1099
|
+
const keySet = opts.keys ? new Set(opts.keys) : null;
|
|
1100
|
+
const usageIndex = buildUsageIndex(cache2);
|
|
1101
|
+
let candidates = [];
|
|
1102
|
+
for (const key of Object.keys(state.keys).sort()) {
|
|
1103
|
+
const entry = state.keys[key];
|
|
1104
|
+
if (entry.context) continue;
|
|
1105
|
+
if (keySet && !keySet.has(key)) continue;
|
|
1106
|
+
if (keyRe && !keyRe.test(key)) continue;
|
|
1107
|
+
if (cutoff) {
|
|
1108
|
+
if (!entry.createdAt) continue;
|
|
1109
|
+
if (entry.createdAt < cutoff) continue;
|
|
1110
|
+
}
|
|
1111
|
+
const source = entry.values[state.config.sourceLocale]?.value ?? "";
|
|
1112
|
+
candidates.push({ id: String(candidates.length), key, source, usageSnippets: [] });
|
|
1113
|
+
}
|
|
1114
|
+
candidates.sort((a, b) => {
|
|
1115
|
+
const ta = state.keys[a.key].createdAt ?? "";
|
|
1116
|
+
const tb = state.keys[b.key].createdAt ?? "";
|
|
1117
|
+
return tb.localeCompare(ta);
|
|
1118
|
+
});
|
|
1119
|
+
if (opts.limit !== void 0) candidates = candidates.slice(0, opts.limit);
|
|
1120
|
+
candidates.forEach((c, i) => {
|
|
1121
|
+
c.id = String(i);
|
|
1122
|
+
});
|
|
1123
|
+
return candidates;
|
|
1124
|
+
}
|
|
1125
|
+
function buildContextSystemPrompt() {
|
|
1126
|
+
return [
|
|
1127
|
+
"You are a localization context writer for a UI string catalog.",
|
|
1128
|
+
"For each translation key you are given: its dot-path name, its source string, and one or more code snippets showing where the string is referenced in the codebase.",
|
|
1129
|
+
"Your task: write a concise 1\u20132 sentence context note that describes WHERE in the UI this string appears and WHAT the user is doing at that point.",
|
|
1130
|
+
"The context is read by human translators AND by an AI translation engine. It must answer: what screen is this on, what element is this (button, label, error, etc.), and what action does it relate to?",
|
|
1131
|
+
"Rules:",
|
|
1132
|
+
"- Use the code snippets as your primary signal. Look at the component name, surrounding labels, event handlers, and variable names.",
|
|
1133
|
+
"- Do NOT restate the source string itself.",
|
|
1134
|
+
"- Do NOT say 'This string is...' \u2014 write the context as a direct description.",
|
|
1135
|
+
"- Keep it under 500 characters.",
|
|
1136
|
+
"- If no code snippets are available, infer from the key path and source value."
|
|
1137
|
+
].join("\n");
|
|
1138
|
+
}
|
|
1139
|
+
function buildContextBatchPrompt(reqs) {
|
|
1140
|
+
const items = reqs.map((r) => {
|
|
1141
|
+
const snippetText = r.usageSnippets.length > 0 ? r.usageSnippets.map((s) => {
|
|
1142
|
+
const extra = s.extraRefs ? ` (and ${s.extraRefs} more call site${s.extraRefs > 1 ? "s" : ""} not shown)` : "";
|
|
1143
|
+
return `File: ${s.file} (lines ${s.startLine}+, scanner: ${s.scanner})${extra}
|
|
1144
|
+
\`\`\`
|
|
1145
|
+
${s.lines}
|
|
1146
|
+
\`\`\``;
|
|
1147
|
+
}).join("\n\n") : "(no code references found \u2014 infer from key path and source value)";
|
|
1148
|
+
return { id: r.id, key: r.key, source: r.source, codeSnippets: snippetText, hasScreenshot: r.image !== void 0 };
|
|
1149
|
+
});
|
|
1150
|
+
return 'Write a context note for each key. Return JSON {"items":[{"id","context"}]}.\n' + JSON.stringify(items, null, 2);
|
|
1151
|
+
}
|
|
1152
|
+
var CONTEXT_BATCH_SCHEMA = {
|
|
1153
|
+
type: "object",
|
|
1154
|
+
properties: {
|
|
1155
|
+
items: {
|
|
1156
|
+
type: "array",
|
|
1157
|
+
items: {
|
|
1158
|
+
type: "object",
|
|
1159
|
+
properties: {
|
|
1160
|
+
id: { type: "string" },
|
|
1161
|
+
context: { type: "string" },
|
|
1162
|
+
error: { type: "string" }
|
|
1163
|
+
},
|
|
1164
|
+
required: ["id"],
|
|
1165
|
+
additionalProperties: false
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
required: ["items"],
|
|
1170
|
+
additionalProperties: false
|
|
1171
|
+
};
|
|
1172
|
+
function applyContext(state, reqs, results, clock = systemClock) {
|
|
1173
|
+
const byId = new Map(reqs.map((r) => [r.id, r]));
|
|
1174
|
+
let written = 0;
|
|
1175
|
+
const errors = [];
|
|
1176
|
+
for (const res of results) {
|
|
1177
|
+
const req = byId.get(res.id);
|
|
1178
|
+
if (!req) continue;
|
|
1179
|
+
if (res.error) {
|
|
1180
|
+
errors.push({ key: req.key, error: res.error });
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const context = res.context?.trim() ?? "";
|
|
1184
|
+
if (!context) {
|
|
1185
|
+
errors.push({ key: req.key, error: "AI returned empty context" });
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (context.length > MAX_CONTEXT_LENGTH) {
|
|
1189
|
+
errors.push({ key: req.key, error: `Context too long (${context.length} chars, max ${MAX_CONTEXT_LENGTH})` });
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
const entry = state.keys[req.key];
|
|
1193
|
+
if (!entry || entry.context) continue;
|
|
1194
|
+
entry.context = context;
|
|
1195
|
+
entry.contextSource = "ai";
|
|
1196
|
+
entry.contextAt = clock();
|
|
1197
|
+
written++;
|
|
1198
|
+
}
|
|
1199
|
+
return { written, errors };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/server/stats.ts
|
|
1203
|
+
function countWords(text) {
|
|
1204
|
+
const t = text.trim();
|
|
1205
|
+
return t === "" ? 0 : t.split(/\s+/).length;
|
|
1206
|
+
}
|
|
1207
|
+
function namespaceOf(key) {
|
|
1208
|
+
const i = key.indexOf(".");
|
|
1209
|
+
return i === -1 ? "(root)" : key.slice(0, i);
|
|
1210
|
+
}
|
|
1211
|
+
function pct(n, d) {
|
|
1212
|
+
return d === 0 ? 0 : Math.round(n / d * 1e3) / 10;
|
|
1213
|
+
}
|
|
1214
|
+
function sourceText(entry, sourceLocale) {
|
|
1215
|
+
const lv = entry.values[sourceLocale];
|
|
1216
|
+
if (!lv) return "";
|
|
1217
|
+
return entry.plural ? lv.forms?.other ?? "" : lv.value ?? "";
|
|
1218
|
+
}
|
|
1219
|
+
function isPresent(entry, locale) {
|
|
1220
|
+
const lv = entry.values[locale];
|
|
1221
|
+
if (!lv) return false;
|
|
1222
|
+
return entry.plural ? (lv.forms?.other ?? "").trim() !== "" : (lv.value ?? "").trim() !== "";
|
|
1223
|
+
}
|
|
1224
|
+
function classify(entry, locale) {
|
|
1225
|
+
if (!isPresent(entry, locale)) return "missing";
|
|
1226
|
+
const st = entry.values[locale].state;
|
|
1227
|
+
if (st === "reviewed") return "reviewed";
|
|
1228
|
+
if (st === "needs-review") return "needsReview";
|
|
1229
|
+
return "machine";
|
|
1230
|
+
}
|
|
1231
|
+
function groupCompletion(state, keys, targets, name) {
|
|
1232
|
+
let translated = 0;
|
|
1233
|
+
let reviewed = 0;
|
|
1234
|
+
for (const k of keys) {
|
|
1235
|
+
const entry = state.keys[k];
|
|
1236
|
+
for (const locale of targets) {
|
|
1237
|
+
const bucket = classify(entry, locale);
|
|
1238
|
+
if (bucket !== "missing") translated++;
|
|
1239
|
+
if (bucket === "reviewed") reviewed++;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const cells = keys.length * targets.length;
|
|
1243
|
+
return { name, total: keys.length, translatedPct: pct(translated, cells), reviewedPct: pct(reviewed, cells) };
|
|
1244
|
+
}
|
|
1245
|
+
function worstFirst(a, b) {
|
|
1246
|
+
return a.translatedPct - b.translatedPct || a.name.localeCompare(b.name);
|
|
1247
|
+
}
|
|
1248
|
+
function computeStats(state) {
|
|
1249
|
+
const { sourceLocale, locales } = state.config;
|
|
1250
|
+
const targets = locales.filter((l) => l !== sourceLocale);
|
|
1251
|
+
const allKeys = Object.keys(state.keys);
|
|
1252
|
+
const expected = allKeys.filter((k) => !state.keys[k].skipTranslate);
|
|
1253
|
+
const locales_ = targets.map((locale) => {
|
|
1254
|
+
const counts = { reviewed: 0, needsReview: 0, machine: 0, missing: 0 };
|
|
1255
|
+
let sourceWords = 0;
|
|
1256
|
+
let missingWords = 0;
|
|
1257
|
+
for (const k of expected) {
|
|
1258
|
+
const entry = state.keys[k];
|
|
1259
|
+
const w = countWords(sourceText(entry, sourceLocale));
|
|
1260
|
+
sourceWords += w;
|
|
1261
|
+
const bucket = classify(entry, locale);
|
|
1262
|
+
counts[bucket]++;
|
|
1263
|
+
if (bucket === "missing") missingWords += w;
|
|
1264
|
+
}
|
|
1265
|
+
const total = expected.length;
|
|
1266
|
+
const translated = counts.reviewed + counts.needsReview + counts.machine;
|
|
1267
|
+
return {
|
|
1268
|
+
locale,
|
|
1269
|
+
total,
|
|
1270
|
+
counts,
|
|
1271
|
+
translated,
|
|
1272
|
+
reviewed: counts.reviewed,
|
|
1273
|
+
translatedPct: pct(translated, total),
|
|
1274
|
+
reviewedPct: pct(counts.reviewed, total),
|
|
1275
|
+
words: { source: sourceWords, missing: missingWords }
|
|
1276
|
+
};
|
|
1277
|
+
});
|
|
1278
|
+
const cells = expected.length * targets.length;
|
|
1279
|
+
let translatedCells = 0;
|
|
1280
|
+
let reviewedCells = 0;
|
|
1281
|
+
for (const ls of locales_) {
|
|
1282
|
+
translatedCells += ls.translated;
|
|
1283
|
+
reviewedCells += ls.reviewed;
|
|
1284
|
+
}
|
|
1285
|
+
const nsMap = /* @__PURE__ */ new Map();
|
|
1286
|
+
for (const k of expected) {
|
|
1287
|
+
const ns = namespaceOf(k);
|
|
1288
|
+
(nsMap.get(ns) ?? nsMap.set(ns, []).get(ns)).push(k);
|
|
1289
|
+
}
|
|
1290
|
+
const byNamespace = [...nsMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
|
|
1291
|
+
const tagMap = /* @__PURE__ */ new Map();
|
|
1292
|
+
for (const k of expected) {
|
|
1293
|
+
for (const tag of state.keys[k].tags ?? []) {
|
|
1294
|
+
(tagMap.get(tag) ?? tagMap.set(tag, []).get(tag)).push(k);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const byTag = [...tagMap.entries()].map(([name, keys]) => groupCompletion(state, keys, targets, name)).sort(worstFirst);
|
|
1298
|
+
return {
|
|
1299
|
+
totals: {
|
|
1300
|
+
keys: allKeys.length,
|
|
1301
|
+
locales: targets.length,
|
|
1302
|
+
translatedPct: pct(translatedCells, cells),
|
|
1303
|
+
reviewedPct: pct(reviewedCells, cells),
|
|
1304
|
+
sourceWords: expected.reduce((sum, k) => sum + countWords(sourceText(state.keys[k], sourceLocale)), 0)
|
|
1305
|
+
},
|
|
1306
|
+
locales: locales_,
|
|
1307
|
+
byNamespace,
|
|
1308
|
+
byTag
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/server/placeholders.ts
|
|
1313
|
+
var ICU_PLURAL_SELECT = /\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/;
|
|
1314
|
+
function withoutQuotedSpans(value) {
|
|
1315
|
+
if (!value.includes("'")) return value;
|
|
1316
|
+
let out = "";
|
|
1317
|
+
for (let i = 0; i < value.length; ) {
|
|
1318
|
+
if (value[i] === "'") {
|
|
1319
|
+
const next = value[i + 1];
|
|
1320
|
+
if (next === "'") {
|
|
1321
|
+
out += " ";
|
|
1322
|
+
i += 2;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (next === "{" || next === "}" || next === "#" || next === "|") {
|
|
1326
|
+
i += 2;
|
|
1327
|
+
while (i < value.length && value[i] !== "'") i++;
|
|
1328
|
+
i++;
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
out += value[i];
|
|
1333
|
+
i++;
|
|
1334
|
+
}
|
|
1335
|
+
return out;
|
|
1336
|
+
}
|
|
1337
|
+
function extractPlaceholders(value) {
|
|
1338
|
+
const scan = withoutQuotedSpans(value);
|
|
1339
|
+
const names = /* @__PURE__ */ new Set();
|
|
1340
|
+
for (const m of scan.matchAll(/\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,/g)) {
|
|
1341
|
+
names.add(m[1]);
|
|
1342
|
+
}
|
|
1343
|
+
if (!isIcuPluralOrSelect(scan)) {
|
|
1344
|
+
for (const m of scan.matchAll(/\{\s*(\w+)\s*\}/g)) names.add(m[1]);
|
|
1345
|
+
}
|
|
1346
|
+
return [...names];
|
|
1347
|
+
}
|
|
1348
|
+
function isIcuPluralOrSelect(value) {
|
|
1349
|
+
return ICU_PLURAL_SELECT.test(value);
|
|
1350
|
+
}
|
|
1351
|
+
function toLaravel(value) {
|
|
1352
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1353
|
+
return value.replace(/\{(\w+)\}/g, ":$1");
|
|
1354
|
+
}
|
|
1355
|
+
function toI18next(value) {
|
|
1356
|
+
if (isIcuPluralOrSelect(value)) return value;
|
|
1357
|
+
return value.replace(/(?<!\{)\{(\w+)\}(?!\})/g, "{{$1}}");
|
|
1358
|
+
}
|
|
1359
|
+
function placeholdersMatch(source, translation) {
|
|
1360
|
+
const a = extractPlaceholders(source).sort();
|
|
1361
|
+
const b = extractPlaceholders(translation).sort();
|
|
1362
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
1363
|
+
}
|
|
1364
|
+
function placeholdersSubset(source, translation) {
|
|
1365
|
+
const allowed = new Set(extractPlaceholders(source));
|
|
1366
|
+
return extractPlaceholders(translation).every((p) => allowed.has(p));
|
|
1367
|
+
}
|
|
1368
|
+
var COUNT_OPTIONAL = /* @__PURE__ */ new Set(["zero", "one", "two"]);
|
|
1369
|
+
function pluralFormPlaceholdersMatch(category, source, form) {
|
|
1370
|
+
return COUNT_OPTIONAL.has(category) ? placeholdersSubset(source, form) : placeholdersMatch(source, form);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/server/ai/run.ts
|
|
1374
|
+
import { readFileSync as readFileSync6, existsSync as existsSync5 } from "fs";
|
|
1375
|
+
import { resolve as resolve3, extname as extname2 } from "path";
|
|
1376
|
+
|
|
1377
|
+
// src/server/glob.ts
|
|
1378
|
+
function globToRegExp2(glob) {
|
|
1379
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
1380
|
+
return new RegExp(`^${escaped}$`);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// src/server/ai/run.ts
|
|
1384
|
+
function selectRequests(state, opts) {
|
|
1385
|
+
const targets = (opts.locales ?? state.config.locales).filter((l) => l !== state.config.sourceLocale);
|
|
1386
|
+
const keyRe = opts.keyGlob ? globToRegExp2(opts.keyGlob) : null;
|
|
1387
|
+
const keySet = opts.keys ? new Set(opts.keys) : null;
|
|
1388
|
+
const reqs = [];
|
|
1389
|
+
let id = 0;
|
|
1390
|
+
for (const key of Object.keys(state.keys).sort()) {
|
|
1391
|
+
const entry = state.keys[key];
|
|
1392
|
+
if (entry.skipTranslate) continue;
|
|
1393
|
+
if (keyRe && !keyRe.test(key)) continue;
|
|
1394
|
+
if (keySet && !keySet.has(key)) continue;
|
|
1395
|
+
const sourceLv = entry.values[state.config.sourceLocale];
|
|
1396
|
+
if (entry.plural) {
|
|
1397
|
+
const sourceForms = sourceLv?.forms;
|
|
1398
|
+
const other = sourceForms?.other;
|
|
1399
|
+
if (!sourceForms || !other) continue;
|
|
1400
|
+
for (const locale of targets) {
|
|
1401
|
+
const have = entry.values[locale]?.forms ?? {};
|
|
1402
|
+
const complete = categoriesFor(locale).every((c) => (have[c] ?? "") !== "");
|
|
1403
|
+
if (opts.onlyMissing && complete) continue;
|
|
1404
|
+
const glossary = relevantGlossary(other, locale, state.glossary);
|
|
1405
|
+
reqs.push({
|
|
1406
|
+
id: String(id++),
|
|
1407
|
+
key,
|
|
1408
|
+
source: other,
|
|
1409
|
+
context: entry.context,
|
|
1410
|
+
targetLocale: locale,
|
|
1411
|
+
maxLength: entry.maxLength,
|
|
1412
|
+
placeholders: extractPlaceholders(other),
|
|
1413
|
+
...glossary.length ? { glossary } : {},
|
|
1414
|
+
plural: { arg: entry.plural.arg, categories: categoriesFor(locale), sourceForms }
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
const source = sourceLv?.value;
|
|
1420
|
+
if (!source) continue;
|
|
1421
|
+
for (const locale of targets) {
|
|
1422
|
+
const existing = entry.values[locale]?.value;
|
|
1423
|
+
if (opts.onlyMissing && existing) continue;
|
|
1424
|
+
const glossary = relevantGlossary(source, locale, state.glossary);
|
|
1425
|
+
reqs.push({
|
|
1426
|
+
id: String(id++),
|
|
1427
|
+
key,
|
|
1428
|
+
source,
|
|
1429
|
+
context: entry.context,
|
|
1430
|
+
targetLocale: locale,
|
|
1431
|
+
maxLength: entry.maxLength,
|
|
1432
|
+
placeholders: extractPlaceholders(source),
|
|
1433
|
+
...glossary.length ? { glossary } : {}
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return reqs;
|
|
1438
|
+
}
|
|
1439
|
+
function relevantGlossary(source, targetLocale, glossary) {
|
|
1440
|
+
const hints = [];
|
|
1441
|
+
for (const entry of glossary) {
|
|
1442
|
+
const haystack = entry.caseSensitive ? source : source.toLowerCase();
|
|
1443
|
+
const needle = entry.caseSensitive ? entry.term : entry.term.toLowerCase();
|
|
1444
|
+
if (!haystack.includes(needle)) continue;
|
|
1445
|
+
hints.push({
|
|
1446
|
+
term: entry.term,
|
|
1447
|
+
doNotTranslate: entry.doNotTranslate,
|
|
1448
|
+
forced: entry.translations?.[targetLocale],
|
|
1449
|
+
notes: entry.notes
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
return hints;
|
|
1453
|
+
}
|
|
1454
|
+
var MEDIA_TYPES = {
|
|
1455
|
+
".png": "image/png",
|
|
1456
|
+
".jpg": "image/jpeg",
|
|
1457
|
+
".jpeg": "image/jpeg",
|
|
1458
|
+
".webp": "image/webp",
|
|
1459
|
+
".gif": "image/gif"
|
|
1460
|
+
};
|
|
1461
|
+
var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
1462
|
+
function attachScreenshots(reqs, state, projectRoot) {
|
|
1463
|
+
const cache2 = /* @__PURE__ */ new Map();
|
|
1464
|
+
for (const req of reqs) {
|
|
1465
|
+
const screenshot = state.keys[req.key]?.screenshot;
|
|
1466
|
+
if (!screenshot) continue;
|
|
1467
|
+
const mediaType = MEDIA_TYPES[extname2(screenshot).toLowerCase()];
|
|
1468
|
+
if (!mediaType) continue;
|
|
1469
|
+
if (!cache2.has(screenshot)) {
|
|
1470
|
+
const abs = resolve3(projectRoot, screenshot);
|
|
1471
|
+
if (!existsSync5(abs)) {
|
|
1472
|
+
cache2.set(screenshot, null);
|
|
1473
|
+
} else {
|
|
1474
|
+
const buf = readFileSync6(abs);
|
|
1475
|
+
cache2.set(screenshot, buf.length > MAX_IMAGE_BYTES ? null : { mediaType, base64: buf.toString("base64") });
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const image = cache2.get(screenshot);
|
|
1479
|
+
if (image) req.image = image;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision) {
|
|
1483
|
+
if (supportsVision) {
|
|
1484
|
+
attachScreenshots(reqs, state, projectRoot);
|
|
1485
|
+
return { skipped: 0 };
|
|
1486
|
+
}
|
|
1487
|
+
const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
|
|
1488
|
+
return { skipped: keys.size };
|
|
1489
|
+
}
|
|
1490
|
+
var DEFAULT_LOCALE_CONCURRENCY = 3;
|
|
1491
|
+
async function runLocaleParallel(reqs, provider, onBatchComplete, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal) {
|
|
1492
|
+
if (!reqs.length) return [];
|
|
1493
|
+
const byLocale = /* @__PURE__ */ new Map();
|
|
1494
|
+
for (const req of reqs) {
|
|
1495
|
+
let group = byLocale.get(req.targetLocale);
|
|
1496
|
+
if (!group) {
|
|
1497
|
+
group = [];
|
|
1498
|
+
byLocale.set(req.targetLocale, group);
|
|
1499
|
+
}
|
|
1500
|
+
group.push(req);
|
|
1501
|
+
}
|
|
1502
|
+
const total = reqs.length;
|
|
1503
|
+
let done = 0;
|
|
1504
|
+
const allResults = [];
|
|
1505
|
+
const groups = [...byLocale.values()];
|
|
1506
|
+
let next = 0;
|
|
1507
|
+
async function worker() {
|
|
1508
|
+
while (next < groups.length) {
|
|
1509
|
+
if (signal?.aborted) break;
|
|
1510
|
+
const group = groups[next++];
|
|
1511
|
+
const localeResults = await provider.translate(group, (_localeDone, _localeTotal, batchResults) => {
|
|
1512
|
+
done += batchResults.length;
|
|
1513
|
+
onBatchComplete?.(done, total, batchResults);
|
|
1514
|
+
}, signal);
|
|
1515
|
+
allResults.push(...localeResults);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const workers = Array.from({ length: Math.min(concurrency, groups.length) }, worker);
|
|
1519
|
+
await Promise.all(workers);
|
|
1520
|
+
return allResults;
|
|
1521
|
+
}
|
|
1522
|
+
function applyResults(state, reqs, results, clock = systemClock, force = false) {
|
|
1523
|
+
const byId = new Map(reqs.map((r) => [r.id, r]));
|
|
1524
|
+
let written = 0;
|
|
1525
|
+
const errors = [];
|
|
1526
|
+
for (const res of results) {
|
|
1527
|
+
const req = byId.get(res.id);
|
|
1528
|
+
if (!req) continue;
|
|
1529
|
+
if (req.plural) {
|
|
1530
|
+
if (res.error || res.forms === void 0) {
|
|
1531
|
+
errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (applyMachineTranslationForms(state, req.key, req.targetLocale, res.forms, clock, force)) written++;
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
if (res.error || res.translation === void 0) {
|
|
1538
|
+
errors.push({ key: req.key, locale: req.targetLocale, error: res.error ?? "no translation" });
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
if (applyMachineTranslation(state, req.key, req.targetLocale, res.translation, clock, force)) written++;
|
|
1542
|
+
}
|
|
1543
|
+
return { written, errors };
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// src/server/spell.ts
|
|
1547
|
+
import nspell from "nspell";
|
|
1548
|
+
var LOADERS = {
|
|
1549
|
+
en: () => init_dictionary_en().then(() => dictionary_en_exports),
|
|
1550
|
+
es: () => import("dictionary-es"),
|
|
1551
|
+
fr: () => import("dictionary-fr")
|
|
1552
|
+
};
|
|
1553
|
+
var instances = /* @__PURE__ */ new Map();
|
|
1554
|
+
var loading = /* @__PURE__ */ new Set();
|
|
1555
|
+
var unavailable = /* @__PURE__ */ new Set();
|
|
1556
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1557
|
+
var norm = (locale) => locale.toLowerCase();
|
|
1558
|
+
var ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
|
|
1559
|
+
var MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
|
|
1560
|
+
var WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
|
|
1561
|
+
async function loadDictionary(locale) {
|
|
1562
|
+
const key = norm(locale);
|
|
1563
|
+
if (instances.has(key) || unavailable.has(key)) return;
|
|
1564
|
+
const loader = LOADERS[key];
|
|
1565
|
+
if (!loader) {
|
|
1566
|
+
unavailable.add(key);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
try {
|
|
1570
|
+
const { default: dict } = await loader();
|
|
1571
|
+
instances.set(key, nspell(dict));
|
|
1572
|
+
} catch {
|
|
1573
|
+
unavailable.add(key);
|
|
1574
|
+
} finally {
|
|
1575
|
+
loading.delete(key);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
function spellValue(locale, value, ignore) {
|
|
1579
|
+
const key = norm(locale);
|
|
1580
|
+
if (unavailable.has(key)) return [];
|
|
1581
|
+
const spell = instances.get(key);
|
|
1582
|
+
if (!spell) {
|
|
1583
|
+
if (!LOADERS[key]) {
|
|
1584
|
+
unavailable.add(key);
|
|
1585
|
+
return [];
|
|
1586
|
+
}
|
|
1587
|
+
if (!loading.has(key)) {
|
|
1588
|
+
loading.add(key);
|
|
1589
|
+
void loadDictionary(key);
|
|
1590
|
+
}
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
const cacheKey = key + " " + value;
|
|
1594
|
+
let allBad = cache.get(cacheKey);
|
|
1595
|
+
if (!allBad) {
|
|
1596
|
+
const words = value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
|
|
1597
|
+
allBad = words.filter((w) => !spell.correct(w));
|
|
1598
|
+
cache.set(cacheKey, allBad);
|
|
1599
|
+
}
|
|
1600
|
+
return allBad.filter((w) => !ignore.has(w.toLowerCase()));
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/server/checks.ts
|
|
1604
|
+
var CHECK_IDS = ["untranslated", "placeholder", "spelling", "length", "glossary"];
|
|
1605
|
+
function contains(haystack, needle, caseSensitive) {
|
|
1606
|
+
return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1607
|
+
}
|
|
1608
|
+
function runChecks(state, opts = {}) {
|
|
1609
|
+
const on = (id) => !opts.only || opts.only.includes(id);
|
|
1610
|
+
const issues = [];
|
|
1611
|
+
let spellPending = false;
|
|
1612
|
+
const { sourceLocale } = state.config;
|
|
1613
|
+
const byTerm = new Map(state.glossary.map((e) => [e.term, e]));
|
|
1614
|
+
const ignore = /* @__PURE__ */ new Set();
|
|
1615
|
+
for (const e of state.glossary) {
|
|
1616
|
+
for (const w of e.term.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
|
|
1617
|
+
for (const t of Object.values(e.translations ?? {})) {
|
|
1618
|
+
for (const w of t.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
for (const word of state.config.spelling?.customWords ?? []) {
|
|
1622
|
+
const w = word.trim().toLowerCase();
|
|
1623
|
+
if (w) ignore.add(w);
|
|
1624
|
+
}
|
|
1625
|
+
const targetLocales = state.config.locales.filter((l) => l !== sourceLocale);
|
|
1626
|
+
for (const key of Object.keys(state.keys).sort()) {
|
|
1627
|
+
const entry = state.keys[key];
|
|
1628
|
+
const source = entry.values[sourceLocale]?.value ?? "";
|
|
1629
|
+
if (on("untranslated") && !entry.skipTranslate) {
|
|
1630
|
+
for (const locale of targetLocales) {
|
|
1631
|
+
const translated = entry.plural ? (entry.values[locale]?.forms?.other ?? "").trim() !== "" : (entry.values[locale]?.value ?? "").trim() !== "";
|
|
1632
|
+
if (!translated) {
|
|
1633
|
+
issues.push({ key, locale, check: "untranslated", message: "Not translated yet" });
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
if (entry.plural) {
|
|
1638
|
+
if (on("placeholder")) {
|
|
1639
|
+
const sourceForm = entry.values[sourceLocale]?.forms?.other ?? "";
|
|
1640
|
+
if (sourceForm.trim() !== "") {
|
|
1641
|
+
const sp = extractPlaceholders(sourceForm);
|
|
1642
|
+
for (const [locale, lv] of Object.entries(entry.values)) {
|
|
1643
|
+
if (locale === sourceLocale || !lv.forms) continue;
|
|
1644
|
+
for (const [cat, form] of Object.entries(lv.forms)) {
|
|
1645
|
+
const text = form ?? "";
|
|
1646
|
+
if (text.trim() === "" || pluralFormPlaceholdersMatch(cat, sourceForm, text)) continue;
|
|
1647
|
+
const tp = extractPlaceholders(text);
|
|
1648
|
+
const missing = COUNT_OPTIONAL.has(cat) ? [] : sp.filter((p) => !tp.includes(p));
|
|
1649
|
+
const extra = tp.filter((p) => !sp.includes(p));
|
|
1650
|
+
const parts = [];
|
|
1651
|
+
if (missing.length) parts.push(`missing ${missing.join(", ")}`);
|
|
1652
|
+
if (extra.length) parts.push(`extra ${extra.join(", ")}`);
|
|
1653
|
+
issues.push({
|
|
1654
|
+
key,
|
|
1655
|
+
locale,
|
|
1656
|
+
check: "placeholder",
|
|
1657
|
+
message: `Placeholder mismatch (${cat}): ${parts.join("; ")}`,
|
|
1658
|
+
detail: [...missing.map((m) => `-${m}`), ...extra.map((e) => `+${e}`)]
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
for (const [locale, lv] of Object.entries(entry.values)) {
|
|
1667
|
+
const value = lv.value ?? "";
|
|
1668
|
+
const isSource = locale === sourceLocale;
|
|
1669
|
+
const blank = value.trim() === "";
|
|
1670
|
+
if (on("length") && entry.maxLength && value.length > entry.maxLength) {
|
|
1671
|
+
issues.push({
|
|
1672
|
+
key,
|
|
1673
|
+
locale,
|
|
1674
|
+
check: "length",
|
|
1675
|
+
message: `Exceeds max length (${value.length}/${entry.maxLength})`,
|
|
1676
|
+
detail: [`${value.length}/${entry.maxLength}`]
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
if (on("spelling") && !blank) {
|
|
1680
|
+
const bad = spellValue(locale, value, ignore);
|
|
1681
|
+
if (bad === null) spellPending = true;
|
|
1682
|
+
else if (bad.length) {
|
|
1683
|
+
issues.push({
|
|
1684
|
+
key,
|
|
1685
|
+
locale,
|
|
1686
|
+
check: "spelling",
|
|
1687
|
+
message: `Possible spelling: ${bad.join(", ")}`,
|
|
1688
|
+
detail: bad
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (isSource || blank) continue;
|
|
1693
|
+
if (on("placeholder") && !placeholdersMatch(source, value)) {
|
|
1694
|
+
const sp = extractPlaceholders(source);
|
|
1695
|
+
const tp = extractPlaceholders(value);
|
|
1696
|
+
const missing = sp.filter((p) => !tp.includes(p));
|
|
1697
|
+
const extra = tp.filter((p) => !sp.includes(p));
|
|
1698
|
+
const parts = [];
|
|
1699
|
+
if (missing.length) parts.push(`missing ${missing.join(", ")}`);
|
|
1700
|
+
if (extra.length) parts.push(`extra ${extra.join(", ")}`);
|
|
1701
|
+
issues.push({
|
|
1702
|
+
key,
|
|
1703
|
+
locale,
|
|
1704
|
+
check: "placeholder",
|
|
1705
|
+
message: `Placeholder mismatch: ${parts.join("; ")}`,
|
|
1706
|
+
detail: [...missing.map((m) => `-${m}`), ...extra.map((e) => `+${e}`)]
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
if (on("glossary") && source) {
|
|
1710
|
+
for (const hint of relevantGlossary(source, locale, state.glossary)) {
|
|
1711
|
+
const cs = byTerm.get(hint.term)?.caseSensitive;
|
|
1712
|
+
if (hint.doNotTranslate) {
|
|
1713
|
+
if (!contains(value, hint.term, cs)) {
|
|
1714
|
+
issues.push({
|
|
1715
|
+
key,
|
|
1716
|
+
locale,
|
|
1717
|
+
check: "glossary",
|
|
1718
|
+
message: `Do-not-translate term "${hint.term}" is missing from the translation`,
|
|
1719
|
+
detail: [hint.term]
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
} else if (hint.forced) {
|
|
1723
|
+
if (!contains(value, hint.forced, cs)) {
|
|
1724
|
+
issues.push({
|
|
1725
|
+
key,
|
|
1726
|
+
locale,
|
|
1727
|
+
check: "glossary",
|
|
1728
|
+
message: `Should use "${hint.forced}" for "${hint.term}"`,
|
|
1729
|
+
detail: [hint.forced]
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
return { issues, spellPending };
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// src/server/adapters/options.ts
|
|
1741
|
+
function applyCase(canonical, style) {
|
|
1742
|
+
const sep3 = style === "lower-underscore" || style === "bcp47-underscore" ? "_" : "-";
|
|
1743
|
+
const lower = style === "lower-hyphen" || style === "lower-underscore";
|
|
1744
|
+
return canonical.split(/[-_]/).map((p, i) => {
|
|
1745
|
+
if (lower || i === 0) return p.toLowerCase();
|
|
1746
|
+
if (/^[a-z]{4}$/i.test(p)) return p[0].toUpperCase() + p.slice(1).toLowerCase();
|
|
1747
|
+
if (/^[a-z]{2}$/i.test(p)) return p.toUpperCase();
|
|
1748
|
+
return p;
|
|
1749
|
+
}).join(sep3);
|
|
1750
|
+
}
|
|
1751
|
+
function resolveLocaleToken(output, canonical, adapterDefault) {
|
|
1752
|
+
const mapped = output.localeMap?.[canonical];
|
|
1753
|
+
if (mapped !== void 0) return mapped;
|
|
1754
|
+
return applyCase(canonical, output.localeCase ?? adapterDefault);
|
|
1755
|
+
}
|
|
1756
|
+
function inferLocaleStyle(pairs, adapterDefault) {
|
|
1757
|
+
const candidates = [adapterDefault, ...LOCALE_CASES.filter((c) => c !== adapterDefault)];
|
|
1758
|
+
let best = adapterDefault;
|
|
1759
|
+
let bestScore = -1;
|
|
1760
|
+
for (const style of candidates) {
|
|
1761
|
+
const score = pairs.filter(([c, obs]) => applyCase(c, style) === obs).length;
|
|
1762
|
+
if (score > bestScore) {
|
|
1763
|
+
bestScore = score;
|
|
1764
|
+
best = style;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
const localeMap = {};
|
|
1768
|
+
for (const [c, obs] of pairs) {
|
|
1769
|
+
if (applyCase(c, best) !== obs) localeMap[c] = obs;
|
|
1770
|
+
}
|
|
1771
|
+
const result = {};
|
|
1772
|
+
if (best !== adapterDefault) result.localeCase = best;
|
|
1773
|
+
if (Object.keys(localeMap).length) result.localeMap = localeMap;
|
|
1774
|
+
return result;
|
|
1775
|
+
}
|
|
1776
|
+
function resolveFormat(state, output) {
|
|
1777
|
+
const f = state.config.format;
|
|
1778
|
+
return {
|
|
1779
|
+
indent: output.indent ?? f.indent,
|
|
1780
|
+
finalNewline: output.finalNewline ?? f.finalNewline
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
function resolveEmptyAs(output, fallback) {
|
|
1784
|
+
return output.emptyAs ?? fallback;
|
|
1785
|
+
}
|
|
1786
|
+
function resolveScalar(entry, locale, sourceLocale, emptyAs) {
|
|
1787
|
+
const raw = entry.values[locale]?.value ?? "";
|
|
1788
|
+
if (raw) return raw;
|
|
1789
|
+
if (locale === sourceLocale) return raw;
|
|
1790
|
+
if (emptyAs === "omit") return null;
|
|
1791
|
+
if (emptyAs === "empty") return "";
|
|
1792
|
+
return entry.values[sourceLocale]?.value ?? "";
|
|
1793
|
+
}
|
|
1794
|
+
function resolveForms(entry, locale, sourceLocale, emptyAs) {
|
|
1795
|
+
const forms = entry.values[locale]?.forms;
|
|
1796
|
+
if (forms?.other) return forms;
|
|
1797
|
+
if (locale === sourceLocale) return forms ?? { other: "" };
|
|
1798
|
+
if (emptyAs === "omit") return null;
|
|
1799
|
+
if (emptyAs === "empty") return { other: "" };
|
|
1800
|
+
return entry.values[sourceLocale]?.forms ?? { other: "" };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// src/server/adapters/flutter-arb.ts
|
|
1804
|
+
var DEFAULT_LOCALE_CASE = "bcp47-underscore";
|
|
1805
|
+
var flutterArb = {
|
|
1806
|
+
name: "flutter-arb",
|
|
1807
|
+
capabilities: {
|
|
1808
|
+
plural: "native",
|
|
1809
|
+
select: "native",
|
|
1810
|
+
nesting: "flat",
|
|
1811
|
+
metadata: true,
|
|
1812
|
+
placeholderStyle: "icu",
|
|
1813
|
+
fileGrouping: "per-locale"
|
|
1814
|
+
},
|
|
1815
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE,
|
|
1816
|
+
export(state, output) {
|
|
1817
|
+
const files = [];
|
|
1818
|
+
const warnings = [];
|
|
1819
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE));
|
|
1820
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
1821
|
+
const fmt = { indent, sortKeys: false, finalNewline };
|
|
1822
|
+
const includeLocale = output.includeLocale ?? true;
|
|
1823
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
1824
|
+
const sortedKeys = Object.keys(state.keys).sort();
|
|
1825
|
+
const built = {};
|
|
1826
|
+
for (const locale of state.config.locales) {
|
|
1827
|
+
const isSource = locale === state.config.sourceLocale;
|
|
1828
|
+
const obj = {};
|
|
1829
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE);
|
|
1830
|
+
if (includeLocale) obj["@@locale"] = token;
|
|
1831
|
+
for (const key of sortedKeys) {
|
|
1832
|
+
const entry = state.keys[key];
|
|
1833
|
+
let value;
|
|
1834
|
+
let placeholderNames;
|
|
1835
|
+
if (entry.plural) {
|
|
1836
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1837
|
+
if (!forms) continue;
|
|
1838
|
+
value = formsToIcu(entry.plural.arg, forms);
|
|
1839
|
+
const srcForms = entry.values[state.config.sourceLocale]?.forms ?? {};
|
|
1840
|
+
placeholderNames = [entry.plural.arg, ...Object.values(srcForms).flatMap((b) => extractPlaceholders(b ?? ""))];
|
|
1841
|
+
} else {
|
|
1842
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1843
|
+
if (raw === null) continue;
|
|
1844
|
+
value = raw;
|
|
1845
|
+
placeholderNames = extractPlaceholders(raw);
|
|
1846
|
+
}
|
|
1847
|
+
obj[key] = value;
|
|
1848
|
+
if (isSource) {
|
|
1849
|
+
const placeholders = {};
|
|
1850
|
+
for (const name of placeholderNames) {
|
|
1851
|
+
if (!/^\w+$/.test(name)) continue;
|
|
1852
|
+
placeholders[name] = {};
|
|
1853
|
+
}
|
|
1854
|
+
const meta = {};
|
|
1855
|
+
if (entry.context) meta.description = entry.context;
|
|
1856
|
+
if (Object.keys(placeholders).length) meta.placeholders = placeholders;
|
|
1857
|
+
if (Object.keys(meta).length) obj["@" + key] = meta;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
built[locale] = obj;
|
|
1861
|
+
files.push({ path: resolvePath(output.path, token), contents: serializeJson(obj, fmt) });
|
|
1862
|
+
}
|
|
1863
|
+
for (const [canonical, aliasCodes] of Object.entries(output.localeAliases ?? {})) {
|
|
1864
|
+
const obj = built[canonical];
|
|
1865
|
+
if (!obj) continue;
|
|
1866
|
+
for (const code of aliasCodes) {
|
|
1867
|
+
const aliasToken = resolveLocaleToken(output, code, DEFAULT_LOCALE_CASE);
|
|
1868
|
+
const aliasObj = includeLocale ? { ...obj, "@@locale": aliasToken } : obj;
|
|
1869
|
+
files.push({ path: resolvePath(output.path, aliasToken), contents: serializeJson(aliasObj, fmt) });
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1873
|
+
return { files, warnings };
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
// src/server/adapters/shared.ts
|
|
1878
|
+
function nestKeys(flat) {
|
|
1879
|
+
const tree = {};
|
|
1880
|
+
const collisions = [];
|
|
1881
|
+
for (const fullKey of Object.keys(flat)) {
|
|
1882
|
+
const parts = fullKey.split(".");
|
|
1883
|
+
let node = tree;
|
|
1884
|
+
let collided = false;
|
|
1885
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1886
|
+
const p = parts[i];
|
|
1887
|
+
const existing = node[p];
|
|
1888
|
+
if (existing === void 0) {
|
|
1889
|
+
const next = {};
|
|
1890
|
+
node[p] = next;
|
|
1891
|
+
node = next;
|
|
1892
|
+
} else if (typeof existing === "object" && existing !== null) {
|
|
1893
|
+
node = existing;
|
|
1894
|
+
} else {
|
|
1895
|
+
collisions.push(fullKey);
|
|
1896
|
+
collided = true;
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
if (collided) continue;
|
|
1901
|
+
const leaf = parts[parts.length - 1];
|
|
1902
|
+
if (typeof node[leaf] === "object" && node[leaf] !== null) {
|
|
1903
|
+
collisions.push(fullKey);
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
node[leaf] = flat[fullKey];
|
|
1907
|
+
}
|
|
1908
|
+
return { tree, collisions };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/server/adapters/laravel-php.ts
|
|
1912
|
+
function splitKey(key) {
|
|
1913
|
+
const dot = key.indexOf(".");
|
|
1914
|
+
if (dot === -1) return { namespace: "messages", inner: key };
|
|
1915
|
+
return { namespace: key.slice(0, dot), inner: key.slice(dot + 1) };
|
|
1916
|
+
}
|
|
1917
|
+
function phpString(s) {
|
|
1918
|
+
return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
|
|
1919
|
+
}
|
|
1920
|
+
function phpArray(node, indent, level) {
|
|
1921
|
+
const pad = " ".repeat(indent * (level + 1));
|
|
1922
|
+
const closePad = " ".repeat(indent * level);
|
|
1923
|
+
const lines = Object.keys(node).sort().map((key) => {
|
|
1924
|
+
const v = node[key];
|
|
1925
|
+
const rhs = v && typeof v === "object" ? phpArray(v, indent, level + 1) : phpString(String(v));
|
|
1926
|
+
return `${pad}${phpString(key)} => ${rhs},`;
|
|
1927
|
+
});
|
|
1928
|
+
return `[
|
|
1929
|
+
${lines.join("\n")}
|
|
1930
|
+
${closePad}]`;
|
|
1931
|
+
}
|
|
1932
|
+
var DEFAULT_LOCALE_CASE2 = "lower-hyphen";
|
|
1933
|
+
var laravelPhp = {
|
|
1934
|
+
name: "laravel-php",
|
|
1935
|
+
capabilities: {
|
|
1936
|
+
plural: "native",
|
|
1937
|
+
select: "lossy",
|
|
1938
|
+
nesting: "nested",
|
|
1939
|
+
metadata: false,
|
|
1940
|
+
placeholderStyle: "named",
|
|
1941
|
+
fileGrouping: "per-locale-namespace"
|
|
1942
|
+
},
|
|
1943
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE2,
|
|
1944
|
+
export(state, output) {
|
|
1945
|
+
const warnings = [];
|
|
1946
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE2));
|
|
1947
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
1948
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
1949
|
+
const tree = {};
|
|
1950
|
+
for (const locale of state.config.locales) tree[locale] = {};
|
|
1951
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
1952
|
+
const { namespace, inner } = splitKey(key);
|
|
1953
|
+
for (const locale of state.config.locales) {
|
|
1954
|
+
if (entry.plural) {
|
|
1955
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1956
|
+
if (!forms) continue;
|
|
1957
|
+
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0).map((body) => toLaravel(body)).filter(Boolean);
|
|
1958
|
+
const value = parts.join("|");
|
|
1959
|
+
if (!value && locale !== state.config.sourceLocale) continue;
|
|
1960
|
+
(tree[locale][namespace] ??= {})[inner] = value;
|
|
1961
|
+
} else {
|
|
1962
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
1963
|
+
if (raw === null) continue;
|
|
1964
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
1965
|
+
warnings.push({
|
|
1966
|
+
code: "lossy-plural",
|
|
1967
|
+
key,
|
|
1968
|
+
locale,
|
|
1969
|
+
message: "laravel-php cannot represent ICU plural/select; written unconverted"
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
(tree[locale][namespace] ??= {})[inner] = toLaravel(raw);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
const files = [];
|
|
1977
|
+
for (const [locale, namespaces] of Object.entries(tree)) {
|
|
1978
|
+
for (const [namespace, flat] of Object.entries(namespaces)) {
|
|
1979
|
+
const { tree: nested, collisions } = nestKeys(flat);
|
|
1980
|
+
for (const c of collisions) {
|
|
1981
|
+
warnings.push({ code: "key-collision", key: `${namespace}.${c}`, locale, message: "key is both a leaf and a parent; dropped from nested output" });
|
|
1982
|
+
}
|
|
1983
|
+
const body = `<?php
|
|
1984
|
+
|
|
1985
|
+
return ${phpArray(nested, indent, 0)};`;
|
|
1986
|
+
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE2), namespace), contents: finalNewline ? body + "\n" : body });
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1990
|
+
return { files, warnings };
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
// src/server/adapters/i18next-json.ts
|
|
1995
|
+
function setNested(root, path, value) {
|
|
1996
|
+
let node = root;
|
|
1997
|
+
let collided = false;
|
|
1998
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1999
|
+
const seg = path[i];
|
|
2000
|
+
if (typeof node[seg] !== "object" || node[seg] === null) {
|
|
2001
|
+
if (node[seg] !== void 0) collided = true;
|
|
2002
|
+
node[seg] = {};
|
|
2003
|
+
}
|
|
2004
|
+
node = node[seg];
|
|
2005
|
+
}
|
|
2006
|
+
const leaf = path[path.length - 1];
|
|
2007
|
+
if (typeof node[leaf] === "object" && node[leaf] !== null) collided = true;
|
|
2008
|
+
node[leaf] = value;
|
|
2009
|
+
return collided;
|
|
2010
|
+
}
|
|
2011
|
+
var DEFAULT_LOCALE_CASE3 = "lower-hyphen";
|
|
2012
|
+
var i18nextJson = {
|
|
2013
|
+
name: "i18next-json",
|
|
2014
|
+
capabilities: {
|
|
2015
|
+
plural: "native",
|
|
2016
|
+
select: "lossy",
|
|
2017
|
+
nesting: "nested",
|
|
2018
|
+
metadata: false,
|
|
2019
|
+
placeholderStyle: "named",
|
|
2020
|
+
fileGrouping: "per-locale"
|
|
2021
|
+
},
|
|
2022
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE3,
|
|
2023
|
+
export(state, output) {
|
|
2024
|
+
const files = [];
|
|
2025
|
+
const warnings = [];
|
|
2026
|
+
const fmt = state.config.format;
|
|
2027
|
+
const collided = /* @__PURE__ */ new Set();
|
|
2028
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE3));
|
|
2029
|
+
for (const locale of state.config.locales) {
|
|
2030
|
+
const obj = {};
|
|
2031
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
2032
|
+
const lv = entry.values[locale];
|
|
2033
|
+
if (!lv) continue;
|
|
2034
|
+
const segments = key.split(".");
|
|
2035
|
+
const leaf = segments[segments.length - 1];
|
|
2036
|
+
const parent = segments.slice(0, -1);
|
|
2037
|
+
if (entry.plural) {
|
|
2038
|
+
if (!lv.forms) continue;
|
|
2039
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
2040
|
+
const body = lv.forms[cat];
|
|
2041
|
+
if (body === void 0) continue;
|
|
2042
|
+
if (setNested(obj, [...parent, `${leaf}_${cat}`], toI18next(body))) collided.add(key);
|
|
2043
|
+
}
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
if (lv.value === void 0) continue;
|
|
2047
|
+
if (setNested(obj, segments, toI18next(lv.value))) collided.add(key);
|
|
2048
|
+
}
|
|
2049
|
+
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
|
|
2050
|
+
}
|
|
2051
|
+
for (const key of [...collided].sort()) {
|
|
2052
|
+
warnings.push({ code: "key-collision", key, message: "key collides with another key's nesting path; one value was overwritten" });
|
|
2053
|
+
}
|
|
2054
|
+
return { files, warnings };
|
|
2055
|
+
}
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
// src/server/adapters/gettext-po.ts
|
|
2059
|
+
function poString(s) {
|
|
2060
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"';
|
|
2061
|
+
}
|
|
2062
|
+
function toGettext(body, arg) {
|
|
2063
|
+
return body.split(`{${arg}}`).join("%d");
|
|
2064
|
+
}
|
|
2065
|
+
var DEFAULT_LOCALE_CASE4 = "lower-hyphen";
|
|
2066
|
+
var gettextPo = {
|
|
2067
|
+
name: "gettext-po",
|
|
2068
|
+
capabilities: {
|
|
2069
|
+
plural: "native",
|
|
2070
|
+
select: "lossy",
|
|
2071
|
+
nesting: "flat",
|
|
2072
|
+
metadata: false,
|
|
2073
|
+
placeholderStyle: "printf",
|
|
2074
|
+
fileGrouping: "per-locale"
|
|
2075
|
+
},
|
|
2076
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE4,
|
|
2077
|
+
export(state, output) {
|
|
2078
|
+
const warnings = [];
|
|
2079
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE4));
|
|
2080
|
+
const files = [];
|
|
2081
|
+
const sourceLocale = state.config.sourceLocale;
|
|
2082
|
+
const keys = Object.keys(state.keys).sort();
|
|
2083
|
+
for (const locale of state.config.locales) {
|
|
2084
|
+
const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE4);
|
|
2085
|
+
const { nplurals, expr, sampled } = gettextPluralForms(locale);
|
|
2086
|
+
const cats = categoriesFor(locale);
|
|
2087
|
+
const blocks = [
|
|
2088
|
+
[
|
|
2089
|
+
'msgid ""',
|
|
2090
|
+
'msgstr ""',
|
|
2091
|
+
poString("Content-Type: text/plain; charset=UTF-8\n"),
|
|
2092
|
+
poString(`Language: ${token}
|
|
2093
|
+
`),
|
|
2094
|
+
poString(`Plural-Forms: nplurals=${nplurals}; plural=${expr};
|
|
2095
|
+
`)
|
|
2096
|
+
].join("\n")
|
|
2097
|
+
];
|
|
2098
|
+
let emittedPlural = false;
|
|
2099
|
+
for (const key of keys) {
|
|
2100
|
+
const entry = state.keys[key];
|
|
2101
|
+
const lv = entry.values[locale];
|
|
2102
|
+
const src = entry.values[sourceLocale];
|
|
2103
|
+
if (entry.plural) {
|
|
2104
|
+
emittedPlural = true;
|
|
2105
|
+
const arg = entry.plural.arg;
|
|
2106
|
+
const srcForms = src?.forms ?? {};
|
|
2107
|
+
const lines = [
|
|
2108
|
+
`msgctxt ${poString(key)}`,
|
|
2109
|
+
// gettext keys a plural on the source singular/plural pair.
|
|
2110
|
+
`msgid ${poString(toGettext(srcForms.one ?? srcForms.other ?? "", arg))}`,
|
|
2111
|
+
`msgid_plural ${poString(toGettext(srcForms.other ?? "", arg))}`
|
|
2112
|
+
];
|
|
2113
|
+
for (let i = 0; i < nplurals; i++) {
|
|
2114
|
+
const cat = cats[i];
|
|
2115
|
+
const body = lv?.forms?.[cat] ?? lv?.forms?.other ?? "";
|
|
2116
|
+
lines.push(`msgstr[${i}] ${poString(toGettext(body, arg))}`);
|
|
2117
|
+
}
|
|
2118
|
+
blocks.push(lines.join("\n"));
|
|
2119
|
+
} else {
|
|
2120
|
+
blocks.push(
|
|
2121
|
+
[
|
|
2122
|
+
`msgctxt ${poString(key)}`,
|
|
2123
|
+
`msgid ${poString(src?.value ?? "")}`,
|
|
2124
|
+
`msgstr ${poString(lv?.value ?? "")}`
|
|
2125
|
+
].join("\n")
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
if (sampled && emittedPlural) {
|
|
2130
|
+
warnings.push({
|
|
2131
|
+
code: "unsupported-metadata",
|
|
2132
|
+
key: "",
|
|
2133
|
+
locale,
|
|
2134
|
+
message: `Plural-Forms for "${locale}" is generated by sampling counts 0\u2013200; verify behaviour for larger numbers.`
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
files.push({ path: resolvePath(output.path, token), contents: blocks.join("\n\n") + "\n" });
|
|
2138
|
+
}
|
|
2139
|
+
return { files, warnings };
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
|
|
2143
|
+
// src/server/adapters/apple-stringsdict.ts
|
|
2144
|
+
function xmlEscape(s) {
|
|
2145
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2146
|
+
}
|
|
2147
|
+
function toApple(body, arg) {
|
|
2148
|
+
return body.split(`{${arg}}`).join("%d");
|
|
2149
|
+
}
|
|
2150
|
+
var DEFAULT_LOCALE_CASE5 = "lower-hyphen";
|
|
2151
|
+
var appleStringsdict = {
|
|
2152
|
+
name: "apple-stringsdict",
|
|
2153
|
+
capabilities: {
|
|
2154
|
+
plural: "native",
|
|
2155
|
+
select: "none",
|
|
2156
|
+
nesting: "flat",
|
|
2157
|
+
metadata: false,
|
|
2158
|
+
placeholderStyle: "printf",
|
|
2159
|
+
fileGrouping: "per-locale"
|
|
2160
|
+
},
|
|
2161
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE5,
|
|
2162
|
+
export(state, output) {
|
|
2163
|
+
const files = [];
|
|
2164
|
+
const warnings = [];
|
|
2165
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE5));
|
|
2166
|
+
const keys = Object.keys(state.keys).sort();
|
|
2167
|
+
for (const locale of state.config.locales) {
|
|
2168
|
+
const lines = [];
|
|
2169
|
+
for (const key of keys) {
|
|
2170
|
+
const entry = state.keys[key];
|
|
2171
|
+
if (!entry.plural) continue;
|
|
2172
|
+
const lv = entry.values[locale];
|
|
2173
|
+
if (!lv?.forms || lv.forms.other === void 0) continue;
|
|
2174
|
+
const arg = entry.plural.arg;
|
|
2175
|
+
lines.push(` <key>${xmlEscape(key)}</key>`);
|
|
2176
|
+
lines.push(` <dict>`);
|
|
2177
|
+
lines.push(` <key>NSStringLocalizedFormatKey</key>`);
|
|
2178
|
+
lines.push(` <string>%#@${xmlEscape(arg)}@</string>`);
|
|
2179
|
+
lines.push(` <key>${xmlEscape(arg)}</key>`);
|
|
2180
|
+
lines.push(` <dict>`);
|
|
2181
|
+
lines.push(` <key>NSStringFormatSpecTypeKey</key>`);
|
|
2182
|
+
lines.push(` <string>NSStringPluralRuleType</string>`);
|
|
2183
|
+
lines.push(` <key>NSStringFormatValueTypeKey</key>`);
|
|
2184
|
+
lines.push(` <string>d</string>`);
|
|
2185
|
+
for (const cat of PLURAL_CATEGORIES) {
|
|
2186
|
+
const body = lv.forms[cat];
|
|
2187
|
+
if (body === void 0) continue;
|
|
2188
|
+
lines.push(` <key>${cat}</key>`);
|
|
2189
|
+
lines.push(` <string>${xmlEscape(toApple(body, arg))}</string>`);
|
|
2190
|
+
}
|
|
2191
|
+
lines.push(` </dict>`);
|
|
2192
|
+
lines.push(` </dict>`);
|
|
2193
|
+
}
|
|
2194
|
+
const contents = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2195
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
2196
|
+
<plist version="1.0">
|
|
2197
|
+
<dict>
|
|
2198
|
+
` + (lines.length ? lines.join("\n") + "\n" : "") + `</dict>
|
|
2199
|
+
</plist>
|
|
2200
|
+
`;
|
|
2201
|
+
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE5)), contents });
|
|
2202
|
+
}
|
|
2203
|
+
return { files, warnings };
|
|
2204
|
+
}
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
// src/server/adapters/vue-i18n-json.ts
|
|
2208
|
+
var DEFAULT_LOCALE_CASE6 = "lower-hyphen";
|
|
2209
|
+
var vueI18nJson = {
|
|
2210
|
+
name: "vue-i18n-json",
|
|
2211
|
+
capabilities: {
|
|
2212
|
+
plural: "native",
|
|
2213
|
+
select: "lossy",
|
|
2214
|
+
nesting: "both",
|
|
2215
|
+
metadata: false,
|
|
2216
|
+
placeholderStyle: "named",
|
|
2217
|
+
fileGrouping: "per-locale"
|
|
2218
|
+
},
|
|
2219
|
+
defaultLocaleCase: DEFAULT_LOCALE_CASE6,
|
|
2220
|
+
export(state, output) {
|
|
2221
|
+
const files = [];
|
|
2222
|
+
const warnings = [];
|
|
2223
|
+
warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE6));
|
|
2224
|
+
const { indent, finalNewline } = resolveFormat(state, output);
|
|
2225
|
+
const fmt = { indent, sortKeys: true, finalNewline };
|
|
2226
|
+
const emptyAs = resolveEmptyAs(output, "omit");
|
|
2227
|
+
const flatOutput = output.style === "flat";
|
|
2228
|
+
for (const locale of state.config.locales) {
|
|
2229
|
+
const flat = {};
|
|
2230
|
+
for (const [key, entry] of Object.entries(state.keys)) {
|
|
2231
|
+
if (entry.plural) {
|
|
2232
|
+
const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2233
|
+
if (!forms) continue;
|
|
2234
|
+
const parts = PLURAL_CATEGORIES.map((c) => forms[c]).filter((v) => v !== void 0);
|
|
2235
|
+
flat[key] = parts.join(" | ");
|
|
2236
|
+
} else {
|
|
2237
|
+
const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
|
|
2238
|
+
if (raw === null) continue;
|
|
2239
|
+
if (raw && isIcuPluralOrSelect(raw)) {
|
|
2240
|
+
warnings.push({
|
|
2241
|
+
code: "lossy-plural",
|
|
2242
|
+
key,
|
|
2243
|
+
locale,
|
|
2244
|
+
message: "vue-i18n-json does not yet convert ICU plural/select; written unconverted"
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
flat[key] = raw;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
let payload = flat;
|
|
2251
|
+
if (!flatOutput) {
|
|
2252
|
+
const { tree, collisions } = nestKeys(flat);
|
|
2253
|
+
for (const key of collisions) {
|
|
2254
|
+
warnings.push({
|
|
2255
|
+
code: "key-collision",
|
|
2256
|
+
key,
|
|
2257
|
+
locale,
|
|
2258
|
+
message: "key is both a leaf and a parent; dropped from nested output"
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
payload = tree;
|
|
2262
|
+
}
|
|
2263
|
+
files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE6)), contents: serializeJson(payload, fmt) });
|
|
2264
|
+
}
|
|
2265
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
2266
|
+
return { files, warnings };
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
|
|
2270
|
+
// src/server/adapters/index.ts
|
|
2271
|
+
function resolvePath(template, locale, namespace = "") {
|
|
2272
|
+
return template.replaceAll("{locale}", locale).replaceAll("{namespace}", namespace);
|
|
2273
|
+
}
|
|
2274
|
+
function localeCollisionWarnings(output, locales, adapterDefault) {
|
|
2275
|
+
const byToken = /* @__PURE__ */ new Map();
|
|
2276
|
+
for (const locale of locales) {
|
|
2277
|
+
const token = resolveLocaleToken(output, locale, adapterDefault);
|
|
2278
|
+
const group = byToken.get(token) ?? [];
|
|
2279
|
+
group.push(locale);
|
|
2280
|
+
byToken.set(token, group);
|
|
2281
|
+
}
|
|
2282
|
+
const warnings = [];
|
|
2283
|
+
for (const [token, group] of byToken) {
|
|
2284
|
+
if (group.length > 1) {
|
|
2285
|
+
warnings.push({
|
|
2286
|
+
code: "locale-collision",
|
|
2287
|
+
key: "",
|
|
2288
|
+
message: `locales ${group.join(", ")} all resolve to the export token "${token}"; only the first (in locale order) is written`
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
return warnings;
|
|
2293
|
+
}
|
|
2294
|
+
var registry;
|
|
2295
|
+
function getRegistry() {
|
|
2296
|
+
return registry ??= {
|
|
2297
|
+
[flutterArb.name]: flutterArb,
|
|
2298
|
+
[laravelPhp.name]: laravelPhp,
|
|
2299
|
+
[i18nextJson.name]: i18nextJson,
|
|
2300
|
+
[gettextPo.name]: gettextPo,
|
|
2301
|
+
[appleStringsdict.name]: appleStringsdict,
|
|
2302
|
+
[vueI18nJson.name]: vueI18nJson
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
function getAdapter(name) {
|
|
2306
|
+
const a = getRegistry()[name];
|
|
2307
|
+
if (!a) throw new Error(`Unknown adapter: ${name}`);
|
|
2308
|
+
return a;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/server/api.ts
|
|
2312
|
+
import { writeFileSync as writeFileSync5, readFileSync as readFileSync11, mkdirSync as mkdirSync6, existsSync as existsSync8, readdirSync as readdirSync7, rmSync as rmSync3 } from "fs";
|
|
2313
|
+
import { dirname as dirname5, resolve as resolve6, basename, relative as relative3, sep } from "path";
|
|
2314
|
+
|
|
2315
|
+
// src/server/ai/anthropic.ts
|
|
2316
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2317
|
+
|
|
2318
|
+
// src/server/ai/provider.ts
|
|
2319
|
+
function buildSystemPrompt() {
|
|
2320
|
+
return [
|
|
2321
|
+
"You are a professional software localization engine for a UI string catalog.",
|
|
2322
|
+
"Your goal: translate each source UI string into its target locale accurately and idiomatically, as a native speaker would phrase it in a real app interface.",
|
|
2323
|
+
"",
|
|
2324
|
+
"You are given, per item: the key path, the source text, optional human context, the target locale, an optional max length, the list of interpolation placeholders, and any relevant glossary entries. Some items also include a screenshot image showing where the string appears in the UI \u2014 use it to disambiguate meaning, tone, and length.",
|
|
2325
|
+
"",
|
|
2326
|
+
"Hard rules:",
|
|
2327
|
+
"- Preserve every interpolation placeholder EXACTLY as written: {name}, {{count}}, %s, %d, :name. Never translate, rename, reorder, or remove them.",
|
|
2328
|
+
"- Preserve ICU plural/select structure verbatim (e.g. {count, plural, one {\u2026} other {\u2026}}); translate only the human-readable text inside each branch.",
|
|
2329
|
+
"- Glossary: a term marked do-not-translate MUST appear unchanged in the translation. A term with a forced translation for the target locale MUST use that exact translation.",
|
|
2330
|
+
"- Respect the max length (characters) when given; prefer a shorter natural phrasing over exceeding it.",
|
|
2331
|
+
"- Match the register and capitalization conventions of the target language and of UI microcopy.",
|
|
2332
|
+
"- Return ONLY the translated string for each item \u2014 no quotes, notes, or explanations.",
|
|
2333
|
+
"",
|
|
2334
|
+
'Plural items: an item with a `plural` field gives you the source plural FORMS (keyed by CLDR category) and the `categories` REQUIRED for the target language. Return a `forms` object with one idiomatic translation per REQUIRED category \u2014 including categories the source language does not have (infer them from meaning). Keep the count token shown in the source forms (e.g. {count}) in every form that states a quantity; the `zero`, `one`, and `two` forms MAY omit it when that is natural in the target language \u2014 e.g. "No files", "One file", or a dual form that encodes the count grammatically (Arabic \u0645\u0644\u0641\u0627\u0646). Never introduce a placeholder the source did not have. For these items return `forms` instead of `translation`.'
|
|
2335
|
+
].join("\n");
|
|
2336
|
+
}
|
|
2337
|
+
function buildBatchPrompt(reqs) {
|
|
2338
|
+
const targetLocale = reqs[0]?.targetLocale ?? "";
|
|
2339
|
+
const items = reqs.map((r) => {
|
|
2340
|
+
const base = {
|
|
2341
|
+
id: r.id,
|
|
2342
|
+
key: r.key,
|
|
2343
|
+
context: r.context ?? null,
|
|
2344
|
+
maxLength: r.maxLength ?? null,
|
|
2345
|
+
placeholders: r.placeholders,
|
|
2346
|
+
glossary: r.glossary ?? [],
|
|
2347
|
+
hasScreenshot: r.image !== void 0
|
|
2348
|
+
};
|
|
2349
|
+
if (r.plural) {
|
|
2350
|
+
return { ...base, plural: { arg: r.plural.arg, categories: r.plural.categories, sourceForms: r.plural.sourceForms } };
|
|
2351
|
+
}
|
|
2352
|
+
return { ...base, source: r.source };
|
|
2353
|
+
});
|
|
2354
|
+
return `Translate every item below into the target locale: ${targetLocale}. All items share this one target language.
|
|
2355
|
+
Glossary entries are constraints you MUST apply. Items with hasScreenshot:true have a screenshot supplied as a separate image block above; use it for context. For a scalar item (has \`source\`) return {"id","translation"}; for a plural item (has \`plural\`) return {"id","forms"} with one string per required category. Return JSON {"items":[\u2026]}.
|
|
2356
|
+
` + JSON.stringify(items, null, 2);
|
|
2357
|
+
}
|
|
2358
|
+
var BATCH_SCHEMA = {
|
|
2359
|
+
type: "object",
|
|
2360
|
+
properties: {
|
|
2361
|
+
items: {
|
|
2362
|
+
type: "array",
|
|
2363
|
+
items: {
|
|
2364
|
+
type: "object",
|
|
2365
|
+
properties: {
|
|
2366
|
+
id: { type: "string" },
|
|
2367
|
+
translation: { type: "string" },
|
|
2368
|
+
forms: {
|
|
2369
|
+
type: "object",
|
|
2370
|
+
properties: {
|
|
2371
|
+
zero: { type: "string" },
|
|
2372
|
+
one: { type: "string" },
|
|
2373
|
+
two: { type: "string" },
|
|
2374
|
+
few: { type: "string" },
|
|
2375
|
+
many: { type: "string" },
|
|
2376
|
+
other: { type: "string" }
|
|
2377
|
+
},
|
|
2378
|
+
additionalProperties: false
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
required: ["id"],
|
|
2382
|
+
additionalProperties: false
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
},
|
|
2386
|
+
required: ["items"],
|
|
2387
|
+
additionalProperties: false
|
|
2388
|
+
};
|
|
2389
|
+
|
|
2390
|
+
// src/server/ai/batch.ts
|
|
2391
|
+
function chunk(items, size) {
|
|
2392
|
+
const out = [];
|
|
2393
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
2394
|
+
return out;
|
|
2395
|
+
}
|
|
2396
|
+
function validateTranslation(req, translation) {
|
|
2397
|
+
if (translation === void 0) return { id: req.id, error: "No translation returned." };
|
|
2398
|
+
if (!placeholdersMatch(req.source, translation)) {
|
|
2399
|
+
return { id: req.id, error: "Placeholder mismatch between source and translation." };
|
|
2400
|
+
}
|
|
2401
|
+
if (req.maxLength !== void 0 && translation.length > req.maxLength) {
|
|
2402
|
+
return { id: req.id, error: `Exceeds maxLength (${translation.length} > ${req.maxLength}).` };
|
|
2403
|
+
}
|
|
2404
|
+
return { id: req.id, translation };
|
|
2405
|
+
}
|
|
2406
|
+
function validatePlural(req, forms) {
|
|
2407
|
+
if (!forms) return { id: req.id, error: "No translation returned." };
|
|
2408
|
+
const plural = req.plural;
|
|
2409
|
+
if (!plural) return { id: req.id, error: "validatePlural called on a non-plural request." };
|
|
2410
|
+
const cats = plural.categories;
|
|
2411
|
+
const missing = cats.filter((c) => typeof forms[c] !== "string");
|
|
2412
|
+
if (missing.length) return { id: req.id, error: `Missing plural categories: ${missing.join(", ")}.` };
|
|
2413
|
+
const badPh = cats.find((c) => !pluralFormPlaceholdersMatch(c, req.source, forms[c]));
|
|
2414
|
+
if (badPh) return { id: req.id, error: `Placeholder mismatch in plural form "${badPh}".` };
|
|
2415
|
+
if (req.maxLength !== void 0) {
|
|
2416
|
+
const over = cats.find((c) => forms[c].length > req.maxLength);
|
|
2417
|
+
if (over) return { id: req.id, error: `Plural form "${over}" exceeds maxLength (${forms[over].length} > ${req.maxLength}).` };
|
|
2418
|
+
}
|
|
2419
|
+
const out = {};
|
|
2420
|
+
for (const c of cats) out[c] = forms[c];
|
|
2421
|
+
return { id: req.id, forms: out };
|
|
2422
|
+
}
|
|
2423
|
+
function validateReply(req, item) {
|
|
2424
|
+
return req.plural ? validatePlural(req, item?.forms) : validateTranslation(req, item?.translation);
|
|
2425
|
+
}
|
|
2426
|
+
async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal) {
|
|
2427
|
+
const results = [];
|
|
2428
|
+
const total = reqs.length;
|
|
2429
|
+
for (const batch of chunk(reqs, Math.max(1, batchSize))) {
|
|
2430
|
+
if (signal?.aborted) break;
|
|
2431
|
+
const reply = await callBatch(batch, signal);
|
|
2432
|
+
const byId = new Map(reply.map((r) => [r.id, r]));
|
|
2433
|
+
const batchResults = [];
|
|
2434
|
+
for (const req of batch) batchResults.push(validateReply(req, byId.get(req.id)));
|
|
2435
|
+
results.push(...batchResults);
|
|
2436
|
+
onBatchComplete?.(results.length, total, batchResults);
|
|
2437
|
+
}
|
|
2438
|
+
return results;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/server/ai/anthropic.ts
|
|
2442
|
+
var AnthropicProvider = class {
|
|
2443
|
+
constructor(config, client) {
|
|
2444
|
+
this.config = config;
|
|
2445
|
+
if (client) {
|
|
2446
|
+
this.client = client;
|
|
2447
|
+
} else {
|
|
2448
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
2449
|
+
throw new Error("ANTHROPIC_API_KEY is not set. AI translation requires it; every other feature works offline.");
|
|
2450
|
+
}
|
|
2451
|
+
this.client = new Anthropic({ baseURL: config.endpoint ?? void 0 });
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
config;
|
|
2455
|
+
client;
|
|
2456
|
+
supportsVision() {
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2459
|
+
translate(reqs, onBatchComplete, signal) {
|
|
2460
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2461
|
+
}
|
|
2462
|
+
// Build the user message as content blocks: each unique key's screenshot is
|
|
2463
|
+
// sent once (a key recurs once per target locale in a batch — dedupe by key),
|
|
2464
|
+
// then the batch prompt text describes every item.
|
|
2465
|
+
buildUserContent(batch) {
|
|
2466
|
+
const content = [];
|
|
2467
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2468
|
+
for (const req of batch) {
|
|
2469
|
+
if (!req.image || seen.has(req.key)) continue;
|
|
2470
|
+
seen.add(req.key);
|
|
2471
|
+
content.push({ type: "text", text: `Screenshot for key "${req.key}":` });
|
|
2472
|
+
content.push({
|
|
2473
|
+
type: "image",
|
|
2474
|
+
source: { type: "base64", media_type: req.image.mediaType, data: req.image.base64 }
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
content.push({ type: "text", text: buildBatchPrompt(batch) });
|
|
2478
|
+
return content;
|
|
2479
|
+
}
|
|
2480
|
+
async complete(req) {
|
|
2481
|
+
const content = req.content.map(
|
|
2482
|
+
(b) => b.type === "image" ? { type: "image", source: { type: "base64", media_type: b.mediaType, data: b.base64 } } : { type: "text", text: b.text ?? "" }
|
|
2483
|
+
);
|
|
2484
|
+
const res = await this.client.messages.create({
|
|
2485
|
+
model: this.config.model,
|
|
2486
|
+
max_tokens: req.maxTokens ?? 8192,
|
|
2487
|
+
system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
|
|
2488
|
+
output_config: { format: { type: "json_schema", schema: req.schema } },
|
|
2489
|
+
messages: [{ role: "user", content }]
|
|
2490
|
+
});
|
|
2491
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
2492
|
+
try {
|
|
2493
|
+
return JSON.parse(text);
|
|
2494
|
+
} catch {
|
|
2495
|
+
return {};
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
async callBatch(batch, signal) {
|
|
2499
|
+
const content = this.buildUserContent(batch);
|
|
2500
|
+
const res = await this.client.messages.create({
|
|
2501
|
+
model: this.config.model,
|
|
2502
|
+
max_tokens: 8192,
|
|
2503
|
+
system: [{ type: "text", text: buildSystemPrompt(), cache_control: { type: "ephemeral" } }],
|
|
2504
|
+
output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
|
|
2505
|
+
messages: [{ role: "user", content }]
|
|
2506
|
+
}, { signal });
|
|
2507
|
+
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
2508
|
+
try {
|
|
2509
|
+
const parsed = JSON.parse(text);
|
|
2510
|
+
return parsed.items ?? [];
|
|
2511
|
+
} catch {
|
|
2512
|
+
return [];
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
|
|
2517
|
+
// src/server/ai/openai.ts
|
|
2518
|
+
import { createRequire } from "module";
|
|
2519
|
+
var OpenAIProvider = class {
|
|
2520
|
+
constructor(config, client) {
|
|
2521
|
+
this.config = config;
|
|
2522
|
+
if (client) {
|
|
2523
|
+
this.client = client;
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
2527
|
+
throw new Error("OPENAI_API_KEY is not set. AI translation requires it; every other feature works offline.");
|
|
2528
|
+
}
|
|
2529
|
+
const require2 = createRequire(import.meta.url);
|
|
2530
|
+
let OpenAICtor;
|
|
2531
|
+
try {
|
|
2532
|
+
const mod = require2("openai");
|
|
2533
|
+
OpenAICtor = mod.OpenAI ?? mod.default ?? mod;
|
|
2534
|
+
} catch {
|
|
2535
|
+
throw new Error('Provider "openai" requires the OpenAI SDK. Install it: npm i openai');
|
|
2536
|
+
}
|
|
2537
|
+
this.client = new OpenAICtor({ baseURL: config.endpoint ?? void 0 });
|
|
2538
|
+
}
|
|
2539
|
+
config;
|
|
2540
|
+
client;
|
|
2541
|
+
supportsVision() {
|
|
2542
|
+
return true;
|
|
2543
|
+
}
|
|
2544
|
+
translate(reqs, onBatchComplete, signal) {
|
|
2545
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2546
|
+
}
|
|
2547
|
+
// User content as an array of parts: each unique key's screenshot once (as an
|
|
2548
|
+
// image_url data URL), then the batch prompt text describing every item.
|
|
2549
|
+
buildUserContent(batch) {
|
|
2550
|
+
const parts = [];
|
|
2551
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2552
|
+
for (const req of batch) {
|
|
2553
|
+
if (!req.image || seen.has(req.key)) continue;
|
|
2554
|
+
seen.add(req.key);
|
|
2555
|
+
parts.push({ type: "text", text: `Screenshot for key "${req.key}":` });
|
|
2556
|
+
parts.push({ type: "image_url", image_url: { url: `data:${req.image.mediaType};base64,${req.image.base64}` } });
|
|
2557
|
+
}
|
|
2558
|
+
parts.push({ type: "text", text: buildBatchPrompt(batch) });
|
|
2559
|
+
return parts;
|
|
2560
|
+
}
|
|
2561
|
+
async complete(req) {
|
|
2562
|
+
const content = req.content.map(
|
|
2563
|
+
(b) => b.type === "image" ? { type: "image_url", image_url: { url: `data:${b.mediaType};base64,${b.base64}` } } : { type: "text", text: b.text ?? "" }
|
|
2564
|
+
);
|
|
2565
|
+
const res = await this.client.chat.completions.create({
|
|
2566
|
+
model: this.config.model,
|
|
2567
|
+
max_tokens: req.maxTokens ?? 8192,
|
|
2568
|
+
response_format: { type: "json_schema", json_schema: { name: "completion", schema: req.schema, strict: false } },
|
|
2569
|
+
messages: [
|
|
2570
|
+
{ role: "system", content: req.system },
|
|
2571
|
+
{ role: "user", content }
|
|
2572
|
+
]
|
|
2573
|
+
});
|
|
2574
|
+
const text = res.choices?.[0]?.message?.content ?? "{}";
|
|
2575
|
+
try {
|
|
2576
|
+
return JSON.parse(text);
|
|
2577
|
+
} catch {
|
|
2578
|
+
return {};
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
async callBatch(batch, signal) {
|
|
2582
|
+
const res = await this.client.chat.completions.create({
|
|
2583
|
+
model: this.config.model,
|
|
2584
|
+
// strict:false — the shared BATCH_SCHEMA marks only `id` required (each
|
|
2585
|
+
// item carries EITHER translation OR forms), which OpenAI's strict mode
|
|
2586
|
+
// (all properties required) disallows. We validate the reply ourselves
|
|
2587
|
+
// via runBatched, so non-strict schema guidance is sufficient.
|
|
2588
|
+
response_format: { type: "json_schema", json_schema: { name: "translations", schema: BATCH_SCHEMA, strict: false } },
|
|
2589
|
+
messages: [
|
|
2590
|
+
{ role: "system", content: buildSystemPrompt() },
|
|
2591
|
+
{ role: "user", content: this.buildUserContent(batch) }
|
|
2592
|
+
]
|
|
2593
|
+
}, { signal });
|
|
2594
|
+
const text = res.choices?.[0]?.message?.content ?? "{}";
|
|
2595
|
+
try {
|
|
2596
|
+
const parsed = JSON.parse(text);
|
|
2597
|
+
return parsed.items ?? [];
|
|
2598
|
+
} catch {
|
|
2599
|
+
return [];
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
// src/server/ai/bedrock.ts
|
|
2605
|
+
import { createRequire as createRequire2 } from "module";
|
|
2606
|
+
var IMAGE_FORMATS = {
|
|
2607
|
+
"image/png": "png",
|
|
2608
|
+
"image/jpeg": "jpeg",
|
|
2609
|
+
"image/webp": "webp",
|
|
2610
|
+
"image/gif": "gif"
|
|
2611
|
+
};
|
|
2612
|
+
var BedrockProvider = class {
|
|
2613
|
+
constructor(config, deps) {
|
|
2614
|
+
this.config = config;
|
|
2615
|
+
if (deps) {
|
|
2616
|
+
this.client = deps.client;
|
|
2617
|
+
this.makeCommand = deps.makeCommand;
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
const region = config.region ?? process.env.AWS_REGION;
|
|
2621
|
+
if (!region) {
|
|
2622
|
+
throw new Error("AWS region is not set. Set config.ai.region or the AWS_REGION environment variable for the bedrock provider.");
|
|
2623
|
+
}
|
|
2624
|
+
const require2 = createRequire2(import.meta.url);
|
|
2625
|
+
let sdk;
|
|
2626
|
+
try {
|
|
2627
|
+
sdk = require2("@aws-sdk/client-bedrock-runtime");
|
|
2628
|
+
} catch {
|
|
2629
|
+
throw new Error('Provider "bedrock" requires the AWS SDK. Install it: npm i @aws-sdk/client-bedrock-runtime');
|
|
2630
|
+
}
|
|
2631
|
+
this.client = new sdk.BedrockRuntimeClient({ region });
|
|
2632
|
+
this.makeCommand = (input) => new sdk.ConverseCommand(input);
|
|
2633
|
+
}
|
|
2634
|
+
config;
|
|
2635
|
+
client;
|
|
2636
|
+
makeCommand;
|
|
2637
|
+
// Meta Llama text models on Bedrock support neither vision nor reliable
|
|
2638
|
+
// forced tool-use; Nova and Claude support both.
|
|
2639
|
+
isMeta() {
|
|
2640
|
+
return this.config.model.includes("meta.");
|
|
2641
|
+
}
|
|
2642
|
+
supportsVision() {
|
|
2643
|
+
return !this.isMeta();
|
|
2644
|
+
}
|
|
2645
|
+
translate(reqs, onBatchComplete, signal) {
|
|
2646
|
+
return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal);
|
|
2647
|
+
}
|
|
2648
|
+
buildContentBlocks(batch) {
|
|
2649
|
+
const blocks = [];
|
|
2650
|
+
if (this.supportsVision()) {
|
|
2651
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2652
|
+
for (const req of batch) {
|
|
2653
|
+
if (!req.image || seen.has(req.key)) continue;
|
|
2654
|
+
const format = IMAGE_FORMATS[req.image.mediaType];
|
|
2655
|
+
if (!format) continue;
|
|
2656
|
+
seen.add(req.key);
|
|
2657
|
+
blocks.push({ text: `Screenshot for key "${req.key}":` });
|
|
2658
|
+
blocks.push({ image: { format, source: { bytes: Buffer.from(req.image.base64, "base64") } } });
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
blocks.push({ text: buildBatchPrompt(batch) });
|
|
2662
|
+
return blocks;
|
|
2663
|
+
}
|
|
2664
|
+
async complete(req) {
|
|
2665
|
+
const blocks = req.content.map(
|
|
2666
|
+
(b) => b.type === "image" && this.supportsVision() ? { image: { format: IMAGE_FORMATS[b.mediaType ?? ""] ?? "png", source: { bytes: Buffer.from(b.base64 ?? "", "base64") } } } : { text: b.text ?? "" }
|
|
2667
|
+
);
|
|
2668
|
+
const SCHEMA_NAME = "emit_completion";
|
|
2669
|
+
const input = {
|
|
2670
|
+
modelId: this.config.model,
|
|
2671
|
+
system: [{ text: req.system }],
|
|
2672
|
+
messages: [{ role: "user", content: blocks }],
|
|
2673
|
+
...this.isMeta() ? {} : {
|
|
2674
|
+
toolConfig: {
|
|
2675
|
+
tools: [{ toolSpec: { name: SCHEMA_NAME, inputSchema: { json: req.schema } } }],
|
|
2676
|
+
toolChoice: { tool: { name: SCHEMA_NAME } }
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
2680
|
+
const res = await this.client.send(this.makeCommand(input));
|
|
2681
|
+
const content = res.output?.message?.content ?? [];
|
|
2682
|
+
const tool = content.find((b) => b.toolUse)?.toolUse;
|
|
2683
|
+
if (tool?.input) return tool.input;
|
|
2684
|
+
const text = content.find((b) => b.text)?.text ?? "{}";
|
|
2685
|
+
try {
|
|
2686
|
+
return JSON.parse(text);
|
|
2687
|
+
} catch {
|
|
2688
|
+
return {};
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
buildInput(batch) {
|
|
2692
|
+
const input = {
|
|
2693
|
+
modelId: this.config.model,
|
|
2694
|
+
system: [{ text: buildSystemPrompt() }],
|
|
2695
|
+
messages: [{ role: "user", content: this.buildContentBlocks(batch) }]
|
|
2696
|
+
};
|
|
2697
|
+
if (!this.isMeta()) {
|
|
2698
|
+
input.toolConfig = {
|
|
2699
|
+
tools: [{ toolSpec: { name: "emit_translations", inputSchema: { json: BATCH_SCHEMA } } }],
|
|
2700
|
+
toolChoice: { tool: { name: "emit_translations" } }
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
return input;
|
|
2704
|
+
}
|
|
2705
|
+
async callBatch(batch, signal) {
|
|
2706
|
+
const res = await this.client.send(this.makeCommand(this.buildInput(batch)), { abortSignal: signal });
|
|
2707
|
+
const blocks = res.output?.message?.content ?? [];
|
|
2708
|
+
const tool = blocks.find((b) => b.toolUse)?.toolUse;
|
|
2709
|
+
if (tool?.input?.items) return tool.input.items;
|
|
2710
|
+
const text = blocks.find((b) => b.text)?.text ?? "{}";
|
|
2711
|
+
try {
|
|
2712
|
+
const parsed = JSON.parse(text);
|
|
2713
|
+
return parsed.items ?? [];
|
|
2714
|
+
} catch {
|
|
2715
|
+
return [];
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
2719
|
+
|
|
2720
|
+
// src/server/ai/index.ts
|
|
2721
|
+
function makeProvider(config) {
|
|
2722
|
+
const ai = config.ai;
|
|
2723
|
+
switch (ai.provider) {
|
|
2724
|
+
case "anthropic":
|
|
2725
|
+
return new AnthropicProvider(ai);
|
|
2726
|
+
case "openai":
|
|
2727
|
+
return new OpenAIProvider(ai);
|
|
2728
|
+
case "bedrock":
|
|
2729
|
+
return new BedrockProvider(ai);
|
|
2730
|
+
default:
|
|
2731
|
+
throw new Error(`Unknown AI provider "${String(ai.provider)}". Supported: anthropic, openai, bedrock.`);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// src/server/ai/log.ts
|
|
2736
|
+
import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
|
|
2737
|
+
import { resolve as resolve4 } from "path";
|
|
2738
|
+
function logPath(projectRoot) {
|
|
2739
|
+
return resolve4(projectRoot, ".glotfile", "ai-log.jsonl");
|
|
2740
|
+
}
|
|
2741
|
+
function appendAiLog(projectRoot, entry) {
|
|
2742
|
+
mkdirSync4(resolve4(projectRoot, ".glotfile"), { recursive: true });
|
|
2743
|
+
appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
|
|
2744
|
+
}
|
|
2745
|
+
function readAiLog(projectRoot, limit = 100) {
|
|
2746
|
+
const path = logPath(projectRoot);
|
|
2747
|
+
if (!existsSync6(path)) return [];
|
|
2748
|
+
const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
|
|
2749
|
+
const entries = lines.map((l) => {
|
|
2750
|
+
const e = JSON.parse(l);
|
|
2751
|
+
e.kind ??= "translate";
|
|
2752
|
+
return e;
|
|
2753
|
+
});
|
|
2754
|
+
return entries.reverse().slice(0, limit);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/server/import/detect.ts
|
|
2758
|
+
import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
|
|
2759
|
+
import { join as join3 } from "path";
|
|
2760
|
+
var LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2761
|
+
var VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
|
|
2762
|
+
function safeIsDir(p) {
|
|
2763
|
+
try {
|
|
2764
|
+
return statSync2(p).isDirectory();
|
|
2765
|
+
} catch {
|
|
2766
|
+
return false;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
function listDirs(dir) {
|
|
2770
|
+
return readdirSync3(dir).filter((e) => safeIsDir(join3(dir, e)));
|
|
2771
|
+
}
|
|
2772
|
+
function fileCount(dir) {
|
|
2773
|
+
try {
|
|
2774
|
+
return readdirSync3(dir).length;
|
|
2775
|
+
} catch {
|
|
2776
|
+
return 0;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
function pickSource(locales, sizeOf) {
|
|
2780
|
+
if (locales.includes("en")) return "en";
|
|
2781
|
+
return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
|
|
2782
|
+
}
|
|
2783
|
+
function detectLaravel(root) {
|
|
2784
|
+
const localeRoot = [join3(root, "resources", "lang"), join3(root, "lang")].find(safeIsDir);
|
|
2785
|
+
if (!localeRoot) return null;
|
|
2786
|
+
const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
|
|
2787
|
+
if (locales.length === 0) return null;
|
|
2788
|
+
const sourceLocale = pickSource(locales, (loc) => fileCount(join3(localeRoot, loc)));
|
|
2789
|
+
return { format: "laravel-php", localeRoot, locales, sourceLocale };
|
|
2790
|
+
}
|
|
2791
|
+
function detectVue(root) {
|
|
2792
|
+
for (const rel of VUE_DIR_CANDIDATES) {
|
|
2793
|
+
const localeRoot = join3(root, rel);
|
|
2794
|
+
if (!safeIsDir(localeRoot)) continue;
|
|
2795
|
+
const locales = readdirSync3(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
|
|
2796
|
+
if (locales.length >= 2) {
|
|
2797
|
+
const sourceLocale = pickSource(locales, (loc) => {
|
|
2798
|
+
try {
|
|
2799
|
+
return statSync2(join3(localeRoot, `${loc}.json`)).size;
|
|
2800
|
+
} catch {
|
|
2801
|
+
return 0;
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
return { format: "vue-i18n-json", localeRoot, locales, sourceLocale };
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
return null;
|
|
2808
|
+
}
|
|
2809
|
+
function detectArb(root) {
|
|
2810
|
+
for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
|
|
2811
|
+
const localeRoot = join3(root, rel);
|
|
2812
|
+
if (!safeIsDir(localeRoot)) continue;
|
|
2813
|
+
const locales = readdirSync3(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
|
|
2814
|
+
if (locales.length >= 1) {
|
|
2815
|
+
return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
return null;
|
|
2819
|
+
}
|
|
2820
|
+
var DETECTORS = [detectLaravel, detectVue, detectArb];
|
|
2821
|
+
var BY_FORMAT = {
|
|
2822
|
+
"laravel-php": detectLaravel,
|
|
2823
|
+
"vue-i18n-json": detectVue,
|
|
2824
|
+
"flutter-arb": detectArb
|
|
2825
|
+
};
|
|
2826
|
+
function detect(root, formatOverride) {
|
|
2827
|
+
if (!existsSync7(root)) return null;
|
|
2828
|
+
if (formatOverride) {
|
|
2829
|
+
const fn = BY_FORMAT[formatOverride];
|
|
2830
|
+
if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
|
|
2831
|
+
return fn(root);
|
|
2832
|
+
}
|
|
2833
|
+
for (const fn of DETECTORS) {
|
|
2834
|
+
const d = fn(root);
|
|
2835
|
+
if (d) return d;
|
|
2836
|
+
}
|
|
2837
|
+
return null;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// src/server/import/parsers/vue-i18n-json.ts
|
|
2841
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
|
|
2842
|
+
import { join as join4 } from "path";
|
|
2843
|
+
|
|
2844
|
+
// src/server/import/flatten.ts
|
|
2845
|
+
function flattenObject(value, prefix, warnings) {
|
|
2846
|
+
const out = {};
|
|
2847
|
+
const walk = (node, path) => {
|
|
2848
|
+
if (typeof node === "string") {
|
|
2849
|
+
out[path] = node;
|
|
2850
|
+
} else if (typeof node === "number" || typeof node === "boolean") {
|
|
2851
|
+
out[path] = String(node);
|
|
2852
|
+
} else if (Array.isArray(node)) {
|
|
2853
|
+
node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
|
|
2854
|
+
} else if (node && typeof node === "object") {
|
|
2855
|
+
for (const [k, v] of Object.entries(node)) {
|
|
2856
|
+
walk(v, path ? `${path}.${k}` : k);
|
|
2857
|
+
}
|
|
2858
|
+
} else {
|
|
2859
|
+
warnings.push(`skipped non-string value at "${path || "(root)"}"`);
|
|
2860
|
+
}
|
|
2861
|
+
};
|
|
2862
|
+
walk(value, prefix.replace(/\.$/, ""));
|
|
2863
|
+
return out;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// src/server/import/parsers/vue-i18n-json.ts
|
|
2867
|
+
var LOCALE_RE2 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2868
|
+
var vueI18nJson2 = {
|
|
2869
|
+
name: "vue-i18n-json",
|
|
2870
|
+
parse(localeRoot, opts) {
|
|
2871
|
+
const warnings = [];
|
|
2872
|
+
const keys = {};
|
|
2873
|
+
const locales = [];
|
|
2874
|
+
for (const file of readdirSync4(localeRoot).sort()) {
|
|
2875
|
+
if (!file.endsWith(".json")) continue;
|
|
2876
|
+
const locale = file.slice(0, -".json".length);
|
|
2877
|
+
if (!LOCALE_RE2.test(locale)) continue;
|
|
2878
|
+
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2879
|
+
let data;
|
|
2880
|
+
try {
|
|
2881
|
+
data = JSON.parse(readFileSync8(join4(localeRoot, file), "utf8"));
|
|
2882
|
+
} catch (e) {
|
|
2883
|
+
warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
if (!locales.includes(locale)) locales.push(locale);
|
|
2887
|
+
for (const [key, value] of Object.entries(flattenObject(data, "", warnings))) {
|
|
2888
|
+
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return { locales, keys, warnings };
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
|
|
2895
|
+
// src/server/import/parsers/laravel-php.ts
|
|
2896
|
+
import { readdirSync as readdirSync5, statSync as statSync3 } from "fs";
|
|
2897
|
+
import { join as join5, relative as relative2 } from "path";
|
|
2898
|
+
import { execFileSync } from "child_process";
|
|
2899
|
+
|
|
2900
|
+
// src/server/import/placeholders.ts
|
|
2901
|
+
function laravelToCanonical(value) {
|
|
2902
|
+
return value.replace(/:([a-zA-Z][a-zA-Z0-9_]*)/g, "{$1}");
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// src/server/import/parsers/laravel-php.ts
|
|
2906
|
+
function listDirs2(dir) {
|
|
2907
|
+
return readdirSync5(dir).filter((e) => statSync3(join5(dir, e)).isDirectory());
|
|
2908
|
+
}
|
|
2909
|
+
function listPhpFiles(dir) {
|
|
2910
|
+
const out = [];
|
|
2911
|
+
const walk = (d) => {
|
|
2912
|
+
for (const e of readdirSync5(d)) {
|
|
2913
|
+
const full = join5(d, e);
|
|
2914
|
+
if (statSync3(full).isDirectory()) walk(full);
|
|
2915
|
+
else if (e.endsWith(".php")) out.push(full);
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
walk(dir);
|
|
2919
|
+
return out.sort();
|
|
2920
|
+
}
|
|
2921
|
+
var PHP_READ_ALL = '$fs=array_filter(array_map("trim",explode("\\n",stream_get_contents(STDIN))),"strlen");$o=[];foreach($fs as $f){try{$o[$f]=require $f;}catch(\\Throwable $e){}}echo json_encode($o);';
|
|
2922
|
+
function readPhpArrays(files) {
|
|
2923
|
+
if (files.length === 0) return {};
|
|
2924
|
+
let stdout;
|
|
2925
|
+
try {
|
|
2926
|
+
stdout = execFileSync("php", ["-r", PHP_READ_ALL], {
|
|
2927
|
+
input: files.join("\n"),
|
|
2928
|
+
encoding: "utf8",
|
|
2929
|
+
maxBuffer: 256 * 1024 * 1024
|
|
2930
|
+
});
|
|
2931
|
+
} catch (e) {
|
|
2932
|
+
const err = e;
|
|
2933
|
+
if (err.code === "ENOENT") {
|
|
2934
|
+
throw new Error("php is required to import Laravel PHP files but was not found on PATH");
|
|
2935
|
+
}
|
|
2936
|
+
throw new Error(`php failed to evaluate Laravel lang files: ${err.message}`);
|
|
2937
|
+
}
|
|
2938
|
+
return JSON.parse(stdout);
|
|
2939
|
+
}
|
|
2940
|
+
var laravelPhp2 = {
|
|
2941
|
+
name: "laravel-php",
|
|
2942
|
+
parse(localeRoot, opts) {
|
|
2943
|
+
const warnings = [];
|
|
2944
|
+
const keys = {};
|
|
2945
|
+
const locales = [];
|
|
2946
|
+
const entries = [];
|
|
2947
|
+
for (const locale of listDirs2(localeRoot).sort()) {
|
|
2948
|
+
if (locale === "vendor") continue;
|
|
2949
|
+
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
2950
|
+
const localeDir = join5(localeRoot, locale);
|
|
2951
|
+
locales.push(locale);
|
|
2952
|
+
for (const file of listPhpFiles(localeDir)) {
|
|
2953
|
+
const group = relative2(localeDir, file).replace(/\\/g, "/").replace(/\.php$/, "");
|
|
2954
|
+
entries.push({ locale, group, file });
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
const data = readPhpArrays(entries.map((e) => e.file));
|
|
2958
|
+
for (const { locale, group, file } of entries) {
|
|
2959
|
+
if (!(file in data)) {
|
|
2960
|
+
warnings.push(`laravel-php: failed to read ${file}`);
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
for (const [inner, value] of Object.entries(flattenObject(data[file], "", warnings))) {
|
|
2964
|
+
const key = `${group}.${inner}`;
|
|
2965
|
+
(keys[key] ??= { values: {} }).values[locale] = laravelToCanonical(value);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
return { locales, keys, warnings };
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
|
|
2972
|
+
// src/server/import/parsers/flutter-arb.ts
|
|
2973
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync9 } from "fs";
|
|
2974
|
+
import { join as join6 } from "path";
|
|
2975
|
+
var LOCALE_RE3 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
|
|
2976
|
+
function localeFromArbName(file) {
|
|
2977
|
+
const m = file.match(/^(.+)\.arb$/);
|
|
2978
|
+
if (!m) return null;
|
|
2979
|
+
let locale = m[1];
|
|
2980
|
+
if (locale.startsWith("app_")) locale = locale.slice(4);
|
|
2981
|
+
return LOCALE_RE3.test(locale) ? locale : null;
|
|
2982
|
+
}
|
|
2983
|
+
function placeholderMeta(raw) {
|
|
2984
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2985
|
+
const out = {};
|
|
2986
|
+
for (const [name, def] of Object.entries(raw)) {
|
|
2987
|
+
if (!def || typeof def !== "object") continue;
|
|
2988
|
+
const o = def;
|
|
2989
|
+
const d = {};
|
|
2990
|
+
if (typeof o.type === "string") d.type = o.type;
|
|
2991
|
+
if (typeof o.format === "string") d.format = o.format;
|
|
2992
|
+
if (typeof o.example === "string") d.example = o.example;
|
|
2993
|
+
if (Object.keys(d).length) out[name] = d;
|
|
2994
|
+
}
|
|
2995
|
+
return Object.keys(out).length ? out : void 0;
|
|
2996
|
+
}
|
|
2997
|
+
var flutterArb2 = {
|
|
2998
|
+
name: "flutter-arb",
|
|
2999
|
+
parse(localeRoot, opts) {
|
|
3000
|
+
const warnings = [];
|
|
3001
|
+
const keys = {};
|
|
3002
|
+
const locales = [];
|
|
3003
|
+
for (const file of readdirSync6(localeRoot).sort()) {
|
|
3004
|
+
if (!file.endsWith(".arb")) continue;
|
|
3005
|
+
const locale = localeFromArbName(file);
|
|
3006
|
+
if (!locale) continue;
|
|
3007
|
+
if (opts?.locales && !opts.locales.includes(locale)) continue;
|
|
3008
|
+
let data;
|
|
3009
|
+
try {
|
|
3010
|
+
data = JSON.parse(readFileSync9(join6(localeRoot, file), "utf8"));
|
|
3011
|
+
} catch (e) {
|
|
3012
|
+
warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
if (!locales.includes(locale)) locales.push(locale);
|
|
3016
|
+
for (const [key, value] of Object.entries(data)) {
|
|
3017
|
+
if (key.startsWith("@@")) continue;
|
|
3018
|
+
if (key.startsWith("@")) {
|
|
3019
|
+
const meta = placeholderMeta(value?.placeholders);
|
|
3020
|
+
if (meta) (keys[key.slice(1)] ??= { values: {} }).placeholders = meta;
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
if (typeof value !== "string") {
|
|
3024
|
+
warnings.push(`flutter-arb: skipped non-string ${file}:${key}`);
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
(keys[key] ??= { values: {} }).values[locale] = value;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
return { locales, keys, warnings };
|
|
3031
|
+
}
|
|
3032
|
+
};
|
|
3033
|
+
|
|
3034
|
+
// src/server/import/parsers/index.ts
|
|
3035
|
+
var REGISTRY = {
|
|
3036
|
+
[vueI18nJson2.name]: vueI18nJson2,
|
|
3037
|
+
[laravelPhp2.name]: laravelPhp2,
|
|
3038
|
+
[flutterArb2.name]: flutterArb2
|
|
3039
|
+
};
|
|
3040
|
+
function getParser(name) {
|
|
3041
|
+
const p = REGISTRY[name];
|
|
3042
|
+
if (!p) throw new Error(`Unknown format: ${name} (known: ${Object.keys(REGISTRY).join(", ")})`);
|
|
3043
|
+
return p;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// src/server/import/assemble.ts
|
|
3047
|
+
var OUTPUT_BY_FORMAT = {
|
|
3048
|
+
"laravel-php": { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" },
|
|
3049
|
+
"vue-i18n-json": { adapter: "vue-i18n-json", path: "src/locale/{locale}.json" },
|
|
3050
|
+
"flutter-arb": { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" }
|
|
3051
|
+
};
|
|
3052
|
+
function assemble2(parsed, opts) {
|
|
3053
|
+
const warnings = [...parsed.warnings];
|
|
3054
|
+
const base = OUTPUT_BY_FORMAT[opts.format];
|
|
3055
|
+
if (!base) throw new Error(`No output mapping for format "${opts.format}"`);
|
|
3056
|
+
const rawLocales = [.../* @__PURE__ */ new Set([opts.sourceLocale, ...parsed.locales])];
|
|
3057
|
+
const pairs = rawLocales.map((obs) => [canonLocale(obs), obs]);
|
|
3058
|
+
const inferred = inferLocaleStyle(pairs, getAdapter(base.adapter).defaultLocaleCase);
|
|
3059
|
+
const output = { ...base, ...inferred };
|
|
3060
|
+
const sourceLocale = canonLocale(opts.sourceLocale);
|
|
3061
|
+
const locales = [...new Set(rawLocales.map(canonLocale))].sort();
|
|
3062
|
+
const keys = {};
|
|
3063
|
+
for (const [key, parsed_key] of Object.entries(parsed.keys)) {
|
|
3064
|
+
const entry = { values: {} };
|
|
3065
|
+
const sourceRaw = parsed_key.values[opts.sourceLocale];
|
|
3066
|
+
const sourcePlural = sourceRaw !== void 0 ? parseIcuPlural(sourceRaw) : null;
|
|
3067
|
+
if (sourcePlural) {
|
|
3068
|
+
entry.plural = { arg: sourcePlural.arg };
|
|
3069
|
+
for (const [locale, value] of Object.entries(parsed_key.values)) {
|
|
3070
|
+
const state = locale === opts.sourceLocale ? "source" : "reviewed";
|
|
3071
|
+
const parsedForms = locale === opts.sourceLocale ? sourcePlural : parseIcuPlural(value);
|
|
3072
|
+
if (parsedForms) {
|
|
3073
|
+
const forms = opts.cldr ? exactFormsToCldr(locale, parsedForms.forms) : parsedForms.forms;
|
|
3074
|
+
entry.values[locale] = { forms, state };
|
|
3075
|
+
} else {
|
|
3076
|
+
entry.values[locale] = { forms: { other: value }, state };
|
|
3077
|
+
warnings.push(
|
|
3078
|
+
`key "${key}" locale "${locale}": value is not a parseable ICU plural; preserved under "other".`
|
|
3079
|
+
);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
} else {
|
|
3083
|
+
for (const [locale, value] of Object.entries(parsed_key.values)) {
|
|
3084
|
+
entry.values[locale] = {
|
|
3085
|
+
value,
|
|
3086
|
+
state: locale === opts.sourceLocale ? "source" : "reviewed"
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
if (!(opts.sourceLocale in entry.values)) {
|
|
3091
|
+
warnings.push(
|
|
3092
|
+
`key "${key}" has no ${opts.sourceLocale} (source) value; its values are marked reviewed without a source.`
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
const canonValues = {};
|
|
3096
|
+
for (const [loc, lv] of Object.entries(entry.values)) canonValues[canonLocale(loc)] = lv;
|
|
3097
|
+
entry.values = canonValues;
|
|
3098
|
+
if (parsed_key.placeholders) entry.placeholders = parsed_key.placeholders;
|
|
3099
|
+
keys[key] = entry;
|
|
3100
|
+
}
|
|
3101
|
+
return {
|
|
3102
|
+
$schema: "https://glotfile.dev/schema/v1.json",
|
|
3103
|
+
version: CURRENT_VERSION,
|
|
3104
|
+
config: {
|
|
3105
|
+
sourceLocale,
|
|
3106
|
+
locales,
|
|
3107
|
+
outputs: [output],
|
|
3108
|
+
ai: { provider: "anthropic", model: "claude-opus-4-8", endpoint: null, batchSize: 25 },
|
|
3109
|
+
format: { indent: 2, sortKeys: true, finalNewline: true },
|
|
3110
|
+
spelling: { customWords: [] }
|
|
3111
|
+
},
|
|
3112
|
+
glossary: [],
|
|
3113
|
+
keys,
|
|
3114
|
+
warnings
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// src/server/import/run.ts
|
|
3119
|
+
function previewImport(projectRoot, format) {
|
|
3120
|
+
const det = detect(projectRoot, format);
|
|
3121
|
+
if (!det) return null;
|
|
3122
|
+
const parsed = getParser(det.format).parse(det.localeRoot, { locales: [det.sourceLocale] });
|
|
3123
|
+
const keys = Object.keys(parsed.keys);
|
|
3124
|
+
const sampleKeys = [];
|
|
3125
|
+
for (const key of keys) {
|
|
3126
|
+
const value = parsed.keys[key].values[det.sourceLocale];
|
|
3127
|
+
if (typeof value === "string") {
|
|
3128
|
+
sampleKeys.push({ key, value });
|
|
3129
|
+
if (sampleKeys.length >= 5) break;
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
return {
|
|
3133
|
+
format: det.format,
|
|
3134
|
+
localeRoot: det.localeRoot,
|
|
3135
|
+
locales: det.locales,
|
|
3136
|
+
sourceLocale: det.sourceLocale,
|
|
3137
|
+
keyCount: keys.length,
|
|
3138
|
+
sampleKeys
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
function runImport(opts) {
|
|
3142
|
+
const det = detect(opts.projectRoot, opts.format);
|
|
3143
|
+
if (!det) throw new Error(`No recognized locale files found in ${opts.projectRoot}`);
|
|
3144
|
+
const parser = getParser(det.format);
|
|
3145
|
+
const parsed = parser.parse(
|
|
3146
|
+
det.localeRoot,
|
|
3147
|
+
opts.locales ? { locales: opts.locales } : void 0
|
|
3148
|
+
);
|
|
3149
|
+
const assembled = assemble2(parsed, {
|
|
3150
|
+
sourceLocale: opts.sourceLocale ?? det.sourceLocale,
|
|
3151
|
+
format: det.format,
|
|
3152
|
+
cldr: opts.cldr
|
|
3153
|
+
});
|
|
3154
|
+
const { warnings, ...rest } = assembled;
|
|
3155
|
+
const state = validate(rest);
|
|
3156
|
+
return {
|
|
3157
|
+
state,
|
|
3158
|
+
warnings,
|
|
3159
|
+
keyCount: Object.keys(state.keys).length,
|
|
3160
|
+
localeCount: state.config.locales.length
|
|
3161
|
+
};
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// src/server/export-run.ts
|
|
3165
|
+
import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync10 } from "fs";
|
|
3166
|
+
import { dirname as dirname4, resolve as resolve5 } from "path";
|
|
3167
|
+
function effectiveLocales(config) {
|
|
3168
|
+
const limit = config.exportLocales;
|
|
3169
|
+
if (!limit || limit.length === 0) return config.locales;
|
|
3170
|
+
return config.locales.filter((l) => limit.includes(l));
|
|
3171
|
+
}
|
|
3172
|
+
function narrowForExport(state) {
|
|
3173
|
+
const locales = effectiveLocales(state.config);
|
|
3174
|
+
if (locales.length === state.config.locales.length) return state;
|
|
3175
|
+
return { ...state, config: { ...state.config, locales } };
|
|
3176
|
+
}
|
|
3177
|
+
function exportToDisk(state, projectRoot, opts) {
|
|
3178
|
+
state = narrowForExport(state);
|
|
3179
|
+
const outputs = opts?.adapter ? state.config.outputs.filter((o) => o.adapter === opts.adapter) : state.config.outputs;
|
|
3180
|
+
const warnings = [];
|
|
3181
|
+
let written = 0;
|
|
3182
|
+
let skipped = 0;
|
|
3183
|
+
for (const output of outputs) {
|
|
3184
|
+
const result = getAdapter(output.adapter).export(state, output);
|
|
3185
|
+
warnings.push(...result.warnings);
|
|
3186
|
+
const writtenPaths = /* @__PURE__ */ new Set();
|
|
3187
|
+
for (const f of result.files) {
|
|
3188
|
+
const abs = resolve5(projectRoot, f.path);
|
|
3189
|
+
if (writtenPaths.has(abs)) {
|
|
3190
|
+
skipped++;
|
|
3191
|
+
continue;
|
|
3192
|
+
}
|
|
3193
|
+
writtenPaths.add(abs);
|
|
3194
|
+
let current = null;
|
|
3195
|
+
try {
|
|
3196
|
+
current = readFileSync10(abs, "utf8");
|
|
3197
|
+
} catch {
|
|
3198
|
+
}
|
|
3199
|
+
if (current === f.contents) {
|
|
3200
|
+
skipped++;
|
|
3201
|
+
continue;
|
|
3202
|
+
}
|
|
3203
|
+
mkdirSync5(dirname4(abs), { recursive: true });
|
|
3204
|
+
writeFileSync4(abs, f.contents, "utf8");
|
|
3205
|
+
written++;
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
return { written, skipped, warnings };
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
// src/server/api.ts
|
|
3212
|
+
var sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
|
|
3213
|
+
var screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
|
|
3214
|
+
function projectName(root) {
|
|
3215
|
+
const nameFile = resolve6(root, ".idea", ".name");
|
|
3216
|
+
if (existsSync8(nameFile)) {
|
|
3217
|
+
try {
|
|
3218
|
+
const name = readFileSync11(nameFile, "utf8").trim();
|
|
3219
|
+
if (name) return name;
|
|
3220
|
+
} catch {
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
return basename(root);
|
|
3224
|
+
}
|
|
3225
|
+
function createApi(deps) {
|
|
3226
|
+
const app = new Hono();
|
|
3227
|
+
const load = () => loadState(deps.statePath);
|
|
3228
|
+
const projectRoot = dirname5(resolve6(deps.statePath));
|
|
3229
|
+
let translateQueue = Promise.resolve();
|
|
3230
|
+
const withTranslateLock = (fn) => {
|
|
3231
|
+
const next = translateQueue.then(fn, fn);
|
|
3232
|
+
translateQueue = next.then(() => {
|
|
3233
|
+
}, () => {
|
|
3234
|
+
});
|
|
3235
|
+
return next;
|
|
3236
|
+
};
|
|
3237
|
+
let autoExportTimer;
|
|
3238
|
+
const scheduleAutoExport = (s) => {
|
|
3239
|
+
if (!deps.autoExport || s.config.autoExport === false) return;
|
|
3240
|
+
clearTimeout(autoExportTimer);
|
|
3241
|
+
autoExportTimer = setTimeout(() => {
|
|
3242
|
+
try {
|
|
3243
|
+
exportToDisk(s, projectRoot);
|
|
3244
|
+
} catch {
|
|
3245
|
+
}
|
|
3246
|
+
}, 200);
|
|
3247
|
+
};
|
|
3248
|
+
const persist = (s) => {
|
|
3249
|
+
saveState(deps.statePath, s);
|
|
3250
|
+
scheduleAutoExport(s);
|
|
3251
|
+
};
|
|
3252
|
+
app.get("/state", (c) => c.json(load()));
|
|
3253
|
+
app.get("/file", (c) => c.json({ path: deps.statePath, name: basename(deps.statePath), dir: projectRoot, project: basename(projectRoot) }));
|
|
3254
|
+
app.get("/files", (c) => {
|
|
3255
|
+
const found = /* @__PURE__ */ new Map();
|
|
3256
|
+
found.set(deps.statePath, { name: basename(deps.statePath), path: deps.statePath });
|
|
3257
|
+
let entries = [];
|
|
3258
|
+
try {
|
|
3259
|
+
entries = readdirSync7(projectRoot);
|
|
3260
|
+
} catch {
|
|
3261
|
+
}
|
|
3262
|
+
for (const name of entries) {
|
|
3263
|
+
let abs;
|
|
3264
|
+
if ((name === "glotfile" || name.endsWith(".glotfile")) && existsSync8(resolve6(projectRoot, name, "config.json"))) {
|
|
3265
|
+
abs = resolve6(projectRoot, `${name}.json`);
|
|
3266
|
+
} else if (name === "glotfile.json" || name.endsWith(".glotfile.json")) {
|
|
3267
|
+
abs = resolve6(projectRoot, name);
|
|
3268
|
+
} else {
|
|
3269
|
+
continue;
|
|
3270
|
+
}
|
|
3271
|
+
if (found.has(abs)) continue;
|
|
3272
|
+
try {
|
|
3273
|
+
loadState(abs);
|
|
3274
|
+
found.set(abs, { name: basename(abs), path: abs });
|
|
3275
|
+
} catch {
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
const files = [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
3279
|
+
return c.json(files);
|
|
3280
|
+
});
|
|
3281
|
+
app.post("/file", async (c) => {
|
|
3282
|
+
const { path } = await c.req.json();
|
|
3283
|
+
if (typeof path !== "string") return c.json({ error: "path must be a string" }, 400);
|
|
3284
|
+
const resolved = resolve6(projectRoot, path);
|
|
3285
|
+
const inside = resolved === projectRoot || resolved.startsWith(projectRoot + sep);
|
|
3286
|
+
if (!inside) return c.json({ error: "file is outside the project" }, 400);
|
|
3287
|
+
if (!existsSync8(resolved)) return c.json({ error: "file not found" }, 400);
|
|
3288
|
+
loadState(resolved);
|
|
3289
|
+
deps.statePath = resolved;
|
|
3290
|
+
return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
|
|
3291
|
+
});
|
|
3292
|
+
app.post("/keys", async (c) => {
|
|
3293
|
+
const { key, value, plural } = await c.req.json();
|
|
3294
|
+
if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
|
|
3295
|
+
if (typeof value !== "string" || !value.trim()) return c.json({ error: "source value is required" }, 400);
|
|
3296
|
+
if (plural !== void 0 && (typeof plural?.arg !== "string" || !plural.arg.trim())) {
|
|
3297
|
+
return c.json({ error: "plural.arg must be a non-empty string" }, 400);
|
|
3298
|
+
}
|
|
3299
|
+
const s = load();
|
|
3300
|
+
createKey(s, key, value, void 0, plural ? { plural: { arg: plural.arg } } : {});
|
|
3301
|
+
persist(s);
|
|
3302
|
+
console.log(`[key] created ${key}`);
|
|
3303
|
+
return c.json({ ok: true });
|
|
3304
|
+
});
|
|
3305
|
+
app.post("/dictionary", async (c) => {
|
|
3306
|
+
const { word } = await c.req.json();
|
|
3307
|
+
if (typeof word !== "string" || !word.trim()) return c.json({ error: "word is required" }, 400);
|
|
3308
|
+
const s = load();
|
|
3309
|
+
addCustomWord(s, word);
|
|
3310
|
+
persist(s);
|
|
3311
|
+
return c.json({ ok: true });
|
|
3312
|
+
});
|
|
3313
|
+
app.delete("/dictionary/:word", (c) => {
|
|
3314
|
+
const s = load();
|
|
3315
|
+
removeCustomWord(s, c.req.param("word"));
|
|
3316
|
+
persist(s);
|
|
3317
|
+
return c.json({ ok: true });
|
|
3318
|
+
});
|
|
3319
|
+
app.patch("/keys/:key", async (c) => {
|
|
3320
|
+
const key = c.req.param("key");
|
|
3321
|
+
const body = await c.req.json();
|
|
3322
|
+
const s = load();
|
|
3323
|
+
if (typeof body.rename === "string") renameKey(s, key, body.rename);
|
|
3324
|
+
const target = typeof body.rename === "string" ? body.rename : key;
|
|
3325
|
+
if (body.metadata) setMetadata(s, target, body.metadata);
|
|
3326
|
+
if (typeof body.source === "string") setSourceValue(s, target, body.source);
|
|
3327
|
+
if (typeof body.pluralArg === "string" && body.pluralArg.trim()) setPluralArg(s, target, body.pluralArg.trim());
|
|
3328
|
+
persist(s);
|
|
3329
|
+
if (typeof body.rename === "string") console.log(`[key] renamed ${key} \u2192 ${body.rename}`);
|
|
3330
|
+
return c.json({ ok: true });
|
|
3331
|
+
});
|
|
3332
|
+
function removeOrphanScreenshot(s, screenshot) {
|
|
3333
|
+
if (!screenshot) return;
|
|
3334
|
+
for (const e of Object.values(s.keys)) if (e.screenshot === screenshot) return;
|
|
3335
|
+
const root = dirname5(resolve6(deps.statePath));
|
|
3336
|
+
const abs = resolve6(root, screenshot);
|
|
3337
|
+
const rel = relative3(root, abs);
|
|
3338
|
+
const seg0 = rel.split(sep)[0] ?? "";
|
|
3339
|
+
if (!rel.startsWith("..") && seg0.endsWith("-screenshots") && existsSync8(abs)) {
|
|
3340
|
+
try {
|
|
3341
|
+
rmSync3(abs);
|
|
3342
|
+
} catch {
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
app.delete("/keys/:key", (c) => {
|
|
3347
|
+
const s = load();
|
|
3348
|
+
const key = c.req.param("key");
|
|
3349
|
+
const shot = s.keys[key]?.screenshot;
|
|
3350
|
+
deleteKey(s, key);
|
|
3351
|
+
removeOrphanScreenshot(s, shot);
|
|
3352
|
+
persist(s);
|
|
3353
|
+
console.log(`[key] deleted ${key}`);
|
|
3354
|
+
return c.json({ ok: true });
|
|
3355
|
+
});
|
|
3356
|
+
app.post("/keys/bulk-clear", async (c) => {
|
|
3357
|
+
const { keys, locales } = await c.req.json();
|
|
3358
|
+
if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
|
|
3359
|
+
if (!Array.isArray(locales)) return c.json({ error: "locales must be an array" }, 400);
|
|
3360
|
+
const s = load();
|
|
3361
|
+
const known = new Set(s.config.locales);
|
|
3362
|
+
for (const l of locales) if (!known.has(l)) return c.json({ error: `Unknown locale: ${l}` }, 400);
|
|
3363
|
+
let cleared = 0;
|
|
3364
|
+
for (const key of keys) {
|
|
3365
|
+
const entry = s.keys[key];
|
|
3366
|
+
if (!entry) continue;
|
|
3367
|
+
for (const locale of locales) {
|
|
3368
|
+
if (locale === s.config.sourceLocale) continue;
|
|
3369
|
+
if (entry.values[locale]) {
|
|
3370
|
+
clearValue(s, key, locale);
|
|
3371
|
+
cleared++;
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
persist(s);
|
|
3376
|
+
console.log(`[bulk] cleared ${cleared} value(s)`);
|
|
3377
|
+
return c.json({ cleared });
|
|
3378
|
+
});
|
|
3379
|
+
app.post("/keys/bulk-delete", async (c) => {
|
|
3380
|
+
const { keys } = await c.req.json();
|
|
3381
|
+
if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
|
|
3382
|
+
const s = load();
|
|
3383
|
+
const removed = [];
|
|
3384
|
+
const shots = [];
|
|
3385
|
+
for (const key of keys) {
|
|
3386
|
+
if (!s.keys[key]) continue;
|
|
3387
|
+
shots.push(s.keys[key].screenshot);
|
|
3388
|
+
deleteKey(s, key);
|
|
3389
|
+
removed.push(key);
|
|
3390
|
+
}
|
|
3391
|
+
for (const shot of shots) removeOrphanScreenshot(s, shot);
|
|
3392
|
+
persist(s);
|
|
3393
|
+
console.log(`[bulk] deleted ${removed.length} key(s)`);
|
|
3394
|
+
return c.json({ removed });
|
|
3395
|
+
});
|
|
3396
|
+
app.post("/keys/bulk-meta", async (c) => {
|
|
3397
|
+
const { keys, addTags, removeTags, skipTranslate } = await c.req.json();
|
|
3398
|
+
if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
|
|
3399
|
+
const s = load();
|
|
3400
|
+
let updated = 0;
|
|
3401
|
+
for (const key of keys) {
|
|
3402
|
+
const entry = s.keys[key];
|
|
3403
|
+
if (!entry) continue;
|
|
3404
|
+
if (Array.isArray(addTags) || Array.isArray(removeTags)) {
|
|
3405
|
+
const tags = new Set(entry.tags ?? []);
|
|
3406
|
+
for (const t of addTags ?? []) if (typeof t === "string" && t.trim()) tags.add(t.trim());
|
|
3407
|
+
for (const t of removeTags ?? []) tags.delete(t);
|
|
3408
|
+
if (tags.size) setMetadata(s, key, { tags: [...tags].sort() });
|
|
3409
|
+
else delete entry.tags;
|
|
3410
|
+
}
|
|
3411
|
+
if (typeof skipTranslate === "boolean") {
|
|
3412
|
+
if (skipTranslate) setMetadata(s, key, { skipTranslate: true });
|
|
3413
|
+
else delete entry.skipTranslate;
|
|
3414
|
+
}
|
|
3415
|
+
updated++;
|
|
3416
|
+
}
|
|
3417
|
+
persist(s);
|
|
3418
|
+
console.log(`[bulk] updated metadata on ${updated} key(s)`);
|
|
3419
|
+
return c.json({ updated });
|
|
3420
|
+
});
|
|
3421
|
+
app.post("/keys/bulk-state", async (c) => {
|
|
3422
|
+
const { keys, locales, state: next } = await c.req.json();
|
|
3423
|
+
if (!Array.isArray(keys) || keys.length === 0) return c.json({ error: "keys must be a non-empty array" }, 400);
|
|
3424
|
+
if (!Array.isArray(locales)) return c.json({ error: "locales must be an array" }, 400);
|
|
3425
|
+
if (next !== "reviewed" && next !== "needs-review") return c.json({ error: "state must be reviewed or needs-review" }, 400);
|
|
3426
|
+
const s = load();
|
|
3427
|
+
const known = new Set(s.config.locales);
|
|
3428
|
+
for (const l of locales) if (!known.has(l)) return c.json({ error: `Unknown locale: ${l}` }, 400);
|
|
3429
|
+
let updated = 0;
|
|
3430
|
+
for (const key of keys) {
|
|
3431
|
+
const entry = s.keys[key];
|
|
3432
|
+
if (!entry) continue;
|
|
3433
|
+
for (const locale of locales) {
|
|
3434
|
+
if (locale === s.config.sourceLocale) continue;
|
|
3435
|
+
if (!entry.values[locale]) continue;
|
|
3436
|
+
setKeyState(s, key, locale, next);
|
|
3437
|
+
updated++;
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
persist(s);
|
|
3441
|
+
console.log(`[bulk] set state ${next} on ${updated} value(s)`);
|
|
3442
|
+
return c.json({ updated });
|
|
3443
|
+
});
|
|
3444
|
+
app.put("/keys/:key/values/:locale", async (c) => {
|
|
3445
|
+
const { value } = await c.req.json();
|
|
3446
|
+
if (typeof value !== "string") return c.json({ error: "value must be a string" }, 400);
|
|
3447
|
+
const s = load();
|
|
3448
|
+
const key = c.req.param("key");
|
|
3449
|
+
const locale = c.req.param("locale");
|
|
3450
|
+
if (locale === s.config.sourceLocale) setSourceValue(s, key, value);
|
|
3451
|
+
else setTargetValue(s, key, locale, value);
|
|
3452
|
+
persist(s);
|
|
3453
|
+
return c.json({ ok: true });
|
|
3454
|
+
});
|
|
3455
|
+
app.delete("/keys/:key/values/:locale", (c) => {
|
|
3456
|
+
const s = load();
|
|
3457
|
+
clearValue(s, c.req.param("key"), c.req.param("locale"));
|
|
3458
|
+
persist(s);
|
|
3459
|
+
return c.json({ ok: true });
|
|
3460
|
+
});
|
|
3461
|
+
app.put("/keys/:key/plural/:locale", async (c) => {
|
|
3462
|
+
const { forms } = await c.req.json();
|
|
3463
|
+
if (!forms || typeof forms !== "object") return c.json({ error: "forms object is required" }, 400);
|
|
3464
|
+
const s = load();
|
|
3465
|
+
const key = c.req.param("key");
|
|
3466
|
+
const locale = c.req.param("locale");
|
|
3467
|
+
if (locale === s.config.sourceLocale) setSourcePluralForms(s, key, forms);
|
|
3468
|
+
else setPluralForms(s, key, locale, forms);
|
|
3469
|
+
persist(s);
|
|
3470
|
+
return c.json({ ok: true });
|
|
3471
|
+
});
|
|
3472
|
+
app.post("/keys/:key/plural", async (c) => {
|
|
3473
|
+
const { arg } = await c.req.json();
|
|
3474
|
+
if (typeof arg !== "string" || !arg.trim()) return c.json({ error: "arg is required" }, 400);
|
|
3475
|
+
const s = load();
|
|
3476
|
+
convertToPlural(s, c.req.param("key"), arg);
|
|
3477
|
+
persist(s);
|
|
3478
|
+
return c.json({ ok: true });
|
|
3479
|
+
});
|
|
3480
|
+
app.delete("/keys/:key/plural", (c) => {
|
|
3481
|
+
const s = load();
|
|
3482
|
+
convertToScalar(s, c.req.param("key"));
|
|
3483
|
+
persist(s);
|
|
3484
|
+
return c.json({ ok: true });
|
|
3485
|
+
});
|
|
3486
|
+
app.put("/keys/:key/values/:locale/state", async (c) => {
|
|
3487
|
+
const { state } = await c.req.json();
|
|
3488
|
+
const s = load();
|
|
3489
|
+
setKeyState(s, c.req.param("key"), c.req.param("locale"), state);
|
|
3490
|
+
persist(s);
|
|
3491
|
+
return c.json({ ok: true });
|
|
3492
|
+
});
|
|
3493
|
+
app.post("/keys/:key/notes", async (c) => {
|
|
3494
|
+
const { text } = await c.req.json();
|
|
3495
|
+
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3496
|
+
const s = load();
|
|
3497
|
+
const note = addNote(s, c.req.param("key"), text);
|
|
3498
|
+
persist(s);
|
|
3499
|
+
return c.json(note);
|
|
3500
|
+
});
|
|
3501
|
+
app.put("/keys/:key/notes/:id", async (c) => {
|
|
3502
|
+
const { text } = await c.req.json();
|
|
3503
|
+
if (typeof text !== "string" || !text.trim()) return c.json({ error: "note text is required" }, 400);
|
|
3504
|
+
const s = load();
|
|
3505
|
+
editNote(s, c.req.param("key"), c.req.param("id"), text);
|
|
3506
|
+
persist(s);
|
|
3507
|
+
return c.json({ ok: true });
|
|
3508
|
+
});
|
|
3509
|
+
app.delete("/keys/:key/notes/:id", (c) => {
|
|
3510
|
+
const s = load();
|
|
3511
|
+
deleteNote(s, c.req.param("key"), c.req.param("id"));
|
|
3512
|
+
persist(s);
|
|
3513
|
+
return c.json({ ok: true });
|
|
3514
|
+
});
|
|
3515
|
+
app.put("/config", async (c) => {
|
|
3516
|
+
const newConfig = await c.req.json();
|
|
3517
|
+
if (!newConfig || !Array.isArray(newConfig.locales)) {
|
|
3518
|
+
return c.json({ error: "config.locales must be an array" }, 400);
|
|
3519
|
+
}
|
|
3520
|
+
const s = load();
|
|
3521
|
+
const removed = s.config.locales.filter((l) => !newConfig.locales.includes(l));
|
|
3522
|
+
for (const l of removed) {
|
|
3523
|
+
for (const e of Object.values(s.keys)) delete e.values[l];
|
|
3524
|
+
}
|
|
3525
|
+
s.config = newConfig;
|
|
3526
|
+
validate(s);
|
|
3527
|
+
persist(s);
|
|
3528
|
+
console.log(`[config] saved \u2014 ${newConfig.locales.length} locale(s), model ${newConfig.ai.model}`);
|
|
3529
|
+
return c.json({ ok: true });
|
|
3530
|
+
});
|
|
3531
|
+
app.get("/glossary", (c) => c.json(load().glossary));
|
|
3532
|
+
app.put("/glossary", async (c) => {
|
|
3533
|
+
const entry = await c.req.json();
|
|
3534
|
+
if (typeof entry?.term !== "string") return c.json({ error: "term must be a string" }, 400);
|
|
3535
|
+
const s = load();
|
|
3536
|
+
upsertGlossaryEntry(s, entry);
|
|
3537
|
+
persist(s);
|
|
3538
|
+
return c.json({ ok: true });
|
|
3539
|
+
});
|
|
3540
|
+
app.delete("/glossary/:term", (c) => {
|
|
3541
|
+
const s = load();
|
|
3542
|
+
deleteGlossaryEntry(s, decodeURIComponent(c.req.param("term")));
|
|
3543
|
+
persist(s);
|
|
3544
|
+
return c.json({ ok: true });
|
|
3545
|
+
});
|
|
3546
|
+
app.post("/keys/:key/screenshot", async (c) => {
|
|
3547
|
+
const key = c.req.param("key");
|
|
3548
|
+
const body = await c.req.parseBody();
|
|
3549
|
+
const file = body["file"];
|
|
3550
|
+
if (!file || typeof file === "string") return c.json({ error: "no file uploaded" }, 400);
|
|
3551
|
+
const root = dirname5(resolve6(deps.statePath));
|
|
3552
|
+
const dirName = screenshotDirName(deps.statePath);
|
|
3553
|
+
const dir = resolve6(root, dirName);
|
|
3554
|
+
mkdirSync6(dir, { recursive: true });
|
|
3555
|
+
const filename = `${sanitize(key)}__${sanitize(file.name)}`;
|
|
3556
|
+
writeFileSync5(resolve6(dir, filename), Buffer.from(await file.arrayBuffer()));
|
|
3557
|
+
const path = `${dirName}/${filename}`;
|
|
3558
|
+
const s = load();
|
|
3559
|
+
const prev = s.keys[key]?.screenshot;
|
|
3560
|
+
setMetadata(s, key, { screenshot: path });
|
|
3561
|
+
if (prev && prev !== path) removeOrphanScreenshot(s, prev);
|
|
3562
|
+
persist(s);
|
|
3563
|
+
return c.json({ path });
|
|
3564
|
+
});
|
|
3565
|
+
app.delete("/keys/:key/screenshot", (c) => {
|
|
3566
|
+
const s = load();
|
|
3567
|
+
const key = c.req.param("key");
|
|
3568
|
+
const shot = s.keys[key]?.screenshot;
|
|
3569
|
+
setMetadata(s, key, { screenshot: void 0 });
|
|
3570
|
+
removeOrphanScreenshot(s, shot);
|
|
3571
|
+
persist(s);
|
|
3572
|
+
return c.json({ ok: true });
|
|
3573
|
+
});
|
|
3574
|
+
app.get("/export/preview", (c) => {
|
|
3575
|
+
const s = narrowForExport(load());
|
|
3576
|
+
const files = [];
|
|
3577
|
+
const warnings = [];
|
|
3578
|
+
for (const output of s.config.outputs) {
|
|
3579
|
+
const result = getAdapter(output.adapter).export(s, output);
|
|
3580
|
+
files.push(...result.files);
|
|
3581
|
+
warnings.push(...result.warnings);
|
|
3582
|
+
}
|
|
3583
|
+
return c.json({ files, warnings });
|
|
3584
|
+
});
|
|
3585
|
+
app.get("/scan/missing", (c) => c.json(findMissing(load())));
|
|
3586
|
+
app.get("/checks", (c) => {
|
|
3587
|
+
const param = c.req.query("checks");
|
|
3588
|
+
const only = param ? param.split(",").map((s) => s.trim()).filter((s) => CHECK_IDS.includes(s)) : void 0;
|
|
3589
|
+
return c.json(runChecks(load(), { only }));
|
|
3590
|
+
});
|
|
3591
|
+
app.get("/stats", (c) => c.json(computeStats(load())));
|
|
3592
|
+
app.get("/import/detect", (c) => {
|
|
3593
|
+
const preview = previewImport(projectRoot);
|
|
3594
|
+
if (!preview) return c.json({ found: false });
|
|
3595
|
+
return c.json({ found: true, ...preview });
|
|
3596
|
+
});
|
|
3597
|
+
app.post("/import", async (c) => {
|
|
3598
|
+
const state = load();
|
|
3599
|
+
if (Object.keys(state.keys).length > 0) {
|
|
3600
|
+
return c.json({ error: "cannot import into a non-empty project" }, 400);
|
|
3601
|
+
}
|
|
3602
|
+
const body = await c.req.json();
|
|
3603
|
+
let result;
|
|
3604
|
+
try {
|
|
3605
|
+
result = runImport({
|
|
3606
|
+
projectRoot,
|
|
3607
|
+
format: body.format,
|
|
3608
|
+
sourceLocale: body.sourceLocale,
|
|
3609
|
+
locales: body.locales,
|
|
3610
|
+
cldr: body.cldr
|
|
3611
|
+
});
|
|
3612
|
+
} catch (e) {
|
|
3613
|
+
return c.json({ error: e.message }, 400);
|
|
3614
|
+
}
|
|
3615
|
+
persist(result.state);
|
|
3616
|
+
console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
|
|
3617
|
+
return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
|
|
3618
|
+
});
|
|
3619
|
+
app.post("/export", (c) => {
|
|
3620
|
+
const s = narrowForExport(load());
|
|
3621
|
+
const root = dirname5(resolve6(deps.statePath));
|
|
3622
|
+
const warnings = [];
|
|
3623
|
+
let count = 0;
|
|
3624
|
+
for (const output of s.config.outputs) {
|
|
3625
|
+
const adapter = getAdapter(output.adapter);
|
|
3626
|
+
const result = adapter.export(s, output);
|
|
3627
|
+
warnings.push(...result.warnings);
|
|
3628
|
+
for (const f of result.files) {
|
|
3629
|
+
const abs = resolve6(root, f.path);
|
|
3630
|
+
mkdirSync6(dirname5(abs), { recursive: true });
|
|
3631
|
+
writeFileSync5(abs, f.contents, "utf8");
|
|
3632
|
+
count++;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
console.log(`[export] ${count} file(s)${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
|
|
3636
|
+
return c.json({ files: count, warnings });
|
|
3637
|
+
});
|
|
3638
|
+
app.post("/translate/stream", async (c) => {
|
|
3639
|
+
const signal = c.req.raw.signal;
|
|
3640
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3641
|
+
const keys = Array.isArray(body.keys) && body.keys.length ? body.keys.filter(Boolean) : void 0;
|
|
3642
|
+
const locales = Array.isArray(body.locales) && body.locales.length ? body.locales.filter(Boolean) : void 0;
|
|
3643
|
+
return streamSSE(c, (stream) => withTranslateLock(async () => {
|
|
3644
|
+
const s = load();
|
|
3645
|
+
const reqs = selectRequests(s, { onlyMissing: true, keys, locales });
|
|
3646
|
+
if (!reqs.length) {
|
|
3647
|
+
await stream.writeSSE({ event: "done", data: JSON.stringify({ written: 0, errors: [] }) });
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
let provider;
|
|
3651
|
+
try {
|
|
3652
|
+
provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
|
|
3653
|
+
} catch (e) {
|
|
3654
|
+
await stream.writeSSE({ event: "error", data: JSON.stringify({ error: e.message }) });
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
const { skipped } = attachScreenshotsForProvider(reqs, s, projectRoot, provider.supportsVision());
|
|
3658
|
+
if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
3659
|
+
console.log(`[translate] ${reqs.length} string(s) \u2192 ${s.config.ai.model}`);
|
|
3660
|
+
let totalWritten = 0;
|
|
3661
|
+
const allErrors = [];
|
|
3662
|
+
const system = buildSystemPrompt();
|
|
3663
|
+
const reqById = new Map(reqs.map((r) => [r.id, r]));
|
|
3664
|
+
await runLocaleParallel(reqs, provider, (done, total, batchResults) => {
|
|
3665
|
+
const { written, errors } = applyResults(s, reqs, batchResults);
|
|
3666
|
+
persist(s);
|
|
3667
|
+
totalWritten += written;
|
|
3668
|
+
allErrors.push(...errors);
|
|
3669
|
+
appendAiLog(projectRoot, {
|
|
3670
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3671
|
+
kind: "translate",
|
|
3672
|
+
model: s.config.ai.model,
|
|
3673
|
+
system,
|
|
3674
|
+
items: batchResults.map((r) => {
|
|
3675
|
+
const req = reqById.get(r.id);
|
|
3676
|
+
return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? s.keys[req.key]?.screenshot : void 0 };
|
|
3677
|
+
}),
|
|
3678
|
+
results: batchResults
|
|
3679
|
+
});
|
|
3680
|
+
console.log(`[translate] ${done}/${total}`);
|
|
3681
|
+
void stream.writeSSE({
|
|
3682
|
+
event: "progress",
|
|
3683
|
+
data: JSON.stringify({ done, total, written: totalWritten, errors })
|
|
3684
|
+
});
|
|
3685
|
+
}, void 0, signal);
|
|
3686
|
+
if (!signal?.aborted) {
|
|
3687
|
+
console.log(`[translate] done \u2014 wrote ${totalWritten}, ${allErrors.length} error(s)`);
|
|
3688
|
+
await stream.writeSSE({ event: "done", data: JSON.stringify({ written: totalWritten, errors: allErrors }) });
|
|
3689
|
+
} else {
|
|
3690
|
+
console.log(`[translate] cancelled \u2014 wrote ${totalWritten} so far`);
|
|
3691
|
+
}
|
|
3692
|
+
}));
|
|
3693
|
+
});
|
|
3694
|
+
app.post("/translate", (c) => withTranslateLock(async () => {
|
|
3695
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3696
|
+
const s = load();
|
|
3697
|
+
const reqs = selectRequests(s, {
|
|
3698
|
+
onlyMissing: body.onlyMissing ?? true,
|
|
3699
|
+
locales: body.locales,
|
|
3700
|
+
keyGlob: body.keyGlob
|
|
3701
|
+
});
|
|
3702
|
+
const force = body.force === true;
|
|
3703
|
+
const toTranslate = [...reqs];
|
|
3704
|
+
let written = 0;
|
|
3705
|
+
let errors = [];
|
|
3706
|
+
if (toTranslate.length) {
|
|
3707
|
+
let provider;
|
|
3708
|
+
try {
|
|
3709
|
+
provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
|
|
3710
|
+
} catch (e) {
|
|
3711
|
+
return c.json({ error: e.message }, 400);
|
|
3712
|
+
}
|
|
3713
|
+
const { skipped } = attachScreenshotsForProvider(toTranslate, s, projectRoot, provider.supportsVision());
|
|
3714
|
+
if (skipped) console.warn(`Model "${s.config.ai.model}" has no vision support; ${skipped} screenshot(s) ignored.`);
|
|
3715
|
+
const results = await runLocaleParallel(toTranslate, provider);
|
|
3716
|
+
({ written, errors } = applyResults(s, toTranslate, results, void 0, force));
|
|
3717
|
+
const entry = {
|
|
3718
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3719
|
+
model: s.config.ai.model,
|
|
3720
|
+
system: buildSystemPrompt(),
|
|
3721
|
+
// Log the screenshot PATH only — never the image bytes.
|
|
3722
|
+
items: toTranslate.map((r) => ({
|
|
3723
|
+
id: r.id,
|
|
3724
|
+
key: r.key,
|
|
3725
|
+
source: r.source,
|
|
3726
|
+
targetLocale: r.targetLocale,
|
|
3727
|
+
context: r.context,
|
|
3728
|
+
glossary: r.glossary,
|
|
3729
|
+
screenshot: s.keys[r.key]?.screenshot
|
|
3730
|
+
})),
|
|
3731
|
+
results
|
|
3732
|
+
};
|
|
3733
|
+
appendAiLog(projectRoot, entry);
|
|
3734
|
+
}
|
|
3735
|
+
persist(s);
|
|
3736
|
+
return c.json({ requested: reqs.length, written, errors });
|
|
3737
|
+
}));
|
|
3738
|
+
app.get("/ai-log", (c) => c.json(readAiLog(projectRoot, 100)));
|
|
3739
|
+
app.post("/scan", async (c) => {
|
|
3740
|
+
const s = load();
|
|
3741
|
+
const existing = loadUsageCache(projectRoot);
|
|
3742
|
+
const result = runScan(projectRoot, s.config.scan ?? {}, existing);
|
|
3743
|
+
const fileCount2 = Object.keys(result.files).length;
|
|
3744
|
+
const refCount = Object.values(result.files).reduce((n, f) => n + f.refs.length, 0);
|
|
3745
|
+
console.log(`[scan] ${fileCount2} file(s), ${refCount} reference(s)`);
|
|
3746
|
+
return c.json({ files: fileCount2, refs: refCount, scannedAt: result.scannedAt });
|
|
3747
|
+
});
|
|
3748
|
+
app.get("/scan", (c) => {
|
|
3749
|
+
const cache2 = loadUsageCache(projectRoot);
|
|
3750
|
+
if (!cache2) return c.json({ indexed: false, files: 0, refs: 0 });
|
|
3751
|
+
const files = Object.keys(cache2.files).length;
|
|
3752
|
+
const refs = Object.values(cache2.files).reduce((n, f) => n + f.refs.length, 0);
|
|
3753
|
+
return c.json({ indexed: true, scannedAt: cache2.scannedAt, files, refs });
|
|
3754
|
+
});
|
|
3755
|
+
app.get("/scan/usage", (c) => {
|
|
3756
|
+
const key = c.req.query("key") ?? "";
|
|
3757
|
+
const cache2 = loadUsageCache(projectRoot);
|
|
3758
|
+
if (!cache2) return c.json({ indexed: false, count: 0, refs: [], prefixCount: 0, prefixRefs: [] });
|
|
3759
|
+
const refs = [];
|
|
3760
|
+
const prefixRefs = [];
|
|
3761
|
+
for (const [file, entry] of Object.entries(cache2.files)) {
|
|
3762
|
+
const abs = resolve6(projectRoot, file);
|
|
3763
|
+
for (const r of entry.refs) {
|
|
3764
|
+
if (r.key === key) refs.push({ file, abs, line: r.line, col: r.col, scanner: r.scanner });
|
|
3765
|
+
}
|
|
3766
|
+
for (const p of entry.prefixes) {
|
|
3767
|
+
if (key.startsWith(p.prefix)) {
|
|
3768
|
+
prefixRefs.push({ file, abs, line: p.line, col: p.col, scanner: p.scanner, prefix: p.prefix });
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
const byFileLine = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
|
|
3773
|
+
refs.sort(byFileLine);
|
|
3774
|
+
prefixRefs.sort(byFileLine);
|
|
3775
|
+
return c.json({
|
|
3776
|
+
indexed: true,
|
|
3777
|
+
scannedAt: cache2.scannedAt,
|
|
3778
|
+
project: projectName(projectRoot),
|
|
3779
|
+
count: refs.length,
|
|
3780
|
+
refs,
|
|
3781
|
+
prefixCount: prefixRefs.length,
|
|
3782
|
+
prefixRefs
|
|
3783
|
+
});
|
|
3784
|
+
});
|
|
3785
|
+
app.get("/scan/used", (c) => {
|
|
3786
|
+
const cache2 = loadUsageCache(projectRoot);
|
|
3787
|
+
if (!cache2) return c.json({ indexed: false, used: [] });
|
|
3788
|
+
return c.json({ indexed: true, scannedAt: cache2.scannedAt, used: computeUsedKeys(load(), cache2) });
|
|
3789
|
+
});
|
|
3790
|
+
app.post("/context/build", async (c) => {
|
|
3791
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3792
|
+
const s = load();
|
|
3793
|
+
const cache2 = loadUsageCache(projectRoot);
|
|
3794
|
+
if (!cache2) return c.json({ error: "No usage index found. Run 'glotfile scan' first." }, 400);
|
|
3795
|
+
const targets = selectContextTargets(s, {
|
|
3796
|
+
all: body.all,
|
|
3797
|
+
keyGlob: body.keyGlob,
|
|
3798
|
+
limit: body.limit,
|
|
3799
|
+
since: body.since,
|
|
3800
|
+
keys: body.keys
|
|
3801
|
+
}, cache2, body.lastRunAt);
|
|
3802
|
+
if (!targets.length) return c.json({ requested: 0, written: 0, errors: [] });
|
|
3803
|
+
let provider;
|
|
3804
|
+
try {
|
|
3805
|
+
provider = deps.makeProvider ? deps.makeProvider(s) : makeProvider(s.config);
|
|
3806
|
+
} catch (e) {
|
|
3807
|
+
return c.json({ error: e.message }, 400);
|
|
3808
|
+
}
|
|
3809
|
+
const fileCache = /* @__PURE__ */ new Map();
|
|
3810
|
+
for (const target of targets) {
|
|
3811
|
+
const allRefs = Object.entries(cache2.files).flatMap(
|
|
3812
|
+
([file, entry]) => entry.refs.filter((r) => r.key === target.key).map((r) => ({
|
|
3813
|
+
key: r.key,
|
|
3814
|
+
file,
|
|
3815
|
+
line: r.line,
|
|
3816
|
+
col: r.col,
|
|
3817
|
+
scanner: r.scanner
|
|
3818
|
+
}))
|
|
3819
|
+
);
|
|
3820
|
+
target.usageSnippets = extractSnippets(allRefs, projectRoot, fileCache);
|
|
3821
|
+
}
|
|
3822
|
+
const system = buildContextSystemPrompt();
|
|
3823
|
+
const prompt = buildContextBatchPrompt(targets);
|
|
3824
|
+
const raw = await provider.complete({ system, content: [{ type: "text", text: prompt }], schema: CONTEXT_BATCH_SCHEMA });
|
|
3825
|
+
const batch = raw;
|
|
3826
|
+
const { written, errors } = applyContext(s, targets, batch.items ?? []);
|
|
3827
|
+
appendAiLog(projectRoot, {
|
|
3828
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3829
|
+
kind: "context",
|
|
3830
|
+
model: s.config.ai.model,
|
|
3831
|
+
system,
|
|
3832
|
+
items: targets.map((t) => ({ id: t.id, key: t.key, source: t.source })),
|
|
3833
|
+
results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
|
|
3834
|
+
});
|
|
3835
|
+
persist(s);
|
|
3836
|
+
console.log(`[context] ${written} context(s) written${errors.length ? `, ${errors.length} error(s)` : ""}`);
|
|
3837
|
+
return c.json({ requested: targets.length, written, errors });
|
|
3838
|
+
});
|
|
3839
|
+
app.onError(
|
|
3840
|
+
(err, c) => c.json({ error: err.message }, err instanceof GlotfileError ? 400 : 500)
|
|
3841
|
+
);
|
|
3842
|
+
return app;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// src/server/server.ts
|
|
3846
|
+
var here = dirname6(fileURLToPath(import.meta.url));
|
|
3847
|
+
var DEFAULT_UI_DIR = join7(here, "..", "ui");
|
|
3848
|
+
var MIME = {
|
|
3849
|
+
".html": "text/html; charset=utf-8",
|
|
3850
|
+
".js": "text/javascript; charset=utf-8",
|
|
3851
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
3852
|
+
".css": "text/css; charset=utf-8",
|
|
3853
|
+
".json": "application/json; charset=utf-8",
|
|
3854
|
+
".map": "application/json; charset=utf-8",
|
|
3855
|
+
".svg": "image/svg+xml",
|
|
3856
|
+
".ico": "image/x-icon",
|
|
3857
|
+
".png": "image/png",
|
|
3858
|
+
".jpg": "image/jpeg",
|
|
3859
|
+
".woff2": "font/woff2",
|
|
3860
|
+
".woff": "font/woff"
|
|
3861
|
+
};
|
|
3862
|
+
async function readFileResponse(absPath) {
|
|
3863
|
+
try {
|
|
3864
|
+
const s = await stat(absPath);
|
|
3865
|
+
if (!s.isFile()) return null;
|
|
3866
|
+
const body = await readFile(absPath);
|
|
3867
|
+
const type = MIME[extname3(absPath).toLowerCase()] ?? "application/octet-stream";
|
|
3868
|
+
return new Response(new Uint8Array(body), { headers: { "content-type": type } });
|
|
3869
|
+
} catch {
|
|
3870
|
+
return null;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
function buildApp(opts) {
|
|
3874
|
+
const app = new Hono2();
|
|
3875
|
+
app.route("/api", createApi({ statePath: opts.statePath, autoExport: true }));
|
|
3876
|
+
const projectRoot = dirname6(resolve7(opts.statePath));
|
|
3877
|
+
app.get("/:dir/*", async (c, next) => {
|
|
3878
|
+
const dirSeg = c.req.param("dir");
|
|
3879
|
+
if (!dirSeg.endsWith("-screenshots")) return next();
|
|
3880
|
+
const shotsRoot = resolve7(projectRoot, dirSeg);
|
|
3881
|
+
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3882
|
+
const rest = pathname.slice(`/${dirSeg}`.length);
|
|
3883
|
+
const target = resolve7(shotsRoot, "." + rest);
|
|
3884
|
+
const inside = target === shotsRoot || target.startsWith(shotsRoot + sep2);
|
|
3885
|
+
if (inside) {
|
|
3886
|
+
const file = await readFileResponse(target);
|
|
3887
|
+
if (file) return file;
|
|
3888
|
+
}
|
|
3889
|
+
return c.notFound();
|
|
3890
|
+
});
|
|
3891
|
+
if (!opts.dev) {
|
|
3892
|
+
const root = resolve7(opts.uiDir ?? DEFAULT_UI_DIR);
|
|
3893
|
+
app.get("/*", async (c) => {
|
|
3894
|
+
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
3895
|
+
const target = resolve7(root, "." + pathname);
|
|
3896
|
+
const inside = target === root || target.startsWith(root + sep2);
|
|
3897
|
+
if (inside && pathname !== "/") {
|
|
3898
|
+
const file = await readFileResponse(target);
|
|
3899
|
+
if (file) return file;
|
|
3900
|
+
}
|
|
3901
|
+
const index = await readFileResponse(join7(root, "index.html"));
|
|
3902
|
+
if (index) return index;
|
|
3903
|
+
return c.notFound();
|
|
3904
|
+
});
|
|
3905
|
+
}
|
|
3906
|
+
return app;
|
|
3907
|
+
}
|
|
3908
|
+
function startServer(opts) {
|
|
3909
|
+
const app = buildApp(opts);
|
|
3910
|
+
return new Promise((resolveP) => {
|
|
3911
|
+
const port = opts.dev ? 8787 : 0;
|
|
3912
|
+
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, (info) => {
|
|
3913
|
+
const url = `http://127.0.0.1:${info.port}`;
|
|
3914
|
+
if (opts.open !== false && !opts.dev) void open(url);
|
|
3915
|
+
resolveP({ url, close: () => server.close() });
|
|
3916
|
+
backgroundScan(opts.statePath);
|
|
3917
|
+
});
|
|
3918
|
+
});
|
|
3919
|
+
}
|
|
3920
|
+
function backgroundScan(statePath) {
|
|
3921
|
+
const projectRoot = dirname6(resolve7(statePath));
|
|
3922
|
+
Promise.resolve().then(() => {
|
|
3923
|
+
const state = loadState(statePath);
|
|
3924
|
+
const existing = loadUsageCache(projectRoot);
|
|
3925
|
+
const result = runScan(projectRoot, state.config.scan ?? {}, existing);
|
|
3926
|
+
const fileCount2 = Object.keys(result.files).length;
|
|
3927
|
+
const refCount = Object.values(result.files).reduce((n, f) => n + f.refs.length, 0);
|
|
3928
|
+
console.log(`[scan] ${fileCount2} file(s), ${refCount} reference(s)`);
|
|
3929
|
+
}).catch((err) => {
|
|
3930
|
+
console.warn("[scan] failed:", err instanceof Error ? err.message : String(err));
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
export {
|
|
3934
|
+
buildApp,
|
|
3935
|
+
startServer
|
|
3936
|
+
};
|