الوثائقمن خادمك7 دقيقة قراءة

من خادمك

الخادم الذي يحفظ نصوصه بنفسه لا يحتاج الحزمة، ولا عمودًا لكل لغة: سمِّ النص مرةً واحدة، واطلب كل اللغات في طلبٍ واحد، ثم اقرأ الجوابات تحت الأسماء التي أعطيتها.

الشكل المعتاد في الخوادم: title_en وtitle_ar وtitle_fr — فكل لغة جديدة ترحيل بنية، ويوم يضيف أحد لغةً تظهر قيمة فارغة. وهنا السطر جملةٌ بلغة واحدة، والجواب في جدول مفتاحه تلك الجملة، فلا تتغيّر بنية قاعدتك بتغيّر أسواقك.

أربعة أجزاء

keystring
اسمك للنص مثل checkout.title. اختياري؛ بدونه تُترجم العبارة وتُحفظ تحت نصها الأصلي.
targets[]string[]
حتى ثماني لغات في دفعة واحدة، ويرجع الجواب في results مفاتيحها الأوسمة المعيارية.
GET /v1/strings?by=keymap
كل نصٍّ مسمّى بلغة واحدة في كائن واحد، وهو الطلب الذي يسخّن القاموس على الخادم.
GET /v1/languagesdeclaration
ما ضبطته المشروع من لغات، فتحرّرها من اللوحة بدل أن تجمّده في النشر.

طلبٌ واحد لكل اللغات

curl
curl -s "$SIYAK_HOST/v1/translate/batch" \
  -H "authorization: Bearer $SIYAK_KEY" \
  -H "content-type: application/json" \
  -d '{
        "targets": ["ar", "ar-EG", "fr", "zh-Hans"],
        "items": [
          { "key": "checkout.title",  "text": "Complete your order", "context": "screen:checkout" },
          { "key": "checkout.button", "text": "Pay now",             "context": "screen:checkout" },
          { "key": "cart.empty",      "text": "Your basket is empty" }
        ]
      }'

# → {"items":[{"index":0,"key":"checkout.title","input":"Complete your order",
#      "results":{"ar":{"output":"أكمل طلبك","origin":"approved","status":"approved"},
#                 "ar-EG":{…},"fr":{…},"zh-Hans":{…}}}, …],
#    "targets":["ar","ar-EG","fr","zh-Hans"],
#    "usage":{"used":5820,"limit":15000,"month":"2026-10"}}

تخرج اللغات داخل العنصر معًا، وتُجاب العناصر بالترتيب فالفهرس صفر هو أول صفوفك دائمًا. وأربع لغات جديدة تعني أربعة أمثال أحرفك محسوبًا؛ هذا كل نموذج التكلفة، ولهذا تُفعَل عند الإقلاع لا مع كل طلب.

Node‏ / Express

siyak.js
// Run once at boot. After this, serving a translated API costs no network call.
const LANGS = ['ar', 'ar-EG', 'fr', 'zh-Hans'];

async function warm(lang) {
  const r = await fetch(`${process.env.SIYAK_URL}/v1/strings?target=${lang}&by=key`, {
    headers: { authorization: `Bearer ${process.env.SIYAK_KEY}` },
  });
  if (!r.ok) throw new Error(`siyak ${r.status}`);
  return (await r.json()).strings; // { "checkout.title": "أكمل طلبك", … }
}

export const dict = Object.fromEntries(
  await Promise.all(LANGS.map(async (lang) => [lang, await warm(lang)])),
);

// The source text is the fallback, so an untranslated string is a slow
// release, never a blank field.
export const t = (key, lang, source) => dict[lang]?.[key] ?? source;
routes/checkout.js
import { t } from '../siyak.js';

app.get('/api/checkout', (req, res) => {
  const lang = req.query.lang ?? 'ar';
  res.json({
    title: t('checkout.title', lang, 'Complete your order'),
    cta: t('checkout.button', lang, 'Pay now'),
  });
});

والنصُّ الذي لم يسمَّه أحد بعد لا يكون في الخريطة فيُجاب بلغتك الأصلية حتى يمتلئ صندوق الوارد؛ فلا شيء يحتاج تسجيلًا مسبقًا.

Laravel

app/Support/Siyak.php
<?php

namespace App\Support;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class Siyak
{
    /** @param array<string,string> $keys  ['checkout.title' => 'Complete your order', …] */
    public static function warm(array $keys, array $targets = ['ar', 'ar-EG', 'fr']): array
    {
        $body = Http::withToken(config('services.siyak.key'))
            ->post(rtrim(config('services.siyak.url'), '/').'/v1/translate/batch', [
                'targets' => $targets,
                'items' => collect($keys)
                    ->map(fn ($text, $key) => ['key' => $key, 'text' => $text])
                    ->values()->all(),
            ])->throw()->json();

        $out = [];
        foreach ($body['items'] as $item) {
            foreach ($item['results'] as $lang => $answer) {
                $out[$lang][$item['key']] = $answer['output'] ?? $keys[$item['key']];
            }
        }
        return $out;
    }

    public static function get(string $key, string $locale, string $source): string
    {
        return Cache::remember('siyak', now()->addDay(), fn () => self::warm(self::$strings))[$locale][$key] ?? $source;
    }
}

وتستدعيه في Blade كما تستدعي trans(): ‏{{ Siyak::get(...) }}‏ بلا عمود زائد ولا ملف lang لكل لغة يواكب الآخر.

Django

siyak.py
import httpx, os

API, KEY = os.environ["SIYAK_URL"], os.environ["SIYAK_KEY"]


def warm(keys: dict[str, str], targets: list[str]) -> dict[str, dict[str, str]]:
    """keys = {"checkout.title": "Complete your order", ...}"""
    resp = httpx.post(
        f"{API}/v1/translate/batch",
        headers={"authorization": f"Bearer {KEY}"},
        json={
            "targets": targets,
            "items": [{"key": k, "text": v} for k, v in keys.items()],
        },
        timeout=30,
    )
    resp.raise_for_status()

    out = {t: {} for t in targets}
    for row in resp.json()["items"]:
        for lang, answer in row["results"].items():
            out[lang][row["key"]] = answer["output"] or keys[row["key"]]
    return out


def t(key: str, lang: str, source: str) -> str:
    return cache.get("siyak", {}).get(lang, {}).get(key, source)

أمر إدارة أو خطاف ready() يستدعي warm() مرة لكل نشر، ولا يقرأ مسار الطلب بعدها إلا من الذاكرة.

ما لا يفعله الاسم

  • فهو تسمية لا هوية: يُحسم النص ببصمة (الأصل | اللغة | السياق)، فالجملة الواحدة تحت اسمين تبقى سطرًا واحدًا بمسمّيين، والاسم الثاني ينتقل إلى السطر الذي جلس عليه الأول.
  • وليس لازمًا: إن كان نصك الأصلي هو مفتاحك فلا ترسله، وخريطة بالنص الأصلي هي الطلب نفسه بـ‏by=source.
  • وتغيير الأصل تحت اسم قائم تعارض لا انتقال صامت: السطر القديم ما زال يحمل الاسم فيُجاب 409 key_conflict بلا حساب؛ انقل الاسم من صندوق الوارد أو أرسل اسمًا جديدًا.
  • ولا يردّ ‏by=key إلا على المسمّى — برسالة حملت key أو باستيراد أو بتسمية من الوارد؛ فالمجهول الاسم لا يظهر فيه عمدًا، ويظلّ يُحلّ عبر /v1/translate.