uvako-cli 0.2.1 → 0.4.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/src/check.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { walk } from "./files.js";
4
+ import { t } from "./i18n.js";
4
5
 
5
6
  // Tema denetimi. Mağazaya yüklenen paketi platform aynı kurallarla yeniden denetler; buradaki
6
7
  // denetim geliştiriciye hatayı yüklemeden önce göstermek içindir, güvenlik kararı sunucudadır.
@@ -10,7 +11,11 @@ import { walk } from "./files.js";
10
11
  // üstüne ayar, bölüm sırası, SCSS ve görsel getirir.
11
12
  // depo — platform deposundaki katalog teması (themes/<anahtar>).
12
13
 
13
- export const limits = { fileBytes: 5 * 1024 * 1024, totalBytes: 60 * 1024 * 1024, files: 600, styleBytes: 512 * 1024, archiveBytes: 40 * 1024 * 1024 };
14
+ export const limits = { fileBytes: 5 * 1024 * 1024, totalBytes: 60 * 1024 * 1024, files: 600, styleBytes: 512 * 1024, archiveBytes: 40 * 1024 * 1024, localeBytes: 256 * 1024, localeEntries: 2000 };
15
+
16
+ // Temanın kendi metinlerinin çevirisi: locales/<dil>.json, {"temadaki metin": "çevirisi"}.
17
+ // Vitrin ve panel bu sözlükle temanın varsayılan metinlerini ziyaretçinin diline çevirir.
18
+ export const textLocales = ["tr", "en", "de"];
14
19
 
15
20
  const keyPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
21
  const versionPattern = /^\d+\.\d+\.\d+$/;
