@secundus-studio/stift-generator 0.0.0-stage → 0.4.31
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/CHANGELOG.md +155 -0
- package/LICENSE +41 -0
- package/README.md +50 -2
- package/dist/index.cjs +37 -0
- package/dist/index.d.cts +1149 -0
- package/dist/index.d.ts +1149 -0
- package/dist/index.js +37 -0
- package/package.json +57 -3
- package/skills/authoring-translations/SKILL.md +142 -0
- package/skills/config/REFERENCE.md +145 -0
- package/skills/config/SKILL.md +62 -0
- package/skills/params-units/SKILL.md +107 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import*as N from'fs/promises';import*as v from'path';import*as q from'fs';import {createJiti}from'jiti';import {z}from'zod';import {UNITS_BY_CATEGORY,UNIT_CATEGORIES,sha256Hex,CATALOG_VERSION,parseMessage,encodeJsonPack,deriveStaticKey,encodeCompiled}from'@secundus-studio/stift-core';export{collectFunctions,parseMessage}from'@secundus-studio/stift-core';import {msgpackCodec}from'@secundus-studio/stift-core/codecs/msgpack';import {cborCodec}from'@secundus-studio/stift-core/codecs/cbor';import {createNodeCompress}from'@secundus-studio/stift-core/decompress/node';var Te=Object.defineProperty;var c=(e,t)=>Te(e,"name",{value:t,configurable:true});function Re(e){if(typeof e!="string"||e.trim()==="")return false;try{return new Intl.Locale(e),!0}catch{return false}}c(Re,"localeTagGuard");var G=z.string().refine(Re);function Ne(e,t){if(typeof t!="string"||t.trim()==="")return false;if(e==="numberingSystem")try{return Intl.supportedValuesOf(e).includes(t)}catch{return true}try{return new Intl.NumberFormat("und",{style:"currency",currency:t}),!0}catch{return false}}c(Ne,"engineValueGuard");function ue(e,t){return z.union([z.literal("any"),z.array(z.string().min(1).refine(o=>Ne(e,o),{message:t})).min(1)])}c(ue,"engineList");var Ie=z.object({name:z.string().min(1),values:z.array(z.string().min(1)).min(1),defaults:z.record(z.string().min(1),z.string()),persist:z.boolean().optional()}),$e=z.object({context:z.array(Ie).optional()}).optional(),Oe=new Set(Object.values(UNITS_BY_CATEGORY).flat()),Z=z.string().min(1).refine(e=>Oe.has(e),{message:"not a sanctioned ECMA-402 unit (docs/17-units.md)"}),Fe=z.union([z.literal("all"),z.record(z.enum(["length","mass","area","temperature","digital"]),z.object({supportedValues:z.union([z.literal("all"),z.array(Z).min(1)]),defaults:z.object({all:Z}).catchall(Z),persist:z.boolean().optional()}))]).optional(),Ee=z.object({tier:z.enum(["none","obfuscated","encrypted-static","encrypted-session"]),encoding:z.enum(["msgpack","cbor","json"]),compression:z.enum(["none","gzip","brotli"]),staticKey:z.string().min(1).optional()}).superRefine((e,t)=>{e.encoding==="json"&&(e.tier!=="none"&&t.addIssue({code:z.ZodIssueCode.custom,path:["tier"],message:`encoding "json" requires tier "none" (got "${e.tier}") \u2014 JSON packs are plain readable bytes`}),e.compression!=="none"&&t.addIssue({code:z.ZodIssueCode.custom,path:["compression"],message:`encoding "json" requires compression "none" (got "${e.compression}") \u2014 JSON packs are served uncompressed`}));}).optional(),De=z.object({maxCachedNamespaces:z.number().int().positive().optional(),evictionPolicy:z.enum(["lru","ttl"]).optional(),ttlMs:z.number().positive().optional()}).optional(),Ke=z.enum(["lazy-per-namespace","critical-then-full-background","boot-all"]).optional(),Ae=z.object({order:z.array(z.string()).min(1),timeout:z.union([z.number(),z.record(z.string(),z.number()).and(z.object({default:z.number()})),z.function()]).optional(),url:z.object({strategy:z.enum(["path-prefix","subdomain","domain","query-param"]),paramName:z.string().optional(),domainMap:z.record(z.string(),G).optional()}).optional(),cookie:z.object({name:z.string().optional(),maxAge:z.number().optional(),path:z.string().optional(),sameSite:z.enum(["strict","lax","none"]).optional(),secure:z.boolean().optional()}).optional(),fallback:G.optional()}).optional(),Ue=z.object({prefix:z.string().min(1).optional(),storage:z.enum(["webStorage","cookie","none"]).optional()}).optional(),B=z.object({locales:z.object({default:G,supported:z.array(G).min(1)}),numberingSystems:ue("numberingSystem","not a numbering system this engine recognizes").optional(),currencies:ue("currency","not a currency code this engine recognizes").optional(),namespaces:z.object({dir:z.string().min(1),discovery:z.enum(["convention","manifest"]),default:z.array(z.string())}),messageFormat:z.enum(["icu-mf1","icu-mf2"]).superRefine((e,t)=>{e==="icu-mf2"&&t.addIssue({code:z.ZodIssueCode.custom,message:'messageFormat "icu-mf2" is not implemented yet \u2014 use "icu-mf1"'});}),params:$e,units:Fe,security:Ee,memory:De,loadingStrategy:Ke,bootExclude:z.array(z.string()).optional(),detection:Ae,state:Ue}).superRefine((e,t)=>{e.locales.supported.includes(e.locales.default)||t.addIssue({code:z.ZodIssueCode.custom,path:["locales","supported"],message:`default locale "${e.locales.default}" must be listed in supported locales`});let o=e.params?.context;if(o){let n=e.locales.supported,a=new Set;o.forEach((i,g)=>{let l=["params","context",g];a.has(i.name)&&t.addIssue({code:z.ZodIssueCode.custom,path:[...l,"name"],message:`context param name "${i.name}" is declared more than once`}),a.add(i.name),"all"in i.defaults||t.addIssue({code:z.ZodIssueCode.custom,path:[...l,"defaults"],message:`context param "${i.name}" must declare an "all" fallback default`});let u=c((m,b)=>{i.values.includes(m)||t.addIssue({code:z.ZodIssueCode.custom,path:b,message:`defaults value "${m}" for context param "${i.name}" is not one of its values [${i.values.join(", ")}]`});},"check");for(let[m,b]of Object.entries(i.defaults))u(b,[...l,"defaults",m]),m!=="all"&&!n.includes(m)&&t.addIssue({code:z.ZodIssueCode.custom,path:[...l,"defaults",m],message:`defaults locale "${m}" is not in locales.supported`});});}let s=e.units;if(s&&s!=="all"){let n=e.locales.supported;for(let[a,i]of Object.entries(s)){let g=["units",a],l=i.supportedValues,u=i.defaults?.all;Array.isArray(l)&&u&&!l.includes(u)&&t.addIssue({code:z.ZodIssueCode.custom,path:[...g,"defaults","all"],message:`default unit "${u}" for "${a}" is not in supportedValues [${l.join(", ")}]`});for(let[m]of Object.entries(i.defaults??{}))m!=="all"&&(n.includes(m)||t.addIssue({code:z.ZodIssueCode.custom,path:[...g,"defaults",m],message:`units locale "${m}" is not in locales.supported`}));}}});var He=["stift.config.ts","stift.config.mts","stift.config.js"];function me(e){for(let t of He){let o=v.join(e,t);if(q.existsSync(o))return o}}c(me,"findConfigFile");async function H(e={}){let t=e.root??process.cwd(),o=e.configFile??me(t)??(()=>{throw new Error(`No stift.config.ts found under "${t}". Create one or pass configFile.`)})(),n=await createJiti(import.meta.url,{interopDefault:true,moduleCache:false}).import(o),a=Je(n),i=B.safeParse(a);if(!i.success){let g=i.error.issues.map(l=>` - ${l.path.join(".")}: ${l.message}`).join(`
|
|
2
|
+
`);throw new Error(`Invalid stift config at ${o}:
|
|
3
|
+
${g}`)}return {config:i.data,file:o}}c(H,"loadConfig");function Je(e){return e&&typeof e=="object"&&"default"in e&&e.default?e.default:e}c(Je,"constructConfig");async function J(e,t="locales"){let o=v.join(e,t),s;try{s=await q.promises.readdir(o,{withFileTypes:!0});}catch{return {byLocale:new Map,all:[]}}let n=new Map,a=[];for(let i of s){if(!i.isDirectory())continue;let g=i.name,l=[];await de(v.join(o,g),g,v.join(o,g),l),l.length>0&&(n.set(g,l),a.push(...l));}return a.sort((i,g)=>(i.locale+"/"+i.namespace).localeCompare(g.locale+"/"+g.namespace)),{byLocale:n,all:a}}c(J,"discoverConvention");async function de(e,t,o,s){let n;try{n=await q.promises.readdir(e,{withFileTypes:!0});}catch{return}for(let a of n){let i=v.join(e,a.name);if(a.isDirectory())await de(i,t,o,s);else if(a.isFile()&&a.name.endsWith(".json")){let l=v.relative(o,i).split(v.sep).join("/").replace(/\.json$/,"");s.push({locale:t,namespace:l,file:i});}}}c(de,"walkNamespaceDir");function V(e){return typeof e=="string"?e:e.value}c(V,"messageValue");function ye(e){let{ast:t}=parseMessage(e),o={};return he(t,o),o}c(ye,"deriveLocaleShape");function he(e,t){for(let o of e){if(o.type!=="argument"&&o.type!=="function")continue;let s=o.name,n=t[s];if(o.argType&&o.argType.toLowerCase()==="select"){let a=o.type==="function"?ze(o):[],i=n?_e(n,a):{kind:"select",categories:a};t[s]=i;}else o.argType&&(o.argType.toLowerCase()==="plural"||o.argType.toLowerCase()==="selectordinal"||o.argType.toLowerCase()==="number")?t[s]={kind:"number"}:t[s]={kind:"primitive"};for(let a of o.options)he(a.value,t);}}c(he,"collectParams");function _e(e,t){return e.kind!=="select"?{kind:"select",categories:t}:{kind:"select",categories:Ce(e.categories,t)}}c(_e,"unionCategories");function ze(e){return e.options.map(t=>t.key)}c(ze,"optionKeys");function Ce(...e){let t=new Set;for(let o of e)for(let s of o)t.add(s);return [...t].sort()}c(Ce,"mergeStrings");function W(e,t){let o=new Map,s=[],n=new Map;for(let a of e){let i=`${a.namespace}\0${a.key}`,g=n.get(i);g?g.push(a):n.set(i,[a]);}for(let[a,i]of n){let[g,l]=a.split("\0"),u={},m={};for(let p of i){let f=ye(p.value);for(let[y,d]of Object.entries(f)){u[y]=Ze(u[y],d);let C=m[y]??(m[y]=[]);C.includes(p.locale)||C.push(p.locale);}}if(t?.context?.length){let p=new Map;for(let f of t.context)p.set(f.name,f);for(let[f,y]of Object.entries(u)){let d=p.get(f);d&&y.kind==="select"&&Be(s,g,l,f,y.categories,d,m[f]??[]);}}let b=o.get(g);b||(b=new Map,o.set(g,b));let L={};for(let[p,f]of Object.entries(u))L[p]=f.kind==="select"?{kind:"select",categories:[...f.categories].sort()}:f;b.set(l,{params:L,paramLocales:m});}return {byNamespace:o,warnings:s}}c(W,"extractShapes");function Ze(e,t){return e?e.kind==="number"&&t.kind==="number"||e.kind==="primitive"&&t.kind==="primitive"?e:e.kind==="select"&&t.kind==="select"?{kind:"select",categories:Ce(e.categories,t.categories)}:e.kind==="number"&&t.kind==="primitive"||e.kind==="primitive"&&t.kind==="number"?{kind:"number"}:t.kind==="select"?t:e:t}c(Ze,"unionParam");function Be(e,t,o,s,n,a,i){let g=new Set(a.values),l=n.filter(u=>u!=="other"&&!g.has(u));l.length!==0&&e.push(`[${t}:${o}] param "${s}" uses categories [${l.join(", ")}] outside the configured canonical set [${a.values.join(", ")}] (used by ${i.join(", ")})`);}c(Be,"warnOnCategoryMismatch");async function Y(e){let t=[];for(let o of e){let s=await N.readFile(o.file,"utf8"),n;try{n=JSON.parse(s);}catch(a){throw new Error(`Invalid JSON in ${o.file}: ${a.message}`)}if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`Namespace file ${o.file} must contain a JSON object`);for(let[a,i]of Object.entries(n)){if(typeof i!="string"&&!qe(i))throw new Error(`Message "${a}" in ${o.file} must be a string or { value: string }`);t.push({locale:o.locale,namespace:o.namespace,key:a,value:V(i)});}}return t}c(Y,"readNamespaceSources");function qe(e){return typeof e=="object"&&e!==null&&typeof e.value=="string"}c(qe,"isMessageSource");function We(e){let t=new Map;for(let o of e){let s=t.get(o.locale);s||(s=new Map,t.set(o.locale,s));let n=s.get(o.namespace);n||(n=new Set,s.set(o.namespace,n)),n.add(o.key);}return t}c(We,"presenceByLocale");function Q(e,t,o){let s=We(e),n=s.get(o);if(!n)return {missing:[]};let a=new Set(t),i=t.filter(l=>!Ye(l,a)),g=[];for(let l of i){if(l===o)continue;let u=s.get(l);for(let[m,b]of n){let L=u?.get(m);for(let p of b)L?.has(p)||g.push({namespace:m,key:p,locale:l});}}return g.sort((l,u)=>(l.namespace+"/"+l.key+"/"+l.locale).localeCompare(u.namespace+"/"+u.key+"/"+u.locale)),{missing:g}}c(Q,"diffLocales");function Ye(e,t){let o=e;for(;;){let s=o.lastIndexOf("-");if(s<=0||s===o.length-1)return false;if(o=o.slice(0,s),t.has(o))return true}}c(Ye,"hasParentWithin");function T(e){return JSON.stringify(e)}c(T,"quote");function Xe(e){return e.kind==="number"?"number":e.kind==="primitive"?"string | number":e.categories.map(T).join(" | ")}c(Xe,"paramToTs");var X={};function et(e){if(X[e])return X[e];let t=[];try{t=Intl.supportedValuesOf(e);}catch{t=[];}return X[e]=t,t}c(et,"engineValues");function xe(e,t){if(e==="any"){let s=et(t);return s.length===0?"string":s.map(T).join(" | ")}return e.map(T).join(" | ")||"string"}c(xe,"listOrAny");function tt(e,t){let o=e.units;if(o==="all"||!o)return UNITS_BY_CATEGORY[t];let s=o[t];if(!s)return null;let n=s.supportedValues;return !n||n==="all"?UNITS_BY_CATEGORY[t]:[...n]}c(tt,"unitsForCategory");function ee(e){let{config:t,extract:o}=e,s=[...o.byNamespace.keys()].sort(),n=new Map;for(let u of t.params?.context??[])n.set(u.name,u.values);let a=[];for(let u of s){let m=o.byNamespace.get(u),b=[];for(let L of [...m.keys()].sort()){let p=m.get(L),f=Object.keys(p.params).sort();if(f.length===0){b.push(` ${T(L)}: {}`);continue}let y=f.map(d=>{let C=n.get(d),x=C!==void 0,I=C?C.map(T).join(" | "):Xe(p.params[d]);return ` ${T(d)}${x?"?":""}: ${I}`}).join(`
|
|
4
|
+
`);b.push(` ${T(L)}: {
|
|
5
|
+
${y}
|
|
6
|
+
}`);}a.push(` ${T(u)}: {
|
|
7
|
+
${b.join(`
|
|
8
|
+
`)}
|
|
9
|
+
}`);}let i=[];if(n.size>0)for(let u of [...n.keys()].sort()){let m=n.get(u).map(T).join(" | ");i.push(` ${T(u)}: ${m}`);}let g=i.length>0?` context: {
|
|
10
|
+
${i.join(`
|
|
11
|
+
`)}
|
|
12
|
+
}
|
|
13
|
+
`:"",l=UNIT_CATEGORIES.flatMap(u=>{let m=tt(t,u);return m?[` ${T(u)}: ${m.map(T).join(" | ")}`]:[]});return `/**
|
|
14
|
+
* Generated by @secundus-studio/stift-generator \u2014 do not edit by hand.
|
|
15
|
+
* Regenerate with \`stift generate\` (and in Phase 5, the dev plugin watches).
|
|
16
|
+
*/
|
|
17
|
+
import "@secundus-studio/stift-core"
|
|
18
|
+
declare module "@secundus-studio/stift-core" {
|
|
19
|
+
interface Register {
|
|
20
|
+
locale: ${t.locales.supported.map(T).join(" | ")}
|
|
21
|
+
numberingSystems: ${xe(t.numberingSystems??"any","numberingSystem")}
|
|
22
|
+
currencies: ${xe(t.currencies??"any","currency")}
|
|
23
|
+
units: {
|
|
24
|
+
${l.join(`
|
|
25
|
+
`)}
|
|
26
|
+
}
|
|
27
|
+
messages: {
|
|
28
|
+
${a.join(`
|
|
29
|
+
`)}
|
|
30
|
+
}
|
|
31
|
+
${g} }
|
|
32
|
+
}
|
|
33
|
+
`}c(ee,"generateTypegen");async function oe(e,t,o){let s=v.join(e,t),n=[];for(let a of o){let i=v.join(s,a);try{await N.access(i);}catch{await N.mkdir(i,{recursive:true}),n.push(i);}}return {createdDirs:n}}c(oe,"ensureLocaleDirectories");async function ot(e){let t=new Map;for(let o of e){let s=JSON.parse(await N.readFile(o.file,"utf8"));if(typeof s!="object"||s===null||Array.isArray(s))throw new Error(`Namespace file ${o.file} must contain a JSON object`);let n=t.get(o.locale);n||(n=new Map,t.set(o.locale,n)),n.set(o.namespace,s);}return t}c(ot,"readRawFiles");function nt(e){let t=Object.entries(e);return t.length===0?"{}":`{
|
|
34
|
+
${t.map(([s,n])=>` ${JSON.stringify(s)}: ${JSON.stringify(n)}`).join(`,
|
|
35
|
+
`)}
|
|
36
|
+
}`}c(nt,"serializeNamespace");async function ne(e){let{root:t,localesDir:o,defaultLocale:s,locales:n}=e,a=await ot(e.discovery.all),i=new Map,g=a.get(s);if(g)for(let[h,S]of g)i.set(h,{order:Object.keys(S),values:S});let l=[];i.size===0&&l.push(`[stift] default locale "${s}" has no namespace files \u2014 nothing to complete`);let u=new Set(n);function m(h){let S=[],k=h;for(;;){let M=k.lastIndexOf("-");if(M<=0)break;k=k.slice(0,M),k!==s&&u.has(k)&&S.push(k);}return [...S,s]}c(m,"fillChain");function b(h,S,k,M){for(let F of k){let $=a.get(F)?.get(S)?.[h];if($!==void 0)return $}return M[h]}c(b,"resolveValue");let L=[],p=[],f=[],y=[],d=0,C=new Set,x={[s]:1};for(let h of n){if(h===s)continue;let S=a.get(h),k=m(h),M=0,F=0,$=c(w=>w!==s,"inheritedIsTranslated");for(let[w,P]of i){let A=S?.get(w),D=v.join(t,o,h,`${w}.json`),Me=S?.has(w)??false;C.add(`${h}\0${w}`);let je=new Set(P.order),K={};for(let j of P.order){let U=A?.[j];if(U!==void 0)K[j]=U,M++;else {K[j]=b(j,w,k,P.values),d++;for(let pe of k){if(!$(pe))break;if(a.get(pe)?.get(w)?.[j]!==void 0){M++;break}}}F++;}if(A)for(let j of Object.keys(A))je.has(j)||(K[j]=A[j],y.push({namespace:w,key:j,locale:h}));let ce=nt(K)+`
|
|
37
|
+
`,le=true;try{le=await N.readFile(D,"utf8")!==ce;}catch{}le&&(await N.mkdir(v.dirname(D),{recursive:true}),await N.writeFile(D,ce,"utf8"),p.push(D),Me||f.push(D));for(let[j,U]of Object.entries(K))L.push({locale:h,namespace:w,key:j,value:V(U)});}x[h]=F>0?Math.min(1,M/F):1;}let R=[...e.sources.filter(h=>!(h.locale!==s&&C.has(`${h.locale}\0${h.namespace}`))),...L];return R.sort((h,S)=>h.locale.localeCompare(S.locale)||h.namespace.localeCompare(S.namespace)||h.key.localeCompare(S.key)),{sources:R,writtenFiles:p,createdFiles:f,orphanKeys:y,filledKeys:d,authoredCoverage:x,warnings:l}}c(ne,"completeSources");var pt={tier:"obfuscated",encoding:"msgpack",compression:"gzip"};function re(e){let t=e.security??pt;return {tier:t.tier,encoding:t.encoding,compression:t.compression,staticKey:t.staticKey??process.env.STIFT_STATIC_KEY??void 0}}c(re,"resolveCompileSecurity");function ut(e){if(e==="cbor")return cborCodec;if(e==="msgpack")return msgpackCodec;throw new Error(`[stift] encoding "${e}" has no binary codec \u2014 JSON packs are raw bytes, not a compiled container`)}c(ut,"codecFor");async function ve(e,t){let{tier:o,encoding:s,compression:n}=t.security;if(s==="json"){if(o!=="none")throw new Error(`[stift] encoding "json" requires tier "none" (got "${o}")`);if(n!=="none")throw new Error(`[stift] encoding "json" requires compression "none" (got "${n}")`);return encodeJsonPack(e)}let a;if(o==="encrypted-static"||o==="encrypted-session"){if(!t.security.staticKey)throw new Error("[stift] security.staticKey is required to compile encrypted tiers");a=await deriveStaticKey(t.security.staticKey);}return encodeCompiled(e,{codec:ut(s),compression:n,tier:o,compress:createNodeCompress(n),key:a})}c(ve,"compileNamespace");function ft(e){let t=new Map;for(let o of e){let s=t.get(o.locale);s||(s=new Map,t.set(o.locale,s));let n=s.get(o.namespace);n||(n={},s.set(o.namespace,n)),n[o.key]=o.value;}return t}c(ft,"groupMessages");async function ae(e){let t=ft(e.sources),o=[],s=[],n=[],a=new Map,i=0,g=0,l=new Map;for(let p of e.locales){let f=t.get(p);if(!f||f.size===0)continue;let y=mt(p,e.locales,e.defaultLocale),d=[],C=[];for(let[S,k]of f){let M=`${p}/${S}`,F=await sha256Hex(gt(JSON.stringify(k))),$=e.cache?.get(M),w,P;$&&$.sourceHash===F?(w=$.bytes,P=$.contentHash,g++):(w=await ve(k,{security:e.security}),P=await sha256Hex(w),i++),d.push({namespace:S,url:e.urlFor(p,S,P),persistKey:M,byteLength:w.byteLength,contentHash:P}),C.push(P),o.push({locale:p,namespace:S,persistKey:M,bytes:w,byteLength:w.byteLength,contentHash:P}),a.set(M,{sourceHash:F,bytes:w,byteLength:w.byteLength,contentHash:P});}d.sort((S,k)=>S.namespace.localeCompare(k.namespace));let x=t.get(e.defaultLocale),I=0,R=0;if(x)for(let[S,k]of x)R+=Object.keys(k).length,I+=f.has(S)?Object.keys(f.get(S)).length:0;let h=e.coverageByLocale?.[p];l.set(p,{baseLocale:y,namespaces:d,hashes:C,coverage:h??(R>0?Math.min(1,I/R):void 0)});}let u=ke([...l.values()].flatMap(p=>p.hashes)),m=Date.now(),b=e.shardUrlFor??((p,f)=>`catalog/${p}.${f}.json`);for(let[p,f]of [...l.entries()].sort(([y],[d])=>y.localeCompare(d))){let y={version:CATALOG_VERSION,locale:p,namespaces:f.namespaces},d=new TextEncoder().encode(JSON.stringify(y)),C=await sha256Hex(d),x=f.namespaces.reduce((R,h)=>R+h.byteLength,0);n.push({locale:p,shard:y,bytes:d,byteLength:d.byteLength,namespacesHash:C});let I={locale:p,namespacesUrl:b(p,C),namespacesHash:C,namespaces:f.namespaces.map(R=>R.namespace),packVersion:ke(f.hashes),totalBytes:x};f.baseLocale!==void 0&&(I.baseLocale=f.baseLocale),f.coverage!==void 0&&(I.coverage=f.coverage),s.push(I);}return {outputs:o,catalog:{version:CATALOG_VERSION,buildId:u,publishedAt:m,locales:s},shards:n,cache:a,compiledCount:i,reusedCount:g}}c(ae,"compileAll");function gt(e){return new TextEncoder().encode(e)}c(gt,"utf8Bytes");function mt(e,t,o){if(e===o)return;let n=e.split("-");for(let a=n.length;a>=2;a--){let i=n.slice(0,a-1).join("-");if(t.includes(i))return i}}c(mt,"directParent");function ke(e){let t=e.sort().join(""),o=0;for(let s=0;s<t.length;s++)o=o*31+t.charCodeAt(s)>>>0;return o.toString(16)}c(ke,"shortHash");var Le=v.join("src","stift.gen.d.ts"),ie=class{static{c(this,"Generator");}constructor(t={}){this.root=t.root??process.cwd();}root;async generateAsync(t={}){let o=t.root??this.root,n=(await H({root:o,configFile:t.configFile})).config,a=await oe(o,n.namespaces.dir,n.locales.supported),i=await J(o,n.namespaces.dir),g=await Y(i.all),l=await ne({root:o,localesDir:n.namespaces.dir,defaultLocale:n.locales.default,locales:[...n.locales.supported],sources:g,discovery:i}),u=l.sources,m=W(u,n.params),b=[...n.locales.supported],L=Q(u,b,n.locales.default),p=v.resolve(o,t.outputFile??Le),f=ee({config:n,extract:m});await N.mkdir(v.dirname(p),{recursive:true}),await N.writeFile(p,f,"utf8");let y=l.orphanKeys.map(({namespace:d,key:C,locale:x})=>`[stift] key "${C}" in locale "${x}" (namespace "${d}") is not part of the default locale's schema \u2014 orphan`);return {config:n,discovery:i,extract:m,diff:L,sources:u,scaffold:a,completion:l,outputFile:p,warnings:[...m.warnings,...y,...l.warnings]}}makeWatchFilter(t,o){let s=v.resolve(t,o);return n=>{let a=v.resolve(n);return a.startsWith(s+v.sep)&&a.endsWith(".json")}}async compileAsync(t={}){let o=t.root??this.root,n=(await H({root:o,configFile:t.configFile})).config;await oe(o,n.namespaces.dir,n.locales.supported);let a=await J(o,n.namespaces.dir),i=await Y(a.all),g=await ne({root:o,localesDir:n.namespaces.dir,defaultLocale:n.locales.default,locales:[...n.locales.supported],sources:i,discovery:a}),l=[...n.locales.supported],u=t.security??re(n),m=u.encoding==="json"?"json":"dat",b=t.urlFor??((y,d)=>`${y}/${d}.${m}`),L=t.shardUrlFor??((y,d)=>`catalog/${y}.${d}.json`),p=await ae({sources:g.sources,locales:l,defaultLocale:n.locales.default,security:u,urlFor:b,shardUrlFor:L,coverageByLocale:g.authoredCoverage}),f=[];if(t.outDir){let y=v.resolve(o,t.outDir);for(let C of p.outputs){let x=v.join(y,`${C.persistKey}.${m}`);await N.mkdir(v.dirname(x),{recursive:true}),await N.writeFile(x,C.bytes),f.push(x);}let d=v.join(y,"catalog.json");await N.mkdir(v.dirname(d),{recursive:true}),await N.writeFile(d,JSON.stringify(p.catalog,null,2),"utf8"),f.push(d);for(let C of p.shards){let x=v.join(y,"catalog",`${C.locale}.${C.namespacesHash}.json`);await N.mkdir(v.dirname(x),{recursive:true}),await N.writeFile(x,C.bytes),f.push(x);}}return {config:n,result:p,writtenFiles:f}}};function dt(e){return e}c(dt,"defineConfig");export{Le as DEFAULT_OUTPUT_REL,ie as Generator,ae as compileAll,ve as compileNamespace,B as configSchema,dt as defineConfig,ye as deriveLocaleShape,Q as diffLocales,J as discoverConvention,W as extractShapes,me as findConfigFile,ee as generateTypegen,H as loadConfig,re as resolveCompileSecurity};
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secundus-studio/stift-generator",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.4.31",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"description": "Bundler-agnostic build core: config loading/validation, namespace discovery, Register typegen, pack compiler.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"stift",
|
|
8
|
+
"tanstack-intent"
|
|
9
|
+
],
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"skills",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@msgpack/msgpack": "^3.1.3",
|
|
40
|
+
"cbor-x": "^1.6.5",
|
|
41
|
+
"chokidar": "^4.0.3",
|
|
42
|
+
"jiti": "^2.4.0",
|
|
43
|
+
"zod": "^3.24.1",
|
|
44
|
+
"@secundus-studio/stift-core": "0.4.31"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@tanstack/intent": "^0.3.6",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"tsup": "^8.5.1",
|
|
50
|
+
"typescript": "^5.7.2",
|
|
51
|
+
"vitest": "^4.1.10"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://stift.secundus.studio",
|
|
54
|
+
"author": "Ali Alkhateeb",
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsup",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
59
|
+
}
|
|
6
60
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: authoring-translations
|
|
3
|
+
description: Author translations in Stift — the default locale is the schema (new keys propagate to every locale automatically), non-default locales are values-only, message shapes (string / { value, description?, maxLength? }), ICU select/plural driven by context params, rich/markdown messages, and how to write dialect-specific translations (ar-SY-x-lattakia) with correct inheritance. Use when adding, editing, or translating message keys, writing locale files, or deciding where a translation belongs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Stift — authoring translations
|
|
7
|
+
|
|
8
|
+
The core rule: **the default locale is the schema**. Its files define every namespace's key set and
|
|
9
|
+
key order. Every other locale is **values-only** — it holds translations, never structure. See the
|
|
10
|
+
dialect/locale model in `@secundus-studio/stift-core#locales`.
|
|
11
|
+
|
|
12
|
+
## 1. Adding a key — edit the default locale only
|
|
13
|
+
|
|
14
|
+
```jsonc
|
|
15
|
+
// locales/en/common.json (default locale)
|
|
16
|
+
{
|
|
17
|
+
"greeting": "Hello, {name}!" // ← add here
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
On the next dev refresh the generator auto-completes the new key into **every** supported locale:
|
|
22
|
+
|
|
23
|
+
- A missing key inherits its value from the **nearest registered chain ancestor**
|
|
24
|
+
(`ar-SY-x-lattakia` → `ar-SY` → `ar` → default), so `"greeting": "Hello, {name}!"` lands in
|
|
25
|
+
`ar`/`ar-SY`/`ar-SY-x-lattakia` too, and you translate each one.
|
|
26
|
+
- Files are re-serialized in the default locale's key order (one key per line), so a key sits on
|
|
27
|
+
the same line number in every locale's file.
|
|
28
|
+
- Shape is preserved through the rewrite: both the plain string and the rich object form.
|
|
29
|
+
|
|
30
|
+
**Never add a key to a non-default locale directly** — a key present in a locale but absent from
|
|
31
|
+
the schema is an *orphan*: it is appended after the schema keys and reported as a warning, and it
|
|
32
|
+
gets no typegen entry.
|
|
33
|
+
|
|
34
|
+
## 2. Translating into a non-default locale
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
// locales/ar/common.json — translate the value, keep the placeholders
|
|
38
|
+
{
|
|
39
|
+
"greeting": "مرحباً، {name}!"
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- A locale file may be **partial or absent**; completion fills the rest from the chain. You only
|
|
44
|
+
ever write what differs.
|
|
45
|
+
- Keep `{param}` placeholders and the full ICU structure intact — translate the strings inside
|
|
46
|
+
the branches, never the selector names or param names.
|
|
47
|
+
- Keep the rich object shape identical to the default locale's:
|
|
48
|
+
|
|
49
|
+
```jsonc
|
|
50
|
+
// en: "confirm": { "value": "Delete?", "description": "Destructive action", "maxLength": 40 }
|
|
51
|
+
// ar: "confirm": { "value": "حذف؟", "description": "إجراء لا يمكن التراجع عنه", "maxLength": 40 }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 3. Message shapes
|
|
55
|
+
|
|
56
|
+
Each value is either a plain string or a rich object `{ value, description?, maxLength? }`
|
|
57
|
+
(description = translator context, maxLength = UI constraint; both drive tooling, not runtime).
|
|
58
|
+
|
|
59
|
+
## 4. ICU — interpolation, select, plural
|
|
60
|
+
|
|
61
|
+
`messageFormat: 'icu-mf1'`. Params are derived from the message source per locale and **unioned
|
|
62
|
+
across locales** — a param required by any locale is required at every call site.
|
|
63
|
+
|
|
64
|
+
- Interpolation: `"greeting": "Hello, {name}!"` → `t('greeting', { name })`.
|
|
65
|
+
- Select (enum branches): the `select` argument's case labels become the literal union. Include a
|
|
66
|
+
catch-all `other` — it is the required fallback branch.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
"greetingNote": "We address you {politeness, select, polite {respectfully} warm {warmly} other {…}}."
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Plural / selectordinal: `{count, plural, one {…} other {…}}` → param type `number`. Always
|
|
73
|
+
include `other`.
|
|
74
|
+
- Context params (gender/politeness/verbosity) appear in messages as ordinary `select` arguments,
|
|
75
|
+
but they are **declared in config** so the app supplies them once and call sites don't repeat
|
|
76
|
+
them — see `@secundus-studio/stift-generator#params-units`.
|
|
77
|
+
|
|
78
|
+
## 5. Rich text & markdown
|
|
79
|
+
|
|
80
|
+
Use tags for inline emphasis/links and let the UI render them (`@secundus-studio/stift-react#values`):
|
|
81
|
+
|
|
82
|
+
```jsonc
|
|
83
|
+
"rich.demo": "This message is <strong>rich</strong> — try the <a>inline link</a>."
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Rendered with `t.rich('rich.demo', { strong: <strong>…</strong>, a: <a>…</a> })`. Markdown messages
|
|
87
|
+
render via `t.markdown(...)`. Author the *message* in the locale files; wire the tag components in
|
|
88
|
+
the UI.
|
|
89
|
+
|
|
90
|
+
## 6. Writing dialect-specific translations
|
|
91
|
+
|
|
92
|
+
A value written at a less-specific level cascades down the chain; a dialect file overrides only
|
|
93
|
+
what actually differs in that dialect:
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
// locales/ar/common.json — Modern Standard Arabic, applies to ar-SY and ar-SY-x-lattakia too
|
|
97
|
+
{ "greeting": "مرحباً، {name}!" }
|
|
98
|
+
|
|
99
|
+
// locales/ar-SY/common.json — Syrian Arabic
|
|
100
|
+
{ "greeting": "أهلاً، {name}!" }
|
|
101
|
+
|
|
102
|
+
// locales/ar-SY-x-lattakia/common.json — Lattakia dialect: override ONLY what differs
|
|
103
|
+
{ "greeting": "أهلين، {name}!" }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Don't copy the whole file down the chain — the generator inherits. Register dialect tags in
|
|
107
|
+
`locales.supported` so they get their own files, packs, and type entries
|
|
108
|
+
(`@secundus-studio/stift-core#locales`).
|
|
109
|
+
|
|
110
|
+
## 7. Deleting / renaming keys
|
|
111
|
+
|
|
112
|
+
- Delete/rename in the **default locale**. The key disappears everywhere; renamed keys behave as
|
|
113
|
+
add + remove. A stale key lingering in a non-default locale is reported as an orphan.
|
|
114
|
+
- Don't delete from non-default locale files to "hide" a string — the schema still owns the key and
|
|
115
|
+
completion will refill it.
|
|
116
|
+
|
|
117
|
+
## 8. Verification
|
|
118
|
+
|
|
119
|
+
- The generated `stift.gen.d.ts` narrows keys/params/select categories to your real messages —
|
|
120
|
+
compile errors are the fastest authoring feedback.
|
|
121
|
+
- The devtools panel (`@secundus-studio/stift-tanstack-devtool-plugin#devtools`) shows the cross-locale table and
|
|
122
|
+
missing-key/param logs; a missing key/param logs `{ code: 'missing-key' | 'missing-param', … }`
|
|
123
|
+
through the runtime hooks.
|
|
124
|
+
- `filledKeys`/`orphanKeys` are surfaced in generator warnings — treat orphan warnings as bugs.
|
|
125
|
+
|
|
126
|
+
## Common mistakes
|
|
127
|
+
|
|
128
|
+
- Adding a key to a non-default locale only → orphan + warning, no typegen entry.
|
|
129
|
+
- Translating the default locale (it's the schema — always English/source).
|
|
130
|
+
- Removing `other` from a `select`/`plural` → runtime falls back / missing branch.
|
|
131
|
+
- Reordering keys by hand in a locale file → the generator rewrites in default order; keep diffs
|
|
132
|
+
to values only.
|
|
133
|
+
- Hand-editing a generated/completed file mid-session → dev-server rewrite converges (idempotent);
|
|
134
|
+
make edits in the source of truth instead.
|
|
135
|
+
- Renaming a param in one locale but not the default → union rule makes it required everywhere.
|
|
136
|
+
|
|
137
|
+
## Where next
|
|
138
|
+
|
|
139
|
+
- Locale/dialect chain and tags → `@secundus-studio/stift-core#locales`
|
|
140
|
+
- Context params & units config (what's declared, not authored) → `@secundus-studio/stift-generator#params-units`
|
|
141
|
+
- Config `messageFormat`, `namespaces` → `@secundus-studio/stift-generator#config`
|
|
142
|
+
- Rendering messages with values/rich/markdown → `@secundus-studio/stift-react#values`
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Stift — config reference
|
|
2
|
+
|
|
3
|
+
Validated by `@secundus-studio/stift-generator`'s zod schema. All fields optional except `locales`.
|
|
4
|
+
|
|
5
|
+
## `locales`
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
locales: {
|
|
9
|
+
default: string // BCP 47 tag, e.g. 'en'
|
|
10
|
+
supported: string[] // every tag with translations + registered dialects, e.g. ['en', 'ar', 'ar-SY', 'ar-SY-x-lattakia']
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`default` must be in `supported`. Registered tags narrow `RegisteredLocale` in the generated typegen.
|
|
15
|
+
|
|
16
|
+
## `namespaces`
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
namespaces: {
|
|
20
|
+
dir: string // translation files dir, e.g. 'locales'
|
|
21
|
+
discovery: 'convention' | 'manifest' // discover by folder convention or an explicit manifest
|
|
22
|
+
default: string[] // namespaces seeded into memory.preload
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Layout convention: `locales/<locale>/<namespace>.json` → `{ "key": "value" }`.
|
|
27
|
+
|
|
28
|
+
## `messageFormat`
|
|
29
|
+
|
|
30
|
+
`'icu-mf1'` only (`'icu-mf2'` names a future engine and is rejected at config
|
|
31
|
+
load). Swap by injecting a custom `FormatterEngine` at runtime.
|
|
32
|
+
|
|
33
|
+
## `numberingSystems` / `currencies`
|
|
34
|
+
|
|
35
|
+
`'any' | string[]`, both optional (absent behaves like `'any'`). Explicit
|
|
36
|
+
lists narrow the generated `Register` unions
|
|
37
|
+
(`RegisteredNumberingSystems`, `RegisteredCurrencies`) used by
|
|
38
|
+
`@secundus-studio/stift-format`. With `'any'`, the union is the
|
|
39
|
+
engine's supported set (`Intl.supportedValuesOf`) — pinned to the ICU/tzdata of the
|
|
40
|
+
machine that regenerates the typegen, so regenerate on the same Node version as CI
|
|
41
|
+
to avoid spurious `stift.gen.d.ts` diffs.
|
|
42
|
+
|
|
43
|
+
Timezones and calendars intentionally have no config key: they are
|
|
44
|
+
per-user runtime values from the browser's own ICU data, not something the
|
|
45
|
+
project ships. The format call-sites take plain strings; `Intl` throws a
|
|
46
|
+
`RangeError` on invalid values at runtime.
|
|
47
|
+
|
|
48
|
+
Each listed item is validated against the engine at config load — a typo fails
|
|
49
|
+
fast. Legacy timezone aliases (`US/Eastern`, `Asia/Calcutta`) are accepted;
|
|
50
|
+
canonical names (`America/New_York`) are what `'any'` unions list.
|
|
51
|
+
|
|
52
|
+
## `params` (grammatical agreement / context params)
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
params: {
|
|
56
|
+
context: [
|
|
57
|
+
{
|
|
58
|
+
name: string // e.g. 'gender', 'politeness'
|
|
59
|
+
values: string[] // canonical allowed values, e.g. ['male', 'female', 'other']
|
|
60
|
+
defaults: {
|
|
61
|
+
all: string // REQUIRED catch-all fallback (value ∈ values)
|
|
62
|
+
[locale: string]: string // optional per-locale override, key ∈ locales.supported
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Every `context` entry must declare `defaults` with an `all` key — the app's value
|
|
70
|
+
when nothing else supplies the param, so `{gender, select, ...}` never degrades.
|
|
71
|
+
Runtime resolution walks the locale hierarchy: exact tag → stripped subtags
|
|
72
|
+
(`ar-SY-x-lattakia` → `ar-SY` → `ar`) → `all`. Keys are typed to the generated
|
|
73
|
+
`Register.locale` union (unsupported locale = config type error); every value is
|
|
74
|
+
validated against `values` at config load. `setParam` overrides beat defaults.
|
|
75
|
+
|
|
76
|
+
Declared params are *optional* at call sites (context-supplied); every other
|
|
77
|
+
message param is *required* — no `strictness` dial.
|
|
78
|
+
|
|
79
|
+
## `memory`
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
memory: {
|
|
83
|
+
maxCachedNamespaces?: number
|
|
84
|
+
evictionPolicy?: 'lru' | 'ttl'
|
|
85
|
+
ttlMs?: number
|
|
86
|
+
preload?: string[] // pinned, LRU-exempt
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## `loadingStrategy`
|
|
91
|
+
|
|
92
|
+
- `'lazy-per-namespace'` — default; load on first use.
|
|
93
|
+
- `'critical-then-full-background'` — SPA/PWA: load `critical` first, swap locale, persist the rest in the background.
|
|
94
|
+
- `'boot-all'` — load every non-excluded namespace pinned before first paint.
|
|
95
|
+
|
|
96
|
+
With `boot-all`, exclude namespaces via `bootExclude: string[]`.
|
|
97
|
+
|
|
98
|
+
## `detection`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
detection: {
|
|
102
|
+
order: string[] // min 1, e.g. ['explicit', 'persisted', 'navigator']
|
|
103
|
+
timeout: number | { default: number; [detector: string]: number }
|
|
104
|
+
url?: { strategy: 'path-prefix' | 'subdomain' | 'domain' | 'query-param'; paramName?: string; domainMap?: Record<string, string> }
|
|
105
|
+
cookie?: { name?: string; path?: string; sameSite?: 'strict' | 'lax' | 'none' }
|
|
106
|
+
fallback: string // usually locales.default
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`'persisted'` is the reserved slot for the stored locale preference (`state.storage`) — no runtime
|
|
111
|
+
registration needed. `'custom'` is the reserved slot for extra app-supplied detectors, registered
|
|
112
|
+
in `createStift({ detectors })`.
|
|
113
|
+
|
|
114
|
+
## `state`
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
state?: {
|
|
118
|
+
prefix?: string // default '__stift_' → __stift_locale, __stift_gender, …
|
|
119
|
+
storage?: 'webStorage' | 'cookie' | 'none' // default 'webStorage'
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Persists the active locale plus `setParam`/`setUnit` overrides, one key per field, read
|
|
124
|
+
synchronously at construction (no flash). A param/unit declaration can opt out with `persist: false`.
|
|
125
|
+
|
|
126
|
+
## `security`
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
security: {
|
|
130
|
+
tier: 'none' | 'obfuscated' | 'encrypted-static' | 'encrypted-session'
|
|
131
|
+
encoding: 'msgpack' | 'cbor' | 'json' // 'json' = plain readable `.json` packs
|
|
132
|
+
compression: 'none' | 'gzip' | 'brotli'
|
|
133
|
+
staticKey?: string // encrypted-static only — never emitted into the runtime config
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`encoding: 'json'` is only valid with `tier: 'none'` + `compression: 'none'`
|
|
138
|
+
(anything else fails config validation): the compiler emits raw UTF-8 JSON
|
|
139
|
+
bytes per namespace and the runtime fetches/parses them — no codec,
|
|
140
|
+
decompression, or key. Debugging, CDN-friendly inspection, non-secret
|
|
141
|
+
strings; the default stays binary `.dat`.
|
|
142
|
+
|
|
143
|
+
## `output`
|
|
144
|
+
|
|
145
|
+
Where generated files land (default `src/`): `output: { dir?: string }`.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: config
|
|
3
|
+
description: Write and validate stift.config.ts with @secundus-studio/stift-generator — locales (incl. registered dialects), namespaces, messageFormat, params/grammatical agreement, units, memory, loadingStrategy, detection, and security tiers. Use when creating or editing the Stift config file, adding locales or namespaces, configuring detection order, or choosing a security/obfuscation tier.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Stift — configuration (`stift.config.ts`)
|
|
7
|
+
|
|
8
|
+
Validated by `@secundus-studio/stift-generator`'s zod schema at the project root. All fields optional except `locales`.
|
|
9
|
+
|
|
10
|
+
## 1. Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add @secundus-studio/stift-generator # defineConfig + schema (pulled in by @secundus-studio/stift-vite-plugin)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 2. Minimal config
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { defineConfig } from '@secundus-studio/stift-generator'
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
locales: { default: 'en', supported: ['en', 'ar'] },
|
|
22
|
+
namespaces: { dir: 'locales', discovery: 'convention', default: ['common'] },
|
|
23
|
+
messageFormat: 'icu-mf1',
|
|
24
|
+
detection: {
|
|
25
|
+
order: ['explicit', 'persisted', 'navigator'],
|
|
26
|
+
timeout: { default: 250 },
|
|
27
|
+
fallback: 'en',
|
|
28
|
+
},
|
|
29
|
+
state: { prefix: '__stift_', storage: 'webStorage' }, // optional — these are the defaults
|
|
30
|
+
loadingStrategy: 'critical-then-full-background',
|
|
31
|
+
memory: { maxCachedNamespaces: 20, evictionPolicy: 'lru' },
|
|
32
|
+
security: { tier: 'obfuscated', encoding: 'msgpack', compression: 'brotli' },
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 3. Key decisions
|
|
37
|
+
|
|
38
|
+
- **`locales.supported`** — every tag with translations + registered dialects; each tag narrows
|
|
39
|
+
`RegisteredLocale` in the generated typegen. `default` must be in `supported`.
|
|
40
|
+
- **`detection.order`** — `'custom'` is the reserved runtime slot (registered in `createStift`, see
|
|
41
|
+
`@secundus-studio/stift-core#core`), not here.
|
|
42
|
+
- **`security.tier`** — `'none' | 'obfuscated' | 'encrypted-static' | 'encrypted-session'`.
|
|
43
|
+
`'encrypted-static'` needs `security.staticKey` (never emitted into the runtime config).
|
|
44
|
+
- **`loadingStrategy`** — `'lazy-per-namespace'` (default) | `'critical-then-full-background'`
|
|
45
|
+
(PWA) | `'boot-all'` (pin everything; shrink with `bootExclude`).
|
|
46
|
+
- **`params.context`** — grammatical-agreement params: `values` = canonical set,
|
|
47
|
+
`defaults` = required per-locale defaults (`all` key is the guaranteed catch-all;
|
|
48
|
+
locale keys restricted to the supported tags). See `@secundus-studio/stift-generator#params-units`.
|
|
49
|
+
- **`units`** — measurement display: per-category `supportedValues` + per-locale `defaults`
|
|
50
|
+
(`all` required; base-unit conversion at render). Unconfigured categories are invisible to the
|
|
51
|
+
type system and runtime. See `@secundus-studio/stift-generator#params-units`.
|
|
52
|
+
- **`locales.supported`** — every tag with translations **plus registered dialects** (`ar-SY`,
|
|
53
|
+
`ar-SY-x-lattakia`); dialects inherit from their registered ancestors. See `@secundus-studio/stift-core#locales`.
|
|
54
|
+
|
|
55
|
+
## 4. Where next
|
|
56
|
+
|
|
57
|
+
- Vite plugin consumes this config + emits typegen/runtime → `@secundus-studio/stift-vite-plugin#build`
|
|
58
|
+
- Boot + instance wiring → `@secundus-studio/stift-core#core`
|
|
59
|
+
- Locale/dialect tags, inheritance → `@secundus-studio/stift-core#locales`
|
|
60
|
+
- Params/units declaration rules → `@secundus-studio/stift-generator#params-units`
|
|
61
|
+
- Writing the messages → `@secundus-studio/stift-generator#authoring-translations`
|
|
62
|
+
- See [REFERENCE.md](REFERENCE.md) for the full schema.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: params-units
|
|
3
|
+
description: Declare and use Stift context params (grammatical agreement) and units correctly — the units-vs-params distinction (params change which translation is selected, units only change how a number renders — never mix the two), defaults.all requirement, per-locale defaults, canonical values/supportedValues, and common config mistakes. Use when writing the params/units blocks of stift.config.ts, adding a context param, configuring unit categories, or deciding whether a value belongs in params or units.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Stift — context params & units (declaration rules)
|
|
7
|
+
|
|
8
|
+
Two config features that are easy to conflate and serve **different value domains**. Get the
|
|
9
|
+
distinction right before writing either block.
|
|
10
|
+
|
|
11
|
+
## 1. The rule: units ≠ params
|
|
12
|
+
|
|
13
|
+
- **Context params** are *global translation settings* that change **which string is chosen** or
|
|
14
|
+
what values flow inside a message. They model grammatical agreement — `gender`, `politeness`,
|
|
15
|
+
`verbosity` — and resolve through ICU `select`/`plural` branches in the messages
|
|
16
|
+
(`@secundus-studio/stift-generator#authoring-translations`).
|
|
17
|
+
- **Units** are *display formatting settings*. They change **how a number renders** (m vs km vs mi)
|
|
18
|
+
and never appear inside a message, never change which string is selected, and never affect
|
|
19
|
+
translation.
|
|
20
|
+
|
|
21
|
+
Two consequences you can test your mental model against:
|
|
22
|
+
|
|
23
|
+
- If a value would appear in a **message branch** (`{gender, select, male {…} female {…}}`) → it's
|
|
24
|
+
a **context param**.
|
|
25
|
+
- If a value would be passed to a **number formatter** → it's a **unit choice**, configured under
|
|
26
|
+
`units` and rendered with `formatLength`/`formatMass`/… (`@secundus-studio/stift-react#units`).
|
|
27
|
+
|
|
28
|
+
## 2. Context params (`params.context`)
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
params: {
|
|
32
|
+
context: [
|
|
33
|
+
{
|
|
34
|
+
name: 'gender',
|
|
35
|
+
values: ['male', 'female', 'other'], // canonical union → drives the types
|
|
36
|
+
defaults: { all: 'other' }, // REQUIRED catch-all
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'politeness',
|
|
40
|
+
values: ['polite', 'warm'],
|
|
41
|
+
defaults: { all: 'polite', ar: 'warm', 'ar-SY': 'warm', 'ar-SY-x-lattakia': 'warm' },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Rules (docs/16):
|
|
48
|
+
|
|
49
|
+
- **R1 — declared params are optional at call sites.** A param in `params.context` becomes a
|
|
50
|
+
typed, optional property on every message shape that uses it. The app supplies it once (from the
|
|
51
|
+
user profile via `setParam`, per-locale defaults, or a `<LocaleScope>`), not at every `t()`.
|
|
52
|
+
- **R2 — every entry must declare `defaults`, and `defaults.all` is required.** It is the
|
|
53
|
+
guaranteed catch-all for every locale; locale keys are restricted to `locales.supported` tags.
|
|
54
|
+
- Per-locale defaults are recomputed when the active locale switches, and they **inherit down the
|
|
55
|
+
dialect chain** — a default on `ar` applies to `ar-SY` and `ar-SY-x-lattakia` unless a more
|
|
56
|
+
specific tag overrides it.
|
|
57
|
+
- `setParam` validates against `values` (an invalid value is rejected and reported via the
|
|
58
|
+
runtime hooks); the `state` backend persists overrides only — defaults come from config. Add
|
|
59
|
+
`persist: false` to a declaration to keep an override in memory only.
|
|
60
|
+
- **Undeclared params are always required** at call sites. There is no strictness dial.
|
|
61
|
+
|
|
62
|
+
## 3. Units (`units`)
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
units: {
|
|
66
|
+
length: {
|
|
67
|
+
supportedValues: ['meter', 'kilometer', 'mile'], // allowed for this category
|
|
68
|
+
defaults: { all: 'meter', 'ar-SY-x-lattakia': 'kilometer', ar: 'mile' },
|
|
69
|
+
},
|
|
70
|
+
mass: { supportedValues: ['kilogram', 'pound'], defaults: { all: 'kilogram' } },
|
|
71
|
+
temperature: { supportedValues: ['celsius', 'fahrenheit'], defaults: { all: 'celsius' } },
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Rules (docs/17 / `packages/core/src/units.ts`):
|
|
76
|
+
|
|
77
|
+
- Categories are `'length' | 'mass' | 'area' | 'temperature' | 'digital'`. Each has a **canonical
|
|
78
|
+
base unit** (meter / kilogram / hectare / celsius / byte). Formatting converts base → active, so
|
|
79
|
+
message data is always stored in the base unit — never convert in the message.
|
|
80
|
+
- `supportedValues` restricts what may be set/used (the full allowed catalog for `'all'` is
|
|
81
|
+
`UNITS_BY_CATEGORY` in `@secundus-studio/stift-core`). Defaults must be inside `supportedValues`.
|
|
82
|
+
- `defaults.all` is the catch-all; per-locale defaults keyed to supported tags inherit down the
|
|
83
|
+
dialect chain exactly like params.
|
|
84
|
+
- **Unconfigured categories are invisible** — no type entry, no `getUnitValues`, rejected by
|
|
85
|
+
`setUnit`, hidden from the devtools panel.
|
|
86
|
+
- Only `setUnit` overrides are persisted (through the `state` backend); defaults come from config.
|
|
87
|
+
`persist: false` on a category keeps its override in memory only.
|
|
88
|
+
|
|
89
|
+
## 4. Wrong vs right
|
|
90
|
+
|
|
91
|
+
| ❌ Wrong | ✅ Right | Why |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `"distance": "{unitSystem, select, metric {…} imperial {…}}"` — branch a message on a unit system | Store `distance` in meters; render `formatLength(distance)` which converts to the active unit | Units never select strings |
|
|
94
|
+
| Declare `unitSystem` as a `params.context` entry | Configure `units.length.supportedValues` + `defaults` | Params don't format numbers |
|
|
95
|
+
| `units.length.defaults: { all: 'mile' }` with `supportedValues: ['meter']` | `'mile'` must be in `supportedValues` | Config validation rejects out-of-set defaults |
|
|
96
|
+
| `params.context` entry without `defaults.all` | Always provide `defaults.all` | R2: required catch-all |
|
|
97
|
+
| Per-locale default keyed to `'ar-sy-x'` (wrong casing/tag) | Key by the exact registered tag `'ar-SY-x-lattakia'` | Keys are restricted to `locales.supported` |
|
|
98
|
+
| `{gender, select, male {…} female {…}}` with no `other` | Add `other {…}` | `other` is the required fallback branch |
|
|
99
|
+
| Passing a gender/politeness value at every call site | Declare it in `params.context`; supply once via `setParam`/`LocaleScope` | Declared params are optional at call sites |
|
|
100
|
+
|
|
101
|
+
## Where next
|
|
102
|
+
|
|
103
|
+
- Writing the messages that consume params → `@secundus-studio/stift-generator#authoring-translations`
|
|
104
|
+
- Full config schema → `@secundus-studio/stift-generator#config`
|
|
105
|
+
- Supplying params at runtime (`setParam`, `LocaleScope`) → `@secundus-studio/stift-react#values`
|
|
106
|
+
- Rendering units (`useUnits`, `formatLength`/…) → `@secundus-studio/stift-react#units`
|
|
107
|
+
- Locale/dialect inheritance for per-locale defaults → `@secundus-studio/stift-core#locales`
|