@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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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`