@@ -30,8 +35,8 @@ const allowedStyleHosts = new Set(["fonts.googleapis.com", "fonts.gstatic.com",
30
35
  const scriptPatterns = [
31
36
  { pattern: /<script\b/i, text: "<script> etiketi" },
32
37
  { pattern: /javascript:/i, text: "javascript: adresi" },
33
- { pattern: /\son[a-z]+\s*=/i, text: "olay özniteliği (onclick, onload…)" },
34
- { pattern: /<iframe\b|<object\b|<embed\b|<foreignObject\b/i, text: "gömülü içerik etiketi" },
38
+ { pattern: /\son[a-z]+\s*=/i, text: t("olay özniteliği (onclick, onload…)") },
39
+ { pattern: /<iframe\b|<object\b|<embed\b|<foreignObject\b/i, text: t("gömülü içerik etiketi") },
35
40
  ];
36
41
 
37
42
  const secretPatterns = [
@@ -51,9 +56,54 @@ function readJson(file, report, label, required = true) {
51
56
  try {
52
57
  return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
53
58
  } catch (error) {
54
- report.errors.push(`${label} geçerli JSON değil: ${error.message}`);
59
+ report.errors.push(t("{0} geçerli JSON değil: {1}", label, error.message));
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // locales/<dil>.json: düz bir metin sözlüğü olmalı. Geçerliyse sözlüğü, değilse null döndürür.
65
+ function checkLocale(rel, file, report) {
66
+ let texts;
67
+ try {
68
+ texts = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
69
+ } catch (error) {
70
+ report.errors.push(t("{0} geçerli JSON değil: {1}", rel, error.message));
71
+ return null;
72
+ }
73
+ if (!texts || typeof texts !== "object" || Array.isArray(texts) || Object.values(texts).some((value) => typeof value !== "string")) {
74
+ report.errors.push(t("{0}: düz bir metin sözlüğü olmalı ({\"temadaki metin\": \"çevirisi\"}).", rel));
55
75
  return null;
56
76
  }
77
+ const entries = Object.entries(texts);
78
+ if (entries.length > limits.localeEntries) report.errors.push(t("{0}: en çok {1} metin içerebilir.", rel, limits.localeEntries));
79
+ if (entries.some(([source, text]) => !source.trim() || source.length > 500 || text.length > 2000)) report.errors.push(t("{0}: metin en çok 500, çevirisi en çok 2000 karakter olabilir; boş metin olamaz.", rel));
80
+ return texts;
81
+ }
82
+
83
+ // Çevrilebilecek metinler: theme.json'daki tanıtım alanları ve settings_data.json'daki yazılar.
84
+ // Renk, adres, dosya yolu ve sayılar metin sayılmaz.
85
+ function translatable(meta, data) {
86
+ const out = new Set();
87
+ const add = (value) => {
88
+ const text = typeof value === "string" ? value.trim() : "";
89
+ if (!text || !/\p{L}{2}/u.test(text)) return;
90
+ if (/^(#|https?:|\/|uploads\/|assets\/|rgba?\(|[\w.-]+\.(png|jpe?g|webp|avif|gif|svg|ico|woff2?|ttf|otf)$)/i.test(text)) return;
91
+ if (/^[a-z0-9_.,:-]+$/.test(text)) return;
92
+ out.add(text);
93
+ };
94
+ add(meta?.description);
95
+ for (const item of [...(Array.isArray(meta?.industries) ? meta.industries : []), ...(Array.isArray(meta?.features) ? meta.features : [])]) add(item);
96
+ if (data && typeof data === "object" && !Array.isArray(data)) for (const value of Object.values(data)) add(value);
97
+ return out;
98
+ }
99
+
100
+ function checkTranslations(locales, meta, data, report) {
101
+ const texts = translatable(meta, data);
102
+ for (const [locale, dict] of Object.entries(locales)) {
103
+ if (!dict) continue;
104
+ const missing = [...texts].filter((text) => !(text in dict));
105
+ if (missing.length) report.warnings.push(t("locales/{0}.json: {1} metnin çevirisi yok (ör. \"{2}\"). Çevirisi olmayan metin yazıldığı gibi gösterilir.", locale, missing.length, missing[0].slice(0, 60)));
106
+ }
57
107
  }
58
108
 
59
109
  // Şemadaki ayar adlarını toplar ({"type":"tab","values":[{name, settings, group}]}).
@@ -64,7 +114,7 @@ function schemaKeys(node, into, report, trail = "") {
64
114
  const where = trail ? `${trail} › ${tab.name ?? "?"}` : String(tab.name ?? "?");
65
115
  for (const [key, setting] of Object.entries(tab.settings ?? {})) {
66
116
  into.add(key);
67
- if (!setting || typeof setting !== "object" || !setting.type) report.errors.push(`Ayar şeması: "${key}" ayarının türü yok (${where}).`);
117
+ if (!setting || typeof setting !== "object" || !setting.type) report.errors.push(t("Ayar şeması: \"{0}\" ayarının türü yok ({1}).", key, where));
68
118
  }
69
119
  schemaKeys(tab.group, into, report, where);
70
120
  }
@@ -83,10 +133,10 @@ function locate(dir) {
83
133
 
84
134
  function scanText(rel, text, report) {
85
135
  for (const { pattern, text: what } of scriptPatterns) {
86
- if (pattern.test(text)) report.errors.push(`${rel}: ${what} içeriyor. Tema içinde betik çalıştırılamaz.`);
136
+ if (pattern.test(text)) report.errors.push(t("{0}: {1} içeriyor. Tema içinde betik çalıştırılamaz.", rel, what));
87
137
  }
88
138
  for (const pattern of secretPatterns) {
89
- if (pattern.test(text)) report.errors.push(`${rel}: gizli anahtar ya da parola gibi görünen bir değer içeriyor.`);
139
+ if (pattern.test(text)) report.errors.push(t("{0}: gizli anahtar ya da parola gibi görünen bir değer içeriyor.", rel));
90
140
  }
91
141
  }
92
142
 
@@ -113,19 +163,19 @@ function scanStyle(rel, source, report, { strict = false, styles = new Set(), to
113
163
  return (match ? held[Number(match[1])] ?? "" : value).trim();
114
164
  };
115
165
  const all = `${text} ${held.join(" ")}`;
116
- if (/expression\s*\(|behavior\s*:|-moz-binding|<\/style/i.test(all)) report.errors.push(`${rel}: tarayıcıda kod çalıştıran CSS (expression, behavior, -moz-binding) içeriyor.`);
117
- if (/javascript:/i.test(all)) report.errors.push(`${rel}: javascript: adresi içeriyor.`);
166
+ if (/expression\s*\(|behavior\s*:|-moz-binding|<\/style/i.test(all)) report.errors.push(t("{0}: tarayıcıda kod çalıştıran CSS (expression, behavior, -moz-binding) içeriyor.", rel));
167
+ if (/javascript:/i.test(all)) report.errors.push(t("{0}: javascript: adresi içeriyor.", rel));
118
168
  if (strict) {
119
169
  const rule = /@(while|for|each|function|mixin|use|forward|debug|warn|error|charset)\b/i.exec(text);
120
- if (rule) report.errors.push(`${rel}: @${rule[1].toLowerCase()} kullanılamaz. Tema stilleri döngü, fonksiyon ve mixin tanımı içeremez; temel temanın mixin'leri @include ile kullanılabilir.`);
121
- if (text.includes("\\")) report.errors.push(`${rel}: tırnak dışında ters eğik çizgi kullanılamaz.`);
170
+ if (rule) report.errors.push(t("{0}: @{1} kullanılamaz. Tema stilleri döngü, fonksiyon ve mixin tanımı içeremez; temel temanın mixin'leri @include ile kullanılabilir.", rel, rule[1].toLowerCase()));
171
+ if (text.includes("\\")) report.errors.push(t("{0}: tırnak dışında ters eğik çizgi kullanılamaz.", rel));
122
172
  }
123
173
  text = text.replace(/url\(([^)]*)\)/gi, (_, inner) => {
124
174
  const target = resolve(inner);
125
175
  if (/^data:/i.test(target)) {
126
- if (strict && !/^data:(image\/(png|jpe?g|gif|webp)|font\/woff2?|application\/font-woff2?);base64,/i.test(target)) report.errors.push(`${rel}: yalnızca görsel ve yazı tipi data: adresleri kullanılabilir.`);
176
+ if (strict && !/^data:(image\/(png|jpe?g|gif|webp)|font\/woff2?|application\/font-woff2?);base64,/i.test(target)) report.errors.push(t("{0}: yalnızca görsel ve yazı tipi data: adresleri kullanılabilir.", rel));
127
177
  } else if ((target.includes("//") || (strict && target.includes(":"))) && !allowedUrl(target)) {
128
- report.errors.push(`${rel}: izin verilmeyen dış adres (${hostOf(target)}). Görsel ve yazı tiplerini assets/ altına koyun; yalnızca şu adresler çağrılabilir: ${[...allowedStyleHosts].join(", ")}.`);
178
+ report.errors.push(t("{0}: izin verilmeyen dış adres ({1}). Görsel ve yazı tiplerini assets/ altına koyun; yalnızca şu adresler çağrılabilir: {2}.", rel, hostOf(target), [...allowedStyleHosts].join(", ")));
129
179
  }
130
180
  return "url()";
131
181
  });
@@ -134,9 +184,9 @@ function scanStyle(rel, source, report, { strict = false, styles = new Set(), to
134
184
  if (/^\s*url\(\)/i.test(part)) continue;
135
185
  const target = resolve(part);
136
186
  if (target.includes("//") || target.includes(":")) {
137
- if (!allowedUrl(target)) report.errors.push(`${rel}: izin verilmeyen dış adres (${hostOf(target)}).`);
187
+ if (!allowedUrl(target)) report.errors.push(t("{0}: izin verilmeyen dış adres ({1}).", rel, hostOf(target)));
138
188
  } else if (strict && !(/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(target) && (styles.has(`${target}.scss`) || styles.has(`_${target}.scss`)))) {
139
- report.errors.push(`${rel}: @import "${target}" pakette yok. Yalnızca paketin kendi styles/ dosyaları içe aktarılabilir.`);
189
+ report.errors.push(t("{0}: @import \"{1}\" pakette yok. Yalnızca paketin kendi styles/ dosyaları içe aktarılabilir.", rel, target));
140
190
  }
141
191
  }
142
192
  }
@@ -144,7 +194,7 @@ function scanStyle(rel, source, report, { strict = false, styles = new Set(), to
144
194
  for (const raw of text.split(";")) {
145
195
  const statement = raw.trim();
146
196
  if (statement === "" || /^\$[A-Za-z0-9_-]+\s*:\s*[^{}@]+$/.test(statement) || /^@import/i.test(statement)) continue;
147
- report.errors.push(`${rel}: yalnızca değişken ataması ($ad: değer;) ve yazı tipi @import'u içerebilir.`);
197
+ report.errors.push(t("{0}: yalnızca değişken ataması ($ad: değer;) ve yazı tipi @import'u içerebilir.", rel));
148
198
  break;
149
199
  }
150
200
  }
@@ -154,21 +204,51 @@ function checkIdentity(dir, meta, report) {
154
204
  const key = typeof meta?.key === "string" ? meta.key.trim() : "";
155
205
  report.key = key;
156
206
  if (!meta) return key;
157
- if (!keyPattern.test(key) || key.length > 40) report.errors.push(`theme.json: "key" küçük harf, rakam ve tireden oluşmalı (ör. "linea").`);
158
- else if (key !== path.basename(dir)) report.errors.push(`theme.json: "key" (${key}) klasör adıyla (${path.basename(dir)}) aynı olmalı.`);
207
+ if (!keyPattern.test(key) || key.length > 40) report.errors.push(t("theme.json: \"key\" küçük harf, rakam ve tireden oluşmalı (ör. \"linea\")."));
208
+ else if (key !== path.basename(dir)) report.errors.push(t("theme.json: \"key\" ({0}) klasör adıyla ({1}) aynı olmalı.", key, path.basename(dir)));
159
209
  if (!String(meta.name ?? "").trim()) report.errors.push(`theme.json: "name" gerekli.`);
160
- if (!versionPattern.test(String(meta.version ?? ""))) report.errors.push(`theme.json: "version" 1.0.0 biçiminde olmalı.`);
161
- if (!String(meta.description ?? "").trim()) report.warnings.push(`theme.json: "description" boş; tema listesinde açıklama görünmez.`);
210
+ if (!versionPattern.test(String(meta.version ?? ""))) report.errors.push(t("theme.json: \"version\" 1.0.0 biçiminde olmalı."));
211
+ if (!String(meta.description ?? "").trim()) report.warnings.push(t("theme.json: \"description\" boş; tema listesinde açıklama görünmez."));
162
212
  return key;
163
213
  }
164
214
 
165
215
  function checkTemplate(index, types, report, source) {
166
216
  if (!index) return;
167
- if (!Array.isArray(index.order) || index.order.length === 0) report.errors.push(`templates/index.json: "order" boş; ana sayfada bölüm görünmez.`);
217
+ if (!Array.isArray(index.order) || index.order.length === 0) report.errors.push(t("templates/index.json: \"order\" boş; ana sayfada bölüm görünmez."));
168
218
  for (const id of index.order ?? []) {
169
219
  const section = index.sections?.[id];
170
- if (!section) report.errors.push(`templates/index.json: "order" içindeki "${id}" bölümü "sections" altında tanımlı değil.`);
171
- else if (types && !types.has(section.type)) report.errors.push(`templates/index.json: "${id}" bölümünün türü (${section.type}) ${source} yok.`);
220
+ if (!section) report.errors.push(t("templates/index.json: \"order\" içindeki \"{0}\" bölümü \"sections\" altında tanımlı değil.", id));
221
+ else if (types && !types.has(section.type)) report.errors.push(t("templates/index.json: \"{0}\" bölümünün türü ({1}) {2} yok.", id, section.type, source));
222
+ }
223
+ }
224
+
225
+ // Ürün sayfası sekmeleri: product_detail_page_tabs anahtarları ve her sekmenin başlık ayarı.
226
+ export const productTabKeys = {
227
+ display_product_comments: "tab_product_comments",
228
+ display_product_questions: "tab_product_questions",
229
+ display_payment_options: "tab_product_payment_options",
230
+ display_product_feeds: "tab_product_feeds",
231
+ display_shopping_experiences: "tab_shopping_experiences",
232
+ display_special_information_area: null,
233
+ };
234
+
235
+ function checkProductTabs(data, report) {
236
+ if (!data || typeof data !== "object" || !("product_detail_page_tabs" in data)) return;
237
+ const value = data.product_detail_page_tabs;
238
+ if ([0, 1, "0", "1", true, false].includes(value)) return;
239
+ const where = 'config/settings_data.json: "product_detail_page_tabs"';
240
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
241
+ report.errors.push(t("{0} 1 (hepsi açık) ya da {\"display_product_comments\": 1, …} nesnesi olmalı.", where));
242
+ return;
243
+ }
244
+ for (const [key, flag] of Object.entries(value)) {
245
+ if (!(key in productTabKeys)) {
246
+ report.warnings.push(t("{0}: \"{1}\" bilinen bir sekme değil ({2}).", where, key, Object.keys(productTabKeys).join(", ")));
247
+ continue;
248
+ }
249
+ if (![0, 1, "0", "1", true, false].includes(flag)) report.errors.push(t("{0}: \"{1}\" değeri 1 ya da 0 olmalı.", where, key));
250
+ const label = productTabKeys[key];
251
+ if (label && label in data && typeof data[label] !== "string") report.errors.push(t("config/settings_data.json: \"{0}\" sekme başlığı metin olmalı.", label));
172
252
  }
173
253
  }
174
254
 
@@ -176,7 +256,7 @@ function checkImages(data, assets, report) {
176
256
  if (!data || typeof data !== "object") return;
177
257
  for (const [name, value] of Object.entries(data)) {
178
258
  if (typeof value !== "string" || !/^uploads\/[^\s]+\.(png|jpe?g|webp|avif|gif|svg|ico)$/i.test(value)) continue;
179
- if (!existsSync(path.join(assets, value))) report.errors.push(`config/settings_data.json: "${name}" ayarındaki görsel bulunamadı (assets/${value}).`);
259
+ if (!existsSync(path.join(assets, value))) report.errors.push(t("config/settings_data.json: \"{0}\" ayarındaki görsel bulunamadı (assets/{1}).", name, value));
180
260
  }
181
261
  }
182
262
 
@@ -184,17 +264,18 @@ function checkImages(data, assets, report) {
184
264
 
185
265
  function checkPackage(dir, meta, report) {
186
266
  const base = String(meta?.base ?? "").trim();
187
- if (meta && !keyPattern.test(base)) report.errors.push(`theme.json: "base" gerekli: paketin temel aldığı katalog teması (ör. "linea"). Temalar: https://themes.uvako.com`);
188
- if (meta && (Number(meta.price ?? 0) < 0 || Number(meta.price ?? 0) > 100000 || Number.isNaN(Number(meta.price ?? 0)))) report.errors.push(`theme.json: "price" 0 ile 100.000 arasında olmalı.`);
189
- if (meta?.demo_url && !/^https:\/\/[^\s]+$/.test(String(meta.demo_url))) report.errors.push(`theme.json: "demo_url" https:// ile başlayan bir adres olmalı.`);
267
+ if (meta && !keyPattern.test(base)) report.errors.push(t("theme.json: \"base\" gerekli: paketin temel aldığı katalog teması (ör. \"linea\"). Temalar: https://themes.uvako.com"));
268
+ if (meta && (Number(meta.price ?? 0) < 0 || Number(meta.price ?? 0) > 100000 || Number.isNaN(Number(meta.price ?? 0)))) report.errors.push(t("theme.json: \"price\" 0 ile 100.000 arasında olmalı."));
269
+ if (meta?.demo_url && !/^https:\/\/[^\s]+$/.test(String(meta.demo_url))) report.errors.push(t("theme.json: \"demo_url\" https:// ile başlayan bir adres olmalı."));
190
270
 
191
271
  const { files, links } = walk(dir);
192
- for (const link of links) report.errors.push(`${link}: sembolik bağ pakete alınamaz.`);
272
+ for (const link of links) report.errors.push(t("{0}: sembolik bağ pakete alınamaz.", link));
193
273
  const styles = new Set(files.filter((file) => file.rel.startsWith("styles/")).map((file) => file.rel.slice("styles/".length)));
194
274
  const totals = { files: 0, bytes: 0, style: 0 };
195
275
  let schema = null;
196
276
  let data = null;
197
277
  let index = null;
278
+ const locales = {};
198
279
  for (const file of files) {
199
280
  const rel = file.rel;
200
281
  if (localOnly(rel)) continue;
@@ -202,15 +283,23 @@ function checkPackage(dir, meta, report) {
202
283
  totals.bytes += file.size;
203
284
  const parts = rel.split("/");
204
285
  if (parts.some((part) => !segmentPattern.test(part))) {
205
- report.errors.push(`${rel}: dosya adında yalnızca harf, rakam, nokta, tire ve alt çizgi olabilir; gizli dosya olamaz.`);
286
+ report.errors.push(t("{0}: dosya adında yalnızca harf, rakam, nokta, tire ve alt çizgi olabilir; gizli dosya olamaz.", rel));
206
287
  continue;
207
288
  }
208
289
  const ext = path.extname(rel).toLowerCase();
209
290
  const text = () => readFileSync(file.full, "utf8");
210
291
  if (rel === "theme.json") scanText(rel, text(), report);
211
292
  else if (rel === "config/settings_schema.json" || rel === "config/settings_data.json" || rel === "templates/index.json") {
212
- if (file.size > 1024 * 1024) report.errors.push(`${rel}: 1 MB sınırını aşıyor.`);
293
+ if (file.size > 1024 * 1024) report.errors.push(t("{0}: 1 MB sınırını aşıyor.", rel));
213
294
  else scanText(rel, text(), report);
295
+ } else if (parts[0] === "locales" && parts.length === 2 && rel.endsWith(".json")) {
296
+ const locale = parts[1].slice(0, -5);
297
+ if (!textLocales.includes(locale)) report.errors.push(t("{0}: desteklenmeyen dil. Kullanılabilir dosyalar: {1}.", rel, textLocales.map((item) => `locales/${item}.json`).join(", ")));
298
+ else if (file.size > limits.localeBytes) report.errors.push(t("{0}: {1} KB sınırını aşıyor.", rel, limits.localeBytes / 1024));
299
+ else {
300
+ scanText(rel, text(), report);
301
+ locales[locale] = checkLocale(rel, file.full, report);
302
+ }
214
303
  } else if (parts[0] === "styles" && parts.length === 2 && stylePattern.test(parts[1])) {
215
304
  totals.style += file.size;
216
305
  if (file.size <= limits.styleBytes) {
@@ -220,39 +309,41 @@ function checkPackage(dir, meta, report) {
220
309
  }
221
310
  } else if (parts[0] === "assets" && parts.length >= 2) {
222
311
  if (!assetExtensions.has(ext)) {
223
- report.errors.push(`${rel}: bu dosya türü (${ext || "uzantısız"}) temada bulunamaz.`);
312
+ report.errors.push(t("{0}: bu dosya türü ({1}) temada bulunamaz.", rel, ext || "uzantısız"));
224
313
  continue;
225
314
  }
226
- if (file.size > limits.fileBytes) report.errors.push(`${rel}: ${(file.size / 1024 / 1024).toFixed(1)} MB; tek dosya sınırı ${limits.fileBytes / 1024 / 1024} MB.`);
227
- else if (file.size > 1024 * 1024 && /\.(png|jpe?g|gif)$/i.test(ext)) report.warnings.push(`${rel}: ${(file.size / 1024 / 1024).toFixed(1)} MB. WebP ya da sıkıştırılmış JPEG sayfayı hızlandırır.`);
315
+ if (file.size > limits.fileBytes) report.errors.push(t("{0}: {1} MB; tek dosya sınırı {2} MB.", rel, (file.size / 1024 / 1024).toFixed(1), limits.fileBytes / 1024 / 1024));
316
+ else if (file.size > 1024 * 1024 && /\.(png|jpe?g|gif)$/i.test(ext)) report.warnings.push(t("{0}: {1} MB. WebP ya da sıkıştırılmış JPEG sayfayı hızlandırır.", rel, (file.size / 1024 / 1024).toFixed(1)));
228
317
  if (ext === ".svg" && file.size <= limits.fileBytes) scanText(rel, text(), report);
229
318
  } else {
230
- report.errors.push(`${rel}: bu dosya pakette bulunamaz. İzin verilenler: theme.json, config/settings_schema.json, config/settings_data.json, templates/index.json, styles/*.scss, assets/**.`);
319
+ report.errors.push(t("{0}: bu dosya pakette bulunamaz. İzin verilenler: theme.json, config/settings_schema.json, config/settings_data.json, templates/index.json, locales/tr.json, locales/en.json, locales/de.json, styles/*.scss, assets/**.", rel));
231
320
  }
232
321
  }
233
- if (totals.style > limits.styleBytes) report.errors.push(`Stil dosyaları toplam ${limits.styleBytes / 1024} KB sınırını aşıyor.`);
234
- if (styles.size > 0 && !styles.has("theme.scss") && !(styles.size === 1 && styles.has("_tokens.scss"))) report.errors.push("styles/theme.scss yok: stil dosyaları oradan içe aktarılır.");
322
+ if (totals.style > limits.styleBytes) report.errors.push(t("Stil dosyaları toplam {0} KB sınırını aşıyor.", limits.styleBytes / 1024));
323
+ if (styles.size > 0 && !styles.has("theme.scss") && !(styles.size === 1 && styles.has("_tokens.scss"))) report.errors.push(t("styles/theme.scss yok: stil dosyaları oradan içe aktarılır."));
235
324
 
236
325
  schema = readJson(path.join(dir, "config", "settings_schema.json"), report, "config/settings_schema.json", false);
237
326
  if (schema) {
238
- if (schema.group?.type !== "tab" || !Array.isArray(schema.group?.values) || schema.group.values.length === 0) report.errors.push(`config/settings_schema.json: "group" sekmeli olmalı ({"type":"tab","values":[…]}).`);
327
+ if (schema.group?.type !== "tab" || !Array.isArray(schema.group?.values) || schema.group.values.length === 0) report.errors.push(t("config/settings_schema.json: \"group\" sekmeli olmalı ({\"type\":\"tab\",\"values\":[…]})."));
239
328
  schemaKeys(schema.group, new Set(), report);
240
329
  }
241
330
  data = readJson(path.join(dir, "config", "settings_data.json"), report, "config/settings_data.json", false);
242
- if (data && (typeof data !== "object" || Array.isArray(data))) report.errors.push("config/settings_data.json: anahtar-değer nesnesi olmalı.");
331
+ if (data && (typeof data !== "object" || Array.isArray(data))) report.errors.push(t("config/settings_data.json: anahtar-değer nesnesi olmalı."));
243
332
  index = readJson(path.join(dir, "templates", "index.json"), report, "templates/index.json", false);
244
333
  // Bölüm türleri temel temadan gelir; `theme init` bunları reference/sections.json dosyasına yazar.
245
334
  const reference = existsSync(path.join(dir, "reference", "sections.json")) ? readJson(path.join(dir, "reference", "sections.json"), { errors: [] }, "") : null;
246
- checkTemplate(index, reference?.sections ? new Set(Object.keys(reference.sections)) : null, report, `${base} temasında`);
335
+ checkTemplate(index, reference?.sections ? new Set(Object.keys(reference.sections)) : null, report, t("{0} temasında", base));
247
336
  checkImages(data, path.join(dir, "assets"), report);
337
+ checkProductTabs(data, report);
338
+ checkTranslations(locales, meta, data, report);
248
339
  for (const shot of [meta?.screenshot, ...(Array.isArray(meta?.screenshots) ? meta.screenshots : [])]) {
249
340
  const rel = String(shot ?? "").trim().replace(/^\//, "").replace(/^assets\//, "");
250
- if (rel && !existsSync(path.join(dir, "assets", rel))) report.errors.push(`theme.json: ekran görüntüsü pakette yok (assets/${rel}).`);
341
+ if (rel && !existsSync(path.join(dir, "assets", rel))) report.errors.push(t("theme.json: ekran görüntüsü pakette yok (assets/{0}).", rel));
251
342
  }
252
343
  report.files = totals.files;
253
344
  report.bytes = totals.bytes;
254
- if (totals.bytes > limits.totalBytes) report.errors.push(`Tema ${(totals.bytes / 1024 / 1024).toFixed(0)} MB; sınır ${limits.totalBytes / 1024 / 1024} MB.`);
255
- if (totals.files > limits.files) report.errors.push(`Tema ${totals.files} dosya içeriyor; sınır ${limits.files}.`);
345
+ if (totals.bytes > limits.totalBytes) report.errors.push(t("Tema {0} MB; sınır {1} MB.", (totals.bytes / 1024 / 1024).toFixed(0), limits.totalBytes / 1024 / 1024));
346
+ if (totals.files > limits.files) report.errors.push(t("Tema {0} dosya içeriyor; sınır {1}.", totals.files, limits.files));
256
347
  }
257
348
 
258
349
  // ── platform deposundaki katalog teması ────────────────────────────────────────────────────────
@@ -260,19 +351,19 @@ function checkPackage(dir, meta, report) {
260
351
  function scanRepoFiles(root, label, report, totals) {
261
352
  if (!existsSync(root)) return;
262
353
  const { files, links } = walk(root);
263
- for (const link of links) report.errors.push(`${label}/${link}: sembolik bağ pakete alınamaz.`);
354
+ for (const link of links) report.errors.push(t("{0}: sembolik bağ pakete alınamaz.", `${label}/${link}`));
264
355
  for (const file of files) {
265
356
  const rel = `${label}/${file.rel}`;
266
357
  const ext = path.extname(file.rel).toLowerCase();
267
358
  totals.files += 1;
268
359
  totals.bytes += file.size;
269
- if (file.rel.split("/").some((part) => part === ".." || part.startsWith("."))) report.errors.push(`${rel}: gizli dosya ya da üst klasöre çıkan yol.`);
360
+ if (file.rel.split("/").some((part) => part === ".." || part.startsWith("."))) report.errors.push(t("{0}: gizli dosya ya da üst klasöre çıkan yol.", rel));
270
361
  if (!repoExtensions.has(ext)) {
271
- report.errors.push(`${rel}: bu dosya türü (${ext || "uzantısız"}) temada bulunamaz.`);
362
+ report.errors.push(t("{0}: bu dosya türü ({1}) temada bulunamaz.", rel, ext || "uzantısız"));
272
363
  continue;
273
364
  }
274
- if (file.size > limits.fileBytes) report.errors.push(`${rel}: ${(file.size / 1024 / 1024).toFixed(1)} MB; tek dosya sınırı ${limits.fileBytes / 1024 / 1024} MB.`);
275
- else if (file.size > 1024 * 1024 && /\.(png|jpe?g|gif)$/i.test(ext)) report.warnings.push(`${rel}: ${(file.size / 1024 / 1024).toFixed(1)} MB. WebP ya da sıkıştırılmış JPEG sayfayı hızlandırır.`);
365
+ if (file.size > limits.fileBytes) report.errors.push(t("{0}: {1} MB; tek dosya sınırı {2} MB.", rel, (file.size / 1024 / 1024).toFixed(1), limits.fileBytes / 1024 / 1024));
366
+ else if (file.size > 1024 * 1024 && /\.(png|jpe?g|gif)$/i.test(ext)) report.warnings.push(t("{0}: {1} MB. WebP ya da sıkıştırılmış JPEG sayfayı hızlandırır.", rel, (file.size / 1024 / 1024).toFixed(1)));
276
367
  if ([".json", ".scss", ".css", ".svg", ".md", ".txt"].includes(ext) && file.size <= limits.fileBytes) {
277
368
  const text = readFileSync(file.full, "utf8");
278
369
  scanText(rel, text, report);
@@ -284,43 +375,44 @@ function scanRepoFiles(root, label, report, totals) {
284
375
  function checkRepo(dir, meta, key, where, report) {
285
376
  const schema = readJson(path.join(dir, "config", "settings_schema.json"), report, "config/settings_schema.json");
286
377
  if (schema) {
287
- if (schema.group?.type !== "tab" || !Array.isArray(schema.group?.values)) report.errors.push(`config/settings_schema.json: "group" sekmeli olmalı ({"type":"tab","values":[…]}).`);
378
+ if (schema.group?.type !== "tab" || !Array.isArray(schema.group?.values)) report.errors.push(t("config/settings_schema.json: \"group\" sekmeli olmalı ({\"type\":\"tab\",\"values\":[…]})."));
288
379
  schemaKeys(schema.group, new Set(), report);
289
380
  }
290
381
  const data = readJson(path.join(dir, "config", "settings_data.json"), report, "config/settings_data.json");
291
- if (data && (typeof data !== "object" || Array.isArray(data))) report.errors.push("config/settings_data.json: anahtar-değer nesnesi olmalı.");
382
+ if (data && (typeof data !== "object" || Array.isArray(data))) report.errors.push(t("config/settings_data.json: anahtar-değer nesnesi olmalı."));
383
+ else checkProductTabs(data, report);
292
384
 
293
385
  const sections = readJson(path.join(dir, "config", "sections.json"), report, "config/sections.json");
294
386
  const types = new Set(Object.keys(sections?.sections ?? {}));
295
- if (sections && types.size === 0) report.errors.push(`config/sections.json: "sections" altında en az bir bölüm türü olmalı.`);
387
+ if (sections && types.size === 0) report.errors.push(t("config/sections.json: \"sections\" altında en az bir bölüm türü olmalı."));
296
388
  for (const [type, section] of Object.entries(sections?.sections ?? {})) {
297
- if (key && !type.startsWith(`${key}-`)) report.warnings.push(`config/sections.json: "${type}" bölüm türü tema anahtarıyla ("${key}-") başlamıyor.`);
298
- if (!String(section?.name ?? "").trim()) report.errors.push(`config/sections.json: "${type}" bölümünün adı yok.`);
389
+ if (key && !type.startsWith(`${key}-`)) report.warnings.push(t("config/sections.json: \"{0}\" bölüm türü tema anahtarıyla (\"{1}-\") başlamıyor.", type, key));
390
+ if (!String(section?.name ?? "").trim()) report.errors.push(t("config/sections.json: \"{0}\" bölümünün adı yok.", type));
299
391
  // "keys" isteğe bağlıdır; verilmişse liste olmalı.
300
- if (section?.keys !== undefined && !Array.isArray(section.keys)) report.errors.push(`config/sections.json: "${type}" bölümünün "keys" alanı liste olmalı.`);
392
+ if (section?.keys !== undefined && !Array.isArray(section.keys)) report.errors.push(t("config/sections.json: \"{0}\" bölümünün \"keys\" alanı liste olmalı.", type));
301
393
  }
302
- checkTemplate(readJson(path.join(dir, "templates", "index.json"), report, "templates/index.json (ana sayfa şablonu)"), sections ? types : null, report, "config/sections.json içinde");
394
+ checkTemplate(readJson(path.join(dir, "templates", "index.json"), report, t("templates/index.json (ana sayfa şablonu)")), sections ? types : null, report, t("config/sections.json içinde"));
303
395
 
304
396
  const totals = { files: 0, bytes: 0 };
305
397
  for (const sub of ["config", "templates"]) scanRepoFiles(path.join(dir, sub), sub, report, totals);
306
398
  scanRepoFiles(where.styles, "styles", report, totals);
307
399
  scanRepoFiles(where.assets, "assets", report, totals);
308
- if (!existsSync(path.join(where.styles, "theme.scss"))) report.errors.push(`Stil dosyası yok: ${path.join(where.styles, "theme.scss")}`);
400
+ if (!existsSync(path.join(where.styles, "theme.scss"))) report.errors.push(t("Stil dosyası yok: {0}", path.join(where.styles, "theme.scss")));
309
401
  report.files = totals.files;
310
402
  report.bytes = totals.bytes;
311
- if (totals.bytes > limits.totalBytes) report.errors.push(`Tema ${(totals.bytes / 1024 / 1024).toFixed(0)} MB; sınır ${limits.totalBytes / 1024 / 1024} MB.`);
312
- if (totals.files > limits.files) report.errors.push(`Tema ${totals.files} dosya içeriyor; sınır ${limits.files}.`);
403
+ if (totals.bytes > limits.totalBytes) report.errors.push(t("Tema {0} MB; sınır {1} MB.", (totals.bytes / 1024 / 1024).toFixed(0), limits.totalBytes / 1024 / 1024));
404
+ if (totals.files > limits.files) report.errors.push(t("Tema {0} dosya içeriyor; sınır {1}.", totals.files, limits.files));
313
405
  checkImages(data, where.assets, report);
314
406
  if (meta?.screenshot) {
315
407
  const shot = String(meta.screenshot).replace(new RegExp(`^/themes/${key}/assets/`), "");
316
- if (!existsSync(path.join(where.assets, shot))) report.warnings.push(`theme.json: ekran görüntüsü bulunamadı (assets/${shot}).`);
408
+ if (!existsSync(path.join(where.assets, shot))) report.warnings.push(t("theme.json: ekran görüntüsü bulunamadı (assets/{0}).", shot));
317
409
  }
318
410
  }
319
411
 
320
412
  export function checkTheme(dir) {
321
413
  const report = { dir, key: "", layout: "", errors: [], warnings: [], files: 0, bytes: 0 };
322
414
  if (!existsSync(path.join(dir, "theme.json"))) {
323
- report.errors.push(`${dir} bir tema klasörü değil: theme.json bulunamadı.`);
415
+ report.errors.push(t("{0} bir tema klasörü değil: theme.json bulunamadı.", dir));
324
416
  return report;
325
417
  }
326
418
  const where = locate(dir);
@@ -333,10 +425,10 @@ export function checkTheme(dir) {
333
425
  }
334
426
 
335
427
  export function printReport(report) {
336
- const title = report.key ? `${report.key} (${report.layout === "repo" ? "platform deposu" : "paket"})` : report.dir;
428
+ const title = report.key ? `${report.key} (${report.layout === "repo" ? t("platform deposu") : t("paket")})` : report.dir;
337
429
  for (const error of report.errors) console.error(` ✖ ${error}`);
338
430
  for (const warning of report.warnings) console.warn(` ! ${warning}`);
339
- const size = report.files ? ` · ${report.files} dosya, ${(report.bytes / 1024 / 1024).toFixed(1)} MB` : "";
340
- if (report.errors.length) console.error(`${title}: ${report.errors.length} hata, ${report.warnings.length} uyarı${size}`);
341
- else console.log(`${title}: denetim geçti${report.warnings.length ? `, ${report.warnings.length} uyarı` : ""}${size}`);
431
+ const size = report.files ? t(" · {0} dosya, {1} MB", report.files, (report.bytes / 1024 / 1024).toFixed(1)) : "";
432
+ if (report.errors.length) console.error(t("{0}: {1} hata, {2} uyarı{3}", title, report.errors.length, report.warnings.length, size));
433
+ else console.log(t("{0}: denetim geçti{1}{2}", title, report.warnings.length ? t(", {0} uyarı", report.warnings.length) : "", size));
342
434
  }
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { t } from "./i18n.js";
4
5
 
5
6
  // Giriş bilgileri kullanıcının ev klasöründe, yalnızca kullanıcının okuyabildiği bir dosyada durur.
6
7
  // Tema klasörüne hiçbir zaman yazılmaz; böylece pakete ya da sürüm kontrolüne karışmaz.
@@ -45,7 +46,7 @@ const local = (hostname) => hostname === "localhost" || hostname === "127.0.0.1"
45
46
  */
46
47
  export function storeOrigin(input) {
47
48
  let value = String(input ?? "").trim().replace(/\/+$/, "");
48
- if (!value) throw new Error("Mağaza adresi gerekli (ör. magazam ya da magazam.uvoo.store).");
49
+ if (!value) throw new Error(t("Mağaza adresi gerekli (ör. magazam ya da magazam.uvoo.store)."));
49
50
  if (!/^https?:\/\//i.test(value)) {
50
51
  if (!value.includes(".") && !value.includes(":")) value = `${value}.${defaultStoreApex}`;
51
52
  value = `${local(value.split(":")[0]) ? "http" : "https"}://${value}`;
@@ -54,10 +55,10 @@ export function storeOrigin(input) {
54
55
  try {
55
56
  url = new URL(value);
56
57
  } catch {
57
- throw new Error(`Mağaza adresi okunamadı: ${input}`);
58
+ throw new Error(t("Mağaza adresi okunamadı: {0}", input));
58
59
  }
59
- if (url.protocol !== "https:" && !local(url.hostname)) throw new Error("Mağazaya yalnızca https ile bağlanılır.");
60
- if (url.username || url.password) throw new Error("Mağaza adresinde kullanıcı adı ya da parola olamaz.");
60
+ if (url.protocol !== "https:" && !local(url.hostname)) throw new Error(t("Mağazaya yalnızca https ile bağlanılır."));
61
+ if (url.username || url.password) throw new Error(t("Mağaza adresinde kullanıcı adı ya da parola olamaz."));
61
62
  return url.origin;
62
63
  }
63
64
 
@@ -70,7 +71,7 @@ export function session(storeOption) {
70
71
  const origin = storeOption ? storeOrigin(storeOption) : data.current;
71
72
  const entry = origin ? data.stores[origin] : null;
72
73
  if (!origin || !entry?.token) {
73
- throw new Error(storeOption ? `Bu mağaza için giriş yapılmamış: ${origin}\nÖnce: uvako login --store ${storeOption}` : "Giriş yapılmamış. Önce: uvako login --store <mağaza>");
74
+ throw new Error(storeOption ? t("Bu mağaza için giriş yapılmamış: {0}\nÖnce: uvako login --store {1}", origin, storeOption) : "Giriş yapılmamış. Önce: uvako login --store <mağaza>");
74
75
  }
75
76
  return { origin, token: entry.token, name: entry.name };
76
77
  }
package/src/i18n.js ADDED
@@ -0,0 +1,67 @@
1
+ import { en } from "./locales/en.js";
2
+
3
+ // CLI dili. Kaynak dil Türkçedir: metinler kodda Türkçe yazılır ve t("…") ile sarılır; sözlük
4
+ // (locales/en.js) Türkçe metni anahtar olarak kullanır. Sözlükte karşılığı olmayan metin Türkçe yazılır.
5
+ //
6
+ // Dil sırasıyla şuradan okunur: --lang seçeneği, UVAKO_LANG ortam değişkeni, sistemin dili
7
+ // (LC_ALL, LC_MESSAGES, LANG). Sistem dili Türkçe değilse İngilizce, belirtilmemişse Türkçe kullanılır.
8
+ export const locales = ["tr", "en"];
9
+
10
+ const dictionaries = { en };
11
+
12
+ function fromArgs(argv) {
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === "--lang" && argv[i + 1]) return argv[i + 1];
15
+ if (argv[i].startsWith("--lang=")) return argv[i].slice(7);
16
+ }
17
+ return "";
18
+ }
19
+
20
+ function detect(argv, env) {
21
+ const asked = (fromArgs(argv) || env.UVAKO_LANG || "").toLowerCase().slice(0, 2);
22
+ if (locales.includes(asked)) return asked;
23
+ const system = (env.LC_ALL || env.LC_MESSAGES || env.LANG || "").toLowerCase();
24
+ if (!system || system === "c" || system.startsWith("c.") || system.startsWith("posix")) return "tr";
25
+ return system.startsWith("tr") ? "tr" : "en";
26
+ }
27
+
28
+ let locale = detect(process.argv.slice(2), process.env);
29
+ let patterns = null;
30
+
31
+ export const getLocale = () => locale;
32
+
33
+ /** Dili değiştirir (testler ve --lang için). Tanınmayan dil Türkçe sayılır. */
34
+ export function setLocale(next) {
35
+ locale = locales.includes(next) ? next : "tr";
36
+ patterns = null;
37
+ }
38
+
39
+ const fill = (text, args) => (args.length ? text.replace(/\{(\d+)\}/g, (whole, index) => (args[Number(index)] !== undefined ? String(args[Number(index)]) : whole)) : text);
40
+
41
+ /** Türkçe kaynak metni CLI diline çevirir; {0}, {1}… yer tutucuları args ile doldurulur. */
42
+ export function t(text, ...args) {
43
+ return fill(dictionaries[locale]?.[text] ?? text, args);
44
+ }
45
+
46
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+
48
+ /**
49
+ * Sunucudan gelen metni (denetim hatası, işlem mesajı) çevirir. Tam karşılığı yoksa yer tutuculu
50
+ * anahtarlarla eşleştirir: "{0}: geçerli JSON değil." ↔ "theme.json: geçerli JSON değil."
51
+ */
52
+ export function tm(text) {
53
+ const dict = dictionaries[locale];
54
+ if (!text || !dict) return text ?? "";
55
+ if (dict[text] !== undefined) return dict[text];
56
+ patterns ??= Object.entries(dict)
57
+ .filter(([key]) => /\{\d+\}/.test(key))
58
+ .map(([key, value]) => {
59
+ const order = [...key.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
60
+ return { match: new RegExp("^" + escape(key).replace(/\\\{\d+\\\}/g, "(.+?)") + "$", "s"), text: value.replace(/\{(\d+)\}/g, (_, index) => `{${order.indexOf(Number(index))}}`) };
61
+ });
62
+ for (const pattern of patterns) {
63
+ const found = pattern.match.exec(text);
64
+ if (found) return fill(pattern.text, found.slice(1).map((part) => (part === text ? part : tm(part))));
65
+ }
66
+ return text;
67
+ }