@eachlang/tanstack 0.0.0-stage → 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/README.md +21 -2
- package/package.json +41 -4
- package/src/client.js +66 -0
- package/src/core.js +241 -0
- package/src/index.js +5 -0
- package/src/server.js +14 -0
- package/src/switcher.css +44 -0
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @eachlang/tanstack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Eachlang for TanStack Start (Lovable): server-side translations (no `fs`, Cloudflare Workers safe) with fail-open fallbacks, missing-string reports, hreflang helpers and an accessible language switcher.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i @eachlang/tanstack
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// src/eachlang.functions.ts (not *.server.ts: TanStack Start blocks importing those from client code)
|
|
11
|
+
import { createServerFn } from '@tanstack/react-start';
|
|
12
|
+
import { createEachlang } from '@eachlang/tanstack/server';
|
|
13
|
+
let e; const client = () => (e ??= createEachlang({ ...cfg, apiBase: process.env.EACHLANG_API_BASE }));
|
|
14
|
+
export const loadTranslations = createServerFn({ method: 'GET' }).handler(async ({ data }) => client().getTranslations(data.lang));
|
|
15
|
+
export const reportMissingFn = createServerFn({ method: 'POST' }).handler(async ({ data }) => { await client().reportMissing(data.lang, data.keys, data.path); });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `@eachlang/tanstack/server`: `createEachlang(cfg, { snapshots? })`. Use only inside server functions or server routes.
|
|
19
|
+
- `@eachlang/tanstack`: `makeT`, `hreflangLinks`, `alternatesList(cfg, path, tr)`, `localePath`, `LanguageSwitcher`, `EachlangProvider`, `useT`, `useLang`.
|
|
20
|
+
- `@eachlang/tanstack/switcher.css`: optional neutral switcher styles.
|
|
21
|
+
|
|
22
|
+
Setup is meant to be done by your coding agent with the Eachlang setup prompt: https://eachlang.com
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eachlang/tanstack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Eachlang for TanStack Start: server-side translations (Workers-safe, no fs) (manifest/bundle, fail-open), missing-string reports, hreflang helpers and the language switcher.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"i18n",
|
|
7
|
+
"translation",
|
|
8
|
+
"localization",
|
|
9
|
+
"eachlang",
|
|
10
|
+
"tanstack-start",
|
|
11
|
+
"tanstack-router",
|
|
12
|
+
"react",
|
|
13
|
+
"cloudflare-workers"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://eachlang.com",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Eachlang",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./server": "./src/server.js",
|
|
22
|
+
"./switcher.css": "./src/switcher.css",
|
|
23
|
+
"./client": "./src/client.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": [
|
|
29
|
+
"*.css"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@eachlang/switcher": "^0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// generated by build.mjs from paketler/cekirdek/react.js, do not edit
|
|
2
|
+
'use client';
|
|
3
|
+
// Eachlang istemci bağlamı (T253): EachlangProvider + useT. @eachlang/next ve @eachlang/tanstack bu dosyanın
|
|
4
|
+
// kopyasını src/client.js olarak taşır (build.mjs). Hook'lar yalnız bağlam okur; çeviri render sırasında, saf
|
|
5
|
+
// (sunucu ve istemci aynı HTML'i üretir, hidrasyon uyuşmazlığı yok). useEffect yalnız eksik metni BİLDİRİR.
|
|
6
|
+
// <EachlangProvider lang strings sourceLang? onMissing? path? images? siteUrl?>
|
|
7
|
+
// images: getTranslations(lang).images (T234); useImg() → img(src) dile özgü görsel adresi.
|
|
8
|
+
// onMissing({ lang, keys, path }): Next'te Server Action, TanStack'te createServerFn. Beklenmez (await yok);
|
|
9
|
+
// path verilmezse location.pathname (önekli) gider.
|
|
10
|
+
// sunucu tarafı reportMissing'i bekler (B1). Kaynak dilde ve onMissing yoksa metin toplanmaz.
|
|
11
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useRef } from 'react';
|
|
12
|
+
import { makeT, makeImg } from './core.js';
|
|
13
|
+
|
|
14
|
+
const Ctx = createContext(null);
|
|
15
|
+
const has = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k) && !!o[k];
|
|
16
|
+
|
|
17
|
+
export function EachlangProvider({ lang, strings, sourceLang, onMissing, path, images, siteUrl, children }) {
|
|
18
|
+
const ref = useRef(null);
|
|
19
|
+
if (!ref.current) ref.current = { kume: new Set(), zaman: null };
|
|
20
|
+
const son = useRef(onMissing); son.current = onMissing;
|
|
21
|
+
const value = useMemo(() => {
|
|
22
|
+
const r = ref.current;
|
|
23
|
+
// Toplanan metinler useT kullanan bileşenlerin efektinden sonra tek istekte gönderilir (istemci geçişinde de).
|
|
24
|
+
const flush = () => {
|
|
25
|
+
if (r.zaman || typeof window === 'undefined') return;
|
|
26
|
+
r.zaman = setTimeout(() => {
|
|
27
|
+
r.zaman = null;
|
|
28
|
+
if (!r.kume.size || typeof son.current !== 'function') return;
|
|
29
|
+
const keys = [...r.kume].slice(0, 50);
|
|
30
|
+
for (const k of keys) r.kume.delete(k);
|
|
31
|
+
const p = path || window.location.pathname;
|
|
32
|
+
try { const x = son.current({ lang, keys, path: p }); if (x && typeof x.catch === 'function') x.catch(() => {}); } catch {}
|
|
33
|
+
}, 0);
|
|
34
|
+
};
|
|
35
|
+
return { lang, strings: strings || {}, images, siteUrl, kume: r.kume, flush, collect: typeof onMissing === 'function' && !!lang && lang !== sourceLang };
|
|
36
|
+
}, [lang, strings, sourceLang, path, images, siteUrl, typeof onMissing === 'function']);
|
|
37
|
+
return createElement(Ctx.Provider, { value }, children);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** t(src, vars): önce çeviri, sonra {ad} (B5). Provider yoksa kaynak metin. */
|
|
41
|
+
export function useT() {
|
|
42
|
+
const c = useContext(Ctx);
|
|
43
|
+
const t = useMemo(() => {
|
|
44
|
+
const strings = c ? c.strings : {};
|
|
45
|
+
const ceviri = makeT(strings, { collect: false });
|
|
46
|
+
return (src, vars) => {
|
|
47
|
+
const s = src == null ? '' : String(src);
|
|
48
|
+
if (c && c.collect && s && !has(strings, s)) c.kume.add(s);
|
|
49
|
+
return ceviri(s, vars);
|
|
50
|
+
};
|
|
51
|
+
}, [c]);
|
|
52
|
+
useEffect(() => { if (c && c.collect && c.kume.size) c.flush(); });
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** T234: img(src) → dile özgü görsel adresi (Provider images); eşleme yoksa src aynen. */
|
|
57
|
+
export function useImg() {
|
|
58
|
+
const c = useContext(Ctx);
|
|
59
|
+
return useMemo(() => makeImg(c ? c.images : null, { siteUrl: c ? c.siteUrl : undefined }), [c]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Geçerli dil (Provider'dan). */
|
|
63
|
+
export function useLang() {
|
|
64
|
+
const c = useContext(Ctx);
|
|
65
|
+
return c ? c.lang : undefined;
|
|
66
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// generated by build.mjs from paketler/cekirdek/cekirdek.js, do not edit
|
|
2
|
+
// Eachlang çeviri çekirdeği (T232). Çatıdan bağımsız; @eachlang/next ve @eachlang/tanstack bu dosyanın
|
|
3
|
+
// birebir kopyasını taşır (her paketin build.mjs'i src/core.js'e kopyalar, --check ayrışmayı yakalar).
|
|
4
|
+
// Kaynak: spike/t74/next/lib/eachlang.js (T74, B1/B2/B5/B8). Kaynak doğrusu bu dosyadır; kopyaları elle düzenleme.
|
|
5
|
+
//
|
|
6
|
+
// Kural: hiçbir durumda hata fırlatmaz. Sıra: API -> son bilinen (bellek) -> anlık görüntü -> kaynak metin.
|
|
7
|
+
// Sözleşme: api/README.md (GET manifest, GET bundle/{lang}?v=N, POST missing).
|
|
8
|
+
|
|
9
|
+
const VARSAYILAN = { timeoutMs: 800, missingTimeoutMs: 5000, manifestTtlMs: 60_000, apiBase: 'https://api.eachlang.com' };
|
|
10
|
+
|
|
11
|
+
function yolTemizle(p) {
|
|
12
|
+
const s = typeof p === 'string' && p ? p : '/';
|
|
13
|
+
return s.startsWith('/') ? s : '/' + s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Yapılandırmayı doğrular ve varsayılanları doldurur. Hata fırlatmaz; eksik alanı uyarı listesine yazar. */
|
|
17
|
+
export function normalizeConfig(cfg = {}) {
|
|
18
|
+
const c = { ...VARSAYILAN, ...cfg };
|
|
19
|
+
c.targetLangs = Array.isArray(c.targetLangs) ? c.targetLangs.filter((l) => l && l !== c.sourceLang) : [];
|
|
20
|
+
c.siteUrl = String(c.siteUrl || '').replace(/\/+$/, '');
|
|
21
|
+
c.apiBase = String(c.apiBase).replace(/\/+$/, '');
|
|
22
|
+
c.uyarilar = [];
|
|
23
|
+
if (!c.sourceLang) c.uyarilar.push('sourceLang yok');
|
|
24
|
+
if (!c.publicKey) c.uyarilar.push('publicKey yok');
|
|
25
|
+
if (!c.siteUrl) c.uyarilar.push('siteUrl yok (hreflang mutlak olmaz)');
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Kaynak dil öneksiz; hedef dil /{lang}/… */
|
|
30
|
+
export function localePath(cfg, lang, p = '/') {
|
|
31
|
+
const clean = yolTemizle(p);
|
|
32
|
+
return !lang || lang === cfg.sourceLang ? clean : `/${lang}${clean === '/' ? '' : clean}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Durum: boolean (paused) ya da getTranslations sonucu { status, languages }. Nesne verilirse yalnız ETKİN hedef
|
|
36
|
+
// diller (manifest languages[] ∩ targetLangs) listelenir; Pause'da yalnız kaynak dil. (T253: istem tr'yi doğrudan verir.)
|
|
37
|
+
function etkinDiller(cfg, durum) {
|
|
38
|
+
if (durum && typeof durum === 'object') {
|
|
39
|
+
if (durum.status === 'paused') return [];
|
|
40
|
+
return Array.isArray(durum.languages) ? cfg.targetLangs.filter((l) => durum.languages.includes(l)) : cfg.targetLangs;
|
|
41
|
+
}
|
|
42
|
+
return durum ? [] : cfg.targetLangs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Next metadata biçimi: { canonical, languages: { tr, en, …, 'x-default' } }. Pause'da yalnız kaynak dil. */
|
|
46
|
+
export function alternates(cfg, lang, p, durum) {
|
|
47
|
+
const abs = (l) => cfg.siteUrl + localePath(cfg, l, p);
|
|
48
|
+
const hedef = etkinDiller(cfg, durum);
|
|
49
|
+
const languages = { [cfg.sourceLang]: abs(cfg.sourceLang) };
|
|
50
|
+
for (const l of hedef) languages[l] = abs(l);
|
|
51
|
+
languages['x-default'] = abs(cfg.sourceLang);
|
|
52
|
+
return { canonical: abs(lang && hedef.includes(lang) ? lang : cfg.sourceLang), languages };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** TanStack biçimi: tek [{ lang, href }] listesi (head() hreflang ve seçici aynı listeyi kullanır). */
|
|
56
|
+
export function alternatesList(cfg, p, durum) {
|
|
57
|
+
return [cfg.sourceLang, ...etkinDiller(cfg, durum)].map((lang) => ({ lang, href: cfg.siteUrl + localePath(cfg, lang, p) }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** [{ lang, href }] -> <link rel="alternate" hreflang> nesneleri (+ x-default = ilk öğe, kaynak dil). */
|
|
61
|
+
export function hreflangLinks(list) {
|
|
62
|
+
if (!Array.isArray(list) || !list.length) return [];
|
|
63
|
+
return [...list.map((a) => ({ rel: 'alternate', hrefLang: a.lang, href: a.href })),
|
|
64
|
+
{ rel: 'alternate', hrefLang: 'x-default', href: list[0].href }];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// B5 (74 §1): yer tutucu anahtarın içinde: t("{count} in cart", { count }). Önce çeviri bulunur, sonra {ad}
|
|
68
|
+
// değerleri yerleştirilir; değeri verilmeyen yer tutucu olduğu gibi kalır.
|
|
69
|
+
// t.missing: bu t ile istenip çevirisi bulunamayan kaynak metinler (reportMissing'e verilir). Kaynak dilde
|
|
70
|
+
// ya da Pause'da makeT(strings, { collect: false }) kullan (eksik sayılmaz).
|
|
71
|
+
export function makeT(strings, { collect = true } = {}) {
|
|
72
|
+
const eksik = new Set();
|
|
73
|
+
const t = (src, vars) => {
|
|
74
|
+
const s = src == null ? '' : String(src);
|
|
75
|
+
const bulunan = strings && Object.prototype.hasOwnProperty.call(strings, s) && strings[s];
|
|
76
|
+
if (!bulunan && collect && s) eksik.add(s);
|
|
77
|
+
const metin = bulunan || s;
|
|
78
|
+
if (!vars) return metin;
|
|
79
|
+
return metin.replace(/\{(\w+)\}/g, (m, ad) => (Object.prototype.hasOwnProperty.call(vars, ad) ? String(vars[ad]) : m));
|
|
80
|
+
};
|
|
81
|
+
Object.defineProperty(t, 'missing', { get: () => [...eksik] });
|
|
82
|
+
return t;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// T234: dil başına görsel adresi. images = manifest `images[lang]` ({ anahtar: yeniUrl }); anahtar kuralı API ile aynı
|
|
86
|
+
// (api/src/gorseller.js): kök göreli yol ya da mutlak http(s), parça atılır; sırayla sorgulu mutlak, sorgusuz mutlak,
|
|
87
|
+
// (siteUrl ile aynı kökense) sorgulu yol, sorgusuz yol aranır. Eşleme yoksa src olduğu gibi döner (biçimi korunur).
|
|
88
|
+
// Yeni adres yalnız kök göreli yol ya da http(s) ise kullanılır. img(src) asla hata fırlatmaz.
|
|
89
|
+
const IMG_TABAN = 'https://eachlang.invalid';
|
|
90
|
+
function imgDegerGecerli(v) {
|
|
91
|
+
return typeof v === 'string' && v.length <= 2048 && !/\s/.test(v) && ((v.startsWith('/') && !v.startsWith('//')) || /^https?:\/\/[^/]/i.test(v));
|
|
92
|
+
}
|
|
93
|
+
export function makeImg(images, { siteUrl } = {}) {
|
|
94
|
+
const h = images && typeof images === 'object' && !Array.isArray(images) ? images : null;
|
|
95
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(h, k);
|
|
96
|
+
return (src) => {
|
|
97
|
+
if (!h || typeof src !== 'string' || !src) return src;
|
|
98
|
+
let u, koken = null;
|
|
99
|
+
try { koken = siteUrl ? new URL(siteUrl).origin : null; } catch {}
|
|
100
|
+
try { u = new URL(src, koken || IMG_TABAN); } catch { return src; }
|
|
101
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return src;
|
|
102
|
+
u.hash = '';
|
|
103
|
+
const goreli = u.origin === (koken || IMG_TABAN);
|
|
104
|
+
const mutlak = u.toString();
|
|
105
|
+
const adaylar = [];
|
|
106
|
+
if (!goreli || koken) adaylar.push(mutlak, mutlak.slice(0, mutlak.length - u.search.length));
|
|
107
|
+
if (goreli) adaylar.push(u.pathname + u.search, u.pathname);
|
|
108
|
+
for (const a of adaylar) if (has(a) && imgDegerGecerli(h[a])) return h[a];
|
|
109
|
+
return src;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* createEachlang(cfg, secenek): sunucu tarafı istemci.
|
|
115
|
+
* secenek.fetch : fetch uygulaması (varsayılan globalThis.fetch; testte sahte)
|
|
116
|
+
* secenek.fetchInit : her isteğe eklenecek alanlar (Next: { cache: 'no-store' }; Workers'ta verme)
|
|
117
|
+
* secenek.readSnapshot : async (lang) => { version?, strings } | null (repo anlık görüntüsü)
|
|
118
|
+
* secenek.state : önbellek nesnesi (varsayılan globalThis.__eachlang[publicKey])
|
|
119
|
+
* secenek.now : () => ms (test)
|
|
120
|
+
*/
|
|
121
|
+
export function createEachlang(cfgIn, secenek = {}) {
|
|
122
|
+
const cfg = normalizeConfig(cfgIn);
|
|
123
|
+
const fetchFn = secenek.fetch || ((...a) => globalThis.fetch(...a));
|
|
124
|
+
const ek = secenek.fetchInit || {};
|
|
125
|
+
const now = secenek.now || Date.now;
|
|
126
|
+
const readSnapshot = secenek.readSnapshot || (async () => null);
|
|
127
|
+
const kok = (globalThis.__eachlang ??= {});
|
|
128
|
+
const mem = secenek.state || (kok[cfg.publicKey || '_'] ??= { manifest: null, manifestAt: 0, bundles: {} });
|
|
129
|
+
const taban = `${cfg.apiBase}/v1/p/${cfg.publicKey}`;
|
|
130
|
+
|
|
131
|
+
async function getJSON(url) {
|
|
132
|
+
const r = await fetchFn(url, { ...ek, signal: AbortSignal.timeout(cfg.timeoutMs) });
|
|
133
|
+
if (!r.ok) throw new Error('http ' + r.status);
|
|
134
|
+
return r.json();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function snapshot(lang) {
|
|
138
|
+
try { return (await readSnapshot(lang)) || null; } catch { return null; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function getManifest() {
|
|
142
|
+
if (mem.manifest && now() - mem.manifestAt < cfg.manifestTtlMs) return { ...mem.manifest, from: 'cache' };
|
|
143
|
+
try {
|
|
144
|
+
const m = await getJSON(`${taban}/manifest`);
|
|
145
|
+
if (!m || typeof m !== 'object') throw new Error('bad manifest');
|
|
146
|
+
// frozen dışarıya paused olarak görünür (T74 F5); bilinmeyen durum active sayılır (fail-open).
|
|
147
|
+
const status = m.status === 'paused' || m.status === 'frozen' ? 'paused' : 'active';
|
|
148
|
+
const version = Number.isInteger(m.version) ? m.version : null;
|
|
149
|
+
mem.manifest = { ...m, status, version }; mem.manifestAt = now();
|
|
150
|
+
return { ...mem.manifest, from: 'api' };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// API yok: son bilinen manifest; o da yoksa "active" varsay (fail-open). Pause yalnız API açıkça söylerse.
|
|
153
|
+
return mem.manifest ? { ...mem.manifest, from: 'stale' } : { status: 'active', version: null, from: 'none', error: String(e && e.message) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Etkin hedef diller: manifest languages[] ∩ targetLangs. Manifest dil listesi vermezse (API yok, eski yanıt)
|
|
158
|
+
// targetLangs (kesinti dil kapatmaz; son bilinen manifest önce gelir). Pause'da [].
|
|
159
|
+
function aktif(m) {
|
|
160
|
+
if (m.status === 'paused') return [];
|
|
161
|
+
return Array.isArray(m.languages) ? cfg.targetLangs.filter((l) => m.languages.includes(l)) : [...cfg.targetLangs];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function getTranslations(lang) {
|
|
165
|
+
const m = await getManifest(); // kaynak dilde de bakılır: Pause'da hreflang hedefleri kalkar
|
|
166
|
+
const sonuc = await ceviri(lang, m);
|
|
167
|
+
// T234: dilin görsel eşlemesi (manifest images[lang]); kaynak dilde, Pause'da ve etkin olmayan dilde {}.
|
|
168
|
+
const im = sonuc.from !== 'source' && sonuc.status === 'active' && m.images && typeof m.images === 'object' ? m.images[sonuc.lang] : null;
|
|
169
|
+
const images = im && typeof im === 'object' && !Array.isArray(im) ? im : {};
|
|
170
|
+
return { ...sonuc, images, languages: aktif(m) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function ceviri(lang, m) {
|
|
174
|
+
const paused = m.status === 'paused';
|
|
175
|
+
if (!lang || lang === cfg.sourceLang) return { lang: cfg.sourceLang, status: paused ? 'paused' : 'active', strings: {}, from: 'source' };
|
|
176
|
+
if (!cfg.targetLangs.includes(lang)) return { lang, status: 'unknown', strings: {}, from: 'source' };
|
|
177
|
+
if (paused) return { lang, status: 'paused', strings: {}, from: m.from };
|
|
178
|
+
if (!aktif(m).includes(lang)) return { lang, status: 'inactive', strings: {}, from: m.from }; // T253: panelde kaldırılmış dil = 307
|
|
179
|
+
const have = mem.bundles[lang];
|
|
180
|
+
if (have && m.version != null && have.version === m.version) return { ...have, from: 'cache' };
|
|
181
|
+
// B8: version null (API'ye hiç ulaşılamadı ya da henüz yayım yok) iken bundle istenmez.
|
|
182
|
+
if (m.version != null) {
|
|
183
|
+
try {
|
|
184
|
+
const b = await getJSON(`${taban}/bundle/${encodeURIComponent(lang)}?v=${m.version}`);
|
|
185
|
+
if (b && b.status === 'paused') return { lang, status: 'paused', strings: {}, from: 'api' };
|
|
186
|
+
const strings = b && b.strings && typeof b.strings === 'object' ? b.strings : {};
|
|
187
|
+
mem.bundles[lang] = { lang, status: 'active', version: Number.isInteger(b.version) ? b.version : m.version, strings };
|
|
188
|
+
return { ...mem.bundles[lang], from: 'api' };
|
|
189
|
+
} catch {}
|
|
190
|
+
}
|
|
191
|
+
if (have) return { ...have, from: 'stale' };
|
|
192
|
+
const snap = await snapshot(lang);
|
|
193
|
+
if (snap) return { lang, status: 'active', version: snap.version ?? null, strings: snap.strings || {}, from: 'snapshot' };
|
|
194
|
+
return { lang, status: 'active', version: null, strings: {}, from: 'source' }; // kaynak metne düş
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// B1/B2 (74 §1): eksik metin bildirimi sunucu tarafında BEKLENİR (Workers ve serverless yanıt gidince
|
|
198
|
+
// beklenmeyen fetch'i iptal eder); render yolunda çağrılmaz. Next: after(() => reportMissing(...)).
|
|
199
|
+
// Workers: ctx.waitUntil(reportMissing(...)). Zaman aşımı missingTimeoutMs (5000), timeoutMs değil.
|
|
200
|
+
// Yalnız 2xx sonrası true. Kaynak dilde ve Pause'da çağırma (çağrılırsa kaynak dilde false döner).
|
|
201
|
+
// Aynı (dil, metin) süreç başına bir kez gönderilir (API zaten "known" der; gereksiz istek ve hız sınırı yok).
|
|
202
|
+
// Hepsi daha önce bildirildiyse istek atılmaz ve true döner. Hata ya da 2xx dışı yanıtta işaretlenmez.
|
|
203
|
+
async function reportMissing(lang, keys, pagePath = '/') {
|
|
204
|
+
if (!lang || lang === cfg.sourceLang || !Array.isArray(keys)) return false;
|
|
205
|
+
const temiz = [...new Set(keys.filter((k) => typeof k === 'string' && k && k.length <= 2000))];
|
|
206
|
+
if (!temiz.length) return false;
|
|
207
|
+
const bil = (mem.bildirilen ??= new Set());
|
|
208
|
+
const strings = temiz.filter((k) => !bil.has(lang + '\u0000' + k)).slice(0, 50);
|
|
209
|
+
if (!strings.length) return true;
|
|
210
|
+
try {
|
|
211
|
+
const r = await fetchFn(`${taban}/missing`, {
|
|
212
|
+
...ek, method: 'POST', headers: { 'content-type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({ lang, path: yolTemizle(pagePath), strings }),
|
|
214
|
+
signal: AbortSignal.timeout(cfg.missingTimeoutMs),
|
|
215
|
+
});
|
|
216
|
+
if (r.ok) {
|
|
217
|
+
if (bil.size > 10_000) bil.clear(); // bellek tavanı
|
|
218
|
+
for (const k of strings) bil.add(lang + '\u0000' + k);
|
|
219
|
+
}
|
|
220
|
+
return r.ok;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
config: cfg,
|
|
228
|
+
getManifest,
|
|
229
|
+
getTranslations,
|
|
230
|
+
reportMissing,
|
|
231
|
+
makeT,
|
|
232
|
+
/** T234: img(src) → dile özgü görsel adresi; images = getTranslations(lang).images */
|
|
233
|
+
makeImg: (images) => makeImg(images, { siteUrl: cfg.siteUrl }),
|
|
234
|
+
localePath: (lang, p) => localePath(cfg, lang, p),
|
|
235
|
+
alternates: (lang, p, durum) => alternates(cfg, lang, p, durum),
|
|
236
|
+
alternatesList: (p, durum) => alternatesList(cfg, p, durum),
|
|
237
|
+
hreflangLinks,
|
|
238
|
+
/** testler için: bellek önbelleğini boşalt */
|
|
239
|
+
_reset() { mem.manifest = null; mem.manifestAt = 0; mem.bundles = {}; mem.bildirilen = new Set(); },
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @eachlang/tanstack ana giriş: istemci ve sunucuda güvenli (hook yok, fs yok, ağ yok).
|
|
2
|
+
// LanguageSwitcher @eachlang/switcher'dan yeniden dışa aktarılır (ISTEM_ADIMI kontrol listesi).
|
|
3
|
+
export { LanguageSwitcher, languageOptions, languageName, isBeta } from '@eachlang/switcher/react';
|
|
4
|
+
export { makeT, makeImg, hreflangLinks, normalizeConfig, localePath, alternatesList } from './core.js';
|
|
5
|
+
export { EachlangProvider, useT, useImg, useLang } from './client.js'; // T253: 'use client' sınırı client.js'te
|
package/src/server.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @eachlang/tanstack/server: yalnız sunucuda (createServerFn handler'ı, server route, Worker). İstemciye girmez:
|
|
2
|
+
// istemciden yalnız createServerFn üzerinden çağır. fs kullanmaz (Cloudflare Workers'ta da çalışır).
|
|
3
|
+
// - anlık görüntü: secenek.snapshots = { de: { version, strings }, … } (ör. import snap from './eachlang/snapshot/de.json')
|
|
4
|
+
// - fetch varsayılan globalThis.fetch; cache alanı verilmez (eski Workers uyumluluk tarihlerinde hata verir).
|
|
5
|
+
// - B1/B2: reportMissing'i bekle (await) ya da Workers'ta ctx.waitUntil(reportMissing(...)).
|
|
6
|
+
import { createEachlang as cekirdek } from './core.js';
|
|
7
|
+
|
|
8
|
+
export function createEachlang(cfg = {}, secenek = {}) {
|
|
9
|
+
const { snapshots, ...geri } = secenek;
|
|
10
|
+
return cekirdek(cfg, {
|
|
11
|
+
readSnapshot: async (lang) => (snapshots && snapshots[lang]) || null,
|
|
12
|
+
...geri,
|
|
13
|
+
});
|
|
14
|
+
}
|
package/src/switcher.css
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* generated by build.mjs from @eachlang/switcher (paketler/dil-secici/src/style.css), do not edit */
|
|
2
|
+
/* İsteğe bağlı varsayılan stil (React ve HTML yolu). Marka dayatmaz: renk ve yazı tipi sitenin kendisinden
|
|
3
|
+
gelir; müşteri yalnız CSS değişkenlerini ezer. Tarayıcı betiği bu dosyaya ihtiyaç duymaz (CSSOM ile stil verir). */
|
|
4
|
+
.eachlang-switcher__list {
|
|
5
|
+
list-style: none;
|
|
6
|
+
margin: 0;
|
|
7
|
+
padding: 0;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-wrap: wrap;
|
|
10
|
+
gap: var(--eachlang-switcher-gap, 0.75em);
|
|
11
|
+
}
|
|
12
|
+
.eachlang-switcher__link { color: var(--eachlang-switcher-link, inherit); }
|
|
13
|
+
.eachlang-switcher__link[aria-current="true"] { font-weight: 600; }
|
|
14
|
+
.eachlang-switcher__summary { cursor: pointer; }
|
|
15
|
+
.eachlang-switcher--menu .eachlang-switcher__menu { position: relative; display: inline-block; }
|
|
16
|
+
.eachlang-switcher--menu .eachlang-switcher__list {
|
|
17
|
+
position: absolute;
|
|
18
|
+
z-index: var(--eachlang-switcher-z, 50);
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
gap: 0.25em;
|
|
21
|
+
min-width: 100%;
|
|
22
|
+
padding: var(--eachlang-switcher-pad, 0.4em 0.75em);
|
|
23
|
+
background: var(--eachlang-switcher-bg, Canvas);
|
|
24
|
+
color: var(--eachlang-switcher-fg, CanvasText);
|
|
25
|
+
border: 1px solid var(--eachlang-switcher-border, rgba(127, 127, 127, 0.35));
|
|
26
|
+
border-radius: var(--eachlang-switcher-radius, 8px);
|
|
27
|
+
}
|
|
28
|
+
.eachlang-switcher__link:focus-visible,
|
|
29
|
+
.eachlang-switcher__summary:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
30
|
+
/* T152: beta rozeti (diller.json kademe=beta, T215). Yalnız betaBadge / eachlang-switcher--beta ile görünür.
|
|
31
|
+
Metin CSS'te: HTML'de "beta" kelimesi yok (kalite kapısı yalnız dil adı görür, hidrasyon etkilenmez). */
|
|
32
|
+
.eachlang-switcher--beta .eachlang-switcher__link[data-beta]::after {
|
|
33
|
+
content: var(--eachlang-switcher-beta, "beta");
|
|
34
|
+
margin-inline-start: 0.35em;
|
|
35
|
+
font-size: 0.7em;
|
|
36
|
+
opacity: 0.7;
|
|
37
|
+
vertical-align: super;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* T276 ek: "Powered by Eachlang" rozeti (manifest badge:true). Sade, sitenin rengini alır; odak halkası görünür. */
|
|
41
|
+
.eachlang-badge { margin: 12px 0 0; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; text-align: center; }
|
|
42
|
+
.eachlang-badge__link { color: inherit; opacity: 0.8; text-decoration: underline; text-underline-offset: 2px; }
|
|
43
|
+
.eachlang-badge__link:hover { opacity: 1; }
|
|
44
|
+
.eachlang-badge__link:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; opacity: 1; }
|