# Mongez ecosystem — full reference > Combined `llms-full.txt` for every package in the @mongez/* family. > Fetch this when you want the entire surface in one shot. For focused > per-package content, see each package's `/llms-full.txt` at > `https://mongez.js.org//llms-full.txt`. Generated: 2026-05-29T18:41:08.683Z --- # @mongez/events # @mongez/events — full reference > **Auto-trigger when loading this full reference:** code uses `import events from "@mongez/events"` or pulls types `EventSubscription`, `EventListeners`, `EventListenersList`, `EventTriggerResponse`; code calls `events.subscribe` / `events.on` / `events.addEventListener`, `events.trigger` / `events.emit`, `events.triggerAll`, `events.triggerAsync`, `events.triggerAllAsync`, `events.unsubscribe` / `events.off`, `events.subscriptions`, `events.unsubscribeNamespace`, `events.getByNamespace`, or `events.getByNamespaceArray`; dot-separated event names like `users.created` or `atoms.${key}.update` appear; user asks "what is @mongez/events", "how do I subscribe / emit / veto an event", "how do namespaces match", "how do I clean up listeners in React or tests", "how do I aggregate handler results", or "events vs RxJS / BroadcastChannel / @mongez/atom". > > **Skip when:** the code is about a sibling package (`@mongez/atom`, `@mongez/react-atom`, `@mongez/cache`, etc.) and not the events bus itself; native DOM `addEventListener` on `window` / elements; Node.js `EventEmitter` / `events` module; RxJS `Subject` / `Observable`; `BroadcastChannel`; CSS / DOM namespacing. > Tiny, zero-dependency event bus. Segment-aware namespace matching, stop-on-`false` semantics, async variants, namespace-scoped cleanup. ## Install ```sh yarn add @mongez/events ``` Zero runtime dependencies. ## Public exports ```ts import events, { type EventSubscription, type EventListeners, type EventListenersList, type EventTriggerResponse, } from "@mongez/events"; ``` The default export is a singleton instance — `events.subscribe(...)`, `events.trigger(...)`, etc. ## API ### Subscribe ```ts events.subscribe(event: string, callback: Function): EventSubscription events.on(event, callback): EventSubscription // alias events.addEventListener(event, callback): EventSubscription // alias ``` Returns: ```ts type EventSubscription = { callback: Function; event: string; dispatch(...args: any[]): any; // invoke callback directly, bypassing bus unsubscribe(): void; }; ``` There is no `off(event, callback)` form. Hold the returned subscription and call `unsubscribe()`. ### Trigger (short-circuits on `false`) ```ts events.trigger(event: string, ...args: any[]): any events.emit(event, ...args): any // alias ``` - Invokes every callback for `event` in subscription order. - If any callback returns `false`, the chain stops and `trigger` returns `false`. Use for veto / "before" hooks. - Otherwise returns the last non-`undefined` return value. ### Trigger all (no short-circuit) ```ts events.triggerAll(event: string, ...args: any[]): EventTriggerResponse type EventTriggerResponse = { event: string; length: number; results: any[]; // non-undefined returns only }; ``` Use for analytics, multi-listener notifications, aggregation patterns. ### Async variants ```ts events.triggerAsync(event, ...args): Promise events.triggerAllAsync(event, ...args): Promise ``` Callbacks are awaited **sequentially**, in subscription order. For parallel dispatch, use `subscriptions(event)` + `Promise.all`. The async variants honor `return false` (after awaiting the offending callback). ### Unsubscribe ```ts events.unsubscribe(event?: string): this // detach one event, or all when undefined events.off(event?: string): this // alias events.unsubscribeNamespace(namespace: string): this ``` ### Inspect ```ts events.subscriptions(event: string): EventSubscription[] events.getByNamespace(namespace: string): { [eventName]: EventSubscription[] } events.getByNamespaceArray(namespace: string): { event: string; subscriptions: EventSubscription[] }[] ``` ## Namespace matching Event names are dot-separated. Namespace operations match at segment boundaries — `users.1` matches `users.1` and `users.1.updated`, but NOT `users.10` / `users.11` / `users.100`. Implementation: ```ts event === namespace || event.startsWith(namespace + ".") ``` So: | namespace | matches | doesn't match | |---|---|---| | `"users"` | `users`, `users.1`, `users.1.updated` | `usersTable`, `users2` | | `"users.1"` | `users.1`, `users.1.profile` | `users.10`, `users.11`, `users.100` | | `"atoms.cart"` | `atoms.cart.update`, `atoms.cart.reset` | `atoms.cartItems.update` | ## Patterns ### Veto pattern (stop-on-`false`) ```ts events.subscribe("save.before", (data) => { if (!isValid(data)) return false; }); const ok = events.trigger("save.before", payload); if (ok === false) return; performSave(payload); events.trigger("save.after", payload); ``` ### Aggregation pattern ```ts events.subscribe("table.columns", () => ({ field: "name", label: "Name" })); events.subscribe("table.columns", () => ({ field: "email", label: "Email" })); const { results } = events.triggerAll("table.columns"); ``` ### Feature-scoped lifecycle ```ts function mount() { events.subscribe("users.created", onCreate); events.subscribe("users.updated", onUpdate); } function unmount() { events.unsubscribeNamespace("users"); } ``` ### Async chains ```ts events.subscribe("file.uploaded", async (file) => await scan(file)); events.subscribe("file.uploaded", async (file) => await thumbnail(file)); // Sequential await events.triggerAsync("file.uploaded", file); // Parallel await Promise.all( events.subscriptions("file.uploaded").map(s => s.dispatch(file)), ); ``` ## Used by `@mongez/atom` Every atom emits lifecycle events under the namespace `atoms.${key}`: - `atoms.${key}.update` — `update`, `change`, `merge` - `atoms.${key}.reset` — `reset`, `silentReset` - `atoms.${key}.delete` — `destroy` `atom.destroy()` calls `events.unsubscribeNamespace(``atoms.${key}``)`. The segment-aware match ensures destroying `users.1` doesn't wipe `users.10`. ## What this package does NOT do - **Typed events.** Callbacks are `Function`; you cast at the use site. - **Backpressure / reactive streams.** Use RxJS for that. - **Cross-window / cross-process.** Use `BroadcastChannel`. - **DOM events.** Use `addEventListener` on the target. --- # @mongez/reinforcements # @mongez/reinforcements — Full reference > Complete API reference for `@mongez/reinforcements` v3.1. This file is the concatenation of every per-namespace skill, intended for single-fetch loading by AI agents. Install: `yarn add @mongez/reinforcements` or `npm i @mongez/reinforcements`. All exports import from the package root: ```ts import { get, set, has, pick, omit, compact, merge, clone, areEqual, toCamelCase, slugify, truncate, template, clamp, formatBytes, percentage, debounce, throttle, memoize, pipe, sleep, retry, pMap, pProps, defer, Random, lazy, type Path, type PathValue, type DeepPartial, } from "@mongez/reinforcements"; ``` --- # Overview > **Auto-trigger:** code first imports anything from `@mongez/reinforcements` and orientation is needed; user asks "what is @mongez/reinforcements / how do I install it / what does this package do / which @mongez package should I use for X"; package.json adds `"@mongez/reinforcements"` as a dependency. > **Skip when:** a specific namespace skill (objects, strings, numbers, arrays, async, functions, lazy, random, mixed, types, recipes) already covers the user's concrete task — load that one directly; questions about unrelated `@mongez/*` packages (`@mongez/supportive-is`, `@mongez/collections`) beyond brief scope-boundary mentions. `@mongez/reinforcements` is a TypeScript utility belt — ~130 functions for objects, strings, numbers, async, randomness, and functional composition. ## Install ```sh yarn add @mongez/reinforcements # or npm i @mongez/reinforcements ``` ## Import pattern Everything is exported from the package root: ```ts import { get, set, has, pick, omit, merge, clone, areEqual, toCamelCase, slugify, truncate, template, clamp, formatBytes, percentage, debounce, throttle, memoize, pipe, sleep, retry, pMap, defer, Random, lazy, type Path, type PathValue, type DeepPartial, } from "@mongez/reinforcements"; ``` ## Namespaces | Namespace | Count | Examples | |---|---|---| | `object` | 23 | `get`, `set`, `pick`, `omit`, `merge`, `clone`, `flatten`, `walk`, `diff` | | `string` | ~45 | Casing family, `slugify`, `truncate`, `template`, `mask`, `stripHtmlTags` | | `number` | 12 | `round`, `clamp`, `formatBytes`, `percentage`, `safeDivide` | | `mixed` | 4 | `clone`, `areEqual`, `shuffle`, `coalesce` | | `array` | 18 | `chunk`, `range`, `unique`, `pluck`, `groupBy`, `sum` | | `random` | 14 | `Random.int`, `Random.uuid`, `Random.sample`, `Random.seed` | | `lazy` | 1 + variants | `lazy`, `lazy.async`, `lazy.from`, `isLazy` | | `function` | 17 | `debounce`, `throttle`, `memoize`, `pipe`, `curry`, `once` | | `async` | 11 | `sleep`, `retry`, `retryable`, `timeout`, `pMap`, `pFilter`, `defer` | | `types` | 16 | `Path`, `PathValue`, `DeepPartial`, `Branded`, `Prettify` | ## Scope boundaries | Concern | Lives in | Why | |---|---|---| | Type/shape predicates (`isString`, `isEmpty`, `isURL`, …) | `@mongez/supportive-is` | Single-purpose package | | Array collection helpers (`partition`, `keyBy`, `sortBy`, …) | `@mongez/collections` | Richer collection model | | HTML sanitization | Use `DOMPurify` | Parser-based, not regex | | Schema validation | Use `zod` / `valibot` | Out of scope | | Date manipulation | Use `dayjs` / `date-fns` / `Temporal` | Out of scope | | HTTP/fetch helpers | Separate concern | Out of scope | ## Mental model - **Object utilities** prefer **paths over keys** — dot-notation is first-class. - **String casings** are powered by a shared `words()` tokenizer that handles acronyms correctly (`AIAgent` → `["AI", "Agent"]`). - **Async helpers** mirror common needs without re-implementing the whole Bluebird/p-* ecosystem. - **`Random`** is a namespace class — `Random.int(1, 10)`, never `new Random()`. - **`lazy`** is the unique tool for breaking ES-module circular imports. ## TypeScript `get(obj, path)` autocompletes every legal dot-notation path on `obj` and infers the value type. Generics flow through `pick`, `omit`, `merge`, `clone`, `keys`, `values`, `entries`, `mapValues`, `pipe`, `compose`, and the async helpers. ```ts type User = { profile: { email: string } }; const u: User = { profile: { email: "x@y.z" } }; get(u, "profile.email"); // typed as string ``` --- # Objects > **Auto-trigger:** code imports `get`, `set`, `has`, `unset`, `pick`, `omit`, `compact`, `merge`, `clone`, `flatten`, `freeze`, `defaults`, `invert`, `mapValues`, `mapKeys`, `map`, `keys`, `values`, `entries`, `fromEntries`, `walk`, `diff`, or `sort` from `@mongez/reinforcements`; user asks "how do I read/write a deep path / dot-notation get / pick keys / omit keys / deep merge / flatten an object / diff two objects"; `import { get, set, pick, omit, merge, clone } from "@mongez/reinforcements"`. > **Skip when:** @mongez/supportive-is is type predicates (`isPlainObject`, `isEmpty`) — use it to test shape, not transform; @mongez/collections handles array-of-object operations (`sortBy`, `keyBy`, `partition`); native `Object.assign`/spread when nesting isn't involved. Path-aware reads/writes, deep transforms, structural diff. Import from `@mongez/reinforcements`. ## Path access — `get` / `set` / `has` / `unset` #### `get` ```ts get>(obj: T, path: P, default?): PathValue get(obj: any, path: string, default?: T): T ``` Read by typed dot-notation. Falsy values pass through correctly (no spurious default-substitution on `0` / `""` / `false`). ```ts get({ user: { email: "ada@x.com" } }, "user.email"); // "ada@x.com" get({ user: {} }, "user.email", "n/a"); // "n/a" get(arr, "0.name"); // numeric segments index arrays ``` #### `set` ```ts set(obj: T, path: string, value: unknown): T ``` Mutating write by dot-notation. **Auto-creates arrays** when the next segment is a numeric index. ```ts set({}, "users.0.name", "Ada"); // { users: [{ name: "Ada" }] } set(obj, "a.b.c", 1); // creates a.b.c chain ``` #### `has` ```ts has(obj: any, path: string): boolean ``` `true` if the path exists, **even when the value is `undefined`**. Use this to distinguish "missing" from "present-but-undefined". ```ts has({ a: { b: 1 } }, "a.b"); // true has({ a: { b: undefined } }, "a.b"); // true has({ a: {} }, "a.b"); // false ``` #### `unset` ```ts unset(obj: T, paths: readonly string[]): T ``` Mutating remove by dot-notation. Returns the same reference. ```ts unset({ a: 1, b: 2 }, ["a"]); // { b: 2 } unset({ a: { b: 1, c: 2 } }, ["a.b"]); // { a: { c: 2 } } ``` ## Key selection — `pick` / `omit` #### `pick` ```ts pick(obj: T, keys: readonly K[]): Pick pick(obj, keys: string[]): Record // supports dot-notation paths pick(obj, predicate: (value, key) => boolean): Record ``` ```ts pick({ a: 1, b: 2, c: 3 }, ["a", "c"]); // { a: 1, c: 3 } pick({ a: { b: 1, c: 2 } }, ["a.b"]); // { a: { b: 1 } } pick({ a: 1, b: 2, c: 3 }, v => v > 1); // { b: 2, c: 3 } ``` #### `omit` ```ts omit(obj: T, keys: readonly K[]): Omit omit(obj, keys: string[]): Record // supports dot-notation paths omit(obj, predicate: (value, key) => boolean): Record ``` Non-mutating; returns a shallow clone minus the keys/paths. ```ts omit({ a: 1, b: 2 }, ["b"]); // { a: 1 } omit({ a: { b: 1, c: 2 } }, ["a.b"]); // { a: { c: 2 } } omit({ a: 1, b: 2 }, (_, k) => k === "a"); // { b: 2 } ``` > `only` and `except` are kept as **`@deprecated` aliases** of `pick` and `omit` — prefer the new names. ## Cleanup — `compact` #### `compact` ```ts compact>(obj: T, options?: CompactOptions): Partial compact(array: T[], options?: CompactOptions): T[] type CompactOptions = { predicate?: (value: any) => boolean; // default: nullish or "" empties?: boolean; // default: true — drop [] and {} too deep?: boolean; // default: true }; ``` Strip "empty" entries from objects/arrays. Default predicate drops `null`, `undefined`, and `""` only — **keeps `0`, `false`, `NaN`** because those are usually meaningful. With `empties` and `deep` on by default, parent containers that become empty after recursion are themselves dropped. ```ts compact({ name: "Ada", email: "", phone: null, age: 0 }); // { name: "Ada", age: 0 } compact({ user: { name: "Ada", email: "" }, meta: {} }); // { user: { name: "Ada" } } compact(["a", "", null, "b"]); // ["a", "b"] compact({ a: 0, b: -1 }, { predicate: v => v === -1 }); // { a: 0 } compact({ tags: [], name: "Ada" }, { empties: false }); // { tags: [], name: "Ada" } ``` Typical uses: cleaning API request payloads, building query strings from filter objects, sanitizing form data. ## Deep transforms — `merge` / `clone` / `flatten` / `freeze` #### `merge` ```ts merge(a: A, b: B): A & B merge(...sources, options?: { arrays?: "replace" | "concat" | "union" }): any ``` Recursive merge of plain objects. Arrays default to **replace**; pass an options object as the last argument to change strategy. ```ts merge({ a: { b: 1 } }, { a: { c: 2 } }); // { a: { b: 1, c: 2 } } merge({ list: [1, 2] }, { list: [3, 4] }, { arrays: "concat" }); // { list: [1, 2, 3, 4] } merge({ list: [1, 2] }, { list: [2, 3] }, { arrays: "union" }); // { list: [1, 2, 3] } ``` Class instances are taken from the latest source rather than merged (cloning custom constructors is out of scope). #### `clone` ```ts clone(value: T): T ``` Deep clone. Handles `Date`, `RegExp`, `Error` (with own props), `Map`, `Set`, typed arrays, `ArrayBuffer`, and **circular references** via internal `WeakMap`. Non-plain class instances are returned by reference. ```ts const copy = clone({ user: { name: "Ada" }, tags: ["x"] }); copy.user.name = "Bob"; // original is untouched ``` #### `flatten` ```ts flatten(obj: any, options?: { separator?: string; // default "." keepNested?: boolean; // default false maxDepth?: number; // default Infinity }): Record ``` Flatten to a single-level map of dot-keyed paths. Descends into plain objects, arrays, and class instances. Treats `Date` / `RegExp` / `Map` / `Set` / typed arrays as leaves. ```ts flatten({ a: { b: 1, c: [2, 3] } }); // { "a.b": 1, "a.c.0": 2, "a.c.1": 3 } flatten({ a: { b: 1 } }, { separator: "/" }); // { "a/b": 1 } ``` #### `freeze` ```ts freeze(value: T): Readonly ``` Recursive `Object.freeze` across plain objects and arrays. ```ts const config = freeze({ api: { url: "..." } }); config.api.url = "x"; // throws in strict mode ``` ## Defaults & inversion #### `defaults` ```ts defaults(target: T, ...sources): T ``` Mutating: fills only **`undefined`** keys on `target` from each source, left to right. ```ts defaults({ a: 1 }, { a: 2, b: 3 }); // { a: 1, b: 3 } defaults({}, { a: 1 }, { a: 2 }); // { a: 1 } (first source wins) ``` #### `invert` ```ts invert(obj: Record): Record ``` Swap keys and values; values are coerced to strings; duplicate values collide last-wins. ```ts invert({ a: 1, b: 2 }); // { "1": "a", "2": "b" } ``` ## Per-entry mapping #### `mapValues` / `mapKeys` ```ts mapValues(obj: T, fn: (value, key, obj) => U): Record mapKeys(obj: T, fn: (key, value, obj) => string): Record ``` ```ts mapValues({ a: 1, b: 2 }, v => v * 2); // { a: 2, b: 4 } mapKeys({ a: 1, b: 2 }, k => k.toUpperCase()); // { A: 1, B: 2 } ``` #### `map` ```ts map(obj: T, fn: (key, value, obj) => U): U[] ``` Map an object to an array. ```ts map({ a: 1, b: 2 }, (k, v) => `${k}=${v}`); // ["a=1", "b=2"] ``` ## Typed enumeration ```ts keys(obj: T): Array values(obj: T): Array entries(obj: T): Array<[keyof T & string, T[keyof T]]> fromEntries(entries: Iterable): Record ``` Typed wrappers around the matching `Object.*` static methods. ## Traversal & comparison #### `walk` ```ts walk(obj: any, visitor: (value, path, parent, key) => void, parentPath?: string): void ``` Recursive **leaf** traversal. Descends into plain objects and arrays; calls `visitor` for every non-container value with the full dot-path. ```ts walk({ a: { b: 1, c: [2, 3] } }, (value, path) => log(path, value)); // "a.b" 1 // "a.c.0" 2 // "a.c.1" 3 ``` #### `diff` ```ts diff(a: object, b: object): { added: Record; removed: Record; changed: Record; } ``` Shallow structural diff; uses deep value equality (`areEqual`) for nested comparison. ```ts diff({ a: 1, b: 2 }, { a: 1, b: 3, c: 4 }); // { added: { c: 4 }, removed: {}, changed: { b: { from: 2, to: 3 } } } ``` ## Sorting #### `sort` ```ts sort(obj: T, recursive?: boolean): T // default recursive = true ``` Return a new object with keys sorted alphabetically. Arrays of objects are out of scope — use `@mongez/collections` `sortBy`. ```ts sort({ b: 1, a: 2, c: 3 }); // { a: 2, b: 1, c: 3 } sort({ b: { y: 1, x: 2 }, a: 1 }); // recursive by default ``` --- # Strings > **Auto-trigger:** code imports `words`, `toCamelCase`, `toStudlyCase`, `toPascalCase`, `toSnakeCase`, `toKebabCase`, `toConstantCase`, `toDotCase`, `toPathCase`, `toTitleCase`, `ucfirst`, `capitalize`, `trim`, `ltrim`, `rtrim`, `replaceAll`, `replaceFirst`, `replaceLast`, `removeFirst`, `removeLast`, `repeatsOf`, `pad`, `padStart`, `padEnd`, `slugify`, `truncate`, `readMoreChars`, `readMoreWords`, `escapeHtml`, `unescapeHtml`, `stripHtmlTags`, `mask`, `template`, `wordCount`, `charCount`, `reverse`, `initials`, `extension`, `toInputName`, `startsWithArabic`, `containsArabic`, `ARABIC_REGEX`, or `ARABIC_PATTERN` from `@mongez/reinforcements`; user asks "how do I camelCase / snake_case / slugify / truncate / mask / strip HTML / get file extension / template with dot-notation / detect Arabic"; `import { toCamelCase, slugify, truncate, mask, template } from "@mongez/reinforcements"`. > **Skip when:** @mongez/supportive-is is type predicates (`isString`, `isEmail`, `isURL`) — use that for validation, not transformation; HTML sanitization for untrusted input (use `DOMPurify`); i18n translation/pluralization (use `i18next`, `intl-messageformat`); regex escaping uses `escapeRegex` from @mongez/reinforcements-functions, not this skill. Casing, trimming, replacement, padding, slugify, truncate, mask, template, HTML helpers, Arabic detection. Import from `@mongez/reinforcements`. ## Casing — powered by `words()` All casing functions share a single tokenizer that handles **acronyms correctly**. #### `words` ```ts words(input: string): string[] ``` ```ts words("XMLHttpRequest"); // ["XML", "Http", "Request"] words("AIAgent"); // ["AI", "Agent"] words("hello-world"); // ["hello", "world"] ``` #### Casing family | Function | Signature | Example | |---|---|---| | `toCamelCase` | `(str) => string` | `toCamelCase("XMLHttpRequest")` → `"xmlHttpRequest"` | | `toStudlyCase` | `(str) => string` | `toStudlyCase("hello-world")` → `"HelloWorld"` | | `toPascalCase` | `(str) => string` | Alias of `toStudlyCase` | | `toSnakeCase` | `(str, separator?, lowerAll?) => string` | `toSnakeCase("AIAgent")` → `"ai_agent"` | | `toKebabCase` | `(str, lowerAll?) => string` | `toKebabCase("getUserID")` → `"get-user-id"` | | `toConstantCase` | `(str) => string` | `toConstantCase("apiBaseUrl")` → `"API_BASE_URL"` | | `toDotCase` | `(str, lowerAll?) => string` | `toDotCase("helloWorld")` → `"hello.world"` | | `toPathCase` | `(str, lowerAll?) => string` | `toPathCase("helloWorld")` → `"hello/world"` | | `toTitleCase` | `(str, options?: { stopWords? }) => string` | `toTitleCase("the lord of rings")` → `"The Lord of Rings"` | ## Letter case primitives ```ts ucfirst(str: string): string // uppercase first char capitalize(str: string): string // ucfirst on every whitespace-separated word ``` ```ts ucfirst("hello"); // "Hello" capitalize("hello world"); // "Hello World" ``` ## Trimming | Function | Signature | Notes | |---|---|---| | `trim` | `(str, needle?: string) => string` | Trims both ends (default whitespace) | | `ltrim` | `(str, needle?: string) => string` | Start only | | `rtrim` | `(str, needle?: string) => string` | End only | ```ts trim(" hi "); // "hi" trim("---hi---", "-"); // "hi" ltrim("//path", "/"); // "path" rtrim("file.tmp", ".tmp"); // "file" ``` ## Replacement family ```ts replaceAll(str, search, replacement): string replaceFirst(str, search, replacement): string replaceLast(str, search, replacement): string removeFirst(str, needle): string // = replaceFirst(str, needle, "") removeLast(str, needle): string // = replaceLast(str, needle, "") ``` `search`/`needle` are **literal strings** — they're regex-escaped internally. ```ts replaceAll("a-b-c", "-", "_"); // "a_b_c" replaceFirst("foo foo foo", "foo", "bar"); // "bar foo foo" replaceLast("foo bar foo", "foo", "baz"); // "foo bar baz" ``` #### `repeatsOf` ```ts repeatsOf(str: string, needle: string, caseSensitive?: boolean): number // default true ``` ```ts repeatsOf("abcabc", "a"); // 2 repeatsOf("AbcAbc", "a", false); // 2 ``` ## Padding ```ts pad(str, length, char?): string // pad both sides; extra char goes to end padStart(str, length, char?): string // typed wrapper around String#padStart padEnd(str, length, char?): string // typed wrapper around String#padEnd ``` ```ts pad("hi", 6); // " hi " pad("hi", 7, "*"); // "**hi***" padStart("7", 3, "0"); // "007" padEnd("7", 3, "0"); // "700" ``` ## URL slugs & truncation #### `slugify` ```ts slugify(str, options?: { separator?: string; // default "-" lower?: boolean; // default true strict?: boolean; // default true — strip non-alphanumeric per token }): string ``` ```ts slugify("Hello, World!"); // "hello-world" slugify("café crème"); // "cafe-creme" slugify("Hello World", { separator: "_" }); // "hello_world" ``` #### `truncate` ```ts truncate(str, length, options?: { suffix?: string; // default "..." byWord?: boolean; // default false — cut at word boundary position?: "end" | "middle"; // default "end" }): string ``` ```ts truncate("hello world", 8); // "hello..." truncate("hello world there", 14, { byWord: true }); // "hello world..." truncate("abcdefghij", 7, { position: "middle" }); // "ab...ij" ``` #### `readMoreChars` / `readMoreWords` ```ts readMoreChars(str, length, suffix?: string): string // default suffix "..." readMoreWords(str, wordCount, suffix?: string): string ``` ```ts readMoreChars("hello world", 5); // "hello..." readMoreWords("a b c d e", 3); // "a b c..." ``` ## HTML & masking #### `escapeHtml` / `unescapeHtml` ```ts escapeHtml(str: string): string // & < > " ' → entities unescapeHtml(str: string): string // reverse ``` ```ts escapeHtml(''); // "<a href="x">" ``` #### `stripHtmlTags` ```ts stripHtmlTags(str, options?: { replacement?: string; // default "" stripScriptsAndStyles?: boolean; // default true — drops content too stripComments?: boolean; // default true }): string ``` **Not a sanitizer** — for untrusted HTML, use DOMPurify. ```ts stripHtmlTags("

Hello world

"); // "Hello world" stripHtmlTags("safe"); // "safe" stripHtmlTags("

hi

", { replacement: " " }); // " hi " ``` #### `mask` ```ts mask(str, options?: { start?: number; // visible chars from start, default 0 end?: number; // visible chars from end, default 0 char?: string; // mask char, default "*" }): string ``` ```ts mask("4242424242424242", { start: 0, end: 4 }); // "************4242" mask("hassan@gmail.com", { start: 2, end: 4 }); // "ha**********.com" ``` ## Templates #### `template` ```ts template(str: string, vars: Record): string ``` `{path}` interpolation with **dot-notation paths** into `vars`. Missing paths render as `""`. ```ts template("Hello {user.name}!", { user: { name: "Ada" } }); // "Hello Ada!" template("{count} items", { count: 3 }); // "3 items" ``` ## Counting & reversal ```ts wordCount(str: string): number charCount(str: string, options?: { unicode?: boolean }): number // grapheme-aware when true reverse(str: string): string // unicode-safe (handles surrogate pairs) ``` ```ts wordCount("hello world"); // 2 charCount("hello"); // 5 charCount("👨‍👩", { unicode: true }); // 1 (single grapheme) reverse("hello"); // "olleh" ``` ## Misc #### `initials` ```ts initials(name: string, separator?: string): string // default separator "" ``` ```ts initials("Ada Lovelace"); // "AL" initials("Ada Lovelace", "."); // "A.L" ``` #### `extension` ```ts extension(filename: string): string ``` Returns the part after the last dot, or `""` if absent. ```ts extension("foo.txt"); // "txt" extension("archive.tar.gz"); // "gz" extension("README"); // "" ``` #### `toInputName` ```ts toInputName(path: string): string ``` `"a.b.c"` → `"a[b][c]"` for HTML form `name` attributes. ```ts toInputName("user.address.city"); // "user[address][city]" toInputName("user.tags[]"); // "user[tags][]" ``` ## Arabic ```ts startsWithArabic(str: string, trimmed?: boolean): boolean // default trimmed = true containsArabic(str: string): boolean ARABIC_REGEX: RegExp // /[؀-ۿ]/ ARABIC_PATTERN: RegExp // @deprecated — same as ARABIC_REGEX ``` ```ts startsWithArabic("مرحبا"); // true startsWithArabic(" مرحبا"); // true (trimmed) containsArabic("hello مرحبا"); // true ``` --- # Numbers > **Auto-trigger:** code imports `round`, `floor`, `ceil`, `toFixed`, `clamp`, `inRange`, `lerp`, `safeDivide`, `percentage`, `parseNumber`, `formatBytes`, or `formatNumber` from `@mongez/reinforcements`; user asks "how do I clamp a number / format bytes / safely divide by zero / round with precision / format currency"; `import { clamp, round, formatBytes, percentage } from "@mongez/reinforcements"`. > **Skip when:** @mongez/supportive-is handles `isNumber`/`isInteger` predicates — use that for type checks, not arithmetic; date/time math (use `dayjs`, `date-fns`, or `Temporal`); BigInt arithmetic (out of scope). Rounding, clamping, formatting, safe arithmetic. Import from `@mongez/reinforcements`. ## Rounding with precision ```ts round(value: number, precision?: number): number // default 2; rounds half-up floor(value: number, precision?: number): number // default 0 ceil(value: number, precision?: number): number // default 0 toFixed(value: number, precision?: number): number // returns number, not string ``` ```ts round(1.235, 2); // 1.24 round(1.5, 0); // 2 (true rounding, not floor) floor(1.99, 1); // 1.9 ceil(1.01, 1); // 1.1 toFixed(1.236, 2); // 1.24 (number, not "1.24") ``` ## Range helpers ```ts clamp(value: number, min: number, max: number): number inRange(value: number, min: number, max: number, options?: { inclusive?: boolean }): boolean lerp(a: number, b: number, t: number): number ``` `clamp` normalizes swapped bounds. `inRange` defaults to `inclusive: true`. ```ts clamp(15, 0, 10); // 10 clamp(-3, 0, 10); // 0 inRange(5, 0, 10); // true inRange(10, 0, 10, { inclusive: false }); // false lerp(0, 100, 0.25); // 25 ``` ## Safe arithmetic #### `safeDivide` ```ts safeDivide(a: number, b: number, fallback?: F): number | F ``` Returns `fallback` (default `0`) when `b === 0` or the result isn't finite. ```ts safeDivide(10, 2); // 5 safeDivide(10, 0); // 0 safeDivide(10, 0, null); // null ``` #### `percentage` ```ts percentage(value: number, total: number, decimals?: number): number // default 2 ``` ```ts percentage(25, 200); // 12.5 percentage(7, 9, 1); // 77.8 percentage(1, 0); // 0 (safe on divide-by-zero) ``` #### `parseNumber` ```ts parseNumber(value: unknown, fallback?: F): number | F ``` Returns `fallback` (default `0`) for `null` / `undefined` / `""` / non-numeric input. ```ts parseNumber("42"); // 42 parseNumber("abc", -1); // -1 parseNumber(null, 0); // 0 ``` ## Formatting #### `formatBytes` ```ts formatBytes(bytes: number, options?: { decimals?: number; // default 2 binary?: boolean; // default false (use 1000-based units) }): string ``` ```ts formatBytes(1500); // "1.50 KB" formatBytes(1024, { binary: true, decimals: 0 }); // "1 KiB" formatBytes(-1500, { decimals: 0 }); // "-2 KB" formatBytes(0); // "0 B" ``` #### `formatNumber` ```ts formatNumber(value: number, options?: Intl.NumberFormatOptions & { locale?: string | string[]; }): string ``` Thin wrapper around `Intl.NumberFormat`. ```ts formatNumber(1234.5); // "1,234.5" formatNumber(0.42, { style: "percent" }); // "42%" formatNumber(99, { style: "currency", currency: "USD" }); // "$99.00" formatNumber(1234, { locale: "ar-EG" }); // "١٬٢٣٤" ``` --- # Mixed (clone, areEqual, shuffle, coalesce) > **Auto-trigger:** code imports `clone`, `areEqual`, `shuffle`, or `coalesce` from `@mongez/reinforcements`; user asks "how do I deep clone / deep compare / shuffle an array / get the first non-null value"; `import { clone, areEqual, shuffle, coalesce } from "@mongez/reinforcements"`. > **Skip when:** @mongez/supportive-is is type predicates (`isEmpty`, `isPlainObject`) — use that for shape/type checks, not value equality; native `structuredClone` for environments where it suffices and circular custom-class handling isn't required; nullish-coalescing `??` operator when only one fallback is needed. Deep clone, deep equality, shuffle, coalesce. Import from `@mongez/reinforcements`. ## `clone` — deep copy ```ts clone(value: T): T ``` Handles `Date`, `RegExp`, `Error` (with own props), `Map`, `Set`, typed arrays, `ArrayBuffer`, and **circular references** via internal `WeakMap`. Non-plain class instances are **returned by reference** (cloning user constructors is out of scope — call your own `.clone()` if you have one). ```ts const a: any = { name: "Ada" }; a.self = a; // circular const copy = clone(a); copy.self === copy; // true — circular ref preserved clone(new Date(0)); // new Date instance, same time clone(/abc/gi); // new RegExp, same source/flags clone(new Map([["k", 1]])); // new Map, deep-cloned values const err = new TypeError("nope"); (err as any).meta = { detail: true }; clone(err).meta; // { detail: true } — own props preserved ``` ## `areEqual` — deep value equality ```ts areEqual(a: any, b: any): boolean ``` Non-mutating deep equality. **Respects element order in arrays.** Handles `Date`, `RegExp`, `Map`, `Set`, and circular references. ```ts areEqual({ a: 1 }, { a: 1 }); // true areEqual([1, 2, 3], [1, 2, 3]); // true areEqual([1, 2, 3], [3, 2, 1]); // false — order matters areEqual(new Date(0), new Date(0)); // true areEqual(/abc/g, /abc/g); // true areEqual(new Set([1, 2]), new Set([2, 1])); // true (Set equality) const a: any = { x: 1 }; a.self = a; const b: any = { x: 1 }; b.self = b; areEqual(a, b); // true — circular-safe ``` > This is a **value comparator**, not a type/shape predicate. For predicates like `isEmpty` / `isPlainObject`, use `@mongez/supportive-is`. ## `shuffle` — Fisher–Yates ```ts shuffle(value: T[], options?: { mutate?: boolean }): T[] shuffle(value: string, options?: { mutate?: boolean }): string ``` Non-mutating by default. Pass `{ mutate: true }` to shuffle the array in place. ```ts shuffle([1, 2, 3, 4]); // e.g. [3, 1, 4, 2]; original untouched shuffle("hello"); // e.g. "lehlo" shuffle(arr, { mutate: true }); // shuffles in place, returns arr ``` ## `coalesce` — first non-nullish ```ts coalesce(...values: Array): T | undefined ``` Returns the first value that isn't `null` or `undefined`. **Falsy-but-defined values** (`0`, `""`, `false`, `NaN`) pass through — unlike `||`. ```ts coalesce(null, undefined, "first"); // "first" coalesce(undefined, 0, "x"); // 0 coalesce(undefined, "", "x"); // "" coalesce(null, undefined); // undefined ``` --- # Random > **Auto-trigger:** code imports `Random`, `RandomDateOptions`, or `WeightedItem` from `@mongez/reinforcements`, or calls `Random.int`/`Random.float`/`Random.bool`/`Random.string`/`Random.id`/`Random.uuid`/`Random.nanoid`/`Random.token`/`Random.date`/`Random.color`/`Random.pick`/`Random.sample`/`Random.weighted`/`Random.seed`; user asks "how do I generate a UUID / nanoid / random integer / pick a random element / seed randomness for tests / make a weighted random choice"; `import { Random } from "@mongez/reinforcements"`. > **Skip when:** cryptographic-strength randomness for security (use `crypto.randomBytes`, `crypto.subtle`); `nanoid`/`uuid` npm packages when not going through `@mongez/reinforcements`; seedable PRNGs for simulations needing different distributions (use a stats library). Namespace class — every method is `public static`. Not instantiable. ```ts import { Random, type RandomDateOptions, type WeightedItem } from "@mongez/reinforcements"; ``` ## Seeding (reproducible mode) ```ts Random.seed(seed?: number): void ``` Switches the RNG to a deterministic mulberry32 PRNG. Call with no args (or `undefined`) to restore `Math.random`. ```ts Random.seed(42); const a = Random.int(1, 1000); Random.seed(42); const b = Random.int(1, 1000); a === b; // true Random.seed(); // back to Math.random ``` Great for fixture-based tests: ```ts beforeEach(() => Random.seed(123)); afterEach(() => Random.seed()); ``` ## Primitives ```ts Random.int(min?: number, max?: number): number // default [1, 9999999], inclusive Random.float(min?: number, max?: number, precision?: number): number // default [0, 1) Random.bool(): boolean ``` ```ts Random.int(1, 10); // e.g. 7 Random.float(0, 1, 2); // e.g. 0.42 Random.bool(); // true or false ``` ## Strings & ids ```ts Random.string(length?: number): string // alphanumeric, default 32 Random.id(length?: number, startsWith?: string): string // default 6, "el-" Random.uuid(): string // RFC 4122 v4 (crypto.randomUUID when available) Random.nanoid(size?: number): string // URL-safe, default 21 Random.token(bytes?: number): string // crypto-backed hex, default 16 ``` ```ts Random.string(8); // e.g. "Xk2pQ9aZ" Random.id(); // e.g. "el-X4kP2a" Random.id(4, "user-"); // e.g. "user-q7Zw" Random.uuid(); // "0a8b40e1-d3ef-4d2e-87f4-1a8b40e1d3ef" Random.nanoid(10); // "rH3kQ_pX7a" Random.token(16); // 32-char hex ``` `uuid` / `token` use `crypto.randomUUID` / `crypto.getRandomValues` when available, falling back to the internal PRNG otherwise. **Use for ids only**, not for cryptography. ## Dates & colors ```ts Random.date(options?: RandomDateOptions): Date type RandomDateOptions = { min?: Date; max?: Date }; Random.color(): string // "#rrggbb", always 6 hex digits ``` ```ts Random.date({ min: new Date("2020-01-01"), max: new Date("2024-12-31") }); Random.color(); // e.g. "#1f3a8a" ``` ## Pick / sample / weighted ```ts Random.pick(array: readonly T[]): T | undefined Random.sample(array: readonly T[], n: number): T[] // n unique elements Random.weighted(items: readonly WeightedItem[]): T | undefined type WeightedItem = { value: T; weight: number }; ``` ```ts Random.pick(["a", "b", "c"]); // e.g. "b" Random.sample([1, 2, 3, 4, 5], 3); // e.g. [3, 1, 5] Random.weighted([ { value: "free", weight: 80 }, { value: "premium", weight: 19 }, { value: "vip", weight: 1 }, ]); // weighted choice ``` `sample` returns at most `array.length` items. Negative weights in `weighted` are clamped to `0`; if every weight is `0` returns `undefined`. ## Gotchas - `Random` is **not instantiable** — `new Random()` is a TS error. Use the static methods directly. - The legacy aliases `Random.integer` / `Random.boolean` from v2 are **removed**. Use `Random.int` / `Random.bool`. - Seeded mode persists until cleared — call `Random.seed()` (no args) in `afterEach` to avoid cross-test contamination. --- # Lazy > **Auto-trigger:** code imports `lazy`, `isLazy`, `Lazy`, or `LazyAsync` from `@mongez/reinforcements`, or calls `lazy.async()` / `lazy.from()`; user asks "how do I break a circular import / defer initialization / memoize a computed value lazily"; `import { lazy, isLazy } from "@mongez/reinforcements"`. > **Skip when:** React.lazy code-splitting (different concept — component dynamic imports); plain `memoize()` for pure-function caching (use @mongez/reinforcements-functions instead); one-shot init that just needs `once()` from the functions skill. Memoised deferred values. The flagship utility of the package — the unique tool for breaking ES-module circular imports. ```ts import { lazy, isLazy, type Lazy, type LazyAsync } from "@mongez/reinforcements"; ``` ## `lazy(producer)` ```ts lazy(producer: () => T): Lazy type Lazy = { resolve(): T; // compute (once) and return the cached value reset(): void; // drop the cache; next resolve() recomputes isResolved(): boolean; // has resolve() been called? peek(): T | undefined; // cached value without forcing computation }; ``` The producer **is not invoked** until the first `resolve()`. The result is then memoised. ```ts const config = lazy(() => loadHeavyConfig()); config.resolve(); // computes config.resolve(); // cached, no recomputation config.reset(); // forget cached value config.resolve(); // recomputes config.peek(); // returns cached value or undefined config.isResolved(); // true / false ``` ## Why it exists: circular imports JavaScript closures capture **variable bindings**, not values. When Module A imports Module B which imports Module A, the value that A wants from B might not be defined yet at module-init time. `lazy()` defers the reference resolution to call time. ```ts // In a module that creates a circular dep: const service = lazy(() => Service); // Service is undefined right now — fine export function handler() { return service.resolve().run(); // Service is guaranteed to exist by this point } ``` ## `lazy.async(producer)` ```ts lazy.async(producer: () => Promise): LazyAsync type LazyAsync = { resolve(): Promise; reset(): void; isResolved(): boolean; peek(): Promise | undefined; }; ``` Same shape as `lazy`, but the cached value is a `Promise`. ```ts const user = lazy.async(() => fetch("/api/me").then(r => r.json())); await user.resolve(); // fetches await user.resolve(); // returns the same cached promise — no refetch user.reset(); await user.resolve(); // refetches ``` ## `lazy.from(value)` ```ts lazy.from(value: T): Lazy ``` Pre-resolved lazy — for tests or for API symmetry where a `Lazy` is expected but the value is already known. ```ts const ref = lazy.from(42); ref.resolve(); // 42 ref.isResolved(); // true ref.reset(); // no-op for pre-resolved lazies ``` ## `isLazy(value)` ```ts isLazy(value: unknown): value is Lazy ``` Type guard. Returns `true` for both `lazy()` and `lazy.async()` results. ```ts if (isLazy(value)) { value.resolve(); // typed as number } ``` ## Idioms **Resetting on config reload:** ```ts const cachedConfig = lazy(() => parseConfigFile()); watchConfigFile(() => { cachedConfig.reset(); }); export function getSetting(key: string) { return cachedConfig.resolve()[key]; // re-parses on first call after reload } ``` **Disambiguating "not yet resolved" from "resolved to undefined":** ```ts const maybe = lazy(() => undefined); maybe.isResolved(); // false maybe.peek(); // undefined maybe.resolve(); maybe.isResolved(); // true maybe.peek(); // undefined ← but isResolved() distinguishes the cases ``` **Error retry:** producers that throw are not memoised; the next `resolve()` re-invokes the producer. --- # Function utilities > **Auto-trigger:** code imports `debounce`, `throttle`, `memoize`, `once`, `after`, `before`, `pipe`, `compose`, `tap`, `curry`, `partial`, `partialRight`, `noop`, `identity`, `constant`, `negate`, or `escapeRegex` from `@mongez/reinforcements`; user asks "how do I debounce / throttle / memoize / cache / pipe / curry a function"; `import { debounce, throttle, memoize, pipe } from "@mongez/reinforcements"`. > **Skip when:** @mongez/reinforcements-async handles promise-returning rate limiting (`debounceAsync`, `retry`, `timeout`) — use that skill for async equivalents; lodash/ramda equivalents without `@mongez/reinforcements` imports; React `useCallback`/`useMemo` hooks (component-scoped, not these utilities). Rate-limiting, memoization, composition, currying, tiny FP primitives. ```ts import { debounce, throttle, memoize, once, after, before, pipe, compose, tap, curry, partial, partialRight, noop, identity, constant, negate, escapeRegex, } from "@mongez/reinforcements"; ``` ## Rate-limiting #### `debounce` ```ts debounce(fn: T, wait: number, options?: { leading?: boolean; // default false trailing?: boolean; // default true maxWait?: number; // force invocation at this cap, even if input keeps coming }): Debounced type Debounced = T & { cancel(): void; // drop any pending invocation flush(): void; // invoke the pending call immediately pending(): boolean; }; ``` ```ts const save = debounce(payload => api.save(payload), 500, { maxWait: 3000 }); input.addEventListener("input", e => save(e.target.value)); button.addEventListener("click", () => save.flush()); window.addEventListener("beforeunload", () => save.cancel()); ``` #### `throttle` ```ts throttle(fn: T, wait: number, options?: { leading?: boolean; // default true trailing?: boolean; // default true }): Throttled type Throttled = T & { cancel(): void; flush(): void; pending(): boolean }; ``` ```ts const onScroll = throttle(() => layout(), 100); window.addEventListener("scroll", onScroll); ``` ## Caching #### `memoize` ```ts memoize(fn: T, options?: { resolver?: (...args: Parameters) => string; // default JSON.stringify(args) ttl?: number; // ms; default Infinity }): Memoized type Memoized = T & { clear(): void; forget(key: string): void }; ``` ```ts const lookup = memoize((id: string) => db.users.find(id), { ttl: 60_000 }); lookup("u1"); // hits DB lookup("u1"); // cached lookup.forget("u1"); // surgical invalidation lookup.clear(); // nuke everything ``` Custom key: ```ts const cached = memoize( (a: User, b: User) => similarity(a, b), { resolver: (a, b) => `${a.id}-${b.id}` }, ); ``` ## Call-count gating ```ts once(fn: T): T // run once, cache result forever after(n: number, fn: T): (...args) => R | undefined // only invokes after N-th call before(n: number, fn: T): (...args) => R | undefined // up to N times; later calls return last result ``` ```ts const init = once(() => expensiveSetup()); init(); init(); init(); // expensiveSetup runs once const onAllDone = after(3, () => render()); onAllDone(); onAllDone(); onAllDone(); // render() on the third call const tryConnect = before(3, () => connect()); // up to 3 actual connect() calls; further calls return the last result ``` ## Composition #### `pipe` / `compose` ```ts pipe
(value: A): A pipe(value: A, fn1: (a: A) => B): B pipe(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C // …up to 4 typed steps; variadic fallback after that compose(fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C // right-to-left ``` ```ts pipe(2, n => n + 1, n => n * 10); // 30 const shout = compose( (s: string) => s + "!", (s: string) => s.toUpperCase(), ); shout("hi"); // "HI!" ``` #### `tap` / `tap.with` ```ts tap(value: T, sideEffect: (value: T) => void): T tap.with(sideEffect: (value: T) => void): (value: T) => T ``` Side-effect probe. `tap.with` returns a pipeline-friendly identity-with-side-effect. ```ts pipe(value, trim, tap.with(console.log), toSnakeCase); ``` ## Currying & partial application ```ts curry(fn: (...args) => R): any partial(fn: T, ...preset): (...rest) => ReturnType partialRight(fn: T, ...preset): (...rest) => ReturnType ``` ```ts const add = curry((a: number, b: number, c: number) => a + b + c); add(1)(2)(3); // 6 add(1, 2)(3); // 6 add(1, 2, 3); // 6 const greet = (greeting: string, name: string) => `${greeting}, ${name}`; const hello = partial(greet, "Hello"); hello("Ada"); // "Hello, Ada" const divide = (a: number, b: number) => a / b; const halve = partialRight(divide, 2); halve(10); // 5 ``` ## Tiny FP primitives ```ts noop(): void // does nothing identity(value: T): T // returns argument constant(value: T): () => T // returns a function that always yields value negate(predicate: T): (...args) => boolean // inverts a predicate ``` ```ts items.filter(identity); // drop falsy values events.on("data", noop); // explicitly ignore const always42 = constant(42); const isOdd = negate((n: number) => n % 2 === 0); ``` ## `escapeRegex` ```ts escapeRegex(string: string): string ``` Escape regex meta-characters so a literal string can be used in a `RegExp`. ```ts new RegExp(escapeRegex("a.b+c")); // matches the literal "a.b+c" ``` --- # Async > **Auto-trigger:** code imports `sleep`, `retry`, `timeout`, `pProps`, `pAll`, `pAllSettled`, `pMap`, `pSeries`, `pFilter`, `defer`, or `debounceAsync` from `@mongez/reinforcements`; user asks "how do I retry with backoff / race a promise against a timeout / limit concurrency / debounce an async call"; `import { sleep, retry, timeout, pMap, defer } from "@mongez/reinforcements"`. > **Skip when:** @mongez/reinforcements-functions handles sync `debounce`/`throttle`/`memoize` — use this skill only for promise-returning helpers; raw `Promise.all`/`Promise.race` usage without `@mongez/reinforcements` imports; HTTP client features (fetch wrappers, interceptors) belong to a request library, not this package. Promise-based control flow: sleep, retry, timeout, bounded concurrent map/filter/series, defer, async debounce. ```ts import { sleep, retry, retryable, timeout, pAll, pAllSettled, pMap, pSeries, pFilter, defer, debounceAsync, } from "@mongez/reinforcements"; ``` ## `sleep` ```ts sleep(ms: number): Promise sleep(ms: number, value: T): Promise ``` ```ts await sleep(100); const ready = await sleep(50, "ok"); // "ok" ``` ## `retry` ```ts retry(fn: () => Promise | T, options?: { attempts?: number; // default 3 delay?: number; // base ms; default 0 backoff?: "linear" | "exponential" // default "linear" | ((attempt: number, baseDelay: number) => number); // or a custom fn maxDelay?: number; // ceiling on the computed delay jitter?: boolean | "full" | "equal"; // spread delays; default false onError?: (error: unknown, attempt: number) => void; // observe (1-based attempt) shouldRetry?: (error: unknown, attempt: number) // decide; false = stop now => boolean | Promise; signal?: AbortSignal; // cancel between/during attempts }): Promise ``` Throws the **last** error if all attempts fail. All options are optional and default to the original behaviour — no breaking change. ```ts const data = await retry(() => fetchUser(id), { attempts: 5, delay: 200, backoff: "exponential", // 200, 400, 800, 1600 ms between attempts onError: (err, attempt) => log(`attempt ${attempt} failed`, err), }); // Bail out on non-retryable errors (observe with onError, decide with shouldRetry): await retry(() => placeOrder(input), { attempts: 3, delay: 500, shouldRetry: err => !(err instanceof ValidationError), // don't retry 4xx }); // Avoid thundering herd + cap the wait + cancel: await retry(() => fetch(url), { attempts: 6, delay: 100, backoff: "exponential", maxDelay: 2_000, // never wait more than 2s as backoff grows jitter: "full", // randomise each delay across [0, computed] signal: controller.signal, }); ``` `jitter: "full"` (or `true`) → `random(0, delay)`; `"equal"` → `delay/2 + random(0, delay/2)`. Jitter draws from the seedable `Random`, so `Random.seed(n)` makes the schedule reproducible. With a `signal`, a pending delay is raced against the abort so cancellation resolves promptly. Tip: `exponential` + many `attempts` with no `maxDelay` produces very long waits — set `maxDelay`. ## `retryable` ```ts retryable(fn: (...args: A) => Promise | T, options?: RetryOptions): (...args: A) => Promise ``` Pre-bind retry options to a function, returning a reusable wrapper so you don't re-pass options at every call site. ```ts const fetchUser = retryable(getUser, { attempts: 4, backoff: "exponential" }); await fetchUser(id); ``` ## `timeout` ```ts timeout(promise: Promise, ms: number, message?: string): Promise ``` Races `promise` against a timer; rejects with `new Error(message)` if the timer wins. ```ts const result = await timeout(fetch(url), 5_000, "Request too slow"); ``` Combines well with `retry`: ```ts await retry( () => timeout(fetch(url), 3_000), { attempts: 3, delay: 500, backoff: "exponential" }, ); ``` ## `pProps` — parallel object destructuring ```ts pProps>( object: T, ): Promise<{ [K in keyof T]: Awaited }> ``` Run an object's worth of promises in parallel and resolve to an object with the same keys but unwrapped values. Modelled on Bluebird's `Promise.props`. Non-promise values pass through unchanged. ```ts const { user, settings, home } = await pProps({ user: getUserFromDB(), settings: loadSettingsAsync(), home: getHome(), }); // Mixed promises and plain values are fine: await pProps({ a: 1, b: Promise.resolve(2) }); // { a: 1, b: 2 } ``` Rejects on the first rejected promise (same semantics as `Promise.all`). ## `pAll` / `pAllSettled` ```ts pAll( promises: readonly [...{ [K in keyof T]: T[K] | Promise }], ): Promise pAllSettled( promises: readonly [...{ [K in keyof T]: T[K] | Promise }], ): Promise<{ [K in keyof T]: PromiseSettledResult }> ``` Typed tuple-preserving wrappers around `Promise.all` / `Promise.allSettled`. ```ts const [user, posts] = await pAll([fetchUser(), fetchPosts()]); // user: User, posts: Post[] ``` ## Bounded concurrency #### `pMap` ```ts pMap( items: readonly T[], mapper: (item: T, index: number) => Promise | U, options?: { concurrency?: number; // default Infinity stopOnError?: boolean; // default true }, ): Promise ``` Preserves input order in the output. With `stopOnError: false`, every error is collected and the first one is thrown after all items complete. ```ts const docs = await pMap(urls, fetch, { concurrency: 5 }); ``` #### `pSeries` ```ts pSeries(items: readonly T[], mapper: (item, index) => Promise | U): Promise ``` Strictly sequential. Stops at the first thrown error. ```ts await pSeries(migrations, m => runMigration(m)); ``` #### `pFilter` ```ts pFilter( items: readonly T[], predicate: (item: T, index: number) => Promise | boolean, options?: { concurrency?: number }, ): Promise ``` ```ts const reachable = await pFilter(urls, u => ping(u), { concurrency: 4 }); ``` ## `defer` ```ts defer(): { promise: Promise; resolve: (value: T | PromiseLike) => void; reject: (reason?: unknown) => void; } ``` Externally resolvable promise — useful for bridging callback APIs. ```ts function whenReady() { const d = defer(); emitter.once("ready", () => d.resolve()); emitter.once("error", err => d.reject(err)); return d.promise; } ``` ## `debounceAsync` ```ts debounceAsync Promise>( fn: T, wait: number, ): (...args: Parameters) => Promise>> ``` Async-aware debounce: every call returns a promise; bursts collapse into a single `fn` invocation; **all the burst's promises resolve with the same final result**. ```ts const search = debounceAsync( (q: string) => fetch(`/search?q=${q}`).then(r => r.json()), 250, ); const a = search("a"); const b = search("ab"); const c = search("abc"); // 250ms later: one fetch("abc"); a, b, c all resolve with that result ``` --- # Arrays > **Auto-trigger:** code imports `chunk`, `range`, `unique`, `pluck`, `groupBy`, `countBy`, `count`, `sum`, `average`, `avg`, `median`, `min`, `max`, `even`, `odd`, `evenIndexes`, `oddIndexes`, `pushUnique`, or `unshiftUnique` from `@mongez/reinforcements`; user asks "how do I dedupe / chunk / group / count / sum / average / pluck from an array"; file does aggregation or reshaping over object arrays. > **Skip when:** richer collection ops (`partition`, `keyBy`, `sortBy`, `intersection`, `where`, pagination) — use `mongez-collection-*` skills instead; plain native `Array` methods without `@mongez/reinforcements` imports; React-specific list rendering or virtualization. Lightweight array helpers. For richer collection operations (`partition`, `keyBy`, `sortBy`, `intersection`, …), use **`@mongez/collections`**. ```ts import { chunk, range, unique, pluck, groupBy, countBy, count, sum, average, avg, median, min, max, even, odd, evenIndexes, oddIndexes, pushUnique, unshiftUnique, } from "@mongez/reinforcements"; ``` ## Reshaping ```ts chunk(array: T[] | string, size: number): T[][] range(min: number, max: number): number[] unique(array: T[], key?: string): T[] ``` ```ts chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]] chunk("abcdef", 2); // [["a","b"], ["c","d"], ["e","f"]] range(1, 5); // [1, 2, 3, 4, 5] unique([1, 1, 2, 3, 3]); // [1, 2, 3] unique([{ id: 1 }, { id: 1 }, { id: 2 }], "id"); // [1, 2] — plucked ``` ## Plucking / projection ```ts pluck(array: any[], key?: string | string[]): any[] ``` ```ts pluck([{ name: "Ada" }, { name: "Bob" }], "name"); // ["Ada", "Bob"] pluck([{ a: 1, b: 2 }, { a: 3, b: 4 }], ["a"]); // [{ a: 1 }, { a: 3 }] ``` Dot-notation paths work: `pluck(users, "address.city")`. ## Grouping & counting ```ts groupBy(array: object[], key: string | string[], listAs?: string): object[] countBy(array: any[], key: string): Record count(array: any[], key: string | ((item) => boolean)): number ``` ```ts groupBy(students, "class"); // [{ class: "A", data: [...] }, ...] groupBy(students, ["class", "grade"]); // multi-key grouping groupBy(students, "class", "items"); // rename "data" → "items" countBy([{ type: "a" }, { type: "b" }, { type: "a" }], "type"); // { a: 2, b: 1 } count(items, "name"); // count items with a defined "name" path count(items, item => item.active); // count by predicate ``` ## Stats ```ts sum(array: any[], key?: string): number average(array: any[], key?: string): number // alias: avg median(array: any[], key?: string): number min(array: any[], key?: string): number max(array: any[], key?: string): number ``` All accept an optional dot-notation key for arrays of objects. ```ts sum([1, 2, 3]); // 6 sum(orders, "total.price"); // sum of total.price across orders average([2, 4, 6]); // 4 median([1, 2, 3, 4]); // 2.5 min(users, "age"); // smallest age max(users, "age"); // largest age ``` ## Parity filters ```ts even(array: any[], key?: string): any[] odd(array: any[], key?: string): any[] evenIndexes(array: T[]): T[] // elements at indices 0, 2, 4, … oddIndexes(array: T[]): T[] // elements at indices 1, 3, 5, … ``` ```ts even([1, 2, 3, 4]); // [2, 4] odd([1, 2, 3, 4]); // [1, 3] even(items, "age"); // items whose age is even evenIndexes(["a", "b", "c", "d"]); // ["a", "c"] oddIndexes(["a", "b", "c", "d"]); // ["b", "d"] ``` ## Mutating uniques ```ts pushUnique(array: T[], ...items: T[]): T[] // push only if not already present unshiftUnique(array: T[], ...items: T[]): T[] // unshift only if not already present ``` ```ts const arr = [1, 2]; pushUnique(arr, 2, 3); // [1, 2, 3] — 2 was skipped unshiftUnique(arr, 0, 1); // [0, 1, 2, 3] — 1 was skipped ``` > These mutate the input array (they return the same reference). Use them when you have a stable array reference you want to grow without dupes. --- # Types > **Auto-trigger:** code imports `Path`, `PathValue`, `DeepPartial`, `DeepRequired`, `DeepReadonly`, `DeepMutable`, `Prettify`, `UnionToIntersection`, `Branded`, `Nullable`, `Maybe`, `Awaitable`, `NonEmptyArray`, `GenericObject`, `AlphaNumeric`, or `Primitive` as a `type` from `@mongez/reinforcements`; user asks "how do I type a dot-notation path / make all properties recursively optional / create a branded nominal type / flatten an intersection in hover / convert a union to an intersection"; `import type { Path, PathValue, DeepPartial } from "@mongez/reinforcements"`. > **Skip when:** runtime utilities (`get`, `pick`, `omit`, etc.) — use @mongez/reinforcements-objects for those; `type-fest` or other third-party type-utility libraries when not going through `@mongez/reinforcements`; Zod/Valibot runtime schema definitions (different layer). TypeScript-only exports — zero runtime cost. Import as types: ```ts import type { Path, PathValue, DeepPartial, DeepRequired, DeepReadonly, DeepMutable, Prettify, UnionToIntersection, Branded, Nullable, Maybe, Awaitable, NonEmptyArray, GenericObject, AlphaNumeric, Primitive, } from "@mongez/reinforcements"; ``` ## Dot-notation paths ```ts type Path // union of every legal dot-notation path type PathValue // resolved value type at path P ``` ```ts type User = { id: number; profile: { email: string; addresses: { city: string }[] }; }; type UserPath = Path; // "id" | "profile" | "profile.email" | "profile.addresses" | "profile.addresses.${number}" | ... type Email = PathValue; // string type City = PathValue; // string ``` Used internally to type `get(obj, path)` overloads. ## Recursive transforms ```ts type DeepPartial // all properties (recursively) optional type DeepRequired // all properties (recursively) required type DeepReadonly // all properties (recursively) readonly type DeepMutable // strips readonly recursively ``` Each preserves primitives, `Date`, `RegExp`, `Function`, `Map`, and `Set` rather than recursing into them. ```ts type Config = { api: { url: string; retries: number } }; type DraftConfig = DeepPartial; // { api?: { url?: string; retries?: number } } type FrozenConfig = DeepReadonly; // { readonly api: { readonly url: string; readonly retries: number } } ``` ## Display & combinators ```ts type Prettify // flatten intersections in hover tooltips type UnionToIntersection // U union → I intersection (variadic typings) ``` ```ts type Combined = Prettify<{ a: 1 } & { b: 2 }>; // hovers as { a: 1; b: 2 } instead of { a: 1 } & { b: 2 } ``` ## Nominal types ```ts type Branded = T & { readonly __brand: B } ``` Distinguish lookalike primitives at the type level. ```ts type UserId = Branded; type Email = Branded; declare function getUser(id: UserId): User; const id = "abc" as UserId; getUser(id); // ok getUser("abc"); // ❌ TS error — string is not assignable to UserId ``` ## Common aliases ```ts type Nullable = T | null type Maybe = T | null | undefined type Awaitable = T | Promise type NonEmptyArray = [T, ...T[]] type GenericObject = Record type AlphaNumeric = string | number type Primitive = AlphaNumeric | boolean ``` ```ts function init(input: Awaitable): Promise { ... } function first(arr: NonEmptyArray): T { return arr[0]; // safe, no `| undefined` } ``` --- # Recipes > **Auto-trigger:** code combines two or more `@mongez/reinforcements` imports (e.g. `retry` + `timeout` + `pMap`, `debounce` + `flush`, `walk` + `diff`, `mask` + `pick`, `slugify` + `truncate` + `pipe`); user asks "how do I build resilient HTTP retries / auto-save with cancel / detect object changes / write PII-safe logs / make a tokenization pipeline / break a circular import with lazy"; user wants an end-to-end worked example, not a single-function reference. > **Skip when:** single-function lookups — load the matching per-namespace skill (objects/strings/numbers/async/functions/lazy/random) for one specific utility; questions about non-@mongez/reinforcements libraries even if the recipe shape looks similar. Cross-namespace compositions for common real-world tasks. ## Typed dot-notation reads / writes ```ts import { get, set, has } from "@mongez/reinforcements"; type User = { id: number; profile: { email: string } }; const u: User = { id: 1, profile: { email: "ada@x.com" } }; get(u, "profile.email"); // typed string get(u, "profile.email", "n/a"); // typed string has(u, "profile.email"); // true set(u, "profile.country", "EG"); ``` ## Resilient HTTP — `retry` + `timeout` + `pMap` ```ts import { retry, timeout, pMap } from "@mongez/reinforcements"; const safeFetch = (id: string) => retry(() => timeout(fetch(`/users/${id}`).then(r => r.json()), 3_000), { attempts: 5, delay: 200, backoff: "exponential", }); const users = await pMap(userIds, safeFetch, { concurrency: 5 }); ``` ## Form submission with cancel & flush ```ts import { debounce } from "@mongez/reinforcements"; const autoSave = debounce(payload => api.save(payload), 500, { maxWait: 3_000 }); input.addEventListener("input", e => autoSave(e.target.value)); button.addEventListener("click", () => autoSave.flush()); // "Save Now" window.addEventListener("beforeunload", () => autoSave.cancel()); ``` ## Cached, time-bounded lookups ```ts import { memoize } from "@mongez/reinforcements"; const lookupUser = memoize(id => db.users.findById(id), { ttl: 60_000 }); await lookupUser("u1"); // hits DB await lookupUser("u1"); // cache (within 60s) lookupUser.forget("u1"); // on update ``` ## Object change detection — `walk` + `diff` ```ts import { walk, diff } from "@mongez/reinforcements"; walk(config, (value, path) => logger.debug(path, value)); const changes = diff(prevState, nextState); for (const key of Object.keys(changes.changed)) { audit.log(`${key}: ${changes.changed[key].from} → ${changes.changed[key].to}`); } ``` ## Reproducible randomness in tests ```ts import { Random } from "@mongez/reinforcements"; beforeEach(() => Random.seed(42)); afterEach(() => Random.seed()); test("picks deterministic sample", () => { expect(Random.sample([1, 2, 3, 4, 5], 3)).toEqual(/* same every run */); }); ``` ## Break a circular import with `lazy` ```ts // a.ts import { lazy } from "@mongez/reinforcements"; import { B } from "./b"; // b.ts imports a.ts — circular const b = lazy(() => B); // capture binding, defer access export const A = { callB: () => b.resolve().run(), }; ``` ## Slug + truncate for URL summaries ```ts import { slugify, truncate, pipe } from "@mongez/reinforcements"; const urlSummary = (title: string) => pipe(title, t => truncate(t, 60, { byWord: true }), t => slugify(t)); urlSummary("How to break circular ES module imports without crying!"); // "how-to-break-circular-es-module-imports-without" ``` ## PII-safe logging ```ts import { mask, pick } from "@mongez/reinforcements"; function logUser(user: { id: string; email: string; phone: string }) { logger.info( pick({ ...user, email: mask(user.email, { start: 2, end: 4 }), phone: mask(user.phone, { start: 4, end: 2 }) }, ["id", "email", "phone"]), ); } ``` ## Pipeline with side-effect logging ```ts import { pipe, tap, trim, toSnakeCase } from "@mongez/reinforcements"; const tokenize = (input: string) => pipe( input, s => trim(s), tap.with(s => log("trimmed:", s)), toSnakeCase, ); ``` ## Deep merge with array union ```ts import { merge } from "@mongez/reinforcements"; const config = merge( defaults, userConfig, envConfig, { arrays: "union" }, // de-dupe across all sources ); ``` ## Templated email body ```ts import { template } from "@mongez/reinforcements"; const body = template( "Hi {user.name}, you have {count} {kind} waiting.", { user: { name: "Ada" }, count: 3, kind: "messages" }, ); // "Hi Ada, you have 3 messages waiting." ``` ## Form input names from a schema path ```ts import { toInputName, flatten } from "@mongez/reinforcements"; const schema = { user: { address: { city: "" }, tags: [] as string[] } }; Object.keys(flatten(schema)).map(toInputName); // [ "user[address][city]", "user[tags][0]" ] ``` ## Defer + retry — bridge a callback API ```ts import { defer, retry } from "@mongez/reinforcements"; const connect = () => { const d = defer(); legacy.connect((err, conn) => (err ? d.reject(err) : d.resolve(conn))); return d.promise; }; const conn = await retry(connect, { attempts: 3, delay: 500, backoff: "exponential" }); ``` ## Burst-tolerant search ```ts import { debounceAsync } from "@mongez/reinforcements"; const search = debounceAsync( (q: string) => fetch(`/search?q=${q}`).then(r => r.json()), 250, ); // User types fast: "a", "ad", "ada" // Only the final query is fetched; all three awaiters get the same result. ``` ## Reset-on-config-reload ```ts import { lazy } from "@mongez/reinforcements"; const settings = lazy(() => parseConfigFile()); configWatcher.on("change", () => settings.reset()); export const get = (key: string) => settings.resolve()[key]; ``` ## Coalesce defaults without `||` falsy bugs ```ts import { coalesce } from "@mongez/reinforcements"; const port = coalesce(input.port, env.PORT, 3000); // input.port = 0 → uses 0 (not 3000) // input.port = undefined → falls through ``` ## Clean an API payload before sending ```ts import { compact } from "@mongez/reinforcements"; const payload = compact({ name: form.name, email: form.email, // possibly "" phone: form.phone, // possibly null preferences: form.prefs, // possibly {} age: form.age, // 0 is valid, kept }); await api.post("/users", payload); ``` ## Parallel destructuring with `pProps` ```ts import { pProps } from "@mongez/reinforcements"; const { user, settings, home, notifications } = await pProps({ user: getUserFromDB(userId), settings: loadSettingsAsync(userId), home: getHomeFeed(userId), notifications: getUnreadCount(userId), }); // All four requests run in parallel; one rejection rejects the whole thing. ``` --- # @mongez/supportive-is # @mongez/supportive-is — full reference > Tree-shakable type & shape predicates. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/supportive-is ``` ## Public exports ```ts import { // Primitives & numbers isString, isNumeric, isInt, isFloat, isPrimitive, isScalar, // Collections & shape isObject, isPlainObject, isIterable, isEmpty, // Formats isRegex, isValidId, isJson, isUrl, isEmail, // Object kinds isPromise, isDate, isGenerator, isFormElement, isFormData, // Environment isMobile, isDesktop, isMac, isBrowser, isChrome, isFirefox, isSafari, isOpera, isIE, isEdge, // Legacy default export Is, } from "@mongez/supportive-is"; import Is from "@mongez/supportive-is"; // alternative legacy import ``` ## Primitives & numbers > **Auto-trigger:** code imports `isString`, `isNumeric`, `isInt`, `isFloat`, `isPrimitive`, or `isScalar` from `@mongez/supportive-is`; user asks "how do I check string / number / int / float", "detect a numeric string like '12'", "difference between isPrimitive and isScalar", or "type-narrow a string in TS"; typical import is `import { isString, isNumeric } from "@mongez/supportive-is"` (or `Is.string(x)` / `Is.numeric(x)` via the legacy default `Is` namespace). > **Skip when:** native `typeof v === "string"` or `Number.isInteger(v)` checks where you don't need this package — those are correct and avoid this package's known `isInt`/`isFloat` negative-number bugs; `@mongez/reinforcements` for string/number transformations (not predicates); schema validators. ### `isString(value)` `typeof value === "string"`. Returns `false` for `new String("x")` (which is `typeof "object"`). ```ts isString(""); // true isString("hello"); // true isString(`x${1}`); // true isString(new String("x")); // false (wrapper object) isString(null); // false isString(undefined); // false ``` ### `isNumeric(value)` Numeric-like check that accepts numbers and numeric strings. Matches sign, decimals, and scientific notation. ```ts isNumeric(0); // true isNumeric(-1.5); // true isNumeric("12"); // true isNumeric("+1"); // true isNumeric("1.5E-3"); // true isNumeric(""); // false isNumeric("1."); // false isNumeric("12abc"); // false isNumeric(null); // false isNumeric(undefined); // false ``` > **BUG (`src/index.ts:10`)**: the regex carries the `/g` flag. `RegExp.prototype.test` advances `lastIndex` between calls, so repeated calls on the same input alternate `true` / `false`. Remove the `g` flag or reset `lastIndex` per call. ### `isInt(value)` Integer number. Requires `typeof value === "number"`. ```ts isInt(0); // true isInt(1); // true isInt(1.0); // true (stringifies to "1") isInt(Number.MAX_SAFE_INTEGER); // true isInt(1.5); // false isInt("2"); // false isInt(NaN); // false isInt(Infinity); // false ``` > **BUGS (`src/index.ts:17`)**: > - Rejects negative integers — regex `/^\d+$/` has no sign branch (`isInt(-1)` returns `false`). > - Rejects integers that stringify in scientific notation — `String(1e21) === "1e+21"` doesn't match `/^\d+$/`. ### `isFloat(value)` Floating-point number with a fractional part. ```ts isFloat(1.5); // true isFloat(0.1); // true isFloat(1); // false isFloat(1.0); // false (stringifies to "1" — no decimal) isFloat("1.5"); // false (must be a number, not a string) isFloat(NaN); // false isFloat(Infinity);// false ``` > **BUGS (`src/index.ts:23`)**: > - Rejects negative floats (`isFloat(-1.5) === false`). > - Uses an unescaped `.` in the regex (`/^\d+.(\d+)$/`). Can't be exploited via stringified numbers, but the regex is wrong. ### `isPrimitive(value)` `string` / `number` / `boolean` / `bigint`. Excludes `Symbol`, `null`, and `undefined`. ```ts isPrimitive("hello"); // true isPrimitive(22.5); // true isPrimitive(false); // true isPrimitive(1n); // true isPrimitive(null); // false isPrimitive(undefined); // false isPrimitive(Symbol("s")); // false (intentional — use isScalar) isPrimitive([]); // false ``` ### `isScalar(value)` `string` / `number` / `boolean` / `bigint` / `symbol`. ```ts isScalar("hello"); // true isScalar(22.5); // true isScalar(false); // true isScalar(Symbol("s")); // true isScalar(1n); // true isScalar(null); // false isScalar(undefined); // false isScalar([]); // false isScalar({}); // false ``` ## Collections & shape > **Auto-trigger:** code imports `isObject`, `isPlainObject`, `isIterable`, `isEmpty`, or uses `Is.array` from `@mongez/supportive-is`; user asks "how do I check if an object is empty / a plain object / iterable", "why does isObject return null instead of false", or "how do I detect a class instance vs literal object"; typical import is `import { isEmpty, isPlainObject } from "@mongez/supportive-is"` (or legacy `import Is from "@mongez/supportive-is"; Is.empty(x)`). > **Skip when:** general-purpose array/object utilities (`get`, `set`, `clone`, `pluck`, `groupBy`) — use `mongez-reinforcements-*` skills, since this package is purely shape/type PREDICATES not transformations; schema validation via `zod`/`valibot`; native `Array.isArray`/`typeof` checks without this package imported. ### `isObject(value)` Truthy AND `typeof value === "object"`. Includes arrays, dates, regexes, and class instances. Excludes `null` and functions. ```ts isObject({}); // true isObject([]); // true isObject(new Date()); // true isObject(/x/); // true isObject(null); // falsy (returns null — see "Non-boolean falsy returns") isObject(undefined); // falsy (returns undefined) isObject(0); // falsy (returns 0) isObject(""); // falsy (returns "") isObject("hello"); // false isObject(() => {}); // false ``` To check "object but not array": `isObject(x) && !Array.isArray(x)`. ### `isPlainObject(value)` `value.constructor.name === "Object"`. Returns `true` only for `{}` and `new Object()`. ```ts isPlainObject({}); // true isPlainObject({ a: 1 }); // true isPlainObject(new Object()); // true isPlainObject([]); // false isPlainObject(new Date()); // false isPlainObject(new class {}); // false ``` > **BUG (`src/index.ts:39`)**: throws on `Object.create(null)` because `value.constructor` is `undefined`. Add an optional chain. ### `Is.array(value)` Alias for `Array.isArray`. ```ts Is.array([]); // true Is.array([1, 2, 3]); // true Is.array(new Set()); // false Is.array({ length: 0 }); // false Is.array(null); // false ``` ### `isIterable(value)` Implements `Symbol.iterator`. ```ts isIterable([]); // true isIterable("hello"); // true isIterable(new Set()); // true isIterable(new Map()); // true isIterable({ *[Symbol.iterator]() { yield 1; } }); // true isIterable({}); // falsy isIterable(null); // falsy isIterable(123); // falsy ``` > **BUG (`src/index.ts:64`)**: returns the empty string instead of `true` for `""` — `value && …` short-circuits on the falsy empty string. The empty string IS iterable (just yields nothing). ### `isEmpty(value)` Smart emptiness check. Branches: 1. `0`, `true`, `false` → `false` (real values). 2. `""`, `null`, `undefined` → `true`. 3. `Map` or `Set` → `size === 0`. 4. Iterable → `length === 0`. 5. Numeric (`isNumeric`) → `false`. 6. Otherwise → `true`. ```ts isEmpty(""); // true isEmpty([]); // true isEmpty({}); // true isEmpty(new Map()); // true isEmpty(new Set()); // true isEmpty(null); // true isEmpty(undefined); // true isEmpty(0); // false (zero is a real value) isEmpty(false); // false isEmpty("0"); // false isEmpty(" "); // false isEmpty([0]); // false isEmpty(1); // false ``` > **BUGS (`src/index.ts:110`)**: > - `isEmpty({ a: 1 })` returns `true` (no `Object.keys.length` fallback). > - `isEmpty(new Date())` returns `true` (Date has no iterator and isn't numeric). > - `isEmpty(NaN)` returns `true` (NaN doesn't match the numeric regex). ## Formats > **Auto-trigger:** code imports `isRegex`, `isValidId`, `isJson`, `isUrl`, or `isEmail` from `@mongez/supportive-is`; user asks "how do I validate URL / email / JSON / HTML id", "check if string is a regex or pattern", or "tell if a value looks like valid JSON"; typical import is `import { isUrl, isEmail } from "@mongez/supportive-is"` (or `Is.url(x)` / `Is.email(x)` via the legacy default `Is` namespace). > **Skip when:** trustworthy validation for auth, redirects, or stored data — use `zod`, `valibot`, or RFC-grade libraries instead since these are convenience filters not security gates; schema-based form validation libraries; `@mongez/reinforcements` for general string transforms, not predicates. ### `isRegex(value)` Constructor-name check (`"RegExp"`). ```ts isRegex(/x/); // true isRegex(new RegExp("x")); // true isRegex("/x/"); // false isRegex(null); // falsy ``` ### `isValidId(value)` / `Is.validHtmlId` Valid HTML `id` (matches `/^[A-Za-z]+[\w\-:.]*$/`). Wrapped in `Boolean(...)` so falsy inputs return real `false`. ```ts isValidId("base-id"); // true isValidId("has.dots"); // true isValidId("has:colon"); // true isValidId("has_underscore"); // true isValidId("1starts-with-digit"); // false isValidId("_starts-with-underscore"); // false isValidId("has,comma"); // false isValidId(null); // false isValidId(undefined); // false ``` ### `isJson(value)` Valid JSON string starting with `{` or `[`. ```ts isJson('{"name":"John"}'); // true isJson("[1,2,3]"); // true isJson('{"nested":{"a":1}}'); // true isJson(""); // false isJson("12"); // false (numeric JSON, but prefix check rejects) isJson('"hello"'); // false (string JSON, same reason) isJson("null"); // false isJson("{name:1}");// false (unquoted key) isJson(null); // false isJson({}); // false (not a string) ``` ### `isUrl(value)` Valid `http://` / `https://` URL with a dotted hostname. ```ts isUrl("https://google.com"); // true isUrl("http://www.example.com:8080"); // true isUrl("https://sub.example.co.uk"); // true isUrl("google.com"); // false (no scheme) isUrl("ftp://example.com"); // false (wrong scheme) isUrl("file:///etc/passwd"); // false isUrl(""); // false isUrl(null); // false isUrl(undefined); // false ``` > **BUGS (`src/index.ts:149`)**: > - `isUrl("https://google.")` returns `true` — the hostname check (`length > indexOf(".")`) doesn't require a non-empty TLD label. > - `isUrl("https://google..com")` returns `true` — empty labels aren't rejected. ### `isEmail(value)` Standard email regex. ```ts isEmail("user@example.com"); // true isEmail("a.b.c@example.co.uk"); // true isEmail("u+tag@example.com"); // true isEmail("user@"); // false isEmail("@example.com"); // false isEmail("a@b"); // false (no TLD) isEmail(""); // false isEmail(null); // false (regex coerces to "null") isEmail(undefined); // false ``` > **BUG (`src/index.ts:168`)**: matches single-element arrays (`isEmail(["x@y.com"]) === true`) because `RegExp.prototype.test` coerces with `String()` and `["x@y.com"].toString() === "x@y.com"`. Gate with `typeof value === "string"`. ## Object kinds > **Auto-trigger:** code imports `isPromise`, `isDate`, `isGenerator`, `isFormElement`, or `isFormData` from `@mongez/supportive-is`; user asks "how do I detect a Promise / Date / generator / form element / FormData", "is this thing a thenable", or "check Date vs string vs number"; typical import is `import { isPromise, isDate } from "@mongez/supportive-is"` (or `Is.promise(x)` / `Is.date(x)` / `Is.form(x)` via the legacy default `Is` namespace). > **Skip when:** thenable-protocol checks for non-native Promises (these are constructor-name checks that miss subclasses — use `value instanceof Promise` instead); date math or arithmetic — use `dayjs`/`date-fns`/`Temporal`, this only tells you "is it a Date instance"; `@mongez/reinforcements` general utilities. ### `isPromise(value)` Native `Promise` instance (constructor-name check). ```ts isPromise(Promise.resolve()); // true isPromise(new Promise((r) => r(1))); // true isPromise({ then() {} }); // false (thenable, not Promise) isPromise(123); // false isPromise(null); // falsy ``` > **BUG (`src/index.ts:87`)**: doesn't recognize subclasses (`class MyPromise extends Promise`). Use `value instanceof Promise` for a proper check. ### `isDate(value)` `Date` instance (any constructor result, including invalid Dates). ```ts isDate(new Date()); // true isDate(new Date(0)); // true isDate(new Date("not real")); // true (invalid, but still a Date) isDate("2024-01-01"); // false (string) isDate(Date.now()); // false (number) isDate(null); // falsy ``` ### `isGenerator(value)` Constructor-name check (`"GeneratorFunction"`). ```ts function* gen() { yield 1; } isGenerator(gen); // depends on the runtime — see BUG below isGenerator(gen()); // false in V8 (see BUG) isGenerator({}); // false isGenerator(() => {}); // false ``` > **BUG (`src/index.ts:99`)**: generator INSTANCES (the result of invoking a generator function) report `constructor.name === ""` in V8 — not `"GeneratorFunction"`. The canonical detection is `Object.prototype.toString.call(value) === "[object Generator]"`. ### `isFormElement(value)` / `Is.form` `HTMLFormElement` instance. Guards on `typeof HTMLFormElement === "undefined"` so it doesn't crash on the server when the global isn't defined. ```ts isFormElement(document.createElement("form")); // true isFormElement(document.createElement("div")); // false isFormElement({}); // false ``` ### `isFormData(value)` `FormData` instance. ```ts isFormData(new FormData()); // true isFormData({}); // false ``` ## Environment / DOM > **Auto-trigger:** code imports `isMobile`, `isMac`, `isDesktop`, `isBrowser`, `isChrome`, `isFirefox`, `isSafari`, `isOpera`, `isIE`, or `isEdge` from `@mongez/supportive-is`; user asks "how do I detect mobile / iOS / Android", "Cmd vs Ctrl on Mac", "browser sniff for Safari/Chrome/Firefox", or "is this code running in browser vs SSR"; typical import is `import { isMobile, isMac } from "@mongez/supportive-is"` (or `Is.mobile.android()` via the legacy default export — note this package commonly exposes methods through the `Is` object too). > **Skip when:** server-side user-agent parsing from request headers — use a dedicated UA parser library on `request.headers.get("user-agent")`; React-Native or Capacitor device-info APIs; CSS media queries for responsive layout; feature-detection beyond what these vendor probes cover. All of the below probe `navigator`, `window`, or `document` at **call time**. Importing them on the server is safe; invoking them on the server throws. ### `isMobile.android()` / `.ios()` / `.iphone()` / `.ipad()` / `.ipod()` / `.windows()` User-agent regex matches. ```ts isMobile.android(); // /Android/i isMobile.ios(); // /iPhone|iPad|iPod/i isMobile.iphone(); // /iPhone/i isMobile.ipad(); // /iPad/i isMobile.ipod(); // /iPod/i isMobile.windows(); // /IEMobile/i ``` ### `isMobile.any()` Logical OR of `.android()`, `.ios()`, `.windows()`. _Note: this method routes through the `Is.mobile` namespace, so the lazy reference depends on module initialization order._ ### `isMac()` / `isDesktop()` ```ts isMac(); // /mac/i on navigator.userAgent isDesktop(); // !isMobile.any() ``` ### `isBrowser(name)` Single entry point — `"chrome" | "safari" | "firefox" | "opera" | "edge" | "ie"`. Case-insensitive on the vendor name. ### `isChrome()` / `isFirefox()` / `isSafari()` / `isOpera()` / `isIE()` / `isEdge()` Vendor-specific feature probes. Each reads a different bespoke property: - `isChrome` → `window.chrome.webstore || window.chrome.runtime` - `isFirefox` → `window.InstallTrigger` - `isSafari` → `/constructor/i.test(window.HTMLElement)` (**see BUG**) - `isOpera` → `window.opr.addons || window.opera || /OPR\//.test(navigator.userAgent)` - `isIE` → `document.documentMode` - `isEdge` → `!document.documentMode && window.StyleMedia` > **BUG (`src/index.ts:273`)**: `isSafari` false-positives on any host whose `HTMLElement.toString()` contains "constructor" (most class-based DOM polyfills, including happy-dom and JSDOM). Replace with a userAgent-based check. > **BUG (`src/index.ts:288`)**: `isOpera` references bare `opr` (not `window.opr`). In strict mode this is a `ReferenceError` when `opr` is undefined. ## The legacy `Is` namespace ```ts import Is from "@mongez/supportive-is"; Is.string(x); // → isString Is.numeric(x); // → isNumeric Is.int(x); Is.float(x); Is.regex(x); Is.object(x); Is.plainObject(x); Is.array(x); // Array.isArray Is.validHtmlId(x); // → isValidId Is.formElement(x); // → isFormElement Is.form(x); // alias of formElement Is.formData(x); Is.iterable(x); Is.scalar(x); Is.primitive(x); Is.promise(x); Is.date(x); Is.generator(x); Is.empty(x); Is.json(x); Is.url(x); Is.email(x); Is.mobile.android(); Is.mac(); Is.desktop(); Is.browser("chrome"); Is.chrome(); Is.firefox(); Is.safari(); Is.opera(); Is.ie(); Is.edge(); ``` Every property is the same function as the corresponding named export. ## Non-boolean falsy returns Predicates implemented as `value && typeof value === "x"` (or similar) return the raw falsy operand instead of `false` for inputs like `null`, `undefined`, `0`, `""`. In a Boolean context (`if`, `&&`, `||`) this is indistinguishable; in `===` comparisons it isn't: ```ts isObject(null) === false; // false (returns null, not false) Boolean(isObject(null)); // false isObject(null) == false; // true (loose comparison) ``` Affected predicates: `isObject`, `isRegex`, `isPlainObject`, `isPromise`, `isDate`, `isGenerator`, `isIterable`. (`isValidId` is the exception — it wraps in `Boolean(...)`.) If you need a real boolean, wrap the call: `Boolean(isObject(x))`. ## What this package does NOT do - General-purpose object/string/array helpers → [`@mongez/reinforcements`](https://github.com/hassanzohdy/reinforcements) - Schema validation (`z.string().email()`) → use `zod` or `valibot` - Date manipulation → use `dayjs`, `date-fns`, or `Temporal` - HTML sanitization → use `DOMPurify` - Server-side user-agent parsing → parse the request header yourself ## Patterns > **Auto-trigger:** code combines multiple `@mongez/supportive-is` imports (e.g. `isEmpty` + `isEmail` + `isUrl` for forms, or `isPlainObject` for deep merge); user asks "how do I validate a form with supportive-is", "deep merge that respects class instances", "filter out empty values", "Cmd vs Ctrl shortcut", "polymorphic string-or-regex argument", or "TS type narrowing with isString"; typical import is `import { isEmpty, isEmail, isUrl } from "@mongez/supportive-is"` — combining several predicates in one module. > **Skip when:** single-predicate questions — use the category-specific skill (`mongez-supportive-is-primitives`/`-collections`/`-formats`/`-misc`/`-environment`); React-specific form-state libraries like `react-hook-form` or `formik`; `@mongez/reinforcements` recipes for object/array transformations. ### A form validator ```ts import { isEmpty, isEmail, isUrl } from "@mongez/supportive-is"; function validate(form: { email?: string; website?: string }) { const errors: Record = {}; if (isEmpty(form.email)) errors.email = "Email is required"; else if (!isEmail(form.email!)) errors.email = "Email is invalid"; if (form.website && !isUrl(form.website)) errors.website = "Website must be a full http(s) URL"; return errors; } ``` ### A deep-merge that respects class instances ```ts import { isPlainObject } from "@mongez/supportive-is"; function deepMerge(target: T, source: Partial): T { for (const key of Object.keys(source) as (keyof T)[]) { const v = source[key]; if (isPlainObject(v) && isPlainObject(target[key])) { target[key] = deepMerge(target[key] as object, v as object) as T[keyof T]; } else { target[key] = v as T[keyof T]; } } return target; } ``` ### A nullable normalizer ```ts import { isEmpty } from "@mongez/supportive-is"; function pickNotEmpty>(input: T): Partial { const out: Partial = {}; for (const k of Object.keys(input) as (keyof T)[]) { if (!isEmpty(input[k])) out[k] = input[k]; } return out; } ``` --- # @mongez/atom # @mongez/atom — full reference > A framework-agnostic, action-shaped state primitive. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/atom ``` ## Public exports ```ts import { createAtom, atomCollection, derive, AtomStore, createAtomStore, enableAtomDevtools, localStorageAdapter, resolvePersistAdapter, getAtom, atomsList, atomsObject, atoms, // module-level registry; usually internal type Atom, type AtomOptions, type AtomActions, type AtomChangeCallback, type AtomCollectionActions, type AtomPartialChangeCallback, type AtomValue, type BaseAtom, type CollectionOptions, type CreateAtomOptions, type DeriveGetter, type DeriveOptions, type EnableDevtoolsOptions, type IsObjectValue, type ObjectAtom, type PersistAdapter, type PersistOption, } from "@mongez/atom"; ``` ## createAtom(options) > **Auto-trigger:** code imports or calls `createAtom`, `getAtom`, `atomsList`, `atomsObject`, or uses `atom.update`, `atom.silentUpdate`, `atom.merge`, `atom.change`, `atom.silentChange`, `atom.watch`, `atom.reset`, `atom.silentReset`, `atom.onChange`, `atom.onReset`, `atom.onDestroy`, `atom.clone`, `atom.destroy`, `beforeUpdate`; user asks "how do I define an atom", "what methods does an atom have", or "how do I subscribe to atom changes"; `import { createAtom } from "@mongez/atom"`. > **Skip when:** array-typed atom mutation verbs (use `mongez-atom-collections`); computed atoms (use `mongez-atom-derived` / `mongez-atom-derived-atoms`); attaching custom methods via `actions` bag (use `mongez-atom-actions`); SSR isolation (use `mongez-atom-atom-store` / `mongez-atom-stores`); persistence (use `mongez-atom-persist` / `mongez-atom-persistence`); React hooks (live in `@mongez/react-atom`). ```ts createAtom = AtomActions>( options: AtomOptions, internal?: { register?: boolean } ): Atom ``` Options: ```ts type AtomOptions = { key: string; // unique identifier default: V; // initial value actions?: A & ThisType>; // bound methods + getters beforeUpdate?: (next: V, prev: V, atom: Atom) => V | void; onUpdate?: (cb: AtomChangeCallback) => EventSubscription; get?: (key: string, defaultValue?: V, atomValue?: V) => V; }; ``` Returns an `Atom` carrying: ### Base methods - `key: string` - `default: V` - `currentValue: V` (also `value`, `defaultValue`) - `update(next | (prev, atom) => next): void` — emits update event - `silentUpdate(next): void` — no event - `reset(): void` — restore default, emit update + reset - `silentReset(): void` — restore default, emit reset only - `onChange(cb): EventSubscription` - `onReset(cb): EventSubscription` - `onDestroy(cb): EventSubscription` - `clone(options?: { register?: boolean }): Atom` — `.clone.{n}` suffix - `destroy(): void` — remove + unsubscribe namespace events - `type: string` — `"object" | "array" | typeof primitive` - `length: number` — when value is array/string ### Object-only methods (when `V extends object`) - `merge(partial: Partial): void` - `change(key: K, value: V[K]): void` - `silentChange(key: K, value: V[K]): void` - `get(key: K, default?: any): V[K]` - `watch(key: K, cb): EventSubscription` Primitive atoms (`Atom`, `Atom`, `Atom`) do NOT carry these — calling them is a compile error. `Atom` keeps both surfaces (legacy). ## atomCollection(options) > **Auto-trigger:** code imports or calls `atomCollection`, or invokes `push`, `unshift`, `pop`, `shift`, `replace`, `remove`, `removeItem`, `removeAll`, `map`, `forEach`, `index`, `get(indexOrPredicate)`, `length` on an atom, or uses `AtomCollectionActions` / `CollectionOptions` types; user asks "how do I manage an array as atom state", "how do I push/pop/remove items in an atom", or "what's the difference between createAtom and atomCollection"; `import { atomCollection } from "@mongez/atom"`. > **Skip when:** scalar/object atoms (use `mongez-atom-atoms` or `mongez-atom-defining-atoms`); computed array views over collections (use `mongez-atom-derived` / `mongez-atom-derived-atoms`); React-list rendering hooks (live in `@mongez/react-atom`). ```ts atomCollection(options: CollectionOptions): Atom> ``` `CollectionOptions = Omit, "default"> & { default?: V[] }`. Adds array-mutation actions: - `push(...items)`, `unshift(...items)` — append/prepend - `pop()`, `shift()` — drop last/first - `replace(index, item)` — overwrite at index - `remove(indexOrPredicate)` — drop one by index or callback - `removeItem(item)` — strict-equality remove of first occurrence - `removeAll(item) → V[]` — returns filtered copy (non-mutating; historical name) - `map(cb) → V[]` — mutating map (rewrites value + returns) - `forEach(cb)` — read-only iteration - `index(predicate) → number` — `findIndex` wrapper - `get(indexOrPredicate) → V | undefined` - `length: number` — property getter ## derive (computed atoms) > **Auto-trigger:** code imports or calls `derive`, uses `DeriveGetter` / `DeriveOptions` types, or builds a value from other atoms via a `get` argument; user asks "how do I create a computed/derived atom", "how do I auto-track atom dependencies", or "how do I chain derived atoms"; `import { derive } from "@mongez/atom"`. > **Skip when:** writable base atoms (use `mongez-atom-atoms` / `mongez-atom-defining-atoms`); array verbs over a collection (use `mongez-atom-collections`); React-side `useValue` / `useState` wiring (lives in `@mongez/react-atom`); the sibling `mongez-atom-derived` / `mongez-atom-derived-atoms` skill — only one of the two should fire for the same request. ```ts derive( key: string, compute: (get: (atom: Atom) => V) => T, options?: { register?: boolean }, ): Atom ``` Returns a regular `Atom` whose value is built from other atoms. Dependencies are auto-tracked through the `get` argument. Recomputes eagerly when any dep changes. Dynamic dependency graphs (conditional reads) supported — diffed each run. Chained derivations propagate. Errors inside `compute` are re-thrown asynchronously to keep the source-atom update cycle intact. `destroy()` unsubscribes from all deps. ```ts const first = createAtom({ key: "first", default: "Ada" }); const last = createAtom({ key: "last", default: "Lovelace" }); const fullName = derive("fullName", get => `${get(first)} ${get(last)}`); ``` ## Persistence > **Auto-trigger:** code sets `persist: true` or `persist: { get, set, remove }` on a `createAtom` call, imports `PersistAdapter`, `PersistOption`, `localStorageAdapter`, or `resolvePersistAdapter`; user asks "how do I save atom state across reloads", "how do I write a custom localStorage / cookie / IndexedDB adapter", or "why is my atom value lost on refresh"; `import { type PersistAdapter, localStorageAdapter } from "@mongez/atom"`. > **Skip when:** per-request SSR isolation (use `mongez-atom-atom-store` / `mongez-atom-stores`); defining the atom itself (use `mongez-atom-atoms` / `mongez-atom-defining-atoms`); server-state caching with HTTP keys (use `@mongez/atomic-query`); the sibling `mongez-atom-persist` / `mongez-atom-persistence` skill — only one of the two should fire for the same request. ```ts type PersistAdapter = { get(key: string): V | undefined | Promise; set(key: string, value: V): void | Promise; remove(key: string): void | Promise; }; type PersistOption = boolean | PersistAdapter; // On AtomOptions: persist?: PersistOption; ``` - `persist: true` → built-in `localStorageAdapter`. JSON-encodes; no-ops on the server. - `persist: PersistAdapter` → custom adapter (sync or async). Behavior: 1. Bootstrap: adapter is read; existing value applied via `silentUpdate`. 2. Every `update`/`change`/`merge` writes through to the adapter. (`silentUpdate` does NOT.) 3. `reset()` removes the entry. 4. Sync throws and async rejections from the adapter are swallowed — never crashes the atom. ```ts const themeAtom = createAtom({ key: "ui.theme", default: "light", persist: true, // localStorage }); const userAtom = createAtom({ key: "user", default: { name: "Anon" }, persist: cookieAdapter, // custom adapter }); ``` ## AtomStore (SSR) > **Auto-trigger:** code imports `AtomStore`, `createAtomStore`, or calls `store.use`, `store.get`, `store.has`, `store.list`, `store.hydrate`, `store.snapshot`, `store.destroy` from `@mongez/atom`; user asks "how do I isolate atom state per SSR request", "why are atoms leaking between requests", or "how do I serialize and rehydrate atoms"; `import { createAtomStore, AtomStore } from "@mongez/atom"`. > **Skip when:** defining the atoms themselves (use `mongez-atom-atoms` or `mongez-atom-defining-atoms`); React-side `AtomStoreProvider` / `useAtomStore` wiring (lives in `@mongez/react-atom`); generic client-only state without SSR (no store needed); the sibling `mongez-atom-atom-store` / `mongez-atom-stores` skill — only one of the two should fire for the same request. ```ts class AtomStore { use(template: Atom): Atom // lazy clone get(key: string): Atom | undefined has(key: string): boolean list(): Atom[] hydrate(snapshot: Record): void snapshot(): Record destroy(): void } createAtomStore(): AtomStore ``` Use one store per SSR request; call `destroy()` at the end of the request lifecycle. The React-side wiring (`AtomStoreProvider`, `useAtom`, `useAtomStore`) lives in `@mongez/react-atom`. ## DevTools > **Auto-trigger:** code imports or calls `enableAtomDevtools`, uses `EnableDevtoolsOptions`, or passes `name` / `ignore` / `scanInterval` options for DevTools; user asks "how do I debug atoms with Redux DevTools", "how do I time-travel atom state", or "how do I skip noisy atoms in the DevTools timeline"; `import { enableAtomDevtools } from "@mongez/atom"`. > **Skip when:** defining atoms (use `mongez-atom-atoms` / `mongez-atom-defining-atoms`); production-only / non-debug code paths; logging atom values to console without the Redux extension; React-renderer profiling (DevTools handles state, React Profiler handles renders). ```ts enableAtomDevtools(options?: EnableDevtoolsOptions): () => void type EnableDevtoolsOptions = { name?: string; ignore?: Array; scanInterval?: number; // default 1000ms }; ``` Connects to `window.__REDUX_DEVTOOLS_EXTENSION__`. No-op when the extension isn't installed. Supports time-travel via `JUMP_TO_STATE` / `JUMP_TO_ACTION`. ## Registry helpers - `getAtom(key)` → `Atom | undefined` - `atomsList()` → `Atom[]` - `atomsObject()` → `Record` - `atoms` → the underlying registry (rarely used directly) ## Lifecycle events Atom events are emitted on the `@mongez/events` bus under the namespace `atoms.${key}`: - `atoms.${key}.update` — fired by `update`, `change`, `merge` - `atoms.${key}.reset` — fired by `reset`, `silentReset` - `atoms.${key}.delete` — fired by `destroy` Namespace matching is segment-aware: `events.unsubscribeNamespace("atoms.users.1")` does NOT also wipe `atoms.users.10`. ## Conditional types The `Atom` type is built as: ```ts type Atom = BaseAtom & (IsObjectValue extends true ? ObjectAtom : {}) & A; ``` Where `IsObjectValue` is true for object/array values (not for primitives, functions, or null). The result: `Atom.change(...)` is a compile error. ## Patterns > **Auto-trigger:** code combines several of `createAtom`, `atomCollection`, `derive`, `createAtomStore`, `enableAtomDevtools`, `onChange`, `watch` in one place; user asks "give me a real-world example", "show me an end-to-end SSR snapshot + hydrate pattern", "build a cart with computed totals", "how do I tear down `enableAtomDevtools` on HMR", or "how do I derive state into another atom via `onChange`"; `import { createAtom, atomCollection, derive, createAtomStore, enableAtomDevtools } from "@mongez/atom"` together. > **Skip when:** single-feature deep dives — route to the focused skill instead (`mongez-atom-atoms`, `mongez-atom-collections`, `mongez-atom-derived`, `mongez-atom-persist`, `mongez-atom-atom-store`, `mongez-atom-devtools`, `mongez-atom-actions`); React-specific composition (lives in `@mongez/react-atom`); query/cache patterns (use `@mongez/atomic-query`). ### Action with custom domain verbs ```ts const auth = createAtom({ key: "auth", default: { user: null as User | null }, actions: { async login(credentials: { email: string; password: string }) { const user = await api.login(credentials); this.merge({ user }); }, logout() { this.merge({ user: null }); }, }, }); ``` ### Getter-based derived property ```ts const cart = atomCollection({ key: "cart", actions: { get total() { return this.value.reduce((s, i) => s + i.price * i.qty, 0); }, }, }); cart.total; // recomputed per read ``` ### Per-request store on the server ```ts const store = createAtomStore(); store.use(userAtom).update(currentRequestUser); const html = renderToString(); const payload = JSON.stringify(store.snapshot()); // embed payload in HTML, then: store.destroy(); ``` ## What this package does NOT do - React hooks → `@mongez/react-atom` - Server-state caching with query keys / invalidation → `@mongez/atomic-query` - The event bus itself → `@mongez/events` - Object/string/random utilities → `@mongez/reinforcements` --- # @mongez/react-atom # @mongez/react-atom — full reference > React adapter for `@mongez/atom`. Every atom carries `useState` / `useValue` / `use(key)` / `useWatch` / `Provider`. `` scopes state per SSR request. ## Install ```sh yarn add @mongez/react-atom # peer: react >= 18, @mongez/atom ``` ## Public exports ```ts import { // Atom factories atom, atomCollection, // Preset atoms openAtom, loadingAtom, fetchingAtom, portalAtom, // SSR / store AtomStoreProvider, AtomStoreContext, useAtom, useAtomStore, // Legacy shims (deprecated, kept for migration) AtomContext, // alias of AtomStoreContext AtomProvider, // shim over AtomStoreProvider // Hydration helpers HydrateAtomsScript, readHydration, serializeSnapshot, serializeStore, DEFAULT_HYDRATION_SCRIPT_ID, // Types type ReactAtom, type ReactActions, type OpenAtomActions, type OpenAtomType, type LoadingAtom, type LoadingAtomActions, type FetchingAtomType, type FetchingAtomActions, type AtomPortal, type PortalActions, type AtomStoreProviderProps, type HydrateAtomsScriptProps, } from "@mongez/react-atom"; ``` ## atom(options) > **Auto-trigger:** code imports `atom`, `atomCollection`, or `useAtom` from `@mongez/react-atom`; code calls `.useValue()`, `.useState()`, `.use(key)`, `.useWatch(key, cb)`, or renders ``; user asks "how do I create an atom in React", "what's the difference between useValue and useState", "how do I subscribe to one key only", or "how do I add a custom action to a React atom"; typical import `import { atom, atomCollection } from "@mongez/react-atom"`. > **Skip when:** framework-agnostic atom primitive (`createAtom`, `createAtomCollection`) — that lives in `@mongez/atom`, the core layer this package sits on top of; preset-atom shorthands (`openAtom`/`loadingAtom`/`fetchingAtom`/`portalAtom`) use `mongez-react-atom-presets`; per-request SSR scoping and hydration use `mongez-react-atom-ssr`; copy-paste end-to-end flows use `mongez-react-atom-recipes`. Same shape as `createAtom` from `@mongez/atom`. Returns a `ReactAtom` — a `Atom` plus the React-aware actions injected by this package. ### Hooks every atom carries - `atom.useValue(): V` — subscribe, return whole value. - `atom.useState(): [V, (next | (prev) => next) => void]` — like React's useState. - `atom.use(key: K): V[K]` — subscribe to one key only. - `atom.useWatch(key, cb)` — effect-style watcher. - `` — pushes value into the atom on mount. All hooks use `useSyncExternalStore`; reads are tear-free under React 18 concurrent rendering. All hooks honor the nearest `` — they operate on the store-scoped clone when one is mounted, fall back to the template otherwise. ## atomCollection(options) React-aware version of `atomCollection` from `@mongez/atom`. Inherits the array helpers (`push`, `unshift`, `pop`, ...) AND the React hooks. ## Preset atoms > **Auto-trigger:** code imports or calls `openAtom`, `loadingAtom`, `fetchingAtom`, or `portalAtom` from `@mongez/react-atom`; code uses `useOpened`, `useLoading`, `useData`, `useError`, `usePagination`, `startLoading`, `stopLoading`, `toggleLoading`, `success`, `failed`, `append`, `prepend`, or `toggle` methods on a preset atom; user asks "how do I make a toggle / open-close atom", "how do I model a loading flag", "how do I do a fetch lifecycle with isLoading/data/error", or "how do I coordinate a modal/drawer"; typical import `import { openAtom, portalAtom } from "@mongez/react-atom"`. > **Skip when:** hand-rolling a custom action atom from scratch — use `mongez-react-atom-atoms`; richer cache-keyed server state (invalidation, refetch on focus) belongs in `@mongez/atomic-query`, not `fetchingAtom`; SSR scoping/hydration of preset atoms still uses `mongez-react-atom-ssr`; end-to-end flow examples use `mongez-react-atom-recipes`. ### openAtom(key, defaultOpened = false) ```ts const m = openAtom("sidebar"); m.open(); m.close(); m.toggle(); const opened = m.useOpened(); ``` ### loadingAtom(key, defaultLoading = false) ```ts const l = loadingAtom("fetching.users"); l.startLoading(); l.stopLoading(); l.toggleLoading(); ``` ### fetchingAtom(key, defaultValue?, defaultFetching = true) Value shape: `{ isLoading, data, error, pagination? }`. Actions: `startLoading`, `stopLoading`, `success(data, pagination?)`, `failed(error)`, `append(data)`, `prepend(data)`. Hooks: `useLoading`, `useData`, `useError`, `usePagination`. ### portalAtom(name, opened = false) Value: `{ opened: boolean, data: T }`. Actions: `open(data?)`, `close()`, `toggle(data?)`, `useOpened()`, `useData()`. Key is suffixed with `-portal`. ## SSR > **Auto-trigger:** code imports `AtomStoreProvider`, `AtomStoreContext`, `useAtom`, `useAtomStore`, `HydrateAtomsScript`, `readHydration`, `serializeStore`, `serializeSnapshot`, `DEFAULT_HYDRATION_SCRIPT_ID`, `AtomStoreProviderProps`, or `HydrateAtomsScriptProps` from `@mongez/react-atom`; user asks "how do I avoid hydration mismatches with atoms in Next.js App Router", "how do I isolate atom state per SSR request", "how do I pre-fill atoms on the server", or "how do I call an action method safely under SSR"; typical import `import { AtomStoreProvider, HydrateAtomsScript, readHydration } from "@mongez/react-atom"`. > **Skip when:** client-only single-page apps where module-level singletons are fine — no provider needed, so reach for `mongez-react-atom-atoms` instead; preset atoms in a non-SSR context use `mongez-react-atom-presets`; the underlying store primitive `createAtomStore` and `snapshot()` itself live in `@mongez/atom`, the core layer this package wraps; mixed end-to-end flows use `mongez-react-atom-recipes`. ### `` ```ts type AtomStoreProviderProps = { store?: AtomStore; // caller-owned if provided initialAtoms?: Atom[]; // pre-register initialValues?: Record; // silent-update on entry children: React.ReactNode; }; ``` - Auto-creates a store if none is passed; destroys it on unmount. - A passed-in store is not destroyed automatically (caller owns lifecycle). ### `useAtom` ```ts useAtom(template: Atom): Atom; useAtom(key: string): Atom | undefined; ``` Template overload → store-scoped clone (or template itself when no provider). String overload → look up by key in the active store. ### `useAtomStore()` Returns the active `AtomStore` or `null`. ## Hydration helpers ```ts serializeSnapshot(snapshot, options?): string serializeStore(store, options?): string readHydration(id?: string = DEFAULT_HYDRATION_SCRIPT_ID): Record | null DEFAULT_HYDRATION_SCRIPT_ID = "__mongez_atom_state" ``` Serializer protections: `` is escaped to `<\/script>`; U+2028/U+2029 are escaped to `
`/`
`. The component uses `dangerouslySetInnerHTML` — safe because the serializer is the only path. ## React version React 18+ only. `useSyncExternalStore` is required. ## What this package does NOT do - The atom primitive itself → `@mongez/atom` - Server-state caching (query keys, invalidation) → `@mongez/atomic-query` - The event bus → `@mongez/events` --- # @mongez/atomic-query # @mongez/atomic-query — full reference > Client-only query cache built on `@mongez/react-atom`. React-Query-style API. Ships `useQuery`, `useMutation`, invalidation, refetch, optimistic updates, list mutation helpers, and SSR integration via ``. ## Install ```sh yarn add @mongez/atomic-query # peer: @mongez/atom, @mongez/react-atom, react >= 18 ``` ## Public exports ```ts import { // Cache atom (carries the action methods) queryAtom, // Hooks useQuery, useMutation, useInfiniteQuery, useSuspenseQuery, useQueryChange, useLoadChange, useErrorChange, useDataChange, // SSR helper HydrateQueries, // Imperative API (re-exported as plain functions) invalidate, invalidateAll, invalidateBackground, invalidateBackgroundAll, refetchQuery, refetchMultipleQueries, refetchQueryBackground, refetchMultipleQueriesBackground, updateQueryData, seedQuery, getData, getQuery, destroyQuery, isStale, // Cache lifecycle clearCache, garbageCollect, getCacheStats, limitCacheSize, setupAutoGC, // Array helpers push, unshift, pop, shift, replace, remove, removeByIndex, clear, sort, reverse, // Low-level utilities parseQueryKey, serializeQueryKey, matchesQueryPrefix, refetch, refetchBackground, refetchMultiple, refetchMultipleBackground, runInBackground, // Types type Query, type QueryKey, type QueryState, type QueryChangeType, type QueryChangeTypeToValue, type QueryActions, type QueryPayload, type AddQueryOptions, type InvalidateOptions, type SeedEntry, type CacheStats, type GenericObject, type HydrateQueriesProps, type MutationStatus, type UseMutationOptions, type UseMutationResult, type InfiniteQueryData, type InfiniteQueryFnContext, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, } from "@mongez/atomic-query"; ``` ## queryAtom.useQuery(options) > **Auto-trigger:** code imports `useQuery`, `queryAtom`, `useLoadChange`, `useDataChange`, `useErrorChange`, `useQueryChange`, `Query`, `QueryKey`, `QueryState`, or `AddQueryOptions` from `@mongez/atomic-query`; user asks "how does useQuery work / what's in Query / what is staleTime vs gcTime / how does retry / refetchOnWindowFocus work / how are query keys hashed"; typical import `import { queryAtom, useQuery } from "@mongez/atomic-query"`. > **Skip when:** quick-start framing for first-time users — use `mongez-atomic-query-basic-query`; suspense-mode behavior — use `mongez-atomic-query-suspense`; write-side mutations — use `mongez-atomic-query-mutations`; invalidation/refetch patterns — use `mongez-atomic-query-invalidation` or `mongez-atomic-query-cache`; cursor/page-based pagination — use `mongez-atomic-query-infinite`. ```ts useQuery(options: AddQueryOptions): Query type AddQueryOptions = { queryKey: QueryKey; queryFn: (ctx: { signal: AbortSignal }) => Promise; onSuccess?: (data: T, query: Query) => void; onError?: (error: any, query: Query) => void; watch?: boolean; // default true retry?: number; // default 0 retryDelay?: number | ((attempt: number) => number); retryCondition?: (error: any, attempt: number) => boolean; staleTime?: number; // default 0 gcTime?: number; // default 5 * 60_000 refetchOnMount?: boolean; // default true refetchOnWindowFocus?: boolean; // default false refetchOnReconnect?: boolean; // default false }; type Query = { data: T | undefined; isLoading: boolean; // first fetch only isFetching: boolean; // any fetch isError: boolean; error: unknown | null; state: "idle" | "loading" | "error" | "success"; isRetrying: boolean; fetchCount, retryCount, maxRetries: number; lastModified, lastAccessed, createdAt: number; lastSuccessAt?, lastErrorAt?: number; queryKey: QueryKey; hashKey: string; queryFn: (...) => Promise; options: Partial>; }; ``` ## useMutation > **Auto-trigger:** code imports `useMutation`, `updateQueryData`, `UseMutationOptions`, `UseMutationResult`, or `MutationStatus` from `@mongez/atomic-query`, or references `mutate`, `mutateAsync`, `onMutate`, `onSuccess`, `onError`, `onSettled`, `isPending`, or `reset` in a mutation context; user asks "how do I POST / PUT / PATCH / DELETE / do an optimistic update / roll back on error / write to the cache without refetching"; typical import `import { useMutation, queryAtom } from "@mongez/atomic-query"`. > **Skip when:** read-side `useQuery` calls — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; forcing a refetch after a mutation completes — use `mongez-atomic-query-invalidation`; array-shaped helpers `push`/`remove`/`sort` for list updates — use `mongez-atomic-query-list-helpers`; cache lifecycle/GC questions — use `mongez-atomic-query-cache`. ```ts useMutation( options: UseMutationOptions, ): UseMutationResult type UseMutationOptions = { mutationFn: (variables: TVariables, ctx: { signal: AbortSignal }) => Promise; onMutate?: (variables) => TContext | Promise; onSuccess?: (data, variables, context?) => void | Promise; onError?: (error, variables, context?) => void | Promise; onSettled?: (data | undefined, error, variables, context?) => void | Promise; }; type UseMutationResult = { mutate(variables): Promise; mutateAsync: mutate; reset(): void; data: TData | undefined; error: unknown; variables: TVariables | undefined; status: "idle" | "pending" | "success" | "error"; isIdle, isPending, isSuccess, isError: boolean; }; ``` A second `mutate` aborts the first. Unmount aborts in-flight. ## useInfiniteQuery > **Auto-trigger:** code imports `useInfiniteQuery`, `InfiniteQueryData`, `InfiniteQueryFnContext`, `UseInfiniteQueryOptions`, or `UseInfiniteQueryResult` from `@mongez/atomic-query`, or calls `fetchNextPage`, `getNextPageParam`, `hasNextPage`, or `isFetchingNextPage`; user asks "how do I implement infinite scroll / load more / cursor pagination / offset pagination"; typical import `import { useInfiniteQuery } from "@mongez/atomic-query"`. > **Skip when:** single-page `useQuery` calls — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; mutating cached array values via `push`/`unshift`/`remove`/`sort` — use `mongez-atomic-query-list-helpers`; write-side mutations — use `mongez-atomic-query-mutations`. ```ts useInfiniteQuery( options: UseInfiniteQueryOptions, ): UseInfiniteQueryResult type UseInfiniteQueryOptions = Omit, "queryFn"> & { queryFn: (ctx: { pageParam: TPageParam; signal: AbortSignal }) => Promise; initialPageParam: TPageParam; getNextPageParam: ( lastPage: TPage, allPages: TPage[], lastPageParam: TPageParam, allPageParams: TPageParam[], ) => TPageParam | undefined; }; type UseInfiniteQueryResult = Query<{ pages: TPage[]; pageParams: TPageParam[]; }> & { hasNextPage: boolean; isFetchingNextPage: boolean; fetchNextPage(): Promise; }; ``` Cached as `{ pages, pageParams }`. `fetchNextPage()` computes the next param via `getNextPageParam` and appends. Each page fetch has its own AbortController. Invalidation refetches starting from page 1. ## useSuspenseQuery > **Auto-trigger:** code imports `useSuspenseQuery` from `@mongez/atomic-query`, or uses it together with `` and `ErrorBoundary`; user asks "how do I use React Suspense with atomic-query / get data typed as non-undefined / pair Suspense with ErrorBoundary"; typical import `import { useSuspenseQuery } from "@mongez/atomic-query"`. > **Skip when:** plain non-suspense `useQuery` usage — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; SSR streaming / server-component-driven loading — use `mongez-atomic-query-ssr`; write-side mutations — use `mongez-atomic-query-mutations`; cache invalidation triggered while suspended — use `mongez-atomic-query-invalidation`. ```ts useSuspenseQuery(options: AddQueryOptions): Query & { data: T } ``` While the query is loading and has no data, throws the in-flight promise (React suspends). On error, throws the error (ErrorBoundary catches). Returns the query with `data: T` (non-undefined) when settled. Render-time cache init: ensures the cache entry exists and the fetch is running before throwing the promise. This is necessary because a suspended-from-first-render component never commits its `useEffect`. The init is idempotent. Pair with `` and an `ErrorBoundary` (outside the Suspense): ```tsx failed

}> }>
``` ## Cache management > **Auto-trigger:** code imports `invalidate`, `invalidateAll`, `invalidateBackground`, `invalidateBackgroundAll`, `refetchQuery`, `refetchMultipleQueries`, `refetchQueryBackground`, `refetchMultipleQueriesBackground`, `updateQueryData`, `seedQuery`, `getQuery`, `getData`, `isStale`, `destroyQuery`, `clearCache`, `garbageCollect`, `limitCacheSize`, `getCacheStats`, `setupAutoGC`, or `onQueryChange` from `@mongez/atomic-query`; user asks "how do I read/write/expire the cache without a hook / invalidate after a mutation / configure GC"; typical import `import { queryAtom, invalidate, updateQueryData } from "@mongez/atomic-query"`. > **Skip when:** writing `useQuery`/`useSuspenseQuery` calls — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; running mutations (POST/PUT/PATCH/DELETE) — use `mongez-atomic-query-mutations`; array-shaped helpers like `push`/`remove`/`sort` — use `mongez-atomic-query-list-helpers`; SSR seeding via `` — use `mongez-atomic-query-ssr`. ```ts queryAtom.invalidate({ queryKey, exact?: boolean }): Promise queryAtom.invalidateAll(): Promise queryAtom.invalidateBackground({ queryKey }): void queryAtom.invalidateBackgroundAll(): void queryAtom.refetchQuery(queryKey): Promise queryAtom.refetchMultipleQueries(queryKey[]): Promise queryAtom.refetchQueryBackground(queryKey): void queryAtom.refetchMultipleQueriesBackground(queryKey[]): void queryAtom.updateQueryData(queryKey, (old: T | undefined) => T): void queryAtom.seedQuery({ queryKey, data, freshFor? }): void queryAtom.destroyQuery(queryKey): void queryAtom.getQuery(queryKey): Query | undefined queryAtom.getData(queryKey): unknown queryAtom.isStale(queryKey, staleTime?): boolean queryAtom.getCacheStats(): CacheStats queryAtom.clearCache(): void queryAtom.garbageCollect(gcTime?): number queryAtom.limitCacheSize(maxQueries?): number queryAtom.setupAutoGC(interval?, gcTime?, maxQueries?): () => void ``` Invalidation matching is segment-aware: `["users", 1]` matches `["users", 1]` and `["users", 1, "profile"]` but NOT `["users", 10]`. ## List helpers > **Auto-trigger:** code imports `push`, `unshift`, `pop`, `shift`, `replace`, `remove`, `removeByIndex`, `clear`, `sort`, or `reverse` from `@mongez/atomic-query`, or calls `queryAtom.push`/`queryAtom.remove`/`queryAtom.sort` etc.; user asks "how do I append/prepend/remove/reorder items in a cached list without refetching"; typical import `import { queryAtom, push, remove } from "@mongez/atomic-query"`. > **Skip when:** cursor/offset pagination via `useInfiniteQuery` — use `mongez-atomic-query-infinite`; full-replacement optimistic writes via `updateQueryData` — use `mongez-atomic-query-mutations` or `mongez-atomic-query-cache`; invalidating instead of mutating — use `mongez-atomic-query-invalidation`; native `Array.prototype` work that doesn't go through `queryAtom`. ```ts queryAtom.push(queryKey, data) queryAtom.unshift(queryKey, data) queryAtom.pop(queryKey) queryAtom.shift(queryKey) queryAtom.replace(queryKey, index, data) queryAtom.removeByIndex(queryKey, index) queryAtom.remove(queryKey, item) queryAtom.clear(queryKey) queryAtom.sort(queryKey, (a, b) => number) queryAtom.reverse(queryKey) ``` All immutable under the hood. No-op when the value is undefined. ## SSR integration > **Auto-trigger:** code imports `HydrateQueries`, `seedQuery`, `SeedEntry`, or `HydrateQueriesProps` from `@mongez/atomic-query`; user asks "how do I SSR with atomic-query / seed cache from Next.js server component / hydrate from a Remix loader / prefetch on hover"; typical import `import { HydrateQueries } from "@mongez/atomic-query"`. > **Skip when:** pure client-side `useQuery` usage — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; cache invalidation/refetch after first paint — use `mongez-atomic-query-invalidation`; suspense rendering boundaries — use `mongez-atomic-query-suspense`; non-SSR cache-API questions — use `mongez-atomic-query-cache`. ```ts ``` Seeds the cache synchronously during render. Consumers see seeded data on first paint. The package is marked `react-server: null`; server components cannot import it directly — fetch in the framework loader and seed via this component. ## Granular subscriptions ```ts queryAtom.useQueryChange(queryKey, changeType: T): QueryChangeTypeToValue | undefined queryAtom.useLoadChange(queryKey): boolean queryAtom.useErrorChange(queryKey): Error | null queryAtom.useDataChange(queryKey): T | null ``` Each subscribes to one field of the named query. Re-renders only when that field changes. ## Non-React subscription ```ts queryAtom.onQueryChange( queryKey, (next: Query | undefined, prev: Query | undefined) => void, ): EventSubscription ``` Fires on create (prev undefined), update (both defined), and destroy (next undefined). ## Key hashing Canonical JSON with sorted object keys. Properties: - Order-independent for objects: `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` hash identically. - Collision-free vs. pipe-joined hash: `["users", "1|2"]` and `["users", 1, 2]` differ. - Boundary-aware: `["users", 1]` matches `["users", 1, "profile"]` but NOT `["users", 10]`. ## Concurrent fetch dedup Multiple `useQuery` mounts with the same hash share one in-flight promise. The same applies to manual `refetchQuery` calls that race a hook-initiated fetch. ## queryFn freshness The latest `queryFn` closure is stored in a registry keyed by hash. Hooks update the registry on every render via a ref. Refetches always invoke the latest closure — never a stale one captured at first-mount time. ## AbortSignal handling - Passed to every `queryFn`: `queryFn: ({ signal }) => fetch(url, { signal })`. - Aborted when a newer fetch starts for the same key (race-condition mitigation). - Aborted when `destroyQuery(key)` is called. - NOT aborted on consumer unmount (Strict Mode / route bounces don't kill fetches). ## Reference-counted GC `useQuery` registers/unregisters observers via `attachObserver` / `detachObserver`. `garbageCollect` only evicts queries with zero observers AND a stale `lastAccessed`. Actively-observed queries never get evicted, even if they've gone unused. `setupAutoGC` runs automatically on the first `useQuery` call. Default cadence: every minute, evict anything unobserved for more than 5 minutes. ## Client-only enforcement Every source file carries `"use client"`. The `exports` map declares `"react-server": null`. Bundlers (Next.js, others) refuse to import the package from RSC contexts. SSR fetching is the framework's job. ## What this package does NOT do - Server-side fetching → use Next.js / Remix / TanStack loaders - Streaming Suspense (`useSuspenseQuery`) → push to the framework loader - Infinite queries with `getNextPageParam` → model as a single array query with `queryAtom.push(...)` - Persistent cache to localStorage/IndexedDB → use `@mongez/cache` --- # @mongez/collection # @mongez/collection — full reference > A chainable, immutable-by-default array wrapper. This is the concatenated reference; load `llms.txt` for the structured index. ## Install ```sh yarn add @mongez/collection # deps: @mongez/reinforcements, @mongez/supportive-is ``` ## Public exports ```ts import { collect, ImmutableCollection, Operators, type ComparisonOperator, type GenericObject, } from "@mongez/collection"; ``` ## collect / ImmutableCollection > **Auto-trigger:** code imports `collect`, `ImmutableCollection`, `collect.create`, `collect.fromIterator`, or `ImmutableCollection.fromIterator` / `ImmutableCollection.create` from `@mongez/collection`; user asks "how do I create a collection", "how to wrap an array / Set / Map / generator", "how to seed N items", "how to make a collection from an iterable"; `import { collect, ImmutableCollection } from "@mongez/collection"`. > **Skip when:** operating on an already-built collection (filter / map / sort / aggregate) — use the operation-specific skills (`mongez-collection-querying`, `mongez-collection-transforming`, etc.); `@mongez/reinforcements` standalone helpers — use `mongez-reinforcements-arrays` for one-shot helpers without a wrapper. ```ts collect(items?: T[] | ImmutableCollection): ImmutableCollection new ImmutableCollection(items?: T[] | ImmutableCollection) ImmutableCollection.fromIterator(iterable: Iterable): ImmutableCollection ImmutableCollection.create(length: number, initialValue?: T | ((i: number) => T)): ImmutableCollection collect.fromIterator = ImmutableCollection.fromIterator collect.create = ImmutableCollection.create ``` The constructor clones the input array. Passing anything other than an array or another collection throws `Invalid items type`. ## Mutation reference (read this first) > **Auto-trigger:** code calls `c.sort`, `c.reverse`, `c.flip`, `c.sortByDesc`, `c.shift`, `c.pop`, `c.clone`, or `c.copy` on an `ImmutableCollection`; user asks "is sort/reverse/shift/pop safe", "why is my original collection changing", "do I need to clone before sorting", "which methods mutate in @mongez/collection", "is toArray a copy"; debugging an unexpected mutation bug on a shared collection. > **Skip when:** non-mutating sort by key (`sortBy(key)` and `sortBy({...})`) — use `mongez-collection-sort-group`; insert / remove / replace operations that always return new — use `mongez-collection-overview` for the global picture; understanding what `@mongez/reinforcements` does on its own — that package has no wrapper to mutate. Despite the "Immutable" prefix, four methods mutate the underlying array: | Method | Behavior | |---|---| | `sort(cb?)` | Calls `Array.prototype.sort` on `this.items` directly. Source is reordered. | | `reverse()` / `flip()` | Calls `Array.prototype.reverse` on `this.items`. Source is reversed. | | `sortByDesc(key)` | Comparator-based but operates on `this.items` (no clone). | | `shift()` | Returns the removed first item; mutates source. | | `pop()` | Returns the removed last item; mutates source. | Workaround: `c.clone().sort(...)` or `c.clone().reverse()`. Everything else returns a new collection. ## Array-builtin parity > **Auto-trigger:** code calls any of `c.map`, `c.filter`, `c.flat`, `c.flatMap`, `c.reduce`, `c.reduceRight`, `c.find`, `c.findIndex`, `c.indexOf`, `c.lastIndexOf`, `c.includes`, `c.contains`, `c.every`, `c.some`, `c.join`, `c.implode`, `c.forEach`, `c.each`, `c.keys`, `c.values`, `c.entries`, `c.indexes`, `c.toArray`, `c.all`, `c.toJson`, `c.toString`, `c.takeWhile`, `c.removeAll` on an `ImmutableCollection`; user asks "how do I map / filter / reduce a collection", "why is reduce returning NaN", "how do I unwrap to a plain array", "is toArray a copy"; file iterates / spreads / `Array.from`s a collection. > **Skip when:** operator-based filtering (`where(...)`) — use `mongez-collection-where` or `mongez-collection-querying`; aggregate math (`sum`/`avg`/`min`/`max`) — use `mongez-collection-math`; `pluck` / `select` / `groupBy` / `partition` — use `mongez-collection-transforming` or `mongez-collection-sort-group`; standalone array helpers without `@mongez/collection` — use `mongez-reinforcements-arrays` instead. ```ts // Transforms (return new collection) c.map(cb): ImmutableCollection c.filter(cb): ImmutableCollection c.takeWhile(cb): ImmutableCollection // alias for filter (kept-items) c.removeAll(cb): ImmutableCollection // alias for filter (kept-items; confusing name) c.flat(depth?: number): ImmutableCollection c.flatMap(cb): ImmutableCollection // Reads (return scalar) c.reduce(cb, initialValue?): Acc c.reduceRight(cb, initialValue?): any c.find(cb): T | undefined c.findIndex(cb): number c.indexOf(item, fromIndex?): number c.lastIndexOf(item, fromIndex?): number c.includes(item): boolean c.contains(item): boolean // alias c.has(cb): boolean // findIndex !== -1 c.every(cb): boolean c.some(cb): boolean c.join(separator?): string c.implode(separator?): string // alias // Iteration / shape c.forEach(cb): this c.each(cb): this // alias c.tap(cb: (c) => any): this // side-effect, returns self c.keys(): ImmutableCollection c.values(): ImmutableCollection c.entries(): ImmutableCollection<[number, T]> c.indexes(): ImmutableCollection // same as keys c.length: number // getter (not a method) c[Symbol.iterator](): Iterator // Conversion c.toArray(map?): T[] // returns live reference c.all(): T[] // alias for toArray() c.toString(): string c.toJson(): string ``` > **Known issue**: `c.reduce(cb)` (no initialValue) passes `undefined` through to `Array.prototype.reduce` instead of omitting the arg, producing `NaN` for numeric arrays. Always provide an initial value. ## Insert / remove ```ts // Insert (returns new) c.push(...items): ImmutableCollection c.append(...items): ImmutableCollection // alias c.pushUnique(...items): ImmutableCollection c.unshift(...items): ImmutableCollection c.prepend(...items): ImmutableCollection // alias for unshift c.prependUnique(...items): ImmutableCollection c.unshiftUnique(...items): ImmutableCollection // alias for prependUnique // Quirk: `prependUnique(a, b, c)` unshifts items one-by-one, so the // final ordering is the REVERSE of the argument list (each new item // goes to position 0). // Remove (returns new) c.delete(index): ImmutableCollection c.unset(...indexes): ImmutableCollection c.remove(value): ImmutableCollection // drops first strict-equal match; no-op if not found // Remove (MUTATES + returns the removed item) c.shift(): T | undefined c.pop(): T | undefined // Replace (returns new) c.set(index, value): ImmutableCollection c.update(index, value): ImmutableCollection // alias for set c.replace(oldValue, newValue): ImmutableCollection // first occurrence c.replaceAll(oldValue, newValue): ImmutableCollection // every occurrence // Reorder (returns new) c.swap(i, j): ImmutableCollection c.move(from, to): ImmutableCollection c.reorder({ [oldIndex]: newIndex }): ImmutableCollection ``` ## Merge ```ts c.merge(...arrays): ImmutableCollection // concat with given arrays c.concat(...arrays): ImmutableCollection // alias ``` ## Reads ```ts c.first(): T | undefined c.last(): T | undefined c.end(): T | undefined // alias for last c.at(i): T | undefined c.index(i): T | undefined // alias for at c.get(path): any // dot-notation read on items c.value(key, default?): any // first item's value for that key c.valueAt(index, key, default?): any // one item's value for a key c.lastValue(key, default?): any // last item's value for that key c.getByIndexes(...indexes): ImmutableCollection c.exceptIndexes(...indexes): ImmutableCollection c.equals(other: T[] | ImmutableCollection): boolean // deep equality c.isEmpty(): boolean c.isNotEmpty(): boolean c.lastIndex(): number // length - 1 ``` ## where(...) — operator filtering > **Auto-trigger:** code calls `c.where`, `c.whereIn`, `c.whereNot`, `c.whereBetween`, `c.whereNotBetween`, `c.whereEmpty`, `c.whereNotEmpty`, `c.heavy`, `c.whereNull`, `c.whereNotNull`, `c.whereUndefined`, `c.whereNotUndefined`, `c.whereExists`, `c.whereNotExists`, `c.firstWhere`, `c.lastWhere`, or imports `Operators` / `ComparisonOperator` from `@mongez/collection`; user asks "how do I filter by `>` / `like` / `between` / `in` / `null`", "what operators does where support", "how to use a RegExp with where", "is `where(key, 'is undefined')` broken". > **Skip when:** predicate-callback filtering (`filter`, `reject`, `except`, `not`) — use `mongez-collection-querying` or `mongez-collection-builtins`; aggregations or projections downstream of a filter — chain into `mongez-collection-math` or `mongez-collection-transforming`; `@mongez/reinforcements` has NO `where(...)` operator engine — recommend this skill over `mongez-reinforcements-arrays` when the user needs operator filtering. Three overloads: ```ts c.where(key, value): ImmutableCollection // equality c.where(key, operator, value): ImmutableCollection // any operator c.where(operator, value): ImmutableCollection // primitive-mode (currently broken) ``` Operators (50+): - Equality: `=` / `equals`, `!=` / `not` / `not equals` - Comparison: `>` / `gt`, `>=` / `gte`, `<` / `lt`, `<=` / `lte` - Substring (case-insensitive): `like` / `%`, `not like` / `!%` - Regex: `regex` (or pass a `RegExp` value with no operator) - Set membership: `in`, `not in` / `!in` - Range (inclusive): `between` / `<>`, `not between` / `!between` / `!<>` - Substring (`.includes`): `contains`, `not contains` / `!contains` - Boundary: `starts with`, `not starts with` / `!starts with`, `ends with`, `not ends with` / `!ends with` - Null: `null` / `is null`, `is not null` / `!null` - Undefined: `undefined` / `is undefined`, `is not undefined` / `!undefined` - Existence: `exists`, `not exists` / `!exists` - Boolean: `true` / `is true`, `is not true` / `!true`, `false` / `is false`, `is not false` / `!false` - typeof: `is` / `typeof`, `is not` / `!is` / `not typeof` - instanceof: `instanceof` / `is a`, `not instanceof` / `!instanceof` / `is not a` / `!is a` - Empty: `empty` / `is empty`, `not empty` / `is not empty` / `!empty` `firstWhere(...)`, `lastWhere(...)` are `c.where(...).first()` / `c.where(...).last()` shortcuts. Dedicated shorthands: ```ts c.whereIn(values | key, values): ImmutableCollection c.whereNot(value | key, value): ImmutableCollection c.whereBetween(values | key, values): ImmutableCollection c.whereNotBetween(values | key, values): ImmutableCollection c.whereEmpty(key?): ImmutableCollection c.whereNotEmpty(key?): ImmutableCollection c.heavy(key?): ImmutableCollection // alias for whereNotEmpty c.whereNull(key?): ImmutableCollection c.whereNotNull(key?): ImmutableCollection c.whereUndefined(key?): ImmutableCollection c.whereNotUndefined(key?): ImmutableCollection c.whereExists(key): ImmutableCollection c.whereNotExists(key): ImmutableCollection ``` Inverse: ```ts c.reject(cb): ImmutableCollection // !cb c.except(cb): ImmutableCollection // alias for reject c.skipWhile(cb): ImmutableCollection // alias for reject c.not(value): ImmutableCollection // !== value c.rejectFirst(cb) / exceptFirst(cb): ImmutableCollection // drop first match only c.rejectLast(cb) / exceptLast(cb): ImmutableCollection // drop last match only ``` ### Known where(...) bugs - `where(operator, value)` primitive mode doesn't rotate args; treat `(">", 2)` as broken, use `.filter` directly. - `where(key, "is undefined")` does not match items whose key is explicitly `undefined`. Use `where(key, "not exists")`. ## Math > **Auto-trigger:** code calls `c.sum`, `c.min`, `c.max`, `c.average`, `c.avg`, `c.median`, `c.plus`, `c.minus`, `c.multiply`, `c.divide`, `c.modulus`, `c.increment`, `c.decrement`, `c.double`, `c.half`, `c.even`, `c.odd`, `c.evenIndexes`, `c.oddIndexes`, `c.count`, `c.countValue`, or `c.countBy` on an `ImmutableCollection`; user asks "how do I sum / average / total / max / min on a collection field", "why does min return 0", "how to bump every item by 1", "why does divide throw". > **Skip when:** math without a fluent chain or operator filter — use `mongez-reinforcements-arrays` (lighter `sum` / `min` / `max` / `average` / `median` / `count` / `countBy`); the higher-level "when to use which math method" tutorial — use `mongez-collection-math-aggregation`; aggregation downstream of `groupBy` — see `mongez-collection-transforming` or `mongez-collection-recipes`. ```ts c.min(key?): number // 0 for empty array (reinforcements quirk) c.max(key?): number // 0 for empty array c.sum(key?): number // 0 for empty array c.average(key?): number // NaN for empty array c.avg(key?): number // alias for average c.median(key?): number c.plus(amount): ImmutableCollection c.plus(key, amount): ImmutableCollection // mutates source items via reinforcements `set` c.minus(amount | key, amount): ImmutableCollection c.multiply(amount | key, amount): ImmutableCollection c.divide(amount | key, amount): ImmutableCollection // throws on 0 divisor c.modulus(amount | key, amount): ImmutableCollection // throws on 0 divisor c.increment(key?): ImmutableCollection // +1 c.decrement(key?): ImmutableCollection // -1 c.double(key?): ImmutableCollection // *2 c.half(key?): ImmutableCollection // /2 c.even(key?): ImmutableCollection // values whose key/item is even c.odd(key?): ImmutableCollection c.evenIndexes(): ImmutableCollection // items at positions 0, 2, 4, ... c.oddIndexes(): ImmutableCollection // items at positions 1, 3, 5, ... c.count(keyOrCb): number c.countValue(value): number c.countBy(key): Record ``` ## Strings > **Auto-trigger:** code calls `c.appendString`, `c.prependString`, `c.concatString`, `c.replaceString`, `c.replaceAllString`, `c.removeString`, `c.removeAllString`, `c.trim`, `c.string`, `c.number`, or `c.boolean` on an `ImmutableCollection`; user asks "how do I append / prepend / replace / strip / trim text on every item", "how to cast a collection of strings to numbers / booleans", "how to apply a string transform to one field of every object". > **Skip when:** ad-hoc string mapping where `c.map(item => ...)` is clearer — use `mongez-collection-builtins`; single-string formatting helpers — those live in `@mongez/reinforcements` (string slugify / kebab / camel / template) and are out of scope here; `replaceAllString` with a `RegExp` value — it forces `new RegExp(s, "g")`, use `replaceString(/regex/g, ...)` instead. ```ts // All keyed forms mutate the source items via reinforcements `set`. c.appendString(s, key?): ImmutableCollection c.prependString(s, key?): ImmutableCollection c.concatString(s, key?): ImmutableCollection // identical to appendString for strings c.replaceString(search: string | RegExp, repl: string, key?): ImmutableCollection c.replaceAllString(search: string, repl: string, key?): ImmutableCollection // search is always promoted to global regex c.removeString(search: string | RegExp, key?): ImmutableCollection c.removeAllString(search: string, key?): ImmutableCollection c.trim(chars?: string, key?): ImmutableCollection // default chars = " " // Casting c.string(): ImmutableCollection c.number(): ImmutableCollection c.boolean(): ImmutableCollection ``` ## Pagination / slice / chunk > **Auto-trigger:** code calls `c.take`, `c.limit`, `c.takeLast`, `c.takeUntil`, `c.takeWhile`, `c.skip`, `c.skipTo`, `c.skipLast`, `c.skipUntil`, `c.skipLastUntil`, `c.skipLastWhile`, `c.skipWhile`, `c.slice`, `c.splice`, `c.chunk`, `c.random`, or `c.shuffle` on an `ImmutableCollection`; user asks "how do I paginate a collection", "how to take the first N / last N / page N", "how to batch into chunks of 100", "how to grab a random sample / shuffle". > **Skip when:** page metadata (total / hasNext / totalPages) — not built-in, manually compute from `.length`; chunk a plain array without a wrapper — use `chunk` from `mongez-reinforcements-arrays`; sorting before pagination — chain `sortBy` from `mongez-collection-sort-group` first. ```ts c.take(n) / limit(n): ImmutableCollection c.takeLast(n): ImmutableCollection c.takeUntil(cb): ImmutableCollection // up to (exclusive) the first match c.takeWhile(cb): ImmutableCollection // alias for filter — keeps every match c.skip(n) / skipTo(n): ImmutableCollection c.skipLast(n): ImmutableCollection c.skipUntil(cb): ImmutableCollection // from (inclusive) the first match c.skipLastUntil(cb): ImmutableCollection // keep up to (exclusive) the first match c.skipLastWhile(cb): ImmutableCollection // drop a trailing run of matches c.skipWhile(cb): ImmutableCollection // alias for reject c.slice(start?, end?): ImmutableCollection // non-mutating c.splice(start, deleteCount?, ...inserts): ImmutableCollection // non-mutating c.chunk(size, returnAsCollection?: boolean = true): ImmutableCollection | T[]> c.random(): T // single random item c.random(n): ImmutableCollection // n random items c.shuffle(): ImmutableCollection ``` ## Sort / group / partition / unique > **Auto-trigger:** code calls `c.sort`, `c.sortBy`, `c.sortByDesc`, `c.groupBy`, `c.partition`, `c.unique`, `c.uniqueList`, `c.swap`, `c.move`, `c.reorder`, `c.reverse`, or `c.flip` on an `ImmutableCollection`; user asks "how do I sort by a field / multiple keys", "ascending vs descending", "how to group by role / category", "how to split active vs inactive", "how to dedupe by email", "how to swap two positions / reorder a list". > **Skip when:** mutation safety details for `sort` / `reverse` / `sortByDesc` — use `mongez-collection-mutation` for the in-depth matrix; column projection after grouping — see `mongez-collection-transforming` for `pluck` / `select`; standalone `groupBy` / `unique` without a wrapper — use `mongez-reinforcements-arrays` (lighter helpers); shuffle / random — see `mongez-collection-pagination`. ```ts c.sort(cb?): ImmutableCollection // MUTATES — clone first c.sortBy(key: string): ImmutableCollection // safe (clones) c.sortBy({ [key]: "asc" | "desc" }): ImmutableCollection c.sortByDesc(key: string): ImmutableCollection // MUTATES c.reverse() / flip(): ImmutableCollection // MUTATES c.groupBy(key | keys): ImmutableCollection<{ [key]: any; items: T[] }> c.partition(cb): [ImmutableCollection, ImmutableCollection] c.unique(key?): ImmutableCollection // values, when key given (not deduped objects) c.uniqueList(key): ImmutableCollection // first object per unique key value ``` ## Projection > **Auto-trigger:** code calls `c.pluck`, `c.select`, `c.collectFrom`, `c.collectFromKey`, `c.partition`, `c.uniqueList` on an `ImmutableCollection`; user asks "how do I extract one field from every item", "how to project to a new shape / DTO", "how to keep only some keys per object", "how to flatten line items", "how to split into matching / not-matching"; chained pipelines that combine map + group + dedupe. > **Skip when:** simple `map` / `filter` / `flat` / `flatMap` reference — use `mongez-collection-builtins`; operator filtering before transforming — chain via `mongez-collection-where` or `mongez-collection-querying`; the sort + group + unique reference matrix — use `mongez-collection-sort-group`; one-shot `pluck` / `groupBy` / `unique` without a chain — use `mongez-reinforcements-arrays` (lighter, tree-shakeable). ```ts c.pluck(key | keys): ImmutableCollection c.select(...keys): ImmutableCollection> c.collectFrom(key): ImmutableCollection // flatten one level when key holds arrays c.collectFromKey(index, key): ImmutableCollection c.collectFromKey("index.key"): ImmutableCollection ``` ## Clone ```ts c.clone(): ImmutableCollection c.copy(): ImmutableCollection // alias for clone ``` ## Iteration ```ts for (const item of c) { ... } [...c] Array.from(c) ``` ## Why this package vs `@mongez/reinforcements`' array helpers Both can do the same things; pick by ergonomics: - One operation on an array → reinforcements (lighter, tree-shakeable). - Multi-step pipeline → collection (fluent chain). - `where(...)` operator filtering → collection (no equivalent in reinforcements). Collection delegates to reinforcements for many primitives (`min`, `max`, `sum`, `unique`, `pluck`, `groupBy`, `chunk`, `countBy`, `even`, `odd`, `shuffle`, `pushUnique`, `unshiftUnique`). ## What this package does NOT do - React hooks / signals / atoms. Use [`@mongez/atom`](https://github.com/hassanzohdy/mongez-atom) — `atomCollection` is the equivalent for reactive arrays. - Real immutability with structural sharing. - Async iteration. All operations are synchronous and walk the array eagerly. - Type predicates (use `@mongez/supportive-is`). --- # @mongez/cache # @mongez/cache — full reference > A framework-agnostic cache facade with pluggable drivers — localStorage, sessionStorage, in-memory, encrypted. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/cache ``` ## Public exports ```ts import cache, { CacheManager, BaseCacheEngine, PlainLocalStorageDriver, PlainSessionStorageDriver, EncryptedLocalStorageDriver, EncryptedSessionStorageDriver, RunTimeDriver, setCacheConfigurations, getCacheConfigurations, getCacheConfig, type CacheDriverInterface, type CacheManagerInterface, type CacheConfigurations, } from "@mongez/cache"; ``` The default export is a ready-to-use `CacheManager` instance — call `setCacheConfigurations({ driver })` once at boot to point it at a backend. ## The driver contract ```ts type CacheDriverInterface = { set(key: string, value: any, expiresAfter?: number): CacheDriverInterface; get(key: string, defaultValue?: any): any; has(key: string): boolean; remove(key: string): CacheDriverInterface; clear(): CacheDriverInterface; setPrefixKey(key: string): CacheDriverInterface; getPrefixKey(): string; setValueParser(parser: any): CacheDriverInterface; setValueConverter(converter: any): CacheDriverInterface; }; ``` Every driver in the package implements this. Custom drivers extend `BaseCacheEngine` and inherit the contract. ## The CacheManager facade > **Auto-trigger:** code imports `cache` (default), `CacheManager`, `setCacheConfigurations`, `getCacheConfigurations`, or `getCacheConfig` from `@mongez/cache`; user asks "how do I bootstrap `@mongez/cache`", "how do I hot-swap the driver", or "how do I have two cache managers side by side"; `import cache, { CacheManager } from "@mongez/cache"`. > **Skip when:** per-driver options (`PlainLocalStorageDriver` etc.) — use `mongez-cache-drivers`; daily `cache.set` / `cache.get` / `cache.has` calls — use `mongez-cache-basic-usage`; encrypted setup — use `mongez-cache-encryption` / `mongez-cache-encrypted-cache`; building a custom backend — use `mongez-cache-custom-drivers`. ```ts import cache, { CacheManager } from "@mongez/cache"; cache.set(...); cache.get(...); cache.remove(...); cache.has(...); cache.clear(); cache.setPrefixKey("scoped-"); cache.getPrefixKey(); cache.setDriver(driver); cache.getDriver(); cache.setValueConverter(fn); cache.setValueParser(fn); ``` Use the shipped singleton for the typical "one cache per app" pattern. Build a second manager when you need sibling stores with different prefixes / drivers: ```ts const sessionStore = new CacheManager(); sessionStore.setDriver(new PlainSessionStorageDriver()); sessionStore.setPrefixKey("session-"); ``` ## Configuration ```ts type CacheConfigurations = { driver: CacheDriverInterface; prefix?: string; expiresAfter?: number; // seconds; per-entry default valueConverter?: (value: any) => any; valueParer?: (value: any) => any; // (sic — see CHANGELOG) encryption?: { encrypt: (value: any) => any; decrypt: (value: any) => any; }; }; setCacheConfigurations(configurations: CacheConfigurations): void getCacheConfigurations(): Partial getCacheConfig(key: K): CacheConfigurations[K] | undefined ``` `setCacheConfigurations`: - Installs the driver on the default `cache` manager. - Applies prefix / value-converter / value-parser to that driver. - Records the rest in a module-level singleton for later reads. ```ts import { PlainLocalStorageDriver, setCacheConfigurations } from "@mongez/cache"; setCacheConfigurations({ driver: new PlainLocalStorageDriver(), prefix: "myapp-", expiresAfter: 60 * 60, // 1 hour default }); ``` ## PlainLocalStorageDriver > **Auto-trigger:** code calls `new PlainLocalStorageDriver()` or imports `PlainLocalStorageDriver` from `@mongez/cache`; user asks "how do I persist cache across reloads", "what's the on-disk format for `@mongez/cache`", or "how do I handle the localStorage quota / SSR"; `import { PlainLocalStorageDriver } from "@mongez/cache"`. > **Skip when:** tab-scoped storage — use `mongez-cache-session-storage`; in-memory ephemeral cache — use `mongez-cache-runtime`; encrypted-at-rest variant — use `mongez-cache-encryption` or `mongez-cache-encrypted-cache`; choosing among all drivers at once — use `mongez-cache-drivers`. ```ts import { PlainLocalStorageDriver } from "@mongez/cache"; const driver = new PlainLocalStorageDriver(); driver.set("name", "Hasan"); driver.get("name"); // "Hasan" driver.has("name"); // true driver.remove("name"); driver.clear(); ``` JSON-serializes values into an envelope `{data, expiresAt}` before writing. Reads parse the envelope and check expiry. A corrupted entry returns the default value silently. ## PlainSessionStorageDriver > **Auto-trigger:** code calls `new PlainSessionStorageDriver()` or imports `PlainSessionStorageDriver` from `@mongez/cache`; user asks "how do I cache scroll position / draft form data per tab", "how do I make a wizard remember progress through refreshes only", or "how do I use sessionStorage with `@mongez/cache`"; `import { PlainSessionStorageDriver } from "@mongez/cache"`. > **Skip when:** cross-session persistence — use `mongez-cache-local-storage`; in-memory only cache — use `mongez-cache-runtime`; encrypted variant of session storage — use `mongez-cache-encryption` or `mongez-cache-encrypted-cache`; choosing among all drivers — use `mongez-cache-drivers`. Same contract; backed by `window.sessionStorage`. Cleared when the tab closes. ## EncryptedLocalStorageDriver / EncryptedSessionStorageDriver > **Auto-trigger:** code imports `EncryptedLocalStorageDriver` or `EncryptedSessionStorageDriver` from `@mongez/cache`; user asks "how do encrypted cache drivers work", "what's the encryption pair contract", or "why don't encrypted entries expire"; `import { EncryptedLocalStorageDriver } from "@mongez/cache"`. > **Skip when:** full step-by-step encrypted setup with `@mongez/encryption` and atom adapters — use `mongez-cache-encrypted-cache`; plain (unencrypted) drivers — use `mongez-cache-local-storage` / `mongez-cache-session-storage`; daily `cache.set` / `cache.get` usage — use `mongez-cache-basic-usage`. Require an encrypt/decrypt pair in configuration: ```ts import { encrypt, decrypt, setEncryptionConfigurations } from "@mongez/encryption"; import { EncryptedLocalStorageDriver, setCacheConfigurations } from "@mongez/cache"; setEncryptionConfigurations({ key: "app-secret" }); setCacheConfigurations({ driver: new EncryptedLocalStorageDriver(), encryption: { encrypt, decrypt }, }); ``` Values pass through `encrypt` on write and `decrypt` on read. The pair is looked up via `getCacheConfig("encryption")` on every operation, so rotating it doesn't require new driver instances. **Known bug**: `expiresAfter` is ignored — encrypted entries never expire automatically (`drivers/EncryptedLocalStorageDriver.ts:12`). ## RunTimeDriver > **Auto-trigger:** code calls `new RunTimeDriver()` or imports `RunTimeDriver` from `@mongez/cache`; user asks "how do I get an in-memory cache for tests", "how do I make cache SSR-safe in Node", or "why does `has()` return `true` for missing keys"; `import { RunTimeDriver } from "@mongez/cache"`. > **Skip when:** localStorage-backed persistence — use `mongez-cache-local-storage`; tab-scoped storage — use `mongez-cache-session-storage`; encrypted drivers — use `mongez-cache-encryption`; building a brand-new backend — use `mongez-cache-custom-drivers`. ```ts import { RunTimeDriver, setCacheConfigurations } from "@mongez/cache"; setCacheConfigurations({ driver: new RunTimeDriver() }); cache.set("name", "Hasan"); cache.get("name"); // "Hasan" // Reload — gone. ``` In-memory map. Two `RunTimeDriver` instances on the same page have independent stores. Respects TTL via the same envelope mechanism as the storage-backed drivers. **Known bug**: `has(missingKey)` returns `true` (`drivers/RunTimeDriver.ts:24`). ## TTL — per call or global Per call (third argument to `set`, in seconds): ```ts cache.set("token", "abc", 60 * 15); // 15 minutes ``` Global default (used when `set` omits the per-call argument): ```ts setCacheConfigurations({ driver: new PlainLocalStorageDriver(), expiresAfter: 60 * 60, }); cache.set("user", payload); // expires in 1 hour cache.set("session", value, 60); // overrides to 1 minute ``` Reads past the expiry window return the default and drop the entry. ## Prefixing ```ts setCacheConfigurations({ driver: new PlainLocalStorageDriver(), prefix: "shop-", }); cache.set("user", value); // On disk: { "shop-user": "{...}" } ``` Or directly on a driver: ```ts driver.setPrefixKey("shop-"); driver.getPrefixKey(); // "shop-" ``` Prefixes are not enforced — overlapping prefixes across drivers share storage. Pick a stable string per app. ## Custom serialization Override the JSON conversion at the configuration level: ```ts setCacheConfigurations({ driver: new PlainLocalStorageDriver(), valueConverter: (v) => myEncode(v), valueParer: (v) => myDecode(v), }); ``` Or per driver: ```ts driver .setValueConverter((v) => myEncode(v)) .setValueParser((v) => myDecode(v)); ``` ## Custom drivers > **Auto-trigger:** code declares `class X extends BaseCacheEngine` or imports `BaseCacheEngine` from `@mongez/cache`; user asks "how do I build a custom cache driver", "how do I back the cache with IndexedDB / cookies", or "how do I override the `{data, expiresAt}` envelope / serialization"; `import { BaseCacheEngine } from "@mongez/cache"`. > **Skip when:** picking among the shipped drivers — use `mongez-cache-drivers`; in-memory runtime driver — use `mongez-cache-runtime`; encrypted drivers — use `mongez-cache-encryption`; everyday `cache.set` / `cache.get` usage — use `mongez-cache-basic-usage`. Extend `BaseCacheEngine`: ```ts import { BaseCacheEngine } from "@mongez/cache"; class CookieDriver extends BaseCacheEngine { public storage = { getItem: (k) => /* read cookie */, setItem: (k, v) => /* write cookie */, removeItem: (k) => /* expire cookie */, clear: () => /* expire all known keys */, }; } ``` The base engine handles: - the `{data, expiresAt}` envelope, - expiration checks on read, - prefix application, - JSON serialization (or your override), - fall-through to the default on parse errors. Only `storage` is required; override `convertValue` / `parseValue` if your backend needs a different shape (the `RunTimeDriver` does this — it stores objects directly rather than JSON strings). ## Using with @mongez/atom `@mongez/atom`'s `persist` option accepts any `PersistAdapter`: ```ts type PersistAdapter = { get(key: string): V | undefined | Promise; set(key: string, value: V): void | Promise; remove(key: string): void | Promise; }; ``` `@mongez/cache`'s manager / driver shape matches by name. The thin wrapper below normalizes return values so atom's TypeScript sees `void` on writes: ```ts import { createAtom } from "@mongez/atom"; import cache from "@mongez/cache"; const userAtom = createAtom({ key: "auth.user", default: { name: "Anon" }, persist: { get: (key) => cache.get(key), set: (key, value) => { cache.set(key, value); }, remove: (key) => { cache.remove(key); }, }, }); ``` For a long-lived shared adapter, extract the wrapper: ```ts // adapters/cacheAdapter.ts import cache from "@mongez/cache"; export const cacheAdapter = { get: (key: string) => cache.get(key), set: (key: string, value: unknown) => { cache.set(key, value); }, remove: (key: string) => { cache.remove(key); }, }; ``` Atom consumers stay terse: ```ts import { cacheAdapter } from "./adapters/cacheAdapter"; const themeAtom = createAtom({ key: "ui.theme", default: "light", persist: cacheAdapter, }); ``` This composes well with the encrypted drivers — tokens / refresh tokens / PII end up encrypted on disk without leaking driver details into atom definitions. ## SSR Web-Storage drivers throw on the server. Pick a different driver per environment: ```ts const driver = typeof window === "undefined" ? new RunTimeDriver() : new PlainLocalStorageDriver(); setCacheConfigurations({ driver }); ``` Or write a custom cookie-backed driver — the contract is small. ## Patterns > **Auto-trigger:** user asks "show me an end-to-end `@mongez/cache` example", "how do I persist `@mongez/atom` atoms with `@mongez/cache`", "how do I namespace multiple apps on one domain", "how do I subscribe to cache writes", or "how do I do SSR with `@mongez/cache`"; pull-in pattern: `import { createAtom } from "@mongez/atom"` alongside `import cache from "@mongez/cache"`. > **Skip when:** bare API surface of a single function or driver — use `mongez-cache-basic-usage`, `mongez-cache-manager`, or the per-driver skills; building a brand-new driver — use `mongez-cache-custom-drivers`; first-time discovery of the package — use `mongez-cache-overview`. ### Multi-app domain — namespace by prefix ```ts // in app A setCacheConfigurations({ driver: new PlainLocalStorageDriver(), prefix: "app-a-", }); // in app B (same domain) setCacheConfigurations({ driver: new PlainLocalStorageDriver(), prefix: "app-b-", }); ``` ### Token storage with short TTL ```ts cache.set("session.token", token, 60 * 30); // 30 minutes cache.set("session.refreshToken", refreshToken, 60 * 60 * 24); // 24 hours ``` ### Encrypted token storage ```ts import { encrypt, decrypt, setEncryptionConfigurations } from "@mongez/encryption"; import { EncryptedLocalStorageDriver, setCacheConfigurations } from "@mongez/cache"; setEncryptionConfigurations({ key: "app-secret" }); setCacheConfigurations({ driver: new EncryptedLocalStorageDriver(), encryption: { encrypt, decrypt }, }); cache.set("session.token", token); // On disk: encrypted cypher, not the bare token. ``` ### Multi-store separation ```ts import { CacheManager, PlainLocalStorageDriver, PlainSessionStorageDriver } from "@mongez/cache"; const longLived = new CacheManager(); longLived.setDriver(new PlainLocalStorageDriver()).setPrefixKey("pref-"); const ephemeral = new CacheManager(); ephemeral.setDriver(new PlainSessionStorageDriver()).setPrefixKey("session-"); longLived.set("theme", "dark"); ephemeral.set("scroll.y", 312); ``` ## Lifecycle events `@mongez/cache` does not emit lifecycle events. If you need write-through subscriptions, layer a wrapper: ```ts import { EventBus } from "@mongez/events"; import cache from "@mongez/cache"; const events = new EventBus(); export const observableCache = { set(key: string, value: unknown, expiresAfter?: number) { cache.set(key, value, expiresAfter); events.trigger("cache.set", { key, value }); }, get: cache.get.bind(cache), remove(key: string) { cache.remove(key); events.trigger("cache.remove", { key }); }, on: events.on.bind(events), }; ``` This is intentionally not in the core package — the cache is meant to be small. ## Known bugs - **`EncryptedLocalStorageDriver.set` ignores `expiresAfter`.** Encrypted entries never expire. (`drivers/EncryptedLocalStorageDriver.ts:12`) - **`RunTimeDriver.has(missingKey)` returns `true`.** The base engine's `has()` checks `!== null`, runtime returns `undefined`. (`drivers/RunTimeDriver.ts:24`) - **`valueParer` typo** (missing `s` in `Parser`). Both the configuration field and the typo are preserved for backward compatibility. (`types.ts:67`) - **`clear()` wipes the entire backend, not just keys under the prefix.** Use carefully on shared domains. See the CHANGELOG for the full list. ## What this package does NOT do - Subscriptions / events on writes → wrap the cache or use `@mongez/atom` with a cache-backed `persist` adapter. - IndexedDB driver → build one by extending `BaseCacheEngine`. - Cookie driver → same — build one with the `BaseCacheEngine` shape, or use a SSR-friendly cookie adapter on `@mongez/atom`'s `persist` slot directly. - TypeScript-typed values per key → the `any`-typed driver shape is intentional. Wrap call sites for stronger guarantees. --- # @mongez/config # @mongez/config — full reference > A tiny, framework-agnostic configuration tree. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/config # peer: @mongez/reinforcements ``` ## Public exports ```ts import config from "@mongez/config"; import type { ConfigurationsList } from "@mongez/config"; ``` That's the entire surface. The default export is a singleton object with three methods. ## API ### `config.set(treeOrPath, value?)` > **Auto-trigger:** code calls `config.set` from `@mongez/config`; user asks "how do I deep-merge config", "why was my array replaced instead of appended", or "how do I write a nested config value"; import pattern `import config from "@mongez/config"` followed by `config.set(...)`. > **Skip when:** `@mongez/dotenv` handles `.env` parsing, not this package; reading values — use `mongez-config-reading`; multi-source boot patterns — use `mongez-config-recipes`; whole-tree replacement / live-reference semantics — use `mongez-config-listing`. Two call shapes: ```ts // Object form: deep-merge a partial tree into existing data. config.set({ api: { url: "https://api.example.com", timeout: 5000 }, features: { darkMode: true }, }); // Path form: write one value at a dotted path. config.set("api.url", "https://api.example.com"); config.set("features.darkMode", true); ``` **Deep merge.** Object form recurses through plain objects. Arrays at the same path are **replaced**, not concatenated (this is `@mongez/reinforcements`' default `merge` behavior). **Intermediate creation.** Path form creates intermediate containers on demand: - Object containers, by default. - Array containers when the **next** segment is a numeric index (`"hosts.0.url"` → `hosts` becomes `[]`). **No-op on empty path.** `config.set("", "x")` is a no-op (and so are the same edge cases of the underlying `set`). ### `config.get(path, defaultValue?)` > **Auto-trigger:** code calls `config.get` from `@mongez/config`; user asks "how do I read a value from config", "why is my default not being used", or "how do I read an array index from config"; import pattern `import config from "@mongez/config"` followed by `config.get(...)`. > **Skip when:** `@mongez/dotenv` handles `.env` parsing, not this package; writing values — use `mongez-config-writing`; whole-tree access — use `mongez-config-listing`; TypeScript typing of returns — use `mongez-config-typing`. ```ts config.get("api.url"); // -> string | null config.get("api.timeout", 30000); // -> number config.get("missing.key", "fallback"); // -> "fallback" // Numeric segments index arrays. config.set({ servers: ["a", "b"] }); config.get("servers.0"); // "a" config.get("servers.5", "default"); // "default" ``` `defaultValue` is returned ONLY when: - The path doesn't exist, OR - The path terminates in `undefined`. Other falsy values (`0`, `""`, `false`, `null`) pass through: ```ts config.set("flag", false); config.get("flag", true); // false (NOT true) config.set("count", 0); config.get("count", 1); // 0 ``` **Default for `defaultValue` is `null`** (not `undefined`). Useful for `??` patterns at call sites. ### `config.list()` > **Auto-trigger:** code calls `config.list` from `@mongez/config`; user asks "how do I get the full config tree", "how do I snapshot/clone the config", or "how do I dump the config for debugging"; import pattern `import config from "@mongez/config"` followed by `config.list()`. > **Skip when:** `@mongez/dotenv` handles `.env` parsing, not this package; reading single values — use `mongez-config-reading`; writing values — use `mongez-config-writing`; reactive subscriptions on the tree — use `mongez-atom-*` skills. Returns the underlying data object **by reference**. ```ts config.list(); // { api: {...}, features: {...} } ``` Mutating the result mutates the live config. Clone (`structuredClone(config.list())`) for snapshots. ## Singleton behavior The default export is a single shared object across importers: ```ts // file-a.ts import config from "@mongez/config"; config.set({ a: 1 }); // file-b.ts import config from "@mongez/config"; config.get("a"); // 1 ``` For per-request isolation (SSR), use [`@mongez/atom`](https://github.com/hassanzohdy/atom)'s `AtomStore` — this package is not isolation-aware. ## Typing > **Auto-trigger:** code imports `ConfigurationsList` from `@mongez/config`; code uses `config.get(...)` with a type parameter; user asks "how do I type @mongez/config", "how do I avoid `any` from `config.get`", or "how do I make config type-safe"; user references `AppConfig` or a typed config wrapper. > **Skip when:** `@mongez/dotenv` handles `.env` parsing, not this package; runtime read/write behaviour — use `mongez-config-reading`/`mongez-config-writing`; pattern recipes — use `mongez-config-recipes`. ```ts // Built-in type — permissive on purpose. export type ConfigurationsList = Record; ``` Declare your own shape for type-safe reads: ```ts // src/config-types.ts export type AppConfig = { api: { url: string; timeout: number; headers?: Record; }; features: { darkMode: boolean; beta: boolean; }; }; // boot import config from "@mongez/config"; import type { AppConfig } from "./config-types"; const appConfig: AppConfig = { /* ... */ }; config.set(appConfig); // read const url = config.get("api.url"); const timeout = config.get("api.timeout", 30000); ``` For tighter inference, wrap `config.get` in a typed helper against `AppConfig`. ## Patterns > **Auto-trigger:** code combines `config.set` calls in a boot sequence (base + env + overrides) from `@mongez/config`; user asks "how do I set up multi-environment config", "how do I do feature flags with @mongez/config", or "how do I make config reactive with @mongez/atom"; per-feature wrapper modules around `config.get`. > **Skip when:** `@mongez/dotenv` handles `.env` parsing, not this package; single-method reference — use `mongez-config-reading`/`mongez-config-writing`/`mongez-config-listing`; pure `@mongez/atom` reactivity without config seed — use `mongez-atom-*` skills. ### Multi-source boot ```ts import config from "@mongez/config"; import baseConfig from "./config/base"; import envConfig from "./config/env"; // Deep-merge: later calls layer on top of earlier ones. config.set(baseConfig); config.set(envConfig); // Single-key overrides win last. if (process.env.API_URL) { config.set("api.url", process.env.API_URL); } ``` ### Feature flags with safe defaults ```ts function isEnabled(feature: string): boolean { return Boolean(config.get(`features.${feature}`, false)); } if (isEnabled("darkMode")) { // ... } ``` ### Reactive config via @mongez/atom ```ts import { createAtom } from "@mongez/atom"; import config from "@mongez/config"; const themeAtom = createAtom({ key: "ui.theme", default: config.get("features.defaultTheme", "light"), }); ``` ## Gotchas - **Dots in keys.** `config.set({ "api.example.com": 1 })` stores the literal key, but `config.get("api.example.com")` reads `obj.api.example.com` and returns the default. Avoid dots inside keys. - **`list()` is a live reference.** Mutations leak. Clone for snapshots. - **`config.set("path", undefined)` writes `null`, not `undefined`.** The internal signature is `set(key, value = null)`, and JS default parameters substitute for `undefined`. To remove a key, use `unset(config.list(), [path])` from `@mongez/reinforcements`. - **Array merge replaces, not concatenates.** Object-form `set` of `{ list: [3] }` over `{ list: [1, 2] }` yields `{ list: [3] }`. Use the path form (`config.set("list.2", 3)`) to extend, or read-modify-write (`config.set("list", [...config.get("list"), 3])`). - **No events / no subscriptions.** This package is intentionally dumb. Layer reactivity on top via `@mongez/atom`. ## What this package does NOT do - Reactivity / subscriptions → `@mongez/atom` - Per-request isolation for SSR → `@mongez/atom` (`AtomStore`) - Event bus / change notifications → `@mongez/events` - Object/path utilities directly → `@mongez/reinforcements` --- # @mongez/dotenv # @mongez/dotenv — full reference > **Auto-trigger when loading this full reference:** code imports `loadEnv`, `loadEnvFile`, `parseLine`, `parseValue`, `env`, `env.all`, `resetEnv`, or `EnvLoaderOptions` from `@mongez/dotenv`; typical import pattern `import { loadEnv, loadEnvFile, parseLine, parseValue, env, resetEnv, type EnvLoaderOptions } from "@mongez/dotenv"`; user asks "how do I load .env files", "how do I pick env file by NODE_ENV", "how does `.env.shared` layering work", "how does `env()` coerce values", "why is my `null` becoming `undefined`", "how does `${VAR}` interpolation work", "how do I read a typed env value", "how do I bootstrap env at startup", "how do I build a typed config from env", or "how do I do a full `process.env` reset in tests". > > **Skip when:** the question is about the higher-level app-config layer with groups, dot-notation lookups, defaults, or schema — that's `@mongez/config`, not this `.env`-file parser; browser/cookie/localStorage env shims; caching concerns — that's `@mongez/cache`; runtime schema validation via `zod`/`valibot` (layered on top, not provided here); or the upstream `dotenv` package itself. > A small `.env` loader for Node.js. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/dotenv ``` No runtime dependencies. No peer dependencies. ## Public exports ```ts import { loadEnv, loadEnvFile, parseLine, parseValue, env, resetEnv, type EnvLoaderOptions, } from "@mongez/dotenv"; ``` ## loadEnv(envPath?, options?) ```ts function loadEnv(envPath?: string, options?: EnvLoaderOptions): void ``` ```ts type EnvLoaderOptions = { override?: boolean; // default true — also write into process.env dir?: string; // default cwd() — search root for .env files loadSharedEnv?: boolean; // default true — load .env.shared first }; ``` Resolution order when `envPath` is omitted: 1. If `loadSharedEnv` is true (default) and `${dir}/.env.shared` exists, load it. 2. Try `${dir}/.env.${process.env.NODE_ENV}`. If it exists, load it. 3. Otherwise load `${dir}/.env`. `override` controls whether parsed values are also written to `process.env[key]`. Defaults to `true`. Even with `override: false`, values are still stored internally and accessible via `env(...)` / `env.all()`. ## loadEnvFile(envPath, override) ```ts function loadEnvFile(envPath: string, override: boolean): void ``` Loads exactly one file. Throws if the path does not exist: ``` .env file not found at /path/to/file ``` This is the building block used by `loadEnv`. Useful when you have a non-standard layout (multiple env files in different directories, deferred loading, etc.). ## parseLine(line) ```ts function parseLine(line: string): [string, any] | [] ``` Returns `[]` for: - Empty / whitespace-only lines - Lines starting with `#` (comments) - Lines without an `=` Otherwise returns `[key, parseValue(rhs)]`. The right-hand side is split on the FIRST `=`; the remainder is joined back together, so `KEY=a=b` yields `["KEY", "a=b"]`. ## parseValue(value) ```ts function parseValue(value: any): any ``` Coercion table: | Input | Output | |---|---| | `"3000"` | `3000` (number) | | `"3.14"` | `3.14` (number) | | `"-7"` | `-7` (number) | | `"true"` | `true` (boolean) | | `"false"` | `false` (boolean) | | `"null"` | `null` | | `"My App"` | `"My App"` (string) | | `'"3000"'` | `"3000"` (string — quotes opt out of coercion) | | `'"a \\"b\\" c"'` | `'a "b" c'` (string — `\"` unescaped) | | `""` | `""` (empty string passes through) | | `undefined` | `undefined` | `true` / `false` / `null` are case-sensitive. `True`, `YES`, `1` stay as strings. `${VAR}` is resolved against the internal store (NOT `process.env`). Unresolved references become the literal string `"undefined"`: ```ts parseValue("prefix:${NOT_DEFINED}:suffix"); // "prefix:undefined:suffix" ``` ## env(key, defaultValue?) ```ts const env: { (key: string, defaultValue?: any): any; all(): Record; }; ``` Reads from the internal store. Falls back to `defaultValue` (or `undefined`) when the key is missing. ```ts env("APP_PORT"); // 3000 env("APP_PORT", 8080); // 3000 (loaded value wins) env("MISSING", 0); // 0 env.all(); // { APP_PORT: 3000, ... } (by reference — do not mutate) ``` **Sharp edge:** `env` uses `??`, so a deliberately-loaded `null` collapses to the default / `undefined`. Use `env.all()[key]` to see the raw stored value when you need to distinguish "missing" from "loaded as null". ## resetEnv() ```ts function resetEnv(): void ``` 1. Clears every key from the internal store. 2. Copies the initial `process.env` snapshot (captured at module import time) back onto `process.env`. It does NOT delete keys that have been added to `process.env` since module load. If you need a clean slate, delete the keys explicitly before calling `resetEnv`. ## File-format rules - `KEY=value` — type-coerced. - `KEY="value"` — string, even if it looks numeric. - `KEY="value with spaces"` — quotes preserve whitespace. - `KEY="value \"with\" escapes"` — `\"` becomes `"`. - `KEY=${OTHER}` — interpolation of an already-loaded key. - `# comment line` — ignored. - Blank lines — ignored. - `KEY=` — yields `["KEY", ""]`. - `JUST_A_KEY` — ignored (no `=`). ## NODE_ENV semantics ```bash # Filesystem .env .env.shared .env.development .env.production .env.staging ``` ```ts process.env.NODE_ENV = "production"; loadEnv(); // Loads .env.shared first, then .env.production (overwrites shared keys). process.env.NODE_ENV = "staging"; loadEnv(); // Loads .env.shared first, then .env.staging. process.env.NODE_ENV = "test"; loadEnv(); // Loads .env.shared first; .env.test does not exist, so falls back to .env. ``` ## Override semantics ```ts loadEnv(); // override: true (default) process.env.APP_PORT; // "3000" — string (Node coerces on assignment) env("APP_PORT"); // 3000 — number (typed store) ``` ```ts loadEnv(undefined, { override: false }); process.env.APP_PORT; // unchanged (was undefined → still undefined) env("APP_PORT"); // 3000 ``` Override only affects `process.env`. The internal store is populated either way. ## Known limitations - **`null` is swallowed by `env()`** — see "Sharp edge" above. - **Quoted values with `#` AND a trailing comment trim wrongly**: `KEY="abc#def" # comment` returns `ab` (the parser splits on `#`, takes the left half, then slices off both ends — but the right end is no longer a quote). - **`resetEnv` does not delete later-added keys** from `process.env`. - **`parseValue` is called twice per line** inside `loadEnvFile` (once in `parseLine`, once in the loop). Currently idempotent for supported value shapes. All four are reproduced as `.skip()`'d tests in `src/__tests__/known-bugs.test.ts`. Flipping `.skip` to `it` reproduces each. ## Patterns ### Boot at process start ```ts import { loadEnv } from "@mongez/dotenv"; loadEnv(); // before requiring anything else that reads env ``` ### Per-environment config with shared defaults ```bash # .env.shared APP_NAME="My App" # .env.development DB_URL="mongodb://localhost/dev" DEBUG=true # .env.production DB_URL="mongodb+srv://prod-host/app?retryWrites=true&w=majority" DEBUG=false ``` ```ts loadEnv(); env("DB_URL"); ``` ### Read typed values ```ts const port: number = env("APP_PORT", 3000); const debug: boolean = env("DEBUG", false); const name: string = env("APP_NAME", "App"); ``` ### Lower-level load by absolute path ```ts import { loadEnvFile } from "@mongez/dotenv"; loadEnvFile("/etc/myapp/secrets.env", /* override */ false); ``` ## What this package does NOT do - Validation / schema. Use `zod` / `valibot` on top of `env()` if you need it. - Type-safe key narrowing. `env("KEY")` returns `any`. - Async / dynamic re-loading. - Cookies / browser storage. This is a Node-only filesystem reader. --- # @mongez/encryption # @mongez/encryption — full reference > A thin wrapper around `crypto-js` for symmetric encrypt/decrypt of JSON-encodable values and the common hash functions. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Security boundary (read this first) > **Auto-trigger:** code first imports anything from `@mongez/encryption` (`encrypt`, `decrypt`, `md5`, `sha1`, `sha256`, `sha512`, `setEncryptionConfigurations`, `getEncryptionConfig`, `EncryptionConfigurations`); user asks "what does @mongez/encryption do", "is @mongez/encryption secure for X", "can I store passwords / tokens / PII with this", or "should I use this or Node `crypto`"; file evaluates whether to adopt the package or audits its threat model. > **Skip when:** deep API reference on a specific export — use `mongez-encryption-encrypt-decrypt`, `mongez-encryption-hashes`, or `mongez-encryption-configuration`; ready-made composition patterns — use `mongez-encryption-recipes`; `@mongez/cache` encrypted entries — its own skill wraps this layer; questions strictly about `crypto-js` itself, libsodium, or Node `crypto`. These helpers are **NOT a substitute for purpose-built cryptography**. Below is what they do and do not provide; mismatching the threat model to the tool is a defect, not a usage error. | Concern | This package | |---|---| | Authenticated encryption (AEAD) | No. `AES.encrypt(text, passphrase)` from `crypto-js` runs AES-CBC with an MD5-based OpenSSL-style key derivation and a random salt. There is no MAC/auth tag — ciphertext can be tampered with undetectably. | | Modern key derivation | No. The OpenSSL-style KDF is one round of MD5. Real passphrase-derived keys want PBKDF2 / scrypt / argon2 with tunable cost. | | Salts and IVs | crypto-js picks a fresh salt per call when the key is a string passphrase, so the cipher is non-deterministic. No IV is exposed to the caller. | | Constant-time comparison | No. Hash outputs are hex strings; comparing them with `===` is not timing-safe. | | `md5` / `sha1` collision resistance | Broken. Both algorithms are unsafe for integrity or signatures; suitable only for non-adversarial fingerprinting (ETags, cache keys). | | FIPS / regulated environments | No. Pure-JS primitives, no validation, uses MD5 and AES-CBC-without-MAC. Inappropriate for several compliance regimes. | **Use for:** opaquing query-string parameters, masking values in logs, signing-free local-storage payloads, non-sensitive round-trips through string form. **Do NOT use for:** passwords, session tokens, PII at rest, payment data, secrets in transit, anything under a compliance regime, anything where integrity or non-repudiation matters. Reach for Node `crypto` with AES-GCM, libsodium, or a managed KMS. ## Install ```sh yarn add @mongez/encryption ``` The runtime dependency is [`crypto-js`](https://www.npmjs.com/package/crypto-js). ## Public exports ```ts import { encrypt, decrypt, md5, sha1, sha256, sha512, setEncryptionConfigurations, getEncryptionConfig, type EncryptionConfigurations, } from "@mongez/encryption"; ``` All exports ship from the package root — no subpath entry points. ## encrypt(value, key?, driver?) > **Auto-trigger:** code imports `encrypt` or `decrypt` from `@mongez/encryption`; user asks "how do I encrypt/decrypt a value", "why does decrypt return null", "how do I round-trip a token through a URL", or "why are two encryptions of the same input different"; file constructs opaque tokens, masks values in logs, or swaps the `crypto-js` driver (AES, TripleDES). > **Skip when:** hashing/fingerprinting — use `mongez-encryption-hashes`; configuring module-level defaults — use `mongez-encryption-configuration`; recipe-style compositions (URL token, HMAC layering) — use `mongez-encryption-recipes`; authenticated encryption / AEAD (use Node `crypto` AES-GCM, libsodium, or JWE); password storage (use `bcrypt`/`scrypt`/`argon2`); `@mongez/cache` encrypted entries (its own skill). ```ts encrypt(value: any, key?: string, driver?: any): string ``` Behavior: 1. Wraps the input as `{ data: value }` so primitives (`0`, `false`, `null`) survive JSON encoding. 2. `JSON.stringify`s the wrapper. 3. Hands the result to `driver.encrypt(plaintext, key)` — typically `AES` from `crypto-js`. The `driver` defaults to whatever was passed to `setEncryptionConfigurations`; the import-time default is `AES`. 4. Returns crypto-js's `CipherParams.toString()` — a base64 string with the OpenSSL `Salted__` prefix. Throws: - If `key` is falsy (no per-call key, no configured default). - If `JSON.stringify(value)` throws — e.g. circular references, BigInt outside Node's serializer, or objects whose `toJSON` throws. Edge cases: - **`undefined`**: `JSON.stringify({ data: undefined })` returns `"{}"`. Encrypts and round-trips to `undefined`. - **`function`**: dropped at JSON time, same as `undefined`. - **Empty string**: round-trips cleanly. - **Unicode**: round-trips via UTF-8. - **Ciphertext is non-deterministic** for AES with a passphrase key — each call uses a fresh salt. ## decrypt(cipher, key?, driver?) ```ts decrypt(cipher: string, key?: string, driver?: any): any | null ``` Behavior: 1. Calls `driver.decrypt(cipher, key)` and decodes the result to UTF-8. 2. Returns `null` if the decoded plaintext is empty — crypto-js silently produces `""` when the key is wrong or the input is not a valid cipher. 3. `JSON.parse`s the plaintext and returns the `.data` property of the wrapper. 4. Catches any thrown error, logs it via `console.warn`, returns `null`. The function CANNOT distinguish: - "wrong key" from "tampered cipher" from "malformed input" …because the wrapper provides no authentication tag. If you need that distinction, layer HMAC-SHA256 over the ciphertext yourself (sign-then-encrypt), or switch to a real AEAD construction. Throws: - Only if `key` is falsy. Every other failure mode resolves to `null`. ## md5 / sha1 / sha256 / sha512 > **Auto-trigger:** code imports `md5`, `sha1`, `sha256`, or `sha512` from `@mongez/encryption`; user asks "how do I hash a string", "how do I make a stable cache key / ETag / idempotency key", "is md5/sha1 safe for X", or "how do I fingerprint a payload"; file derives a content-addressed key from JSON / a query / a request body. > **Skip when:** symmetric `encrypt`/`decrypt` — use `mongez-encryption-encrypt-decrypt`; module defaults — use `mongez-encryption-configuration`; password storage (use `bcrypt`/`scrypt`/`argon2`); message authentication (use HMAC — `CryptoJS.HmacSHA256`, or Node `crypto.createHmac`); constant-time secret comparison (use `crypto.timingSafeEqual`); `@mongez/cache` cache-key derivation that already wraps this; signatures over adversarial inputs. ```ts md5(text: string): string sha1(text: string): string sha256(text: string): string sha512(text: string): string ``` All four return a lowercase hex string of the digest. Test vectors: ``` md5("123456") === "e10adc3949ba59abbe56e057f20f883e" sha1("123456") === "7c4a8d09ca3762af61e59520943dc26494f8941b" sha256("123456") === "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92" sha512("123456") === "ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413" md5("") === "d41d8cd98f00b204e9800998ecf8427e" ``` **Suitable uses**: content fingerprints, ETags, cache keys, deduplication. **Unsuitable uses**: - Password storage → use `bcrypt`, `scrypt`, or `argon2`. Plain hashes are too fast and lack per-record salts. - Message authentication → use HMAC. `crypto-js` ships `CryptoJS.HmacSHA256(message, key).toString()` if you want to stay in the same dep. - Equality checks on secret material → use a constant-time comparison. - Anywhere collision resistance matters and the input is attacker-controlled (signatures, certificates, deduplicating untrusted data) → `md5` and `sha1` are broken. ## setEncryptionConfigurations(opts) / getEncryptionConfig(key) > **Auto-trigger:** code imports `setEncryptionConfigurations`, `getEncryptionConfig`, or type `EncryptionConfigurations` from `@mongez/encryption`; user asks "how do I set a default encryption key / driver", "how do I configure encryption at boot", or "how does the per-call fallback work"; file does process-wide encryption setup (e.g. `src/setup/encryption.ts`) or wires `process.env.ENCRYPTION_KEY`. > **Skip when:** per-call usage of `encrypt`/`decrypt` without touching defaults — use `mongez-encryption-encrypt-decrypt`; HMAC/AEAD/JWT setup; password hashing config; `@mongez/cache` encryption-driver config (its own skill — note `@mongez/cache` has its own encrypted cache layer that wraps this); multi-tenant per-request keys (pass key explicitly instead). ```ts type EncryptionConfigurations = { key?: string; driver?: any; // any crypto-js cipher module (.encrypt / .decrypt) }; setEncryptionConfigurations(opts: EncryptionConfigurations): void getEncryptionConfig(key: keyof EncryptionConfigurations): any ``` `setEncryptionConfigurations` shallow-merges over the existing defaults. The import-time defaults are `{ key: null, driver: AES }`, so if you only need AES, you only need to set `key`. These are module-level globals; in multi-tenant servers (one key per request) prefer passing `key` explicitly per call to avoid leaking one tenant's key into another's flow. ## Patterns > **Auto-trigger:** code composes `encrypt` + `decrypt` + `sha256` + `setEncryptionConfigurations` together; user asks "how do I build an opaqued URL token", "how do I add integrity / tamper detection on top of this", "how do I tell apart `decrypt → null` from `value was null`", or "what's the right pattern for boot-time encryption setup"; file layers `HmacSHA256` over a cipher (encrypt-then-MAC). > **Skip when:** single-function API lookups (use the per-export skills `mongez-encryption-encrypt-decrypt`, `mongez-encryption-hashes`, `mongez-encryption-configuration`); first-time evaluation of the package — use `mongez-encryption-overview`; full AEAD via Node `crypto.createCipheriv("aes-256-gcm", …)` or libsodium; signed JWT / JWS flows; `@mongez/cache` encrypted entries (its own skill). ### Configured once, used everywhere ```ts import AES from "crypto-js/aes"; import { encrypt, decrypt, setEncryptionConfigurations } from "@mongez/encryption"; setEncryptionConfigurations({ key: process.env.ENCRYPTION_KEY!, driver: AES, }); const c = encrypt({ userId: 42 }); const v = decrypt(c); // { userId: 42 } ``` ### Explicit per-call key ```ts import AES from "crypto-js/aes"; import { encrypt, decrypt } from "@mongez/encryption"; const c = encrypt({ a: 1 }, tenantKey, AES); const v = decrypt(c, tenantKey, AES); ``` ### Switching driver ```ts import TripleDES from "crypto-js/tripledes"; import { setEncryptionConfigurations, encrypt, decrypt } from "@mongez/encryption"; setEncryptionConfigurations({ driver: TripleDES }); // key already set ``` Any `crypto-js` cipher module with `.encrypt(text, key)` / `.decrypt(cipher, key)` works. Prefer `AES`. ### Adding integrity with HMAC (for when you need it) This pattern is OUTSIDE the package because the package intentionally does not provide it; if your threat model requires tamper-detection, build it explicitly: ```ts import AES from "crypto-js/aes"; import HmacSHA256 from "crypto-js/hmac-sha256"; import { encrypt, decrypt } from "@mongez/encryption"; function seal(value: unknown, encKey: string, macKey: string) { const cipher = encrypt(value, encKey, AES); const tag = HmacSHA256(cipher, macKey).toString(); return `${cipher}.${tag}`; } function open(sealed: string, encKey: string, macKey: string) { const [cipher, tag] = sealed.split("."); const expected = HmacSHA256(cipher, macKey).toString(); if (tag !== expected) return null; // (non-constant-time; see notes) return decrypt(cipher, encKey, AES); } ``` Note: the string-equality MAC check is NOT constant-time. For real use, swap in a constant-time compare or use a JWT/JWS library. ## What this package does NOT do - Authenticated encryption / AEAD → Node `crypto.createCipheriv("aes-256-gcm", …)`, libsodium, or JWE. - Password hashing → `bcrypt`, `scrypt`, `argon2`. - Key derivation from passphrases → `crypto.scryptSync`, PBKDF2 with high cost, argon2. - Random IDs / UUIDs → `crypto.randomUUID`, `nanoid`. - Public-key crypto → Node `crypto.generateKeyPair`, libsodium. - Streaming or chunked encryption → use Node `crypto` streams. --- # @mongez/concat-route # @mongez/concat-route — full reference > **Auto-trigger when loading this full reference:** code uses the default export `concatRoute` or matches `import concatRoute from "@mongez/concat-route"`; user asks about joining/concatenating URL path segments, normalizing slashes, building API URLs, locale-prefixed routes, normalizing a configurable base path, paginated routes, sub-resources, spreading a breadcrumb array, why the result is missing a leading slash, what `concatRoute` returns for empty/`null`/`undefined`, or "should I use `concatRoute` or `path.posix.join`"; helpers like `userUrl(id)` or `route(locale, ...rest)` appear near the import. > > **Skip when:** query-string parsing/building (use `@mongez/query-string`); absolute URL composition with protocol/host (use the platform `URL`); route pattern matching like `/users/:id` (use `@mongez/react-router`); percent-encoding (use `encodeURIComponent`); `node:path` / `path.posix.join` or other generic string joiners. > A tiny, dependency-free path joiner. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/concat-route ``` No runtime dependencies. Default export only. ## Public export ```ts import concatRoute from "@mongez/concat-route"; ``` The default export is the only public surface. There are no named exports, no types exported alongside it. ## Signature ```ts function concatRoute(...segments: string[]): string; ``` Variadic. Returns a single normalized path string. ## Normalization rules The function applies these steps in order: 1. **Filter falsy.** Segments that fail `value && String(value).length > 0` are dropped. This removes `""`, `null`, `undefined`, `0`, and `false` before joining. The TypeScript signature is `string[]`, but the runtime check uses truthiness. 2. **Strip outer slashes per segment.** A leading `/` and a trailing `/` are each removed once per segment via `String(text).replace(/^\/|\/$/g, "")`. Despite the `/g` flag, the anchors `^` and `$` limit each side to one match, so `"///foo///"` becomes `"//foo//"` after this step. 3. **Prefix each surviving segment with `/` and join.** `["foo", "bar"]` becomes `"/foo/bar"`. 4. **Collapse runs of `/` to a single `/`.** The previous step can leave embedded `//` from segments like `"//foo//"`; `.replace(/(\/)+/g, "/")` flattens them. 5. **Strip outer slashes one more time, then prepend `/`.** Guarantees the result starts with exactly one `/` and never ends with `/`. ## Return contract - Always a `string`. - Always starts with exactly one `/`. - Never ends with `/` **except** when the result is the empty/root path, in which case the return value is `"/"`. - `concatRoute()`, `concatRoute("")`, `concatRoute("/")`, `concatRoute(null as any)`, `concatRoute(undefined as any)`, and `concatRoute("", "", "")` all return `"/"`. ## Behavior table | Input | Output | Why | |---|---|---| | `()` | `"/"` | Empty input → root | | `("")` | `"/"` | Filtered as falsy | | `("/")` | `"/"` | Outer slash stripped, joined to empty, root prepended | | `("foo")` | `"/foo"` | Leading slash always added | | `("/foo/")` | `"/foo"` | Trailing slash stripped | | `("/", "home")` | `"/home"` | Slash segment collapses | | `("foo", "bar")` | `"/foo/bar"` | Joined with `/` | | `("//foo//", "bar")` | `"/foo/bar"` | Inner doubles collapsed by step 4 | | `("a/b", "c")` | `"/a/b/c"` | Embedded `/` is preserved | | `("a//b", "c")` | `"/a/b/c"` | Embedded `//` collapses too | | `("", null, undefined, 0, false)` | `"/"` | All filtered as falsy | | `("/api", "v1", "users", "42")` | `"/api/v1/users/42"` | Typical usage | | `("/home", "?q=1")` | `"/home/?q=1"` | Query string treated as a segment; **gets a leading slash** | | `("/home", "#x")` | `"/home/#x"` | Hash treated as a segment; **gets a leading slash** | | `("https://example.com", "/api")` | `"/https:/example.com/api"` | The `//` of the protocol is collapsed by step 4 — function is path-only, not URL-aware | ## What this package does NOT do - It does not parse or build query strings. See [`@mongez/query-string`](https://github.com/hassanzohdy/mongez-query-string). - It does not encode URL components. Use `encodeURIComponent`. - It does not handle absolute URLs. The protocol's `//` is destroyed by the slash-collapse step. Use the platform `URL` for that. - It does not validate that the result is a legal URL path. Reserved characters, encoded sequences, and dot segments (`.`, `..`) pass through unchanged. - It does not generate route patterns or match parameters — that's the router's job. ## Patterns ### Building an API URL from a base path ```ts import concatRoute from "@mongez/concat-route"; const apiBase = "/api/v1"; const userRoute = concatRoute(apiBase, "users", String(userId)); // "/api/v1/users/42" ``` ### Locale-prefixed routes ```ts const locale = currentLocale ?? ""; const path = concatRoute("/", locale, "products", productSlug); // locale = "en" → "/en/products/foo" // locale = "" → "/products/foo" (falsy segment dropped) ``` ### Normalizing user-provided base paths ```ts function buildPath(base: string | undefined, ...rest: string[]) { return concatRoute(base ?? "", ...rest); } buildPath("/app/", "settings"); // "/app/settings" buildPath("app", "settings"); // "/app/settings" buildPath(undefined, "settings"); // "/settings" buildPath("", "settings"); // "/settings" buildPath("/", "settings"); // "/settings" ``` ### Composing with a query string ```ts const path = concatRoute("/api", "v1", "search"); const url = `${path}?q=${encodeURIComponent(query)}`; // path "/api/v1/search", then add the query yourself ``` Do NOT pass `"?q=..."` as a segment to `concatRoute` — it will be wrapped in `/` and produce `/api/v1/search/?q=...`, which may or may not be what your router expects. ## Related packages - [`@mongez/react-router`](https://github.com/hassanzohdy/mongez-react-router) — the router this helper feeds. - [`@mongez/query-string`](https://github.com/hassanzohdy/mongez-query-string) — query parameter parsing/stringifying. - [`@mongez/localization`](https://github.com/hassanzohdy/mongez-localization) — locale segments commonly prepended via `concatRoute`. --- # @mongez/query-string # @mongez/query-string — full reference > **Auto-trigger when loading this full reference:** code imports or calls `queryString`, `parse`, `all`, `get`, `toString`, `toQueryString`, or `update`; dev phrasings like "how do I read a URL query string", "how do I extract a query param", "how do I build a query string from an object", "how do I update the URL without reloading", "how do I parse `tags[]=a&tags[]=b`", "how do I do URL-driven filters", "how do I do pagination in the URL", "how do I round-trip values", "why does `?zip=007` become `7`", "why isn't `&` encoded", "why does `null` disappear", "how do I clear the query string", "how do I parse a query string server-side", or "which @mongez package handles query strings"; the import pattern `import queryString from "@mongez/query-string"`. > > **Skip when:** the task is URL/path joining without query strings — use `@mongez/concat-route`; React-aware URL hooks, route params, or `useSearchParams` — use `@mongez/react-router`; or the code uses native `URLSearchParams`, the `qs` npm package, or the `query-string` npm package instead of `@mongez/query-string`. > Tiny query-string parse/serialize with nested-object and array support. Default-export API. Browser helpers for `window.location`. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/query-string ``` No runtime dependencies. ## Public export ```ts import queryString from "@mongez/query-string"; ``` The default export is a single object with six methods: ```ts { parse(searchParams: string): Record; all(searchParams?: string): Record; get(key: string, defaultValue?: any): any; toString(): string; toQueryString(params: Record | string): string; update(params: Record | string): void; } ``` ## queryString.parse(searchParams) ```ts queryString.parse(searchParams: string): Record ``` Parses a query string into an object. - A leading `?` is stripped. - Empty input returns `{}`. - Values that look numeric (`!isNaN(value - parseFloat(value))`) become numbers. - Non-numeric values are passed through `decodeURIComponent`. (`+` is NOT translated to a space — use `replace(/\+/g, "%20")` first if your producer uses it.) - Keys suffixed with `[]` produce array values. Repeat the key for multiple elements. - Keys with `[sub]` produce nested objects. Any depth: `a[b][c]=1` → `{ a: { b: { c: 1 } } }`. - Duplicate non-array keys overwrite — last write wins. Behavior table: | Input | Output | |---|---| | `""` | `{}` | | `"?"` | `{}` | | `"?foo=bar"` | `{ foo: "bar" }` | | `"a=1&b=2"` | `{ a: 1, b: 2 }` | | `"n=42"` | `{ n: 42 }` | | `"zip=007"` | `{ zip: 7 }` (number coercion is lossy) | | `"q=hello%20world"` | `{ q: "hello world" }` | | `"tags[]=a&tags[]=b"` | `{ tags: ["a", "b"] }` | | `"user[name]=alice&user[age]=30"` | `{ user: { name: "alice", age: 30 } }` | | `"a[b][c]=1"` | `{ a: { b: { c: 1 } } }` | | `"foo=true"` | `{ foo: "true" }` (no boolean coercion) | | `"foo=NaN"` | `{ foo: "NaN" }` (NaN check excludes itself) | | `"foo"` (no `=`) | `{ foo: "undefined" }` (documented bug) | ## queryString.all(searchParams?) ```ts queryString.all(searchParams?: string): Record ``` Same parsing rules as `parse`, but `searchParams` defaults to `window.location.search`. Use `all()` (no arg) to read the current URL; pass an explicit string to override. ```ts // On URL: /products?tag=books&page=2 queryString.all(); // { tag: "books", page: 2 } queryString.all("?x=1"); // { x: 1 } queryString.all(""); // {} ``` ## queryString.get(key, defaultValue?) ```ts queryString.get(key: string, defaultValue?: any = null): any ``` Reads one key from `queryString.all()`. Returns `defaultValue` if the parsed value is falsy (because the implementation uses `all[key] || defaultValue`). The default `defaultValue` is `null`. ```ts // On URL: /products?page=2&empty= queryString.get("page"); // 2 queryString.get("missing"); // null queryString.get("missing", 1); // 1 queryString.get("missing", { x: 1 }); // { x: 1 } queryString.get("empty", "fallback"); // "fallback" — falsy values fall through ``` For strict presence checks ("did the user pass `?empty=`?"), use `queryString.all()` and look up the key yourself. ## queryString.toString() ```ts queryString.toString(): string ``` Returns `window.location.search.substring(1)` — the current query string without the leading `?`. Empty string if there's no query. ## queryString.toQueryString(params) ```ts queryString.toQueryString(params: Record | string): string ``` Serializes an object back into a query string. - An empty object returns `""`. - A string argument is returned unchanged (passthrough for callers that already hold one). - Arrays use the `key[]=value` form, one entry per element. - Objects recurse with `parent[child]=value`. Deep nesting is supported. - Primitive values are stringified via implicit `toString()`. Numbers become their decimal representation; booleans become `"true"` / `"false"`; `undefined` becomes the literal string `"undefined"`. - **`null` drops the key.** `typeof null === "object"` triggers the recursive branch and `{ ...null }` collapses to `{}`. Documented quirk. - **Values are NOT percent-encoded.** A value containing `&` or `=` produces ambiguous output that won't round-trip through `parse`. Pre-encode values that may contain reserved characters. Behavior table: | Input | Output | |---|---| | `{}` | `""` | | `{ foo: "bar" }` | `"foo=bar"` | | `{ a: 1, b: 2 }` | `"a=1&b=2"` | | `{ tags: [] }` | `""` | | `{ tags: ["a", "b"] }` | `"tags[]=a&tags[]=b"` | | `{ user: { name: "alice" } }` | `"user[name]=alice"` | | `{ a: { b: { c: 1 } } }` | `"a[b][c]=1"` | | `{ v: null }` | `""` (documented quirk) | | `{ v: undefined }` | `"v=undefined"` | | `{ on: true }` | `"on=true"` | | `"already=encoded"` | `"already=encoded"` (passthrough) | ## queryString.update(params) ```ts queryString.update(params: Record | string): void ``` Calls `window.history.replaceState({}, "", url)` with `url = window.location.pathname + (queryStringText ? "?" + queryStringText : "")`. Keeps the pathname, replaces the query string. - Pass an object to serialize through `toQueryString`. - Pass a string to use it verbatim. - An empty object or empty string clears the query. - Does NOT push a new history entry, does NOT fire `popstate`, does NOT trigger a reload. For navigation that creates a history entry, use `history.pushState` directly with `queryString.toQueryString(params)`. ```ts queryString.update({ tag: "books", page: 3 }); // URL: /products?tag=books&page=3 queryString.update("page=4"); // URL: /products?page=4 queryString.update({}); // URL: /products ``` ## Patterns ### URL-driven filters ```ts import queryString from "@mongez/query-string"; type Filters = { tag?: string; sort?: string; page?: number }; function readFilters(): Filters { return queryString.all() as Filters; } function writeFilters(f: Filters) { queryString.update(f as Record); } ``` ### Pagination ```ts const page = (queryString.get("page", 1) as number); queryString.update({ ...queryString.all(), page: page + 1 }); ``` ### Safe round-trip ```ts const obj = { tag: "books", page: 2, ids: [1, 2, 3] }; const text = queryString.toQueryString(obj); queryString.parse(text); // structurally equal to obj (with numeric coercion applied) ``` For values that may contain `&` / `=` / `%`, pre-encode each value with `encodeURIComponent` BEFORE handing it to `toQueryString` — the serializer will not encode for you. ## Internals The package ships two pure helpers as named exports — they're used by the default-export facade and exposed for callers that want to bypass it: ```ts import { toObjectParser, toStringParser, } from "@mongez/query-string/src/query-string-parsers"; toObjectParser("a=1&b=2"); // { a: 1, b: 2 } toStringParser({ a: 1, b: 2 }); // "a=1&b=2" toStringParser({ name: "alice" }, "user"); // "user[name]=alice" — second arg is the parent-key prefix used for recursion ``` These are NOT part of the stable surface — treat them as implementation details. ## Documented quirks Each of these has a corresponding `.skip()` test under `src/__tests__/` so a future fix has a regression target. None of them changes behaviour in this release. 1. **No percent-encoding on output** — `src/query-string-parsers.ts:55-60`. Values containing `&` or `=` produce ambiguous query strings. Round-trip fails. 2. **`null` drops the key** — `src/query-string-parsers.ts:55`. `typeof null === "object"` routes `null` into the recursive branch. 3. **Key without `=` parses to literal `"undefined"`** — `src/query-string-parsers.ts:14`. Caused by `decodeURIComponent(undefined)`. 4. **`get(key, default)` returns default for falsy values** — `src/index.ts:38`. Implementation uses `||`. Documented as a behaviour contract, not a fix target. ## What this package does NOT do - React-aware URL state hooks — pair with `@mongez/react-router`. - A general URL builder — pair with `@mongez/concat-route`. - A persistent client-side cache — see `@mongez/cache`. - Server-side query parsing — `parse` works without `window`, but the four browser-bound methods (`all`, `get`, `toString`, `update`) reference `window.location` / `window.history` and will throw on the server. --- # @mongez/dom # @mongez/dom — full reference > Browser-side DOM utilities. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/dom ``` No runtime dependencies. No subpath entry points — every export ships from `@mongez/dom`. ## Environment This package targets the browser. Calling these functions on the server (Node, edge runtimes, workers without DOM) will throw — `document`, `window.matchMedia`, `FontFace`, `document.fonts`, and other globals don't exist there. If the same module loads on the server, guard the call site: ```ts if (typeof window !== "undefined") { setTitle("Hello"); } ``` ## Public exports ```ts import { // Page metadata (high-level) setPageMeta, setTitle, setDescription, setKeywords, setImage, setPageColor, setFavIcon, setCanonicalUrl, getMetaData, // Element attributes setHTMLAttributes, setElementAttributes, getElementAttributes, // Head element building (low-level) createHeadElement, createNewMeta, meta, itemprop, metaLink, twitter, og, // Stylesheets and fonts styleSheet, googleFont, loadFont, // CSS variables cssVariable, // default export of "./css-variable" — re-exported as named setCssVariable, getCssVariable, // Viewport dimensions getWindowWidth, getWindowHeight, getScreenWidth, getScreenHeight, // Keyboard pressed, TAB_KEY, ESC_KEY, ENTER_KEY, CONTROL_KEY, // Misc scrollTo, loadScript, htmlToText, userPrefersDarkMode, // Types type MetaData, type OpenGraph, type AttributesList, type FontOptions, type FontWeightSetup, } from "@mongez/dom"; ``` ## Page metadata — high level > **Auto-trigger:** code imports `setPageMeta`, `setTitle`, `setDescription`, `setKeywords`, `setImage`, `setPageColor`, `setFavIcon`, `setCanonicalUrl`, `getMetaData`, or the `MetaData` type from `@mongez/dom`; user asks "how do I set the page title", "how do I update Open Graph / Twitter Card tags", "how do I set the canonical URL", "how do I update the favicon", or "how do I read back page metadata"; `import { setPageMeta, setTitle, ... } from "@mongez/dom"`. > **Skip when:** user needs custom `og:*`, `article:*`, manifest links, or arbitrary ``/`` tags the high-level setters don't cover — load `mongez-dom-head-elements` instead; React-idiomatic declarative head management belongs to `@mongez/react-helmet`, this package is framework-agnostic DOM; loading stylesheets/fonts/scripts → `mongez-dom-assets`. ```ts setPageMeta(metaList: MetaData): void ``` ```ts type MetaData = { title?: string; description?: string; image?: string; keywords?: string | string[]; url?: string; // canonical favIcon?: string; color?: string; // theme color type?: "website" | "article" | "profile" | "book" | "music" | "video" | string; }; ``` Applies any subset of fields. Each delegates to a per-field helper. Repeat calls update existing tags instead of duplicating. | Helper | Effect on `` | |---|---| | `setTitle(t)` | ``, `meta[property=og:title]`, `meta[property=og:image:alt]`, `meta[property=twitter:title]`, `meta[property=twitter:image:alt]`, `meta[itemprop=name]` | | `setDescription(d)` | `meta[name=description]`, `meta[itemprop=description]`, `meta[property=og:description]`, `meta[property=twitter:description]` | | `setKeywords(s\|s[])` | `meta[name=keywords]`. Arrays joined with `,`. | | `setImage(url)` | `meta[property=image]`, `meta[property=og:image]`, `meta[property=twitter:image]`, `meta[itemprop=image]` | | `setPageColor(c)` | `meta[property=theme-color]` *(see "Known bugs" — spec is `name=`)* | | `setFavIcon(url)` | `link[rel=icon]` | | `setCanonicalUrl(url)` | `link[rel=canonical]`, `meta[property=og:url]`, `meta[property=twitter:url]` | | `metaList.type` | `meta[property=og:type]` via the lower-level `meta()` | ```ts getMetaData(name?: keyof MetaData): MetaData | string | undefined ``` Returns the internal `currentMetaData` singleton, or one field when called with a key. Reflects only fields that were updated via the high-level helpers — see "Known bugs". ## Element attributes > **Auto-trigger:** code imports `createHeadElement`, `createNewMeta`, `meta`, `itemprop`, `metaLink`, `twitter`, `og`, `setHTMLAttributes`, `setElementAttributes`, `getElementAttributes`, or the `AttributesList` type from `@mongez/dom`; user asks "how do I add custom og:* / article:* meta tags", "how do I add a manifest link", "how do I set html[lang] / html[dir]", "how do I add a preconnect / alternate link", or "how do I read all attributes off an element"; `import { meta, metaLink, itemprop, setHTMLAttributes } from "@mongez/dom"`. > **Skip when:** user only needs the common `title` / `description` / `og:*` / `twitter:*` / favicon / canonical fields — load `mongez-dom-metadata` instead; `<link rel="stylesheet">` / Google Fonts injection lives in `mongez-dom-assets`; React declarative head — `@mongez/react-helmet`; this package is framework-agnostic DOM. ```ts setElementAttributes(element: HTMLElement, attributes: AttributesList): void setHTMLAttributes(attributes: AttributesList): void // = setElementAttributes(document.documentElement, ...) getElementAttributes(element: HTMLElement): AttributesList type AttributesList = { [attributeKey: string]: any }; ``` Plain pass-through to `element.setAttribute(key, value)` per entry. `getElementAttributes` walks `element.attributes` and returns a `Record<string, string>`. ## Head element building — low level ```ts createHeadElement(tagName: string, props: object): HTMLElement createNewMeta(props: object): HTMLMetaElement // = createHeadElement("meta", props) ``` Creates a tag, calls `setAttribute(key, value)` for each prop, appends to `<head>`, and returns the element. ```ts meta(metaName: string, value: string): HTMLMetaElement itemprop(name: string, value: string): void ``` `meta()`: Selector is `meta[name="${n}"]` when `n` is `"keywords"` or `"description"`, otherwise `meta[property="${n}"]`. Reuses an existing tag if present, otherwise creates one. The content value is `.trim()`-ed. `itemprop()`: Selector is `meta[itemprop="${name}"]`. Same reuse semantics. ```ts metaLink(rel: string, href: string, otherAttributes?: object): HTMLLinkElement ``` Reuses an existing `link[rel="${rel}"]` or creates one. Sets `href`. Patches any `otherAttributes` afterward via `setAttribute`. ```ts styleSheet(href: string, id?: string | null): HTMLLinkElement ``` If `id` is given, reuses `document.getElementById(id)` when present; otherwise creates a `<link rel="stylesheet">` with that id. Without an `id`, generates `id="link-<random>"` (no reuse possible across calls without supplying the id). ```ts twitter(type: string = "summary"): HTMLMetaElement // -> meta[property=twitter:card] og(_: OpenGraph): void // no-op stub (placeholder) ``` `og()` is currently a no-op — the function body is empty. Listed in the surface for forward compatibility; do not rely on it. ## Stylesheets and fonts > **Auto-trigger:** code imports `styleSheet`, `googleFont`, `loadFont`, `loadScript`, `cssVariable`, `setCssVariable`, `getCssVariable`, or the `FontOptions` / `FontWeightSetup` types from `@mongez/dom`; user asks "how do I load a Google Font / custom font at runtime", "how do I inject a stylesheet from JS", "how do I swap themes", "how do I read or set a CSS variable from JS", or "how do I load a third-party script when consent is granted"; `import { googleFont, loadFont, styleSheet, cssVariable } from "@mongez/dom"`. > **Skip when:** user is updating `<head>` meta tags (title, og:*, canonical, …) — load `mongez-dom-metadata` instead; raw `<meta>` / `<link>` / element-attribute building — `mongez-dom-head-elements`; keyboard / scroll / viewport / dark-mode detection — `mongez-dom-interactions`; React-specific declarative head management — `@mongez/react-helmet`. ```ts googleFont(href: string, id?: string | null): HTMLLinkElement ``` On first call: emits two `<link rel="preconnect">` tags pointing at `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (the second with `crossorigin=""`). Subsequent calls skip the preconnect step. Then delegates to `styleSheet(href, id)`. ```ts loadFont(fontSettings: FontOptions): Promise<FontFace | FontFace[]> type FontOptions = { name: string; src?: string; descriptors?: FontFaceDescriptors; weights?: FontWeightSetup[]; }; type FontWeightSetup = FontFaceDescriptors & { src?: string; woff?: string; woff2?: string; ttf?: string; eot?: string; svg?: string; otf?: string; }; ``` Two modes: 1. **Single file (`src` at the top level).** Constructs `new FontFace(name, "url(${src})", descriptors)`, calls `.load()`, adds the loaded face to `document.fonts`, resolves with the face. 2. **Multiple weights (`weights` array).** For each weight, joins all provided sources into the standard `url(...) format("...")` shape, constructs a `FontFace` (mapping `weight: "light"` to `"300"` since the FontFace API doesn't accept `"light"`), and calls `.load()`. Waits for `Promise.all`, adds each loaded face to `document.fonts`, resolves with the array. ```ts loadFont({ name: "Inter", src: "/inter.woff2" }); loadFont({ name: "Inter", weights: [ { weight: "normal", woff2: "/inter-400.woff2" }, { weight: "bold", woff2: "/inter-700.woff2" }, ], }); ``` ## CSS variables ```ts cssVariable(name: string): string | void // when value omitted -> read from :root cssVariable(name: string, value: string): void // when value provided -> write to :root setCssVariable(name: string, value: string, element?: HTMLElement): void // defaults :root getCssVariable(name: string, element?: HTMLElement): string // defaults :root ``` `cssVariable` is dual-purpose: if `value` is omitted or falsy, it reads from `:root`; otherwise it writes to `:root`. `setCssVariable` / `getCssVariable` are explicit single-purpose siblings that also accept a target element. Reads return an empty string when the variable is unset. ## Viewport dimensions > **Auto-trigger:** code imports `pressed`, `TAB_KEY`, `ESC_KEY`, `ENTER_KEY`, `CONTROL_KEY`, `scrollTo`, `userPrefersDarkMode`, `htmlToText`, `getWindowWidth`, `getWindowHeight`, `getScreenWidth`, or `getScreenHeight` from `@mongez/dom`; user asks "how do I detect Enter / Esc / Tab keys", "how do I smooth-scroll to an anchor", "how do I detect OS dark mode", "how do I strip HTML tags for a plain-text preview", or "how do I read window/screen width"; `import { pressed, ENTER_KEY, scrollTo } from "@mongez/dom"`. > **Skip when:** user is updating `<head>` meta tags / favicon / canonical — load `mongez-dom-metadata` instead; loading stylesheets, fonts, scripts, or setting CSS variables — `mongez-dom-assets`; raw `<meta>` / `<link>` / element-attribute building — `mongez-dom-head-elements`; React-specific declarative head management — `@mongez/react-helmet`; this package is framework-agnostic DOM. ```ts getWindowWidth(): number // window.outerWidth getWindowHeight(): number // window.outerHeight getScreenWidth(): number // window.screen.width getScreenHeight(): number // window.screen.height ``` Thin one-liner getters. These read live globals; call them when you need the value, don't cache. ## Keyboard ```ts pressed(event: { keyCode?: number; charCode?: number }, key: number): boolean const TAB_KEY = 9; const ENTER_KEY = 13; const CONTROL_KEY = 17; const ESC_KEY = 27; ``` `pressed` compares `event.keyCode || event.charCode` to the supplied key. Use the named constants instead of magic numbers in your own code. ## Misc ```ts scrollTo(querySelector: string): void ``` Calls `element.scrollIntoView({ behavior: "smooth" })` for the first matching element. No-op if no element matches. ```ts loadScript(src: string, onLoad: () => void): HTMLScriptElement ``` Creates `<script src=...>`, sets `script.onload = onLoad`, appends to `<body>`, returns the element so callers can patch additional attributes (`async`, `defer`, …). ```ts htmlToText(text: string): string ``` Parses `text` as HTML into a throwaway `<div>` and returns `textContent || innerText || ""`. Strips all tags; decodes HTML entities; drops `<script>` and `<style>` content. ```ts userPrefersDarkMode(): boolean ``` `Boolean(window.matchMedia("(prefers-color-scheme: dark)").matches)`. ## Lifecycle / side effects - Functions that touch the DOM mutate `<head>` or the given element directly. There is no event bus and no subscription model — they are imperative. - `setPageMeta`-family functions short-circuit when called with a value equal to the one previously seen (cached in the module-level `currentMetaData` singleton). - `googleFont` keeps a module-level flag (`isGoogleFontInitialized`) so the preconnect tags emit exactly once per module lifetime. ## Known bugs (documented in tests, not fixed) - `setFavIcon(favIcon)` writes `currentMetaData.color = favIcon` instead of `currentMetaData.favIcon = favIcon`. `getMetaData("favIcon")` won't reflect the value. (`metadata.ts:249`) - `setCanonicalUrl(url)` writes `currentMetaData.color = url` instead of `currentMetaData.url = url`. Same consequence for `getMetaData("url")`. (`metadata.ts:275`) - `setPageColor(color)` emits `meta[property="theme-color"]`; the spec is `meta[name="theme-color"]`. User agents won't honour the `property=` variant. (`metadata.ts:107`) - A `src/elments.ts` file (typo intended) declares a private `attributesList(domElement)` function that is not re-exported from `src/index.ts`. Dead code. ## What this package does NOT do - React hooks / React helmet → see `@mongez/react-helmet`. - Persistent storage → see `@mongez/cache`. - SSR-safe metadata orchestration → none of these helpers work without `document`; guard at the call site or move the call to a client-only effect. --- # @mongez/localization # @mongez/localization — full reference > A framework-agnostic i18n primitive. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/localization # peer deps: @mongez/events, @mongez/reinforcements ``` ## Public exports ```ts import { // configuration setLocalizationConfigurations, getLocalizationConfigurations, getLocaleConfig, // registering translations extend, groupedTranslations, setTranslationsList, getTranslationsList, getKeywordsListOf, // translating trans, transFrom, plainTrans, transObject, // locale switching setCurrentLocaleCode, getCurrentLocaleCode, setFallbackLocaleCode, getFallbackLocaleCode, getTranslationLocaleCode, // converters setConverter, plainConverter, // events localizationEvents, // types type TranslationsList, type Keywords, type Converter, type Translatable, type LocalizationConfigurations, type LocaleCodeChangeCallback, type LocalizationEventName, type CountRuleFunction, type LanguageCountRules, type CountRulesConfig, type GroupedTranslations, type WithPlaceholder, } from "@mongez/localization"; ``` ## setLocalizationConfigurations(options) ```ts type LocalizationConfigurations = { defaultLocaleCode?: string; // initial current locale; default "en" fallback?: string; // initial fallback locale; default "en" translations?: TranslationsList; // bulk-seed all locales converter?: Converter; // swap the placeholder converter placeholderPattern?: "colon" | "doubleCurly" | RegExp; // default "colon" → /:(\w+)/g countRules?: { [localeCode: string]: LanguageCountRules }; countRanges?: { enabled?: boolean; separator?: string; // default "_" — controls the suffix delimiter ranges?: Array<[number, number]>; // default [[0,5],[6,20],[21,Infinity]] }; translationLocaleCode?: string; // runtime locale override at lookup time translationLocalCode?: string; // @deprecated misspelled — kept for backward compat }; ``` `setLocalizationConfigurations` merges into the stored configuration and applies side effects for the supplied keys: - `defaultLocaleCode` → calls `setCurrentLocaleCode`, firing the `localeCode` event. - `fallback` → calls `setFallbackLocaleCode`, firing the `fallback` event. - `translations` → `setTranslationsList` (full replace). - `converter` → `setConverter` (replace). - `placeholderPattern` → installs the chosen pattern. - Other keys (e.g. `countRules`) are stored and looked up lazily. `getLocalizationConfigurations()` returns the merged config object. `getLocaleConfig(key, defaultValue?)` reads a single key with an optional fallback. ## Translation dictionary shape ```ts type TranslationsList = { [localeCode: string]: Keywords }; type Keywords = { [key: string]: string | Keywords }; ``` Nested `Keywords` objects are read with dot-notation in `trans`/`transFrom` (`trans("ui.home")`). ## extend(localeCode, keywords) > **Auto-trigger:** code imports `extend`, `groupedTranslations`, `setTranslationsList`, `getTranslationsList`, `getKeywordsListOf`, `TranslationsList`, `Keywords`, or `GroupedTranslations` from `@mongez/localization`; user asks "how do I register translations", "how do I structure a TranslationsList", "should I use one file per locale or per feature", or "how do I load translations from JSON"; `import { extend, groupedTranslations } from "@mongez/localization"`. > **Skip when:** `mongez-localization-translating` (calling `trans`/`transFrom`/`transObject` to read keywords back out), `mongez-localization-interpolation` (placeholder syntax), `mongez-localization-count-translations` (pluralization suffixes), `mongez-localization-overview` (mental model only); `@mongez/react-localization` is the React-specific layer on top of this core — use its skills for React-bound dictionary patterns. ```ts extend(localeCode: string, keywords: Keywords): void ``` Merges into the locale's existing keyword bag. Calling `extend` repeatedly for the same locale is the idiomatic pattern for splitting a locale across feature files. ```ts extend("en", { home: "Home" }); extend("en", { contact: "Contact Us" }); // → { en: { home: "Home", contact: "Contact Us" } } ``` ## groupedTranslations(groupKey?, dict) ```ts groupedTranslations(dict: GroupedTranslations): void groupedTranslations(groupKey: string, dict: GroupedTranslations): void type GroupedTranslations = { [keyword: string]: GroupedTranslations | string }; ``` Declares translations keyword-first (every keyword carries its locale map), instead of locale-first (`extend` style). Internally flattens the input and pushes each leaf into the right locale. ```ts groupedTranslations({ home: { en: "Home", ar: "الرئيسية" }, contact: { en: "Contact Us", ar: "اتصل بنا" }, }); groupedTranslations("store", { orders: { en: "Orders", ar: "الطلبات" }, }); groupedTranslations({ general: { // nested groups home: { en: "Home", ar: "الرئيسية" }, }, }); ``` `trans("store.orders")` and `trans("general.home")` both work afterwards. ## trans / transFrom / plainTrans > **Auto-trigger:** code imports `trans`, `transFrom`, `plainTrans`, `transObject`, `getTranslationLocaleCode`, `Translatable`, or `WithPlaceholder` from `@mongez/localization`; user asks "how do I translate a keyword", "how do I read a translation from a specific locale", "how do I get typed access to feature translations", or "why is my empty-string translation falling through to fallback"; `import { trans, transFrom, transObject } from "@mongez/localization"`. > **Skip when:** `mongez-localization-translations` (registering dictionaries via `extend`/`groupedTranslations`), `mongez-localization-interpolation` (placeholder syntax and converters), `mongez-localization-count-translations` (count-based plural lookups); `@mongez/react-localization` is the React-specific layer on top of this core — use its `transX` and React hooks instead when working with JSX placeholders. ```ts trans(keyword: Translatable, placeholders?, converter?): any transFrom(localeCode: string, keyword: Translatable, placeholders?, converter?): any plainTrans(keyword: string, placeholders?): string type Translatable = string | { [localeCode: string]: string }; ``` - **`trans(keyword)`** — translates against the current locale (or the configured `translationLocalCode` if set), falls back to the fallback locale, then returns the keyword itself. - **`transFrom(locale, keyword)`** — translates against an explicit locale. Same fallback chain. - **`plainTrans(keyword)`** — like `trans`, but always uses `plainConverter` regardless of the configured converter. Useful for mixing JSX (`jsxConverter` as default) with the occasional plain-string lookup. `keyword` may be a string (looked up in the dictionary) or an inline object (`{ en: "Home", ar: "الرئيسية" }`) — the latter is useful for per-feature translation literals declared next to the component that uses them. `placeholders` are interpolated via the active converter (or `plainConverter` for `plainTrans`). ## Placeholders and patterns > **Auto-trigger:** code imports `plainConverter`, `setConverter`, `Converter`, or sets `placeholderPattern`/`converter` on `setLocalizationConfigurations` from `@mongez/localization`; code passes a `placeholders` object to `trans`/`transFrom`; user asks "how do I change placeholder syntax to {{name}}", "how do I write a custom converter", "why is my placeholder left as `:name` in the output", or "how do I escape HTML in translations"; `import { plainConverter, setConverter } from "@mongez/localization"`. > **Skip when:** `mongez-localization-count-translations` (count-based lookups — though `:count` is still interpolated via this layer), `mongez-localization-translating` (the lookup functions themselves); `@mongez/react-localization` is the React-specific layer on top of this core — use its `jsxConverter` and `transX` skills for JSX placeholder values like `<strong>`/`<Link>`. Default pattern: `/:(\w+)/g` (`:name`). Named alternatives: `"colon"` (default), `"doubleCurly"` (`{{name}}`). Any RegExp also works — see `src/placeholder-pattern-config.ts`. ```ts setLocalizationConfigurations({ placeholderPattern: "doubleCurly" }); extend("en", { hi: "Hello {{who}}" }); trans("hi", { who: "Ada" }); // "Hello Ada" ``` A placeholder that has no matching key in the object is left in the output untouched. ## plainConverter ```ts plainConverter( translation: string, placeholders: { [k: string]: string | number | undefined } = {}, placeholderPattern: RegExp = /:([a-zA-Z0-9_-]+)/g, ): string ``` The default converter. Replaces placeholders by name, stringifies numbers, leaves un-matched slots intact. ## Custom converters ```ts type Converter = ( keyword: string, placeholders: any, placeholderPattern: RegExp, ) => any; setLocalizationConfigurations({ converter: myConverter }); // or setConverter(myConverter); ``` The converter receives the resolved translation (already in the right locale), the placeholders object, and the active placeholder RegExp. Return type isn't constrained — `jsxConverter` returns an array of React fragments rather than a string. A converter is **only invoked when placeholders are passed**. `trans("home")` (no placeholders) returns the raw translation string with no converter call. ## Count-based translations > **Auto-trigger:** code passes `{ count: n }` to `trans`/`plainTrans`/`transFrom`; code uses `countRules`, `countRanges`, `CountRuleFunction`, `LanguageCountRules`, or `CountRulesConfig` from `@mongez/localization`; keywords end in `_zero`/`_one`/`_two`/`_three`/`_few`/`_many`/`_negative`/`_other`/`_range_*`; user asks "how do I pluralize a keyword", "how do Arabic plural rules work", "how do I define custom plural rules for French/Polish", or "how do range-based count suffixes work". > **Skip when:** `mongez-localization-interpolation` (plain placeholder substitution without count machinery — use a non-`count` placeholder name like `:n`/`:total`), `mongez-localization-translating` (non-count lookups); `@mongez/react-localization` is the React-specific layer on top of this core — pluralization is the same in both, but use its skills for JSX rendering concerns. Suffix a keyword with a count-rule name and pass `{ count: n }`: ```ts extend("en", { products_zero: "No products", products_one: "One product", products_two: "Two products", products_three: "Three products", products_many: ":count products", products_negative: "Invalid count (:count)", products_other: ":count products", }); trans("products", { count: 0 }); // "No products" trans("products", { count: 4 }); // "4 products" (from _many) trans("products", { count: -2 }); // "Invalid count (2)" (negative → abs) ``` Built-in rule packs: - `en`: zero (n===0), one (n===1), two (n===2), three (n===3), many (n>3), negative (n<0), other (true). - `ar`: zero, one, two, few (mod100 in [3..10]), many (mod100 in [11..99]), negative, other. Override per-locale: ```ts setLocalizationConfigurations({ countRules: { fr: { one: n => n === 0 || n === 1, // French treats 0 and 1 as one other: () => true, }, }, }); ``` Selector order: current-locale's count-tagged variant → fallback-locale's count-tagged variant → current `_other` → fallback `_other` → current bare → fallback bare → keyword itself. `:count` is interpolated as the **absolute** value (negatives turn into positives in the output). ## transObject(dict) ```ts function transObject<T extends Keywords>(dict: T): WithPlaceholder<T> & { [K in keyof T]: string }; type WithPlaceholder<T> = { p: (keyword: keyof T, placeholders?: any) => string; plain: (keyword: keyof T, placeholders?: any) => string; }; ``` Builds a Proxy where direct property reads return the current-locale translation, and the reserved methods `p` / `plain` handle placeholder interpolation: ```ts const t = transObject({ name: { en: "name", ar: "الاسم" }, welcome: { en: "Hi :who", ar: "مرحبا :who" }, }); t.name; // current-locale value t.p("welcome", { who: "Ada" }); // interpolates via configured converter t.plain("welcome", { who: "Ada" }); // forces plainConverter ``` Unknown keys on the proxy fall through to a `transFrom(fallbackLocaleCode, key)` lookup, so reading `t.somethingNotInDict` will resolve against the global translations under the fallback locale and return the bare key if that also misses. This lets you mix per-feature `transObject` dictionaries with globally-registered keywords. ## Locale switching ```ts setCurrentLocaleCode(localeCode: string): void getCurrentLocaleCode(): string setFallbackLocaleCode(localeCode: string): void getFallbackLocaleCode(): string getTranslationLocaleCode(): string // returns configurations.translationLocalCode || getCurrentLocaleCode() ``` `setCurrentLocaleCode` fires the `localeCode` event. `setFallbackLocaleCode` fires the `fallback` event. Both fire on **every** call, including when the new value equals the old. ## Events > **Auto-trigger:** code imports `localizationEvents`, `LocaleCodeChangeCallback`, or `LocalizationEventName` from `@mongez/localization`; code calls `localizationEvents.onChange("localeCode", ...)` or `localizationEvents.onChange("fallback", ...)`; code subscribes to `localization.change.localeCode`/`localization.change.fallback` on `@mongez/events`; user asks "how do I react to a locale switch", "how do I persist the locale to a cookie/localStorage", "how do I sync the URL `?lang=` with the current locale", or "how do I trigger a React re-render on locale change without @mongez/react-localization". > **Skip when:** `mongez-localization-translating` (just reading translations), `mongez-localization-recipes` (full end-to-end setups including events); `@mongez/react-localization` is the React-specific layer on top of this core — if it's already installed, prefer its `useCurrentLocale`/provider hooks over a hand-rolled subscriber. ```ts import { localizationEvents } from "@mongez/localization"; localizationEvents.onChange("localeCode", (next, prev) => { … }); localizationEvents.onChange("fallback", (next, prev) => { … }); type LocaleCodeChangeCallback = (newLocaleCode: string, oldLocaleCode: string) => void; type LocalizationEventName = "localeCode" | "fallback"; ``` Returns an `EventSubscription` (from `@mongez/events`) with `.unsubscribe()`. Bus namespace: `localization.change.localeCode` and `localization.change.fallback`. Listening with raw `events.subscribe("localization.change.localeCode", cb)` works too. ## Internal state The package keeps four pieces of module-level state: - The merged configuration (`localesConfig` in `src/config.ts`). - The current locale code (`currentLocaleCode` in `src/translator.ts`). - The fallback locale code (`fallbackLocaleCode` in `src/translator.ts`). - The translations dictionary (`translationsList` in `src/translator.ts`). - The active converter (`currentConverter` in `src/translator.ts`). - The active placeholder RegExp (`placeholderPattern` in `src/placeholder-pattern-config.ts`). For tests, reset these between cases. The test suite ships a `helpers.ts` utility that does so via the public setters. ## Bugs and gotchas (current code) - **Empty translations bypass.** `transFrom` uses `||` chains on the `get(translationsList, …)` result. An intentionally-empty string in a locale falls through to the fallback chain. Translations that should be empty in one locale aren't expressible. - **Arabic `many` rule cuts off at 99.** `src/count-rules.ts:30-33` matches `mod100` in `[11, 99]`. Counts of 100, 200, 1000 land on `_other`, not `_many`. The README says "count > 10" without mentioning the upper bound. - **Events don't dedupe.** Calling `setCurrentLocaleCode("en")` while the current locale is already `"en"` still fires the event. Subscribers should dedupe themselves if needed. - **Legacy `translationLocalCode` (misspelled).** Still accepted for backward compatibility but `@deprecated`; prefer the correctly-spelled `translationLocaleCode`. When both are set, the correctly-spelled key wins. ## What this package does NOT do - JSX placeholders → `@mongez/react-localization` (`jsxConverter`, `transX`). - React hooks / context providers → none yet. Use `localizationEvents.onChange` to drive re-renders manually, or implement a hook on top. - Storage / persistence of the selected locale → bring your own (cookies, localStorage, query string, …). - Direction-awareness (`dir="rtl"`) → derive from the locale yourself. - ICU MessageFormat-style nested syntax → out of scope; the package is `name → string + placeholders`. --- # @mongez/react-localization # @mongez/react-localization — full reference > React adapter for `@mongez/localization`. The entire public API surface is two exports: `jsxConverter` (a placeholder converter that produces React fragments) and `transX` (a `trans` variant pre-bound to that converter). ## Install ```sh yarn add @mongez/react-localization # peer: @mongez/localization, react >= 18 ``` ## Public exports ```ts import { jsxConverter, transX, } from "@mongez/react-localization"; ``` That's the entire surface. Everything else — `extend`, `trans`, `transFrom`, `setCurrentLocaleCode`, `getCurrentLocaleCode`, `setFallbackLocaleCode`, `groupedTranslations`, `transObject`, `localizationEvents`, count rules, range rules — is imported from `@mongez/localization` and works unchanged with this adapter. ## `jsxConverter(translation, placeholders, placeholderPattern)` > **Auto-trigger:** code imports `jsxConverter` from `@mongez/react-localization`; code calls `setLocalizationConfigurations({ converter: jsxConverter })`; user asks "how do I render JSX inside trans()", "why does trans() return an array", "how do placeholders work with React elements", or "why does jsxConverter crash on null"; `import { jsxConverter } from "@mongez/react-localization"`. > **Skip when:** `mongez-react-localization-trans-x` (per-call JSX without flipping global converter), `mongez-react-localization-overview` (package-level intro), `mongez-react-localization-recipes` (usage patterns); `@mongez/localization` is the framework-agnostic core that defines `trans`, `plainConverter`, and the placeholder pattern — this skill is the React-specific converter layer; react-i18next, react-intl, or other i18n libraries. ```ts function jsxConverter( translation: string, placeholders: any, placeholderPattern: RegExp, ): string | React.ReactNode[]; ``` The converter that swaps placeholder tokens in a translation string for React children. Wire it once via `setLocalizationConfigurations({ converter: jsxConverter })` and every `trans(...)` call gains JSX support. ### Behaviour 1. **Non-object placeholders are a no-op.** When `placeholders` is a primitive (e.g. `10`, `"x"`) or an empty object, the translation is returned as a plain string. This is the guard that lets `trans("hello")` and `trans("hello", 10)` still return strings. 2. **Splits on the supplied pattern.** The function calls `translation.split(placeholderPattern)`. The pattern must be a global RegExp with a single capturing group around the placeholder name. The default in `@mongez/localization` is `/:([a-zA-Z0-9_-]+)/g`. 3. **Odd-indexed parts are placeholder keys.** `String.prototype.split` with a capturing group interleaves literals and captures. Even indices are literal text; odd indices are placeholder names. 4. **Missing keys fall back to the bare key.** If `placeholders[key]` is `undefined` (or `null`), the rendered text is the key name itself — e.g. `Create new :item` with `placeholders = { wrong: "x" }` renders as `Create new item`. The leading `:` is gone because the splitter stripped it. 5. **Returns an array.** When at least one placeholder is found, the result is `Array<React.ReactNode>` of `React.Fragment`s with deterministic numeric keys. Render via `{trans(...)}` inside any JSX expression slot. ### Caller contract - `translation`: the already-localized string from `@mongez/localization`'s lookup. - `placeholders`: the bag passed by the caller. May be any value. - `placeholderPattern`: a global RegExp with one capturing group. Sourced from `getPlaceholderPattern()` in the core package. ### Known bug `src/converters.tsx:18`. The guard reads `typeof placeholders !== "object" || Object.keys(placeholders).length === 0`. Because `typeof null === "object"`, passing `null` falls through to the second clause and `Object.keys(null)` throws `Cannot convert undefined or null to object`. In practice this doesn't bite — `trans` only calls the converter when `placeholders` is truthy. But if you wire `jsxConverter` directly into a custom translate pipeline, never pass `null`/`undefined`. A skipped test in `src/__tests__/converters.test.tsx` pins this. ## `transX(keyword, placeholders?)` > **Auto-trigger:** code imports `transX` from `@mongez/react-localization`; code calls `transX(keyword, placeholders)` at a JSX call site; user asks "how is transX different from trans", "how do I use JSX placeholders without changing the global converter", or "how do I mix plain and JSX trans calls"; `import { transX } from "@mongez/react-localization"`. > **Skip when:** `mongez-react-localization-jsx-converter` (converter mechanics and the null bug), `mongez-react-localization-overview` (package-level intro and the two paths), `mongez-react-localization-recipes` (real-world locale-switching and `Translate` patterns); `@mongez/localization` exposes the underlying `trans`, `transFrom`, `plainTrans` — this skill is the React-bound variant; react-i18next, react-intl. ```ts function transX(keyword: string, placeholders?: any): string | React.ReactNode[]; ``` Equivalent to: ```ts import { getTranslationLocaleCode, transFrom } from "@mongez/localization"; import { jsxConverter } from "@mongez/react-localization"; transFrom(getTranslationLocaleCode(), keyword, placeholders, jsxConverter); ``` `transX` is `trans` with the converter argument hard-coded to `jsxConverter`. It bypasses whatever converter is configured in `setLocalizationConfigurations({ converter })`. Useful when most of your translations are plain strings (and you want `trans` to stay typed as `string`) but a few call sites need JSX placeholder support. ### When to reach for it - **You configured `plainConverter` globally** (or left it as the default) AND - **A specific call site needs JSX** (icon, link, formatted number). If `jsxConverter` is your global converter, `trans` and `transX` produce identical output — prefer `trans` for consistency. ### What it does NOT do - It does not subscribe to locale changes. Calling `setCurrentLocaleCode("ar")` after a component has rendered will NOT re-render that component. See the "Limitations" section. ## Limitations ### No locale-change subscription This package does NOT expose a `useLocale()` hook, a `useTranslate()` hook, a `<Translate>` component, or a context provider. `transX(...)` is a plain function that reads the current locale at call time and returns. A component that renders `<p>{transX("hello")}</p>` will retain the original translation in the DOM even after `setCurrentLocaleCode("ar")` runs — until something else triggers a re-render of that component. Common ways to drive a re-render: ```ts // 1. State in a parent component. function App() { const [locale, setLocale] = useState("en"); useEffect(() => setCurrentLocaleCode(locale), [locale]); return <Page key={locale} />; } // 2. An atom (from @mongez/react-atom). const localeAtom = atom({ key: "ui.locale", default: "en" }); localeAtom.onChange((next) => setCurrentLocaleCode(next)); function Title() { localeAtom.useValue(); // subscribes; drives re-render return <h1>{transX("title")}</h1>; } // 3. A custom hook over the event bus. import { localizationEvents } from "@mongez/localization"; function useLocale() { return useSyncExternalStore( (cb) => { const sub = localizationEvents.onChange("localeCode", cb); return () => sub.unsubscribe(); }, () => getCurrentLocaleCode(), () => getCurrentLocaleCode(), ); } ``` If you build option 3 inside this package as a first-class export, **use `useSyncExternalStore`**. The `useState + useEffect(localizationEvents.onChange(...))` pattern has the same React 18 concurrent-rendering tearing risk that bit `@mongez/react-atom` — a sibling-component disagreement window between the synchronous render snapshot and the effect-time subscription. ### Return type is conditional `trans` / `transX` return `string` OR `string | React.ReactNode[]` depending on whether placeholders were resolved. Consumers that pass the result to non-React APIs (e.g. setting `document.title`) must handle the array case. The simplest rule: if you might pass JSX as a placeholder, the return is an array. ## Patterns > **Auto-trigger:** code uses `transX`, `jsxConverter`, `setCurrentLocaleCode`, `localizationEvents`, or `useSyncExternalStore` for locale switching; user asks "how do I render a link inside a translated sentence", "how do I re-render on locale change", "how do I build a useLocale hook", "how do I make a Translate component", or "how do I do pluralization with JSX"; `import { transX } from "@mongez/react-localization"` alongside `extend`/`setCurrentLocaleCode` from `@mongez/localization`. > **Skip when:** `mongez-react-localization-jsx-converter` (converter internals), `mongez-react-localization-trans-x` (the bare function reference), `mongez-react-localization-overview` (install/intro); `@mongez/localization` is the framework-agnostic core for registry and count rules; `@mongez/react-atom` for atom-driven state — referenced here but its own skill set covers atom internals; react-i18next, react-intl. ### Drop JSX into a translated sentence ```tsx extend("en", { agreeToTerms: "By clicking Continue, you agree to our :tos and :privacy.", }); function ToS() { return ( <p> {transX("agreeToTerms", { tos: <a href="/terms">Terms of Service</a>, privacy: <a href="/privacy">Privacy Policy</a>, })} </p> ); } ``` ### Pluralization (delegated) `@mongez/localization` handles count rules per-locale. `transX` flows the count placeholder through unchanged. ```ts extend("en", { products_zero: "No products", products_one: "1 product", products_many: ":count products", }); transX("products", { count: 0 }); // → "No products" transX("products", { count: 1 }); // → "1 product" transX("products", { count: 42 }); // → "42 products" ``` Note that when `count` is present in placeholders, the converter runs even on the `_one` template (which has no `:count` token), so the return value is still a fragment array. Use `<>{...}</>` or render into an element. ### Switching converters per call ```tsx import { plainTrans, trans } from "@mongez/localization"; import { transX } from "@mongez/react-localization"; trans("greeting"); // honors global converter plainTrans("greeting"); // always plain string transX("greeting"); // always JSX-able ``` ## Internal mechanics ``` +-------------------+ +-----------------------+ +-----------------+ | setLocalization | | transX() | | jsxConverter | | Configurations() | | | | | | { converter } | | transFrom( | --> | splits on | | | | currentLocale, | | pattern, | | | | keyword, | | builds React | | | | placeholders, | | fragments | | | | jsxConverter, | | | +-------------------+ | ) | +-----------------+ +-----------------------+ ``` `transX` ignores the configured converter; it hard-codes `jsxConverter` as the fourth argument to `transFrom`. ## React version React 18+ for the peer dependency. The package itself only uses `React.Fragment`, so technically it would work on 16.8+, but the `@mongez/*` family standardized on 18 for `useSyncExternalStore` and tear-free state in adjacent packages, and this one tracks that floor. ## What this package does NOT do - Locale-change subscriptions / hooks → would need to use `useSyncExternalStore` over `localizationEvents.onChange`. Not provided. - `<Translate>` component → trivial to write on top: `({ k, p }) => <>{transX(k, p)}</>`. Not provided. - Translation registry / locale switching / count rules → all in `@mongez/localization`. - State management → `@mongez/atom` + `@mongez/react-atom`. - Event bus → `@mongez/events`. --- # @mongez/react-router # @mongez/react-router — full reference > **Auto-trigger when loading this full reference:** code imports any of `router`, `Router`, `Link`, `navigateTo`, `navigateBack`, `silentNavigation`, `refresh`, `changeLocaleCode`, `currentRoute`, `previousRoute`, `currentApp`, `getHash`, `setApps`, `NAVIGATING`, `setRouterConfigurations`, `getRouterConfig`, `getRouterConfigurations`, `shouldAppendLocaleCodeToUrl`, `queryString`, `setQueryStringOptions`, or `routerEvents` from `@mongez/react-router`; references types `Route`, `RouteOptions`, `RouterConfigurations`, `Middleware`, `MiddlewareProps`, `LinkProps`, `LinkOptions`, `App`, `PublicApp`, `Module`, `Loaders`, `NavigationMode`, `ChunkErrorHandler`, `ChunkErrorStrategy`, `LazyLoadingOptions`, `LazyLoadingProps`, `LocalizationOptions`, `ChangeLanguageReloadMode`, `NotFoundConfigurations`, `GroupedRoutesOptions`, `QueryStringOptions`, or `UrlMatcher`; calls `router.add`, `router.group`, `router.partOf`, `router.scan`, `router.list`, or `queryString.update`; user asks "how do I add a route / navigate / read params / lazy-load a route / add a language prefix / handle 404s / auth-gate a section"; `import router from "@mongez/react-router"` or `import { ... } from "@mongez/react-router"`. > > **Skip when:** the file imports from `react-router`, `react-router-dom`, `@tanstack/router`, `next/router`, or `next/link` — this is the @mongez-flavored router, **not** the upstream `react-router` / `react-router-dom`; questions about translation message catalogs (this package only handles URL/locale plumbing); other `@mongez/*` packages such as `@mongez/react-atom`, `@mongez/atomic-query`, or `@mongez/cache`. > Configuration-based React router. Routes are data on a singleton; navigation, locale prefixes, lazy-loaded apps/modules, middleware, prefetch-on-hover, and chunk-error recovery all live behind one API surface. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add @mongez/react-router # peer: react >= 18, react-dom >= 18 ``` ## Public exports ```ts import router, { // class + named alias Router, // components Link, // utilities navigateTo, navigateBack, silentNavigation, refresh, changeLocaleCode, currentRoute, previousRoute, currentApp, getHash, setApps, NAVIGATING, // config setRouterConfigurations, getRouterConfig, getRouterConfigurations, shouldAppendLocaleCodeToUrl, // query string queryString, setQueryStringOptions, // events routerEvents, // types type Route, type RouteOptions, type RouterConfigurations, type Middleware, type MiddlewareProps, type LinkProps, type LinkOptions, type App, type PublicApp, type Module, type Loaders, type NavigationMode, type ChunkErrorHandler, type ChunkErrorStrategy, type LazyLoadingOptions, type LocalizationOptions, type NotFoundConfigurations, type GroupedRoutesOptions, type QueryStringOptions, type UrlMatcher, type ObjectType, type Component, type LazyLoadingProps, type ChangeLanguageReloadMode, } from "@mongez/react-router"; ``` ## Mental model 1. You declare routes by calling `router.add(...)` once per route. Each `add` is a pure data registration into `router.list()`. 2. You optionally configure the router with `setRouterConfigurations({ ... })`. 3. You call `router.scan()` once at app startup. That mounts a `<RouterWrapper>` into `#root` (via `createRoot` or `hydrateRoot`), parses the initial URL, and renders the matching route. 4. Navigation (via `<Link>`, `navigateTo`, or `popstate`) calls `router.refresh(mode)`, which fires `"navigating"` and `"rendering"` events. `<RouterWrapper>` listens for `"rendering"` and re-resolves the page. 5. Components rendered by the router receive `{ params, localeCode }` as props. ## `router.add(...)` ```ts router.add(path: string, component: ComponentType<any>, middleware?: Middleware, layout?: ComponentType<any>): Router router.add(options: Route): Router ``` `Route` shape: ```ts type Route = { path: string; component: ComponentType<any>; middleware?: Middleware; layout?: ComponentType<any>; }; ``` The path may contain dynamic segments: | Pattern | Means | Example match | |---|---|---| | `:name` | one required segment | `/users/:id` matches `/users/42` | | `:name?` | zero or one segment | `/users/:id?` matches `/users` and `/users/42` | | `:name+` | one or more segments | `/files/:path+` matches `/files/a/b/c` | | `:name*` | zero or more segments | `/wildcard/:rest*` matches `/wildcard` and `/wildcard/a/b/c` | Matched values are placed on `router.params` and passed to the rendered component as the `params` prop. ## `router.group(options)` ```ts router.group({ path?: string, middleware?: Middleware, layout?: Component, routes: Route[], }); ``` Each child route inherits the group's `path` prefix, `middleware` (concatenated before per-route middleware), and `layout` (the group layout wins). ## `router.partOf(layout, routes)` Thin wrapper over `group({ layout, routes })`. Use it when many routes share a layout but nothing else. ## Middleware ```ts type MiddlewareProps = { route: RouteOptions; params: ObjectType; localeCode: string; }; type Middleware = ( | FC<MiddlewareProps> | ((options: MiddlewareProps) => ReactNode) )[]; ``` Return values: - `null` / `false` / `undefined` → run the next middleware, then the page - `NAVIGATING` (exported sentinel `<></>`) → don't render — this middleware already navigated - Any other `ReactNode` → render that instead of the page (e.g. a "Loading session" splash) ```ts import { navigateTo, NAVIGATING } from "@mongez/react-router"; function authMiddleware({ route, params, localeCode }) { if (!user.isLoggedIn()) { navigateTo("/login"); return NAVIGATING; } return null; } router.add("/dashboard", DashboardPage, [authMiddleware]); ``` ## `<Link>` ```ts type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & { to?: string; // primary path; relative to current app href?: string; // alias of `to`; or an external URL email?: string; // renders `mailto:` tel?: string; // renders `tel:` localeCode?: string; // override the locale prefix app?: string; // override the app prefix newTab?: boolean; // set target=_blank + rel=noopener,noreferrer silent?: boolean; // updates URL without navigating prefetch?: boolean; // prefetch lazy module on hover; default from config component?: ComponentType<any> | string; // render-as; default "a" }; ``` Behavior: - Internal paths (start with `/`) intercept clicks → `router.goTo(path)` (or `router.silentNavigation` when `silent`). - External URLs (anything `isUrl` matches), `mailto:`, `tel:`, and `#hash` paths render verbatim and don't intercept. - Modifier keys (Ctrl / Meta / Shift / Alt / middle-click) bypass interception so the browser handles them. - When `prefetch` is on and the path is internal, mouseover triggers `router.prefetch(path)` once (deduped via a ref). - The rendered `href` for internal paths is prefixed with the configured `basePath`. ## Programmatic navigation ```ts navigateTo(path: string, localeCode?: string, appName?: string): ReactNode; navigateBack(): ReactNode; silentNavigation(path: string, querySting?: string | ObjectType): void; refresh(): void; ``` - `navigateTo` returns the `NAVIGATING` sentinel so it can be used as middleware return value. - `navigateBack` navigates to `router.getPreviousRoute()` (NOT `history.back()` — it pushes a new entry). - `silentNavigation` replaces the URL via `history.replaceState` and updates `router.currentRoute` / `router.previousRoute`, but does NOT trigger the rendering event. - `refresh` temporarily flips `forceRefresh` on, refreshes the active route key, fires `"navigating"` and `"rendering"` with `mode: "refresh"`. ## `changeLocaleCode(localeCode, reloadMode?)` ```ts changeLocaleCode(localeCode: string, reloadMode?: "soft" | "hard"): void ``` - `"soft"` (default): fires `localeCodeChanging`, refreshes the active route key, navigates to the same route under the new locale, fires `localeChanged`. No full page reload. - `"hard"`: sets `window.location.href` to the locale-prefixed full URL (with query string and hash preserved). Triggers a browser navigation. ## `router.scan()` Call once at app startup. It: 1. Detects whether to auto-redirect to the default locale (when `localeCodes.length > 1` and `appendLocaleCodeToUrl` is true). 2. Parses the URL to extract locale code and current app. 3. Fires `"navigating"` and renders the initial page via `<RouterWrapper>` into `#root`. ## Events (`routerEvents`) ```ts routerEvents.onNavigating(callback: (route: string, mode: NavigationMode, previousRoute: string) => void): EventSubscription routerEvents.onRendering(callback: (route: string, mode: NavigationMode) => void): EventSubscription routerEvents.onPageRendered(callback: (route: string, mode: NavigationMode) => void): EventSubscription routerEvents.onLocaleChanging(callback: (next: string, prev: string) => void): EventSubscription routerEvents.onLocaleChanged(callback: (next: string, prev: string) => void): EventSubscription routerEvents.onDetectingInitialLocaleCode(callback: (localeCode: string) => void): EventSubscription routerEvents.onChunkLoadError(callback: (info: { error: Error; path: string; attempt: number; maxAttemptsReached: boolean }) => void): EventSubscription ``` `NavigationMode` is one of `"navigation" | "changeLocaleCode" | "swinging" | "refresh"`. `"swinging"` is browser back/forward. All event subscribers return `{ unsubscribe(): void }`. ## Query string ```ts queryString.all(): ObjectType; queryString.parse(searchParams: string): ObjectType; queryString.get(key: string, defaultValue?: any): any; queryString.toString(): string; queryString.toQueryString(params: ObjectType | string): string; queryString.update(params: Record<string, any> | string, reRender?: boolean): void; ``` Default parser: - Numeric-looking values come back as numbers - `key[]=v1&key[]=v2` becomes `{ key: [v1, v2] }` - `key[sub]=v` becomes `{ key: { sub: v } }` - Nested-object stringification uses `parent[child]` notation Swap parsers via: ```ts setQueryStringOptions({ objectParser: (queryString) => /* … */, stringParser: (queryObject) => /* … */, }); ``` ## Lazy loading ```ts type Loaders = { app: (app: string) => Promise<any>; module: (app: string, module: string) => Promise<any>; }; ``` The `app` loader resolves the per-app provider module (where you call `router.add(...)` for that app's routes). The `module` loader resolves a per-module provider. Module entries are keyed by URL's first segment. ```jsonc { "name": "front-office", "path": "/", "modules": [ { "entry": ["/"], "name": "home" }, { "entry": ["/account"], "name": "account" } ] } ``` On first visit to a module entry path, the router fetches both the app and module providers (deduped via internal `loadedApps` / `loadedModules` arrays), then looks up the route and renders. ## Chunk error handler ```ts type ChunkErrorHandler = { strategy?: "reload" | "notify" | "custom"; maxReloadAttempts?: number; // default 1 onChunkLoadError?: (error: Error, path: string, attempt: number) => boolean | Promise<boolean>; notificationComponent?: Component; // for "notify" strategy }; ``` - `reload` (default): increments per-path counter in `sessionStorage` (key `mrr_reload_attempt_${path}`) and does `window.location.href = path`. - `custom`: calls `onChunkLoadError(error, path, attempt)`; if it (or its resolved Promise) returns `true`, reloads. - `notify`: fires the `chunkLoadError` event with `{ error, path, attempt, maxAttemptsReached }` and, if a `notificationComponent` is configured, renders it into a `<div id="mrr-cle">` appended to `<body>`. When `attempt >= maxReloadAttempts`, the router emits the event with `maxAttemptsReached: true` and stops auto-reloading. ## Router configuration ```ts type RouterConfigurations = { basePath?: string; // "/" forceRefresh?: boolean; // true autoRedirectToLocaleCode?: boolean; // derived appendLocaleCodeToUrl?: boolean; // true scrollToTop?: false | "smooth" | "default"; // "smooth" strictMode?: boolean; // true localization?: LocalizationOptions; urlMatcher?: UrlMatcher; queryString?: QueryStringOptions; rootComponent?: Component; // wraps the whole tree suspenseFallback?: ReactNode; // <></> lazyLoading?: LazyLoadingOptions; notFound?: NotFoundConfigurations; link?: LinkOptions; // { component } prefetch?: boolean; // true }; ``` `setRouterConfigurations(config)` is additive — call it many times; later calls override the same keys. ## Not found ```ts type NotFoundConfigurations = { mode?: "render" | "redirect"; component?: Component; path?: string; // for redirect mode; default "/404" }; ``` `mode: "render"` renders the component in place of the page (URL unchanged). `mode: "redirect"` calls `navigateTo(path || "/404")`. ## URL matcher ```ts type UrlMatcher = (pattern: string) => { regexp: RegExp; keys: { name: string }[]; }; ``` The default matcher handles `:name`, `:name?`, `:name+`, `:name*`. Override it for richer patterns (e.g. `path-to-regexp`). Compiled patterns are memoized in a module-level cache. ## URL shape ``` /basePath/appPath/(localeCode?)/routePath ``` Examples (with `basePath: "/"`, app `admin` at `/admin`): | User-facing URL | Parsed | |---|---| | `/` | app `/`, route `/` | | `/en` | app `/`, route `/`, locale `en` | | `/en/contact-us` | app `/`, route `/contact-us`, locale `en` | | `/admin` | app `/admin`, route `/` | | `/en/admin/customers` | app `/admin`, route `/customers`, locale `en` | When registering routes via `router.add("/customers", …)` for the admin app, do NOT include the locale or the `/admin` prefix — the router prepends both automatically. ## What this package does NOT do - React-tree-as-routes (`react-router-dom`-style `<Routes>` / `<Route>` JSX). Routes are registered as data. - Server-side rendering of the router itself. SSR your page HTML separately; the router boots in the browser and hydrates `#root` when it contains pre-rendered markup. - Loaders / actions / data revalidation (cf. `react-router@6`'s data API). Compose a data layer separately (e.g. `@mongez/atomic-query`). - Nested-routes-as-outlets. Use `layout` per route or per group. --- # @mongez/react-helmet # @mongez/react-helmet — full reference > React adapter for `@mongez/dom`'s metadata module. A single `<Helmet>` component sets the document title (with optional app-name suffix), description, keywords, Open Graph / Twitter meta, canonical URL, favicon, and `<html>` attributes — declarative, with cleanup on unmount. ## Install ```sh yarn add @mongez/react-helmet # peer: react >= 18, @mongez/dom >= 1.1.2 ``` ## Public exports ```ts import Helmet, { setHelmetConfigurations, getHelmetConfigurations, getHelmetConfig, type HelmetProps, type HelmetConfigurations, } from "@mongez/react-helmet"; ``` ## The Helmet component > **Auto-trigger:** code imports `Helmet` (default) or `HelmetProps` from `@mongez/react-helmet`; JSX renders `<Helmet title=... />` with props like `title`, `appName`, `appendAppName`, `appNameSeparator`, `translatable`, `description`, `keywords`, `image`, `url`, `htmlAttributes`, `pageId`, or `className`; user asks "how do I set the page title / description / og:image in React", "why doesn't Helmet revert on unmount", or "how do I use Helmet inside Suspense / a route component". > **Skip when:** app-wide config setup (`setHelmetConfigurations`) — use `mongez-react-helmet-configuration`; the framework-agnostic head writers in `@mongez/dom` (`setTitle`, `setDescription`, `setImage`) when you're outside React; the upstream `react-helmet` / `react-helmet-async` libraries; Next.js `<Head>` and App Router `export const metadata`. ```ts type HelmetProps = { title: string; // required // App-name suffix; falls back to config when undefined. appName?: string; appendAppName?: boolean; // default: true appNameSeparator?: string; // default: " | " // i18n translatable?: boolean; // default: true // Page meta description?: string; keywords?: string | string[]; image?: string; url?: boolean | string; // true → window.location.href; default: true // <html> tag controls htmlAttributes?: Record<string, any>; pageId?: string; className?: string; }; declare function Helmet(props: HelmetProps): null; export default Helmet; ``` ### Effects produced (per prop) | Prop | Tags written to `document.head` (via `@mongez/dom`) | |---|---| | `title` | `<title>` (via `document.title`), `meta[property="og:title"]`, `meta[property="og:image:alt"]`, `meta[property="twitter:title"]`, `meta[property="twitter:image:alt"]`, `meta[itemprop="name"]` | | `description` | `meta[name="description"]`, `meta[itemprop="description"]`, `meta[property="og:description"]`, `meta[property="twitter:description"]` | | `keywords` | `meta[name="keywords"]` (array is `.join(",")`'d) | | `image` | `meta[property="image"]`, `meta[property="og:image"]`, `meta[property="twitter:image"]`, `meta[itemprop="image"]` | | `url` | `link[rel="canonical"]`, `meta[property="og:url"]`, `meta[property="twitter:url"]` | | `htmlAttributes` | each entry `.setAttribute`'d on `<html>` | | `pageId` | `<html>.id` | | `className` | each space-separated token `classList.add`'d on `<html>` | ### Lifecycle - Mount: snapshot the current `<html>` attribute set, id, and className; then run one effect per prop concern. - Re-render: each effect's deps array is its corresponding prop, so changing `title` re-runs the title effect, changing `description` re-runs the description effect, etc. - Unmount: each effect's cleanup attempts to restore the snapshot for its concern. `pageId` and `className` reliably restore. `title` / `description` / `keywords` / `image` / `url` cleanup is observably broken because the snapshot is a live reference to `@mongez/dom`'s mutable singleton (documented in CHANGELOG / `src/__tests__/helmet.test.tsx` skipped tests). ### Return value `<Helmet>` always returns `null`. It is a side-effect component, not a render component. ## Configuration > **Auto-trigger:** code imports `setHelmetConfigurations`, `getHelmetConfigurations`, `getHelmetConfig`, or `HelmetConfigurations` from `@mongez/react-helmet`; user asks "how do I set up app-wide Helmet defaults", "how do I configure appName / appNameSeparator", or "how do I wire @mongez/localization into Helmet titles"; file is a config bootstrap module (e.g. `src/config/helmet.ts`) calling `setHelmetConfigurations({...})`. > **Skip when:** per-page `<Helmet>` prop usage — use `mongez-react-helmet-helmet` instead; the lower-level `@mongez/dom` metadata functions (`setTitle`, `setDescription`, etc.) that have no React or config layer; unrelated React Helmet libraries (e.g. `react-helmet`, `react-helmet-async`) or Next.js `export const metadata`. ```ts type HelmetConfigurations = { appName?: string; appendAppName?: boolean; // default: true appNameSeparator?: string; // default: " | " url?: boolean; // default: true (whether to canonicalize automatically) htmlAttributes?: Record<string, any>; className?: string; translatable?: boolean; // default: true translateAppName?: boolean; // default: true translationFunction?: (key: string) => string; }; function setHelmetConfigurations(partial: HelmetConfigurations): void; function getHelmetConfigurations(): HelmetConfigurations; function getHelmetConfig(key?: keyof HelmetConfigurations, defaultValue?: any): any; ``` - `setHelmetConfigurations` shallow-merges with the existing config. - `getHelmetConfigurations` returns the entire config object. - `getHelmetConfig()` (no args) returns the entire config; `getHelmetConfig(key)` returns one key with an optional fallback default. Note: internally uses `||`, so any falsy value (including `false`, `""`, `0`) falls through to the default — pass an unambiguous default if you depend on it. ### Translation When `translatable` is true and `translationFunction` is set, the title (and `appName`, if `translateAppName` is also true) is passed through `translationFunction` before being written. ```ts setHelmetConfigurations({ appName: "appName", // a translation key translatable: true, translateAppName: true, translationFunction: (key) => i18n.t(key), }); ``` Per-call `translatable={false}` opts a single `<Helmet>` out. ## Title resolution ``` final = translate?(title) + appNameSeparator + translate?(appName) ``` Concretely: 1. `title` = `translatable && translate ? translate(props.title) : props.title`. 2. If `appendAppName && appName`: append `appNameSeparator` then either `translate(appName)` (when `translateAppName` is on) or `appName`. 3. The result is passed to `setTitle`, which also writes the `og:title` / `twitter:title` / `itemprop=name` mirror tags. ## `<html>` attributes / id / className - `htmlAttributes` — written via `setAttribute` on `document.documentElement`. The captured snapshot's `lang` and `dir` are intentionally **not** restored on unmount (so localization layers that mutate `lang`/`dir` outside `<Helmet>` keep their value). - `pageId` — sets `document.documentElement.id`. Restored on unmount to the value at mount. - `className` — split on whitespace; each token `classList.add`'d on `<html>`. Restored on unmount to the className string at mount. ## Browser-only `src/components/Helmet.tsx` accesses `document.documentElement` at module-evaluation time. To use in a Next.js App Router project, place the component (or its importing page) under a `"use client"` boundary. In Pages Router or Remix, use a `dynamic(..., { ssr: false })` import. ## What this package does NOT do - A virtual `<head>` registry / dedupe layer. Writes are direct; if two `<Helmet>`s set the same field, the later commit wins. - A server-render string for SSR head injection. The body's effects run in the browser; on the server the page ships with whatever the static `<head>` already contains. - Per-instance scoped configuration. `setHelmetConfigurations` is a module-level singleton; there is no React context provider for it. ## Related packages - [`@mongez/dom`](https://github.com/hassanzohdy/dom) — the framework-agnostic DOM utilities under the hood. - [`@mongez/react-atom`](https://github.com/hassanzohdy/mongez-react-atom) — sibling React adapter for atom-based state. - [`@mongez/localization`](https://github.com/hassanzohdy/mongez-localization) — the `trans()` function commonly wired into `translationFunction`. --- # @mongez/react-form # @mongez/react-form — Full Documentation (LLM-optimized) A headless React form handler for Web and React Native. The library owns state, validation, and value collection; the consumer owns rendering. This file is structured for LLM consumption: each section is self-contained, canonical patterns appear once, and the API reference is at the end. For tutorial-style human-readable docs, see README.md. --- ## 1. Installation > **Auto-trigger:** code imports `Form`, `NativeForm`, `useFormControl`, `enValidationTranslation`, `arValidationTranslation`, `frValidationTranslation`, `esValidationTranslation`, `itValidationTranslation`, or `deValidationTranslation` from `@mongez/react-form`; user asks "how do I install @mongez/react-form", "how do I set up a form in a new project", or "why does my validation show validation.required instead of a real message"; `import { Form, useFormControl } from "@mongez/react-form"` at app entry. > **Skip when:** `mongez-react-form-create-form-control` for writing custom input components; `mongez-react-form-validation-rules` for picking/composing rules; `mongez-react-form-react-native-usage` for RN-specific wiring beyond initial install; `react-hook-form`, `formik`, or `final-form` projects; locale wiring for non-form `@mongez/localization` usage. ```bash npm install @mongez/react-form ``` Runtime dependencies (`@mongez/events`, `@mongez/localization`, `@mongez/supportive-is`, `@mongez/reinforcements`) install transitively. ## 2. One-time setup — locale registration Validation messages flow through `@mongez/localization`. Register the bundles under the `validation` namespace at app entry: ```ts import { extend } from "@mongez/localization"; import { enValidationTranslation, arValidationTranslation, frValidationTranslation, esValidationTranslation, itValidationTranslation, deValidationTranslation, } from "@mongez/react-form"; extend("en", { validation: enValidationTranslation }); extend("ar", { validation: arValidationTranslation }); // ...register only the locales the app uses ``` Without this, error messages render as raw keys like `validation.required`. --- ## 3. Form components > **Auto-trigger:** code imports `NativeForm` from `@mongez/react-form`, or imports `useFormControl` / `useForm` / `useSubmitButton` in a file that also imports from `react-native` (e.g. `TextInput`, `Pressable`, `View`, `Text` from `"react-native"`); user asks "how do I use @mongez/react-form on React Native or Expo", "why doesn't my form submit fire on RN", "how do I wire `onChangeText` / `onFocus` / `Pressable` into a form control", or "how do I make a checkbox on React Native"; `import { NativeForm } from "@mongez/react-form"`. > **Skip when:** `mongez-react-form-getting-started` once install and locale registration are done; `mongez-react-form-create-form-control` for the platform-agnostic hook contract; `mongez-react-form-submit-button` for non-RN button patterns; Web-only `Form` component usage; `react-hook-form`/`formik` on RN. Two components, same API, different platforms: - **`Form`** — Web. Renders an HTML `<form>` element. Browser submit event drives the submission. - **`NativeForm`** — React Native. Renders a Fragment by default (or any component passed via `component` prop). Submission is always programmatic via `form.submit()`. Both implement `FormInterface` and share the abstract `BaseForm` engine. ### Form props (both Web and Native) ```ts type FormProps = { onSubmit?: (options: FormSubmitOptions) => void; onError?: (invalidControls: FormControl[]) => void; component?: React.ComponentType<any>; // override the rendered element defaultValue?: Record<string, any>; // default values for every control by name (dot-notation supported) ignoreEmptyValues?: boolean; // omit empty values from form.values() id?: string; // form id, auto-generated if absent children: React.ReactNode; }; ``` ### `FormSubmitOptions` (the argument to `onSubmit`) ```ts { form: FormInterface; event?: React.FormEvent; // undefined when programmatically submitted (and always on Native) values: Record<string, any>; // getter — calls form.values() each access formData: FormData; // getter — calls form.formData() each access } ``` ### Canonical form (Web) ```tsx import { Form } from "@mongez/react-form"; <Form onSubmit={({ values, form }) => api.save(values).catch(() => form.submitting(false))} onError={(invalidControls) => scrollTo(invalidControls[0])} defaultValue={{ user: { firstName: "John" } }} ignoreEmptyValues > {/* ...inputs... */} </Form> ``` ### Canonical form (React Native) ```tsx import { NativeForm } from "@mongez/react-form"; import { View } from "react-native"; <NativeForm onSubmit={handle} component={View} style={{ padding: 16 }}> {/* ...inputs... */} </NativeForm> ``` --- ## 4. Form controls — the canonical pattern > **Auto-trigger:** code imports `useFormControl`, `useRadioInput`, `RadioGroupContext`, `HiddenInput`, `FormControlProps`, `FormControlHook`, or `FormControl` from `@mongez/react-form`; user asks "how do I build a custom text input / checkbox / radio / multi-select for @mongez/react-form", "how do I wire `inputRef` / `otherProps` / `checked` / `setChecked`", or "how do I make a multi-value input"; `import { useFormControl } from "@mongez/react-form"` in a component file. > **Skip when:** `mongez-react-form-validation-rules` for choosing or writing rules (rules go into the `rules` array, but the rules system itself is a separate skill); `mongez-react-form-submit-button` for submit-button wiring; `mongez-react-form-form-events` for subscribing to lifecycle events; raw React `useState` form inputs unrelated to `@mongez/react-form`; `react-hook-form`'s `useController` or `Controller`. Every input is built around `useFormControl`. The hook registers the input with the surrounding form and returns a state bundle. ### Canonical text input ```tsx import { useFormControl, type FormControlProps } from "@mongez/react-form"; export default function TextInput(props: FormControlProps) { const { value, changeValue, id, error, inputRef, otherProps } = useFormControl(props); return ( <> <input id={id} ref={inputRef} value={value} onChange={(e) => changeValue(e.target.value)} {...otherProps} /> {error && <span className="error">{error}</span>} </> ); } ``` Equivalent on React Native: ```tsx import { useFormControl, type FormControlProps } from "@mongez/react-form"; import { TextInput as RNTextInput, Text, View } from "react-native"; export default function TextInput(props: FormControlProps) { const { value, changeValue, inputRef, formControl, error, disabled } = useFormControl(props); return ( <View> <RNTextInput ref={inputRef} value={value} onChangeText={changeValue} onFocus={() => (formControl.isTouched = true)} editable={!disabled} /> {error && <Text style={{ color: "red" }}>{error}</Text>} </View> ); } ``` ### Checkbox For checkboxes use `checked` / `setChecked`, not `value` / `changeValue`. **Must** set `type: "checkbox"`. ```tsx const { checked, setChecked, id } = useFormControl({ ...props, type: "checkbox" }); ``` Optional collection behavior via the second argument: ```ts useFormControl(props, { uncheckedValue: 0, // value to emit when unchecked collectUnchecked: true, // include unchecked controls in form.values() }); ``` ### Radio group Build a `RadioGroup` (one `useFormControl`) that provides `RadioGroupContext`. Each `RadioInput` consumes via `useRadioInput(value)`: ```tsx import { useFormControl, RadioGroupContext, requiredRule, useRadioInput } from "@mongez/react-form"; export function RadioGroup({ children, ...props }) { const { value, changeValue } = useFormControl({ ...props, rules: [requiredRule] }); return ( <RadioGroupContext.Provider value={{ value, changeValue }}> {children} </RadioGroupContext.Provider> ); } export function RadioInput({ value, children }) { const { isSelected, changeValue } = useRadioInput(value); return ( <label> <input type="radio" checked={isSelected} onChange={changeValue} /> {children} </label> ); } ``` ### Multi-value control ```ts const { value, changeValue } = useFormControl(props, { multiple: true }); // value is always an array ``` ### Hidden input ```tsx import { HiddenInput } from "@mongez/react-form"; <HiddenInput name="csrfToken" value={token} /> ``` ### Hook return shape (`FormControlHook`) ```ts { id: string; name: string; type: string; value: any; changeValue: (value, options?: FormControlChangeOptions) => void; error: ReactNode; errorsList: { [ruleName: string]: ReactNode }; setError: (error: ReactNode) => void; checked: boolean; setChecked: (checked: boolean) => void; inputRef: RefObject; visibleElementRef: RefObject; formControl: FormControl; // escape hatch — the underlying registration disabled: boolean; disable: () => void; enable: () => void; isInvalid: boolean; // touched AND failing validation otherProps: object; // pass-through props (excludes hook + rule-preserved keys) validate: () => void; } ``` --- ## 5. Validation > **Auto-trigger:** code imports `requiredRule`, `minLengthRule`, `maxLengthRule`, `lengthRule`, `minRule`, `maxRule`, `emailRule`, `numberRule`, `integerRule`, `floatRule`, `urlRule`, `patternRule`, `alphabetRule`, `matchRule`, `strongRule`, or `InputRule` from `@mongez/react-form`; user asks "how do I validate email / required / min length / pattern / password strength in @mongez/react-form", "how do I write a custom validation rule", or "how do I override a validation error message"; `rules: [...]` array passed to `useFormControl` with rule identifiers. > **Skip when:** `mongez-react-form-create-form-control` for the input component contract itself (rules plug into it but aren't the same topic); `mongez-react-form-form-events` for lifecycle events; `mongez-react-form-getting-started` for locale bundle registration; `@mongez/supportive-is` raw predicate checks unrelated to the rules system; `zod`, `yup`, `valibot`, or HTML5 `pattern`/`required` constraint validation. ### Composition Pass `rules: InputRule[]` in the first argument to `useFormControl`. Rules run in array order; the first failing rule short-circuits unless `{ validateAll: true }` is set in the second argument. ```tsx import { useFormControl, requiredRule, minLengthRule, emailRule, } from "@mongez/react-form"; useFormControl({ ...props, rules: [requiredRule, minLengthRule, emailRule] }); ``` ### Built-in rules | Rule | Activated by prop | Type-gated | Notes | |---|---|---|---| | `requiredRule` | `required` | — | Empty = null/undefined/""/[] | | `minLengthRule` | `minLength` | — | Strings + arrays | | `maxLengthRule` | `maxLength` | — | Strings + arrays | | `lengthRule` | `length` | — | Exact length | | `minRule` | `min` | — | Numeric | | `maxRule` | `max` | — | Numeric | | `emailRule` | — | `type="email"` | | | `numberRule` | — | `type="number"` | | | `integerRule` | — | `type="integer"` | | | `floatRule` | — | `type="float"` | | | `urlRule` | — | `type="url"` | | | `alphabetRule` | — | `type="alphabet"` | | | `patternRule` | `pattern` (RegExp) | — | | | `matchRule` | `match` (other input name) | — | Subscribes to the other input's changes | | `strongRule` | `strong` (boolean or object) | `type="password"` | Composite: 5 criteria, per-criterion errors in `errorsList["strong.<key>"]` | `requiresType` rules only run when `formControl.type` matches. `requiresValue: true` rules (the default) skip empty values — that's why `requiredRule` must come first and is the only rule with `requiresValue: false`. ### Per-instance message overrides ```tsx // Replace the entire error string <TextInput pattern={/^[a-z]+$/} errors={{ pattern: "Lowercase only" }} /> // Replace named placeholders within the localized template <TextInput match="password" errorKeys={{ matchingInput: "Password" }} /> ``` ### Per-instance custom validation ```tsx <TextInput name="username" validate={async ({ value }) => { if (!value) return; if (await isTaken(value)) return "Username already taken"; }} /> ``` The `validate` prop accepts sync or async functions. Async returns block downstream rules until resolved. ### Writing a custom rule ```ts import { trans } from "@mongez/localization"; import type { InputRule } from "@mongez/react-form"; export const phoneNumberRule: InputRule = { name: "phoneNumber", requiresType: "phoneNumber", validate: ({ value }) => { if (!/^01[0-2|5]{1}[0-9]{8}$/.test(value)) { return trans("validation.phoneNumber"); } }, }; ``` `InputRule` shape: ```ts { name?: string; validate: (options: InputRuleOptions) => ReactNode | undefined | Promise<...>; requiresValue?: boolean; // default true: skip on empty value requiresType?: string; // run only when control's type matches preservedProps?: string[]; // keep these props OUT of otherProps onInit?: (options) => EventSubscription | undefined; // setup on mount } ``` ### `strongRule` (composite password rule) The only built-in rule with non-trivial configuration. Activated by the `strong` prop, requires `type="password"`. ```ts type StrongPasswordCriteria = { minLength?: number; // default 8 — set to 0 to disable uppercase?: boolean; // default true lowercase?: boolean; // default true digit?: boolean; // default true symbol?: boolean; // default true }; <PasswordInput type="password" strong /> // all defaults <PasswordInput type="password" strong={{ minLength: 12 }} /> // override <PasswordInput type="password" strong={{ symbol: false }} /> // disable one ``` Each failing criterion populates a namespaced entry on `formControl.errorsList`: ``` errorsList["strong"] // first failing message (canonical rule entry) errorsList["strong.minLength"] // only if length check failed errorsList["strong.uppercase"] // only if uppercase check failed errorsList["strong.lowercase"] errorsList["strong.digit"] errorsList["strong.symbol"] ``` Use this to drive password-strength checklist UIs without composing five separate rules. Translation keys: `validation.strongMinLength` (with `:length`), `validation.strongUppercase`, `validation.strongLowercase`, `validation.strongDigit`, `validation.strongSymbol`. Override per-criterion via `errors={{ "strong.minLength": "..." }}`. Don't combine with `minLengthRule` — duplicates the length error. ### `validateAll` mode ```ts useFormControl(opts, { validateAll: true }); // error becomes ReactNode[], errorsList[ruleName] populated for every failing rule ``` --- ## 6. Submit buttons > **Auto-trigger:** code imports `useSubmitButton` or calls `form.submitting(true|false)`, `form.submit()`, `form.isSubmitting()`, or `form.disable()` from `@mongez/react-form`; user asks "how do I build a submit button for @mongez/react-form", "why is my submit button stuck disabled after a failed API request", or "how do I disable submit until the form is dirty"; `import { useSubmitButton } from "@mongez/react-form"` in a button component. > **Skip when:** `mongez-react-form-form-events` for subscribing to `submit` / `submitting` / `invalidControls` events directly (use that when not using `useSubmitButton`); `mongez-react-form-create-form-control` for input components rather than submit buttons; native `<button type="submit">` outside a `@mongez/react-form` `<Form>`; `react-hook-form`'s `formState.isSubmitting`. `useSubmitButton()` exposes auto-tracked button state: ```ts { disabled, isSubmitting, isDirty, disable, setSubmitState } ``` ### Web ```tsx import { useSubmitButton } from "@mongez/react-form"; export function SubmitButton({ children }) { const { disabled, isSubmitting } = useSubmitButton(); return <button type="submit" disabled={disabled}>{isSubmitting ? "..." : children}</button>; } ``` ### React Native ```tsx import { useForm, useSubmitButton } from "@mongez/react-form"; import { Pressable, Text } from "react-native"; export function SubmitButton({ children }) { const form = useForm(); const { disabled, isSubmitting } = useSubmitButton(); return ( <Pressable disabled={disabled} onPress={() => form?.submit()}> <Text>{isSubmitting ? "..." : children}</Text> </Pressable> ); } ``` ### Re-enabling after a failed request ```ts api.save(values).catch(() => form.submitting(false)); ``` This is mandatory — without it the button stays disabled forever. --- ## 7. Form events > **Auto-trigger:** code calls `form.on(...)`, `useForm()`, `getActiveForm()`, or `getForm(...)`, or imports `FormEventType`, `FormInterface`, or `EventSubscription` from `@mongez/react-form`; references events like `submit`, `submitting`, `validating`, `validation`, `validControl`, `invalidControl`, `validControls`, `invalidControls`, `dirty`, `register`, `unregister`, `reset`, `resetting`, or `disable`; user asks "how do I autosave on dirty change", "how do I scroll to the first invalid input", "how do I block submission conditionally", or "how do I track form analytics on submit/validation failure". > **Skip when:** `mongez-react-form-submit-button` when `useSubmitButton` already covers the disabled/submitting state derivation; `mongez-react-form-validation-rules` for writing/composing rules (rules fire validation events but the rule authoring topic is separate); `mongez-react-form-create-form-control` for the input component contract; raw `addEventListener` on a DOM `<form>`; `react-hook-form`'s `watch` / `formState` subscriptions. Subscribe via `form.on(event, callback)`. Returns an `EventSubscription` — unsubscribe in `useEffect` cleanup. ### Event catalog | Event | Payload | When | |---|---|---| | `register` / `registering` | `(formControl, form)` | A control registers | | `unregister` | `(formControl, form)` | A control unregisters | | `validating` | `(form)` | Pre-validation — return `false` to abort | | `validation` | `(isValid, validatedInputs, form)` | Validation completed | | `validControl` / `invalidControl` | `(formControl, form)` | Per-control transition | | `validControls` / `invalidControls` | `(controls[], form)` | Debounced aggregate state | | `submitting` | `(isSubmitting, form)` | In-flight state changes | | `submit` | `(form)` | After submission completes (and again on `submitting(false)`) | | `resetting` / `reset` | `(form)` | Reset lifecycle | | `dirty` | `(isDirty, form)` | Aggregate dirty state changed | | `disable` | `(isDisabled, form)` | `form.disable()` / `form.enable()` | ### Submit ordering `validating` → per-control validation → `validControl` / `invalidControl` per control → `validation` → `validControls` or `invalidControls` (debounced) → if invalid: `onError` prop called → if valid: `submitting(true)` event → `onSubmit` prop called → `submit` event. `submit` may fire twice per user action (once after sync submit, once on `submitting(false)`). Listeners must be idempotent. ### Per-control events ```ts formControl.onChange(callback); formControl.onReset(callback); formControl.onDestroy(callback); ``` --- ## 8. Form-level helpers ### Access from anywhere ```ts import { useForm, getActiveForm, getForm } from "@mongez/react-form"; useForm(); // hook — returns the surrounding form or null getActiveForm(); // module-level — returns the most recently mounted form getForm("form-id"); // module-level — returns by id ``` ### Programmatic API (`FormInterface`) ```ts form.submit(); // trigger validation + submission form.validate(controls?); // Promise<FormControl[]> form.validateVisible(); // validate only visible controls (Web only; on RN it's identical to validate()) form.values(names?); // collect values as nested object form.value(name); // single control value by name form.formData(); // FormData object (for multipart uploads) form.controls(names?); // array of FormControl form.control(name, getBy?); // single FormControl by name or id form.change(name, value); // mutate a control's value programmatically form.reset(); // reset all to initial values form.resetErrors(); // clear errors only form.disable(isDisabled); // disable/enable all controls form.enable(); // shorthand for disable(false) form.isValid(); // boolean form.isSubmitting(); // boolean form.submitting(true | false); // toggle in-flight state form.on(event, callback); // event subscription form.id; // string form.formElement; // HTMLFormElement (Web) | any (Native) ``` ### Default values Set at the form level (preferred for shared default sets): ```tsx <Form defaultValue={{ user: { firstName: "John", lastName: "Doe" } }}> ``` Or per-control (overrides form-level): ```tsx <TextInput name="user.firstName" defaultValue="Jane" /> ``` Per-control `defaultValue` wins; otherwise the form-level value is looked up by name (dot-notation honored). ### Ignoring empty values ```tsx <Form ignoreEmptyValues> ``` Causes `form.values()` to skip null/undefined/empty-string/empty-array values. Set globally via `setFormConfigurations({ ignoreEmptyValues: true })`. ### Configuration ```ts import { setFormConfigurations } from "@mongez/react-form"; setFormConfigurations({ ignoreEmptyValues: true, // default false formComponent: MyCustomForm, // default "form" — replaces the rendered element for ALL Web Forms }); ``` --- ## 9. Input name and value collection The `name` prop supports **dot notation** and is mapped into nested objects: - `user.firstName` → `{ user: { firstName: "..." } }` - `addresses.0.city` → `{ addresses: [{ city: "..." }] }` - `tags[0]` → `tags.0` (same as `tags.0`) - Repeated names → collected into an array, or use `multiple: true` to force array form `form.formData()` emits the same nested structure as bracket notation on the wire: - `{ user: { firstName: "X" } }` → `user[firstName]=X` - `{ tags: ["a", "b"] }` → `tags[]=a&tags[]=b` --- ## 10. Stepper / multi-step forms Use `form.validateVisible()` between steps. Each input (or its wrapper) must attach `visibleElementRef`: ```tsx const { visibleElementRef, value, changeValue, error } = useFormControl(props); return ( <div ref={visibleElementRef}> <input value={value} onChange={(e) => changeValue(e.target.value)} /> </div> ); ``` ```ts await form.validateVisible(); if (form.isValid()) goToNextStep(); ``` **Inactive steps must stay mounted but hidden** for this to work — `visibleElementRef.current.hidden` (or any ancestor's `hidden`) is what the check looks for. Unmounted steps are simply not registered. On React Native, `validateVisible()` is identical to `validate()` (the DOM-based visibility check is a no-op on Native). --- ## 11. Active forms registry ```ts getActiveForm(); // most recently mounted form getForm("form-id"); // by id ``` The "active form" tracks the most recently mounted form globally — when forms unmount, the previous active form (if still mounted) is restored. Useful for non-React code (deep-link handlers, global keyboard shortcuts, autosave drivers). --- ## 12. Type reference ### `FormControl` (key fields) ```ts { id: string; name: string; type: string; value: any; initialValue: any; checked: boolean; initialChecked: boolean; isDirty: boolean; isTouched: boolean; isValid: boolean | null; // null = not yet validated error: ReactNode; errorsList: { [ruleName: string]: ReactNode }; disabled: boolean; multiple?: boolean; isControlled: boolean; rendered: boolean; defaultValue?: any; uncheckedValue?: any; collectUnchecked?: boolean; inputRef: any; visibleElementRef: any; data?: any; change(value, opts?): void; setChecked(checked): void; setError(error): void; validate(): ReactNode; isCollectable(): boolean; collectValue(): any; isVisible(): boolean; focus(): void; blur(): void; clear(): void; reset(): void; disable(isDisabled): void; unregister(): void; onChange(callback): EventSubscription; onReset(callback): EventSubscription; onDestroy(callback): EventSubscription; } ``` ### `FormControlProps` (consumer-facing prop type) ```ts { name: string; // required id?: string; type?: string; // default "text" value?: any; // controlled value defaultValue?: any; // uncontrolled initial value checked?: boolean; // controlled checked defaultChecked?: boolean; required?: boolean; disabled?: boolean; readOnly?: boolean; placeholder?: string; label?: ReactNode; rules?: InputRule[]; validate?: InputRule["validate"]; // per-instance custom validation errors?: { [ruleName: string]: ReactNode }; // override messages per rule errorKeys?: { [placeholder: string]: ReactNode }; // override message placeholders onChange?: (value, options) => void; onError?: (error: ReactNode) => void; validateOn?: "change" | "blur"; [key: string]: any; // anything else flows to otherProps } ``` ### `InputRule` ```ts { name?: string; validate: (opts: InputRuleOptions) => ReactNode | undefined | Promise<ReactNode | undefined>; requiresValue?: boolean; // default true requiresType?: string; preservedProps?: string[]; onInit?: (opts) => EventSubscription | undefined; } ``` ### `InputRuleOptions` (the argument to `validate`) ```ts { value: any; name: string; formControl: FormControl; form: FormInterface | null; checked: boolean; errorKeys: { [key: string]: ReactNode }; [key: string]: any; // also includes everything from the props (label, placeholder, etc.) } ``` --- ## 13. Common anti-patterns to avoid - Listing `requiredRule` after value-dependent rules — they skip empty values and won't run. - Returning `false` from a `validate` function — must return a `ReactNode` or `undefined`. `false` is treated as truthy. - Spreading raw `props` (not `otherProps`) onto the host element — leaks hook-internal props. - Forgetting `form.submitting(false)` in the API-failure path — button stays disabled. - Subscribing to events without unsubscribing — leaks on remount, duplicate handlers. - Mixing `Form` (Web) and `NativeForm` (Native) in the same code path — pick the one matching the platform. - Using `<button onClick={() => form.submit()}>` on Web inside `<Form>` — works, but `<button type="submit">` is simpler and lets the browser handle the event natively. - Forgetting to set `formControl.isTouched = true` manually in RN inputs' `onFocus` — the auto-touch DOM listener is a no-op on Native. --- ## 14. Architecture & extending `BaseForm` ### Class hierarchy ``` BaseForm<P extends FormProps> (abstract — engine, no rendering) ├── Form (Web — renders <form>, DOM submit handling) └── NativeForm (React Native — Fragment default, programmatic submit) ``` `BaseForm` is exported from the package root. Subclass it to support any other React renderer (`react-three-fiber`, terminal UIs, server-rendered validation, custom hosts). ### What `BaseForm` provides out of the box Inheriting from `BaseForm` gives you the full `FormInterface` for free: **Control registry:** `register(formControl)`, `unregister(formControl)`, `control(value, getBy?)`, `controls(names?)`, internal `formControls[]` array. Automatically wires per-control `onChange` subscriptions to maintain `dirtyControls`. **Validation pipeline:** `validate(controls?)`, `validateVisible()`, plus the events `validating`, `validation`, `validControl`, `invalidControl`, `validControls`, `invalidControls`. Listener returning `false` from `validating` aborts the run. **Value collection:** `values(names?)`, `value(name)`, `formData()`, `collectValues(names?)`, `shouldIgnoreEmptyValues()`. Honors dot-notation names (`user.firstName` → nested objects), array indices (`tags.0`), `multiple: true` controls, and `ignoreEmptyValues`. **State tracking:** `isDirty`, `dirtyControls[]`, `setIsDirty()`, `isSubmitting()`, `submitting(boolean)`, `isValid()`, `disable(isDisabled?)`, `enable()`. Each transition fires the matching event. **Event bus:** `on(event, cb)`, `trigger(event, ...args)`, `triggerAll(event, ...args)`. Built on `@mongez/events`. Event names are typed via `FormEventType`. **Active-form registry:** Constructor auto-registers via `setActiveForm()` + `addToFormsList()`. `componentWillUnmount` auto-unregisters. So `getActiveForm()` and `getForm(id)` work without any subclass setup. **Reset:** `reset()`, `resetErrors()`. `reset()` fires `resetting` → calls each control's `reset()` → resets internal flags → fires `reset`. **Default-value resolution:** `props.defaultValue` is exposed as `this.defaultValue` and consumed by `useFormControl` via the `FormContext` lookup chain. **Shared submit pipeline:** Protected `handleSubmit(event?)` method — runs `validate()`, short-circuits on invalid form / already-submitting, calls `props.onSubmit({ form, event, values, formData })` with `values` and `formData` as getters (lazy), then fires the `submit` event. ### What you must implement Two abstract surfaces: 1. **`submit(): void`** — programmatic submit. Typical implementation calls `this.handleSubmit()`. 2. **`render(): ReactNode`** — must wrap children in `<FormContext.Provider value={this}>` so descendants' `useFormControl` calls find the form. Beyond that, render whatever fits the platform. ### Canonical subclass templates **Headless (no host element):** ```tsx import { BaseForm, FormContext, type FormProps, type FormInterface } from "@mongez/react-form"; export class HeadlessForm extends BaseForm implements FormInterface { public formElement = null; public submit() { if (this.isSubmitting()) return; this.handleSubmit(); } public render() { return ( <FormContext.Provider value={this}> {(this.props as FormProps).children} </FormContext.Provider> ); } } ``` **Web (mirrors what `Form.tsx` does):** ```tsx import { BaseForm, FormContext } from "@mongez/react-form"; export class CustomWebForm extends BaseForm { public formElement!: HTMLFormElement; public submit() { if (!this.formElement || this.isSubmitting()) return; this.formElement.requestSubmit(); } protected async triggerSubmit(e: React.FormEvent) { e.preventDefault(); e.stopPropagation(); await this.handleSubmit(e); } public render() { return ( <FormContext.Provider value={this}> <form ref={(el) => (this.formElement = el!)} onSubmit={this.triggerSubmit.bind(this)} noValidate > {(this.props as any).children} </form> </FormContext.Provider> ); } } ``` ### Generic prop type `BaseForm<P extends FormProps = FormProps>` — pass a wider prop type to your subclass when it needs platform-specific props: ```tsx type MyFormProps = FormProps & { view: "modal" | "inline" }; export class MyForm extends BaseForm<MyFormProps> { public submit() { this.handleSubmit(); } public render() { const { view, children } = this.props; return ( <FormContext.Provider value={this}> <div className={`form form-${view}`}>{children}</div> </FormContext.Provider> ); } } ``` ### When NOT to subclass If all you need is a different rendered element, **don't subclass — use the `component` prop:** ```tsx <Form component={MyStyledFormWrapper}>...</Form> <NativeForm component={View} style={{ padding: 16 }}>...</NativeForm> ``` Subclass only when submit semantics, rendering rules, or platform integration genuinely differ from both Web and Native. ### DOM-guarded code in `useFormControl` The same `useFormControl` hook runs on Web and Native unchanged. DOM-specific code paths are guarded with `typeof document !== "undefined"`: - `formControl.isVisible()` walks `parentElement` chain on Web; on Native it always returns `true`. Consequence: `form.validateVisible()` on Native is identical to `form.validate()`. - The auto-touch DOM `focus` event listener is a no-op on Native. Native input components must set `formControl.isTouched = true` manually in their own `onFocus` handler if they want the flag tracked. - `inputRef.current.focus()` / `.blur()` work on both — React Native's `TextInput` ref exposes both methods natively. ### Where the platform-specific code lives When extending `BaseForm` or contributing back, these are the only files you need to read: - `src/components/BaseForm.ts` — the engine (≈400 lines, no rendering). - `src/components/Form.tsx` — Web concrete (≈55 lines). - `src/components/NativeForm.tsx` — Native concrete (≈45 lines). - `src/hooks/useFormControl.ts` — the hook, with DOM guards inline. All other files (rules, hooks, contexts, configurations, active-form registry, types) are 100% platform-agnostic. --- # @mongez/user # @mongez/user — Full Reference Framework-agnostic user/auth state manager. Class-based, no framework coupling, designed to plug into any storage adapter via a three-method `UserCacheDriverInterface`. ## Install ```sh yarn add @mongez/user # peer: @mongez/events, @mongez/reinforcements ``` ## Imports ```ts import { User, UserEventsListener, setCurrentUser, getCurrentUser, type UserInterface, type UserInfo, type UserCacheDriverInterface, type UserEvents, type UserEventName, type WithDataCallback, type Role, type PermissionGroup, } from "@mongez/user"; ``` ## Mental model | Concept | Type | Mental model | |---|---|---| | User instance | `User` (subclass) | A typed user payload plus methods bound to it. | | Cache driver | `UserCacheDriverInterface` | Three methods (`get`/`set`/`remove`) that persist user data anywhere. | | Event listener | `UserEventsListener` | Per-instance pub/sub for `boot`/`login`/`logout`/`change`/`keyChange`. | | Current user | module-level pointer | A single global slot, set/read via `setCurrentUser`/`getCurrentUser`. | ## The base class > **Auto-trigger:** code extends `User` (`class AppUser extends BaseUser`) or calls `user.boot()`, `user.login(...)`, `user.logout()`, `user.update(...)`, `user.get(...)`, `user.set(...)`, `user.all()`, `user.isLoggedIn()`, `user.isNotLoggedIn()`, `user.getAccessToken()`, `user.setAccessToken(...)`, `user.refreshToken(...)`, `user.getCacheKey()`, `user.getAccessTokenKey()`, or `user.setAccessTokenKey(...)`; sets `protected cacheDriver`, `cacheKey`, `accessTokenKey`, `enableEvents`, or `eventsBaseName` on a subclass; user asks "how do I subclass User / log in a user / read user fields / change the token key / preserve token on update"; `import { User } from "@mongez/user"`. > **Skip when:** cache driver implementation details (use `mongez-user-cache-drivers`); event subscriptions (use `mongez-user-events`); permission checks (use `mongez-user-permissions`); module-level current user (use `mongez-user-current-user`). The pattern is: subclass, declare a `cacheDriver` field, optionally override `accessTokenKey`, `cacheKey`, `enableEvents`, `eventsBaseName`. Then `new` it and call `boot()`. ```ts import { User as BaseUser, UserCacheDriverInterface } from "@mongez/user"; class AppUser extends BaseUser { protected cacheDriver: UserCacheDriverInterface = myDriver; protected accessTokenKey: string = "token"; // default "accessToken" protected cacheKey: string = "current-user"; // default "user" protected enableEvents: boolean = true; // default false protected eventsBaseName: string = "auth"; // default = cacheKey } const user = new AppUser(); user.boot(); ``` `boot()` does three things: 1. Reads `cacheDriver.get(cacheKey)` to hydrate `userData`. 2. If `enableEvents`, instantiates `UserEventsListener` on `this.events` with `eventsBaseName || cacheKey`. 3. If events are enabled, fires the `boot` event. ## Method surface ### Identity ```ts user.isLoggedIn(); // boolean — true if access token has length > 0 user.isNotLoggedIn(); // boolean — inverse user.id; // shorthand for user.get("id") ``` ### Session ```ts user.login(userData); // store, cache, fire login event user.logout(); // clear, remove from cache, fire logout event user.update(userData); // replace whole payload; preserves existing token if not in userData ``` ### Token ```ts user.getAccessToken(); // string ("" when not logged in) user.setAccessToken(token); // set the token key in userData; fires keyChange user.refreshToken(token); // alias for setAccessToken ``` ### Reads — dot-notation supported via `@mongez/reinforcements` ```ts user.get("email"); user.get("profile.address.country"); user.get("optional", "fallback"); user.all(); // entire userData object ``` ### Writes — dot-notation supported ```ts user.set("profile.address.country", "Egypt"); ``` `set` is a no-op if the new value equals the current value (referential equality). ### Permissions ```ts user.setPermissions({ posts: { create: true, delete: false } }); user.can("posts.create"); // true user.can("posts.delete"); // false user.can("missing"); // false ``` `can(path)` returns `true` only when the value at the dot-notation path is truthy. ### Cache key access ```ts user.getCacheKey(); // returns the configured cacheKey user.getAccessTokenKey(); // returns the configured accessTokenKey user.setAccessTokenKey("jwt"); // change it at runtime ``` ## Cache drivers > **Auto-trigger:** code references `UserCacheDriverInterface`, `cacheDriver`, or assigns to `protected cacheDriver` on a `User` subclass; user asks "how do I persist the user / store auth in localStorage / use cookies for auth / write a cache driver for @mongez/user"; file imports `UserCacheDriverInterface` from `@mongez/user` or wires a `get`/`set`/`remove` driver to a `User` class. > **Skip when:** @mongez/cache for general-purpose caching unrelated to user/session state; storage primitives unrelated to `@mongez/user` (e.g. plain `localStorage.setItem` calls with no `User` subclass); SSR session middleware that doesn't use the `User` class. ```ts type UserCacheDriverInterface = { get(key: string, defaultValue?: any): any; set(key: string, value: any): void; remove(key: string): void; [id: string]: any; }; ``` ### localStorage ```ts const localStorageDriver: UserCacheDriverInterface = { get: (key) => { if (typeof localStorage === "undefined") return null; const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : null; }, set: (key, value) => localStorage.setItem(key, JSON.stringify(value)), remove: (key) => localStorage.removeItem(key), }; ``` ### `@mongez/cache` ```ts import cache from "@mongez/cache"; class AppUser extends BaseUser { protected cacheDriver = cache; } ``` ### In-memory (tests, SSR per-request) ```ts function memoryDriver(): UserCacheDriverInterface { const store = new Map<string, any>(); return { get: (key) => store.get(key) ?? null, set: (key, value) => { store.set(key, value); }, remove: (key) => { store.delete(key); }, }; } ``` ## Events > **Auto-trigger:** code sets `enableEvents = true` or `eventsBaseName` on a `User` subclass; calls `user.events.onLogin`, `onLogout`, `onChange`, `onKeyChange`, `onBoot`, or uses `UserEventsListener`; user asks "how do I react to login / fire side effects on logout / subscribe to auth events / invalidate caches on logout"; subscribes to `events.subscribe("auth.login", ...)` topics from `@mongez/events`. > **Skip when:** @mongez/events for generic app-wide pub/sub unrelated to auth lifecycle; DOM `addEventListener`; framework state-change hooks (e.g. React effect deps); pure component re-render concerns. Off by default. Set `protected enableEvents = true` on your subclass. ```ts class AppUser extends BaseUser { protected cacheDriver = myDriver; protected enableEvents = true; protected eventsBaseName = "auth"; } const user = new AppUser(); user.boot(); const sub = user.events!.onLogin((userData, u) => { /* … */ }); sub.unsubscribe(); ``` ### Available listeners | Method | Signature | Fired by | |---|---|---| | `onBoot(cb)` | `(initData, user) => void` | `boot()` | | `onLogin(cb)` | `(userData, user) => void` | `login()` | | `onLogout(cb)` | `(user) => void` | `logout()` | | `onChange(cb)` | `(newData, oldData, user) => void` | `update()` | | `onKeyChange(cb)` | `(key, newValue, oldValue, user) => void` | `set()` and per-key inside `update()` | All return `EventSubscription` from `@mongez/events`. Call `.unsubscribe()` to remove. ### Event bus topics Events are dispatched on the `@mongez/events` bus under `${eventsBaseName}.${eventType}`. You can subscribe directly with `events.subscribe("auth.login", cb)` from anywhere. ## Current user pointer > **Auto-trigger:** code imports `setCurrentUser` or `getCurrentUser` from `@mongez/user`; user asks "how do I access the current user from anywhere / get the logged-in user in middleware / share user across modules"; file calls `getCurrentUser()?.getAccessToken()` or sets the global slot after `boot()`; `import { setCurrentUser, getCurrentUser } from "@mongez/user"`. > **Skip when:** @mongez/atom for app-wide reactive state beyond a single global slot; React Context / Redux-style stores; per-request SSR user threading (use request context instead — see `mongez-user-recipes`). ```ts import { setCurrentUser, getCurrentUser } from "@mongez/user"; setCurrentUser(user); getCurrentUser(); // returns the same instance, or undefined ``` A single module-level slot. Use it sparingly — in tests it persists between cases unless you reset it. ## TypeScript - `UserInfo` is `{ accessToken?: string; [key: string]: any }`. Extend it for your domain shape. - `UserInterface` is the contract every method conforms to. Declare `implements UserInterface` on your subclass for compile-time checking. - `UserEvents` is the listener contract `UserEventsListener` satisfies. - `Role` / `PermissionGroup` types are exported for callers wiring permission UI, but `setPermissions` accepts any object — the type is not enforced internally. ## SSR notes - The `currentUser` slot is module-level → shared per Node process. For per-request SSR, do NOT use `setCurrentUser`; pass the `user` instance explicitly through your render tree. - The default `cacheDriver` is `undefined` — you must provide one. For SSR, use a per-request in-memory driver, or skip the driver entirely (no persistence, just session-state). ## Gotchas - `boot()` is required. The constructor does NOT hydrate from the cache driver. - `update(newData)` replaces the whole `userData`. Pass the token in the payload, or it's preserved from the previous value. - `set(key, value)` does a referential-equality check — passing the same object reference is a no-op even if you mutated its contents. - `setPermissions` REPLACES the permissions object, it doesn't merge. --- # @mongez/http # @mongez/http — Full Reference > **Auto-trigger when loading this full reference:** `Http`, `HttpError`, `Resource`, `CancellablePromise`, `CancellableAsyncIterable`, `ResourceService`, `CacheDriver`, `HttpConfig`, `RequestOptions`, `StreamRequestOptions`, `BeforeInterceptor`, `AfterInterceptor`, `AfterInterceptorContext`, `HttpRetryConfig`, `HttpCacheConfig`, `setCurrentHttp`, `getCurrentHttp`, `makeCancellable`, `http.get`, `http.post`, `http.stream`, `http.all`, `http.race`, `http.before`, `http.after`, `http.invalidate` imported from `@mongez/http`; user asks to make HTTP requests, handle errors, stream SSE/NDJSON, cache responses, write RESTful CRUD, intercept or retry requests, or download files with progress. > > **Skip when:** using `axios`, `ky`, `ofetch`, or native `fetch` directly without `@mongez/http`; framework-specific data fetchers like `swr` or `@tanstack/react-query` in isolation (unless wired to `@mongez/http`); Node.js `http`/`https` core modules. --- ## Overview `@mongez/http` v3 is a zero-Axios, native-`fetch` HTTP client for TypeScript applications. It replaces the old Axios-based `Endpoint` + `RestfulEndpoint` with a cleaner architecture: - **`Http` class** — sends requests, manages config, interceptors, events - **`Resource` class** — RESTful CRUD helper built on top of Http - **`{data, error}` pattern** — every method returns a discriminated union; no try/catch noise - **`CancellablePromise`** — `.cancel()` and `.signal` on every returned promise - **`CancellableAsyncIterable`** — `.cancel()` and `.error` on every stream - **`HttpError`** — structured error with status predicates Runtime dependency: `@mongez/concat-route` only. --- ## Http class ### Constructor ```ts new Http(config?: HttpConfig) ``` ### HttpConfig ```ts interface HttpConfig { baseURL?: string auth?: string | ((req: OutgoingRequest) => string | null | undefined) putToPost?: boolean // default false — convert PUT→POST for file uploads putMethodKey?: string // default "_method" timeout?: number // ms, no timeout by default headers?: Record<string, string> params?: HttpParams // default query params merged into every request cache?: boolean | HttpCacheConfig retry?: HttpRetryConfig publishKey?: string // default "published" // Fetch-native options forwarded to every fetch() call credentials?: RequestCredentials mode?: RequestMode keepalive?: boolean redirect?: RequestRedirect fetchCache?: RequestCache // browser HTTP cache directive — distinct from app-level `cache` // Custom body serializer (e.g. MessagePack/CBOR). FormData/Blob/string pass through. serializer?: (data: unknown) => { body: BodyInit; contentType: string } // Custom GET deduplication key — default keys by URL + serialised params. dedupeKey?: (url: string, params?: HttpParams) => string } ``` ### Methods ```ts // HTTP verbs — all return CancellablePromise<HttpResult<T>> get<T>(path, options?) post<T>(path, data?, options?) put<T>(path, data?, options?) patch<T>(path, options?) // body via options.data delete<T>(path, options?) head(path, options?) options<T>(path, options?) request<T>(method, path, data?, options?) // escape hatch for any verb // Streaming stream<T>(path, options?: StreamRequestOptions): CancellableAsyncIterable<T> // Concurrent helpers — cancel() cancels every inner request all<T>(requests: CancellablePromise<T>[]): CancellablePromise<T[]> // all settle race<T>(requests: CancellablePromise<T>[]): CancellablePromise<T> // first wins; rest cancelled // Cache management invalidate(key: string): Promise<void> invalidateAll(): Promise<void> // requires CacheDriver.clear() // Config extend(overrides: HttpConfig): Http // new instance, merged config getConfig(): Readonly<HttpConfig> // Interceptors before(fn: BeforeInterceptor): this after<T>(fn: AfterInterceptor<T>): this // runs on success AND error // Events on(event: string, handler: HttpEventHandler): this off(event: string, handler: HttpEventHandler): this ``` --- ## HttpResult<T> ```ts type HttpResult<T> = | { data: T; error: null; status: number; response: Response; headers: Record<string, string>; request: OutgoingRequest } | { data: null; error: HttpError; status: number | null; response: Response | null; headers: Record<string, string> | null; request: OutgoingRequest } ``` --- ## CancellablePromise<T> ```ts type CancellablePromise<T> = Promise<T> & { cancel(reason?: string): void readonly signal: AbortSignal } ``` Factory: `makeCancellable(factory, externalSignal?)` --- ## CancellableAsyncIterable<T> ```ts type CancellableAsyncIterable<T> = AsyncIterable<T> & { cancel(reason?: string): void readonly error: HttpError | null // set after iteration ends with an error } ``` Returned by `http.stream()`. Iteration ends silently on cancel; check `.error` afterwards. --- ## RequestOptions ```ts interface RequestOptions { params?: HttpParams headers?: Record<string, string> signal?: AbortSignal cache?: boolean | Omit<HttpCacheConfig,'driver'> & { driver?: CacheDriver } cacheKey?: string retry?: boolean | Partial<HttpRetryConfig> throw?: boolean // default false — throw HttpError instead of returning it timeout?: number data?: unknown // body for PATCH / DELETE and any method needing a body responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'stream' onDownloadProgress?: (event: DownloadProgressEvent) => void onUploadProgress?: (event: UploadProgressEvent) => void // Per-request fetch-native overrides credentials?: RequestCredentials mode?: RequestMode keepalive?: boolean redirect?: RequestRedirect fetchCache?: RequestCache } ``` `responseType: "stream"` returns raw `ReadableStream<Uint8Array>` — body is never read by the library. `onDownloadProgress` and `cache` are silently ignored when `responseType: "stream"` is set. --- ## HttpError ```ts class HttpError extends Error { status: number | null // null for network/abort/timeout body: unknown // parsed response body response: Response | null headers: Record<string, string> | null request: OutgoingRequest | null isAborted: boolean isTimeout: boolean isNetwork: boolean // Status predicates — getters, no () needed get isClientError(): boolean // 4xx get isServerError(): boolean // 5xx get isUnauthorized(): boolean // 401 get isForbidden(): boolean // 403 get isNotFound(): boolean // 404 get isValidationError(): boolean // 422 get isRateLimited(): boolean // 429 toJSON(): Record<string, unknown> // omits `request` } ``` --- ## Resource class ```ts class Resource { route: string defaultListParams: HttpParams list<T>(params?, options?): CancellablePromise<HttpResult<T>> get<T>(id, options?): CancellablePromise<HttpResult<T>> create<T>(data, options?): CancellablePromise<HttpResult<T>> update<T>(id, data, options?): CancellablePromise<HttpResult<T>> patch<T>(id, options?): CancellablePromise<HttpResult<T>> // body via options.data delete<T>(id, options?): CancellablePromise<HttpResult<T>> bulkDelete<T>(data, options?): CancellablePromise<HttpResult<T>> publish<T>(id, published, publishKey?, options?): CancellablePromise<HttpResult<T>> action<T>(id, actionName, data?, options?, method?): CancellablePromise<HttpResult<T>> path(suffix?): string actionPath(id, actionName): string useHttp(instance): this } ``` `http` getter is lazy — resolved from `getCurrentHttp()` on first request. --- ## CacheDriver ```ts interface CacheDriver { get<T = unknown>(key: string): Promise<T | null | undefined> set(key: string, value: unknown, ttl?: number): Promise<void> | void remove?(key: string): Promise<void> | void clear?(): Promise<void> | void // required for invalidateAll() } ``` ### HttpCacheConfig ```ts interface HttpCacheConfig { driver: CacheDriver ttl?: number // default 300s generateKey?: (url, params?) => string // default: baseURL + path + params JSON } ``` --- ## HttpRetryConfig ```ts interface HttpRetryConfig { attempts: number delay: number // base ms backoff?: boolean // default true — delay * 2^attempt jitter?: boolean // default false — multiply delay by random [0.5, 1.0) retryOn?: number[] // default [429, 500, 502, 503, 504] onRetry?: (attempt: number, error: HttpError, delay: number) => void } ``` Network errors are always retried. Aborts and timeouts are never retried. --- ## Interceptors ```ts // Before: modify outgoing request (receives read-only RequestOptions snapshot as 2nd arg) type BeforeInterceptor = ( req: OutgoingRequest, options: Readonly<RequestOptions>, ) => OutgoingRequest | void | Promise<OutgoingRequest | void> // After: transform result (success OR error); context.replay() re-fires original request interface AfterInterceptorContext<T> { replay(): Promise<HttpResult<T>> } type AfterInterceptor<T> = ( result: HttpResult<T>, context: AfterInterceptorContext<T>, ) => HttpResult<T> | void | Promise<HttpResult<T> | void> ``` --- ## Streaming ```ts interface StreamRequestOptions { method?: HttpMethod // default "GET" data?: HttpData format?: 'sse' | 'ndjson' // default "sse" parseLine?: (line: string) => unknown params?: HttpParams headers?: Record<string, string> signal?: AbortSignal timeout?: number reconnect?: boolean // default false maxReconnectAttempts?: number // default Infinity reconnectDelay?: number // default 3000 ms } ``` ```ts for await (const chunk of http.stream<ChatChunk>('/chat', { method: 'POST', data: body })) { process(chunk); } // stream.error is set if the stream ended with an error ``` --- ## Bootstrap ```ts import { Http, setCurrentHttp } from '@mongez/http'; const http = new Http({ baseURL: import.meta.env.VITE_API_URL, auth: () => localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : null, retry: { attempts: 2, delay: 300 }, }); setCurrentHttp(http); export { http }; ``` --- ## File map | File | Exports | |------|---------| | `src/Http.ts` | `Http` | | `src/HttpError.ts` | `HttpError` | | `src/Resource.ts` | `Resource` | | `src/Resource.types.ts` | `ResourceService` | | `src/cancellable.ts` | `CancellablePromise`, `CancellableAsyncIterable`, `makeCancellable` | | `src/current-http.ts` | `setCurrentHttp`, `getCurrentHttp` | | `src/Http.types.ts` | All shared types/interfaces | | `src/utils/params.ts` | `buildQueryString`, `appendParams` | | `src/utils/body.ts` | `prepareBody`, `parseBody` | | `src/index.ts` | Re-exports everything | --- ## Changelog ### 3.3.x - `responseType: "stream"` — raw `ReadableStream` pass-through - `http.all()` / `http.race()` — concurrent request helpers with cancel-all semantics - `HttpConfig.fetchCache` / `RequestOptions.fetchCache` — fetch-native `RequestCache` directive - `HttpConfig.dedupeKey` — custom GET deduplication key generator - `HttpConfig.serializer` — custom body serializer (MessagePack, CBOR, etc.) - `BeforeInterceptor` second param: `Readonly<RequestOptions>` — inspect per-request options in before-interceptors ### 3.0.0 - Complete rewrite: native `fetch`, drop Axios - `{data, error}` result pattern - `CancellablePromise` on all methods - `HttpError` with typed predicates - `Resource` replaces `RestfulEndpoint` (fixes double-URL bug in `publish`) - Lazy `http` getter in Resource (fixes silent undefined) - Built-in caching (any CacheDriver) - Built-in retry with exponential backoff - Before/after interceptors - `Http.extend()` for scoped instances - `putToPost` for Laravel-style file uploads --- # @mongez/vite # @mongez/vite — full reference > A drop-in Vite plugin for SPA workflows. This is the concatenated reference; load `llms.txt` for the structured index instead. ## Install ```sh yarn add -D @mongez/vite # peer: vite >= 5.0.0 ``` ## Public exports ```ts import mongezVite from "@mongez/vite"; import type { MongezViteOptions } from "@mongez/vite"; ``` That's the surface — a default export (the plugin factory) and a single named type. ## The factory ```ts function mongezVite(options?: MongezViteOptions): PluginOption ``` Returns a Vite plugin with: - `name: "mongez-vite"` - A `config` hook that mutates `UserConfig` in place - A `transformIndexHtml` hook that runs env-token replacement - A `writeBundle` hook (declared `sequential: true`) that emits `.htaccess` and zips the build ## Options ```ts type MongezViteOptions = { baseUrl?: string; envBaseUrlKey?: string; productionEnvName?: string; htmlEnvPrefix?: string; htmlEnvSuffix?: string; autoOpenBrowser?: boolean; linkTsconfigPaths?: boolean; tsconfigAlias?: boolean; optimizeDeps?: UserConfig["optimizeDeps"]; compressBuild?: boolean; compressedFileName?: string | (() => string) | (() => Promise<string>); htaccess?: boolean; preRender?: { crawlers?: string; url?: string; delay?: number; cache?: boolean; } | false; }; ``` ## Defaults | Option | Default | |---|---| | `envBaseUrlKey` | `"PUBLIC_URL"` | | `htmlEnvPrefix` | `"__"` | | `htmlEnvSuffix` | `"__"` | | `autoOpenBrowser` | `true` | | `linkTsconfigPaths` | `true` | | `tsconfigAlias` | `true` | | `compressBuild` | `true` | | `compressedFileName` | `"build.zip"` | | `htaccess` | `false` | | `preRender` | `false` | | `optimizeDeps.entries` | `[<cwd>/index.html, <cwd>/src/apps/**/provider.ts]` | ## Env file resolution > **Auto-trigger:** code configures `mongezVite({ productionEnvName: ... })` in `vite.config.ts` / `vite.config.js`, or imports `env` from `@mongez/dotenv` alongside `mongezVite` usage; user asks "how do I load .env in Vite with mongezVite", "how do I switch between .env.staging / .env.production at build time", "why is my .env not loading"; project has `.env.shared` / `.env.production` / `.env.development` / `.env.build` / `.env.local` files plus `mongezVite()` registered. > **Skip when:** in-HTML token replacement (use `mongez-vite-env-in-html`); deriving `config.base` from env (use `mongez-vite-production-base-url`); raw `@mongez/dotenv` usage with no `mongezVite()` plugin in the config; Vite's built-in `loadEnv` / `import.meta.env.VITE_*` without `@mongez/vite`; generic dotenv libraries (`dotenv`, `dotenv-flow`). The plugin delegates to `@mongez/dotenv`. From `process.cwd()`: | Command | Search order | |---|---| | `build` | `.env.production` → `.env.build` → `.env` | | `serve` (vite dev) | `.env.development` → `.env.local` → `.env` | With `productionEnvName: "<name>"` and `command: "build"`: - Loads `.env.<name>` only. No fallback — returns early if missing. Loaded values are coerced (`"3000"` → `3000`, `"true"` → `true`, `"null"` → `null`) and written through to `process.env` (which stringifies them) plus `@mongez/dotenv`'s internal store. Read typed values back via `env("KEY")` from `@mongez/dotenv`. ## Production base URL > **Auto-trigger:** code passes `envBaseUrlKey` (or relies on the default `PUBLIC_URL`) to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; `.env.production` / `.env.<stage>` declares `PUBLIC_URL` or a custom CDN key alongside `mongezVite()` registered; user asks "how do I set Vite's base URL from env", "why do production assets load from the wrong origin / 404 from `/assets/...`", "how do I deploy a Vite SPA behind a CDN or subpath". > **Skip when:** dev-server base behaviour (the plugin never touches `config.base` during `serve`); hand-setting `base` in `vite.config.ts` (the plugin defers to that); `MongezViteOptions.baseUrl` (currently informational — set `base` on the Vite config directly); env file resolution itself (use `mongez-vite-env-loading`). During `vite build`, the plugin sets `config.base` from `env(envBaseUrlKey)` (default key: `PUBLIC_URL`). The URL is normalised with `rtrim(..., "/") + "/"` so a missing trailing slash is added and multiple trailing slashes collapse to one. - Build mode + env unset → `config.base = "/"`. - Build mode + user already set `config.base` → user value wins. - Serve mode → never touched. ## Env-in-HTML interpolation > **Auto-trigger:** code passes `htmlEnvPrefix` or `htmlEnvSuffix` to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; `index.html` contains `__KEY__`-style tokens (or custom-delimited `{{KEY}}` / `<!--KEY-->` shapes) paired with `mongezVite()` in `plugins: []`; user asks "how do I inject env values into index.html", "why are my `__APP_NAME__` tokens not being replaced", "how do I change the env token delimiters in HTML". > **Skip when:** env file resolution / `productionEnvName` (use `mongez-vite-env-loading`); reading env values at runtime in the browser (that's Vite's `import.meta.env.VITE_*`, not `@mongez/vite`); HTML transforms unrelated to env tokens; generic templating engines (EJS, Handlebars) not driven by `mongezVite`. Every occurrence of `<prefix><KEY><suffix>` in `index.html` is replaced with the typed env value (coerced to string via JS template substitution). Default delimiters are `__`...`__`. Override via `htmlEnvPrefix` / `htmlEnvSuffix`. Tokens for keys not loaded into the env store pass through unchanged. ```html <title>__APP_NAME__ ``` ## Auto-open browser > **Auto-trigger:** code passes `autoOpenBrowser` to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; `vite.config.ts` has both `server: { open: ... }` and `mongezVite()` and the user wants to reconcile them; user asks "how do I stop Vite from auto-opening the browser with mongezVite", "why does my browser open / not open on `vite dev`", "how does mongezVite interact with `server.open`". > **Skip when:** pure Vite `server.open` configuration with no `@mongez/vite` plugin in scope; opening a specific path on dev start (the plugin clobbers the string form — user should set `autoOpenBrowser: false`); HMR / dev-server port / host issues; production-build behaviour (the helper short-circuits during `build`). During `vite dev` (`serve` command), sets `config.server.open = true` if: 1. The `autoOpenBrowser` option is truthy (default: true), AND 2. The user has not already set `server.open` to either `true` or `false`. During `vite build`, the helper is a no-op. ## tsconfig path aliases > **Auto-trigger:** code passes `linkTsconfigPaths` or `tsconfigAlias` to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; project has `tsconfig.json` with `compilerOptions.paths` (e.g. `"@/*": ["src/*"]`) alongside `mongezVite()` registered; user asks "why do my `@/...` imports work in tsc but fail in Vite", "how do I sync tsconfig paths with Vite aliases", "how does mongezVite handle `resolve.alias`". > **Skip when:** third-party path-alias plugins like `vite-tsconfig-paths` not paired with `@mongez/vite`; hand-written `resolve.alias` arrays in `vite.config.ts` (the plugin defers to those); Webpack / Jest `moduleNameMapper` path resolution; tsconfig `references` / project-references setup. Reads `tsconfig.json` from `process.cwd()`. For every entry in `compilerOptions.paths`: - Strips a trailing `/*` from the find key (e.g. `@/*` → `@`). - Resolves the replacement to an absolute path relative to `process.cwd()`. - Pushes `{ find, replacement }` onto a vite `resolve.alias` array. The alias array is installed onto `config.resolve.alias` ONLY if the user has not already declared their own. Both `linkTsconfigPaths` AND `tsconfigAlias` must be truthy. When `tsconfig.json` is missing or has no `paths`, the helper is a no-op. ## Build zip > **Auto-trigger:** code passes `compressBuild` or `compressedFileName` (string / sync / async function) to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; deploy script chains `vite build && `; user asks "how do I zip Vite output for deploy", "why is `dist/build.zip` missing or partial after `vite build`", "how do I name the zip per git tag / build number". > **Skip when:** `.htaccess` generation (use `mongez-vite-htaccess`); prerender PHP emission (use `mongez-vite-prerender`); other archive formats like `.tar.gz` (this plugin emits zip only — script it yourself); generic Node zip libraries (`archiver`, `adm-zip`) used without `@mongez/vite`. `writeBundle` schedules a zip job that: 1. Resolves the output directory from `config.build.outDir` (default `"dist"`). 2. Resolves the filename from `compressedFileName` (static string, sync function, or async function). 3. Creates a `.zip` containing the **contents** of the output directory. 4. Moves the zip back into the output directory. > **Sharp edge:** the zip is created inside a `setTimeout(..., 1000)`. Vite's `writeBundle` returns before the zip finishes. Tools that run `vite build && next-step` may see the artifact missing. ## `.htaccess` emission > **Auto-trigger:** code passes `htaccess: true` to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; project deploys a Vite SPA to Apache and ships a `.htaccess` with `RewriteEngine On` / SPA fallback rules; user asks "how do I generate `.htaccess` for my Vite SPA", "what's inside the mongezVite `.htaccess` template", "how do I add SPA rewrite rules / force HTTPS / GZIP on Apache". > **Skip when:** prerender PHP and the crawler rewrite that lives inside `.htaccess` (use `mongez-vite-prerender`); Nginx / Caddy / Cloudflare Workers SPA routing (Apache-specific); customising cache headers per filetype beyond what the bundled template ships (post-process the emitted file); raw `.htaccess` authoring with no `mongezVite()` plugin in the config. When `htaccess: true`, the plugin writes a bundled `.htaccess` into the output dir. It includes: - `RewriteEngine On` + `Options +FollowSymLinks -Indexes`. - Force HTTPS / strip leading `www.`. - SPA-friendly: every URL that isn't an existing file or asset routes to `index.html`. - GZIP via `mod_gzip` and `mod_deflate`. - 31-day `Expires` headers for assets; 2-hour for HTML. - Cache-Control headers per file type. If `preRender` is set, a `RewriteCond` + `RewriteRule` block is spliced into the htaccess routing crawler user agents to a generated `prerender.php` instead of `index.html`. ## Pre-render > **Auto-trigger:** code passes a `preRender: { url, crawlers?, delay?, cache? }` object to `mongezVite({...})` in `vite.config.ts` / `vite.config.js`; project pairs `htaccess: true` with bot/SEO crawler concerns (Googlebot, facebookexternalhit, WhatsApp, Slack, Twitter); user asks "how do I prerender for crawlers in a Vite SPA", "how does the `prerender.php` work / what does it do", "how do I route bots to a render service like render.mentoor.io". > **Skip when:** the `.htaccess` template itself (use `mongez-vite-htaccess` — note `preRender` requires `htaccess: true`); Nginx / non-Apache prerender pipelines (this emits a PHP file gated by `.htaccess` rewrites); SSR frameworks like Next.js / Remix / vite-plugin-ssr; client-side hydration concerns. `preRender: { url, crawlers, delay, cache }`: - `url` — the prerender service to POST to. Default in the README is `https://render.mentoor.io`. **Required if `preRender` is set as an object.** No safe default in code. - `crawlers` — pipe-separated regex alternation of user agents. Default in the README is `Google-Site-Verification|Googlebot|facebook|crawl|WhatsApp|bot|Slack|Twitter|bot`. The duplicate trailing `bot` is intentional in the source. - `delay` — milliseconds to wait before rendering. Default `5000`. - `cache` — when truthy, the generated PHP caches each render under `/cache/.html`. Requires `htaccess: true` — the rewrite rule that routes bots to `prerender.php` lives in the `.htaccess`. ## Lifecycle hooks summary ``` mongezVite(options) returns: { name: "mongez-vite", config(config, { command }) { resolveAutoOpenBrowser(config, command, options); resolveTsConfigAlias(config, options); resolveEnvironmentVariables(command, options); resolveOtherConfig(config, command, options); return config; }, transformIndexHtml(html) { return transformEnvironmentVariablesInHtml(html, options); }, writeBundle: { sequential: true, handler: async () => { await generateHtaccess(config, options); await compressBuild(config, options); }, }, } ``` `config` runs once at startup. `transformIndexHtml` runs per HTML output. `writeBundle` runs once at the end of a build. ## Caveats - **The plugin never overwrites user-provided config.** Pre-existing `server.open`, `config.base`, `resolve.alias`, and `optimizeDeps` all win. Don't expect the plugin to "fix" a config you set wrong. - **`compressBuild` races vite's `writeBundle`.** See above. - **`preRender` requires `htaccess: true`.** The PHP file is emitted but unreachable without the rewrite rule. - **`process.cwd()` is the only env-file search root.** Run vite from your project root, not from a subdir. ## What this package does NOT do - **Bundle splitting / chunking / asset transforms** → Vite's built-in tooling. - **Service worker / PWA** → use `vite-plugin-pwa`. - **Image optimisation** → use `vite-imagetools` or `vite-plugin-imagemin`. - **Type-checking** → use `vite-plugin-checker` or run `tsc --noEmit` in parallel. - **`.env` parsing logic itself** → that's in `@mongez/dotenv`. ## Related packages - `@mongez/dotenv` — the underlying loader. - `@mongez/fs` — filesystem helpers (`getFile`, `putFile`, `moveFile`). - `@mongez/copper` — ANSI colors for build logs. - `@mongez/reinforcements` — `rtrim` for the base URL. --- # @mongez/pkgist # @mongez/pkgist — Full reference > Complete API + workflow reference for `@mongez/pkgist`. Concatenation of every per-topic skill, intended for single-fetch loading by AI agents. > **Auto-trigger when loading this full reference:** code or config imports `defineConfig` from `@mongez/pkgist`; user runs `pkgist build`, `pkgist build:family`, `pkgist build:all`, `pkgist list`, `pkgist validate`; user edits `pkgist.config.ts` or `builder.ts`; user asks "how do I release my packages with pkgist", "how do I configure pkgist for X", "what does pkgist do step by step", "how do family versions work", "how do I auto-generate commit messages", "what's `commit: true`", "what files end up in the published package"; user is debugging a pkgist build or pipeline step. > > **Skip when:** questions about other release tools (lerna, changesets, semantic-release, release-please); questions about the underlying bundler `tsdown` specifically (use tsdown docs); operational playbook for releasing a specific monorepo (use that project's own RELEASING.md or `.claude/skills/`); generic semver/npm/git questions not tied to pkgist. Install: `npm install -g @mongez/pkgist` or `npm install -D @mongez/pkgist`. Use: ```ts import { defineConfig } from "@mongez/pkgist"; export default defineConfig({ /* ... */ }); ``` ```sh pkgist build:all --dry-run pkgist build:all ``` --- # Overview > **Auto-trigger:** user installs, imports, or asks about `@mongez/pkgist`; user mentions `pkgist` CLI; user asks "how do I release my packages", "how do I build TypeScript packages with ESM+CJS", "what tool builds @mongez packages"; questions about monorepo release tooling for TS packages. > **Skip when:** questions about other release tools; questions about the underlying `tsdown` bundler specifically; operational playbook for a specific monorepo. A build, version, and publish tool for TypeScript/React npm packages. Powered by tsdown (Rolldown/Rust-based bundler). ## What it does For each registered package, pkgist: 1. Reads source `package.json` and resolves the next version (auto-patch by default). 2. Compiles `src/` with tsdown into `esm/` + `cjs/` + `.d.ts` files. 3. Clones whitelisted assets (README, LICENSE, skills, llms.txt, etc.) into the build. 4. Writes a clean `package.json` for the build (no devDeps, no scripts). 5. Updates the source `package.json` version in place. 6. Commits, tags `v`, and pushes (when `commit` is set). 7. Publishes to npm with the configured access. Runs per-package in parallel up to the configured concurrency. ## Install ```sh npm install -g @mongez/pkgist # or npm install -D @mongez/pkgist ``` ## Minimum viable config ```ts import { defineConfig } from "@mongez/pkgist"; export default defineConfig({ settings: { buildDir: "../builds" }, standalone: [ { name: "@my-scope/utils", root: "../utils" }, ], }); ``` # Configuration > **Auto-trigger:** user creating or editing `pkgist.config.ts` or `builder.ts`; user asks "how do I set up pkgist", "what goes in the config", "where do I configure pkgist"; user imports `defineConfig`; questions about `settings.concurrency`, `settings.buildDir`, `settings.sourcesDir`, or how `standalone[]` differs from `families[]`. > **Skip when:** per-package field details (use Package options); CLI invocation (use CLI); semver rules (use Versioning); git behavior (use Git workflow). pkgist auto-discovers a config file in cwd. Two filenames are recognised: - `pkgist.config.ts` (preferred) - `builder.ts` (legacy alias) Loaded at runtime via dynamic `import()`. Use ESM `import` syntax freely. ## defineConfig shape ```ts import { defineConfig } from "@mongez/pkgist"; export default defineConfig({ settings: { /* ... */ }, standalone: [ /* ... */ ], families: [ /* ... */ ], }); ``` ## settings | Field | Type | Default | Description | |---|---|---|---| | `concurrency` | `number` | `4` | Max parallel package builds | | `buildDir` | `string` | **required** | Where compiled packages are written, relative to config | | `sourcesDir` | `string` | — | Optional. Where source snapshots are archived | ## standalone[] Independent-lifecycle packages. Each entry is one npm package. ```ts standalone: [ { name: "@my-scope/utils", root: "../utils", version: "patch", commit: true, clone: ["README.md", "LICENSE", "skills", "llms.txt"], }, ] ``` ## families[] Synchronized-version package groups. The family picks the highest current version across all members, then bumps it. ```ts families: [ { name: "state", version: "patch", commit: true, packages: [ { name: "@my-scope/core", root: "../core" }, { name: "@my-scope/react-core", root: "../react-core", type: "react" }, ], }, ] ``` Members use the same fields as standalone packages except `version` (family-level only). ## Override config path ```sh pkgist build:all --config ./packaging/pkgist.config.ts ``` ## Sharing constants ```ts const RELEASE_COMMIT = "chore: release"; const STANDARD_CLONE = ["README.md", "LICENSE", "skills", "llms.txt", "llms-full.txt"]; export default defineConfig({ settings: { buildDir: "../builds" }, standalone: [ { name: "@x/a", root: "../a", commit: RELEASE_COMMIT, clone: STANDARD_CLONE }, { name: "@x/b", root: "../b", commit: RELEASE_COMMIT, clone: STANDARD_CLONE }, ], }); ``` ## Gitignoring local configs If your config carries operational secrets (commit conventions, internal layouts), add it to `.gitignore` and ship a `pkgist.config.example.ts` template instead. --- # Package options > **Auto-trigger:** user configures a package entry; user asks about `clone`, `entries`, `srcDir`, `mainType`, `formats`, `preserveModules`, `dts`, `sourcemap`, `minify`, `publish`, `access`, `type`; user asks "what fields can I set on a package", "what's the default for X", "what does preserveModules do"; user asks about React vs TypeScript package config. > **Skip when:** `version` semantics (Versioning); `commit` semantics in depth (Git workflow); top-level `settings` (Configuration); CLI flags (CLI). ## Required | Field | Type | Description | |---|---|---| | `name` | `string` | npm package name | | `root` | `string` | Path to package root, relative to config file | ## Build shape | Field | Type | Default | Description | |---|---|---|---| | `type` | `"typescript" \| "react"` | `"typescript"` | React enables JSX/TSX transform | | `formats` | `("esm" \| "cjs")[]` | `["esm", "cjs"]` | Output formats | | `mainType` | `"cjs" \| "esm"` | `"cjs"` | Primary format — determines `main` field | | `entries` | `string \| string[]` | `["index.ts"]` | Entry files inside `srcDir` | | `srcDir` | `string` | `"src"` | Source directory name | | `dts` | `boolean` | `true` | Generate declarations | | `sourcemap` | `boolean` | `true` | Emit sourcemaps | | `minify` | `boolean` | `false` | Minify output (rarely useful for libraries) | | `preserveModules` | `boolean` | `true` | Keep one output file per source module (preserves stack traces) | ## preserveModules — why it matters `true` (default): each source file becomes its own output file. Stack traces show real file names (`array/chunk.mjs:4`) instead of bundle offsets (`index.js:1027`). `false`: everything bundled into single `esm/index.js` + `cjs/index.js`. Only acceptable for trivial single-file packages. ## Cloning extras ```ts clone: [ "README.md", "LICENSE", "skills", // directory copy "llms.txt", "llms-full.txt", ["dist-extras/icon.svg", "icon.svg"], // [src, dest] rename form ] ``` The only way to ship non-source files. Skip README → no README on npmjs.com. ## Publish | Field | Type | Default | Description | |---|---|---|---| | `publish` | `boolean` | `true` | Run `npm publish` after build | | `access` | `"public" \| "restricted"` | `"public"` | `npm publish --access` value | ## Git | Field | Type | Default | Description | |---|---|---|---| | `commit` | `string \| true \| false` | omitted | Drives git. See Git workflow | | `branch` | `string` | current branch | Branch to push to | ## Standalone-only | Field | Type | Default | Description | |---|---|---|---| | `version` | `"auto" \| "patch" \| "minor" \| "major" \| string` | `"auto"` | Version bump strategy | Not allowed on family-member entries — families declare one shared version. ## Common shapes ```ts // Standard library { name: "@scope/util", root: "../util", commit: true, clone: ["README.md", "LICENSE"] } // React components { name: "@scope/ui", root: "../ui", type: "react", commit: true, clone: ["README.md", "LICENSE"] } // ESM-only Vite plugin { name: "@scope/vite-plugin", root: "../vite-plugin", mainType: "esm", formats: ["esm"], commit: true } // Library + CLI entries { name: "@scope/agent-kit", root: "../agent-kit", entries: ["index.ts", "cli/index.ts"], commit: true, clone: ["README.md", "LICENSE", "bin"] } // Build-only, no publish { name: "@scope/internal", root: "../internal", publish: false, commit: true } ``` --- # Versioning > **Auto-trigger:** user asks about `version: "auto" | "patch" | "minor" | "major"`; user asks "how does pkgist pick the next version", "how do family versions work", "what if family members have different starting versions"; user sets explicit version like `version: "3.0.0"`; semver questions in pkgist context. > **Skip when:** general semver concepts not tied to pkgist; commit/git questions (Git workflow); CLI (CLI). ## Five strategies | Strategy | Behaviour | |---|---| | `"auto"` (default) | Bump patch. `2.1.0` → `2.1.1` | | `"patch"` | Identical to `"auto"` | | `"minor"` | Bump minor, reset patch. `2.1.0` → `2.2.0` | | `"major"` | Bump major, reset minor + patch. `2.1.0` → `3.0.0` | | Literal semver string | Use exactly this version. `"3.0.0"` → `3.0.0` | ## Standalone packages ```ts { name: "@scope/utils", root: "../utils", version: "auto" } // 2.1.0 → 2.1.1 { name: "@scope/utils", root: "../utils", version: "minor" } // 2.1.0 → 2.2.0 { name: "@scope/utils", root: "../utils", version: "3.0.0" } // any → 3.0.0 ``` ## Family packages Family picks the **highest current version** across all members, then bumps it. All members land on the same new version. ``` Members on disk: atom = 1.0.5 react-atom = 5.1.3 ← highest atomic-query = 0.1.0 family version: "patch" → all three become 5.1.4 ``` Adding a low-versioned package to a family means it jumps to match the family on first build. The family version is the compatibility contract. `version` is family-level only — members don't declare their own. ## Picking the right strategy | Change type | Use | |---|---| | Bug fixes, internal refactors, docs, lockfile/CI updates with NO API surface change | `"patch"` | | Strictly additive: new exports, new optional params, new fields on result types | `"minor"` | | Anything a consumer's code would need to change for (removals, renames, signature changes, default-behavior flips, runtime deps bumped major) | `"major"` | For families: pick based on the most significant change in any member. ## Explicit-string use cases - First `1.0.0` release (vs bumping 0.x.x) - Re-publishing after a botched release attempt (only works if version isn't already on npm) - Pre-releases: `"2.0.0-beta.1"`, then `"2.0.0-beta.2"`, then `"2.0.0"` - Hotfix lineage on an older major --- # Git workflow > **Auto-trigger:** user asks about pkgist git automation; user sets or asks about `commit: true`, `commit: false`, `commit: "message"`, `branch`; user asks "how do I auto-generate release commit messages", "how do I skip git in pkgist", "what does pkgist tag look like", "does pkgist push tags"; debugging why git ops did or didn't run; questions about `--no-git`. > **Skip when:** npm publish (Pipeline / CLI); version-bump rules (Versioning); CLI in general (CLI). ## Four commit shapes | Value | Behaviour | |---|---| | `"explicit message"` (string) | Use that exact message | | `true` (boolean) | Auto-generate `Released ` (added in pkgist 1.1.0) | | `false` (boolean) | Explicitly skip git for this package | | omitted (`undefined`) | Skip git (back-compat default) | `false` and omitted are functionally identical — use `false` when intent should be obvious in the config diff. ```ts { name: "@scope/utils", root: "../utils", commit: "fix: prevent overflow" } { name: "@scope/utils", root: "../utils", commit: true } // "Released 2.1.1" { name: "@scope/utils", root: "../utils" } // skip { name: "@scope/utils", root: "../utils", commit: false } // explicit skip ``` ## What "git runs" means When `commit` resolves to a non-empty message, in the package's `root`: ``` 1. git add . 2. git commit -m "" 3. git push origin 4. git tag v 5. git push origin v ``` Branch from `pkg.branch` or `git rev-parse --abbrev-ref HEAD`. **`git add .` adds everything dirty**, not just the version bump. Clean the tree before release to keep commits focused. ## Family-level commit override Family `commit` overrides per-package `commit`. Same shape (`string | true | false`). ```ts // Single message for every member { name: "atom-family", commit: "feat: improved API", packages: [ /* ... */ ] } // Auto-message for every member with shared family version { name: "atom-family", commit: true, packages: [ /* ... */ ] } // → atom commits "Released 6.0.5", react-atom commits "Released 6.0.5" ``` Per-package `commit` on a family member is ignored when the family has `commit` set. ## Tagging Tags: `v` (`v2.1.1`, `v6.0.0-beta.1`). Created locally, pushed to remote in the same step. Tag already exists on remote → push fails loudly. Reasons: - Re-running a build for an already-released version → bump first - Concurrent releases racing → `git fetch --tags` before retry ## Branch handling Default: pushes to currently-checked-out branch in `root`. Override: ```ts { name: "@scope/utils", root: "../utils", commit: true, branch: "main" } ``` Use when: - Multiple long-lived branches but releases only from one - Want a config-level assertion (defends against accidental release from feature branch) ## Skipping git for local test builds ```ts { name: "@scope/test", root: "../test" } // omitted { name: "@scope/test", root: "../test", commit: false } // explicit { name: "@scope/test", root: "../test", commit: "" } // empty string also skips ``` Or `pkgist build:all --no-git` to skip git for the whole run, overriding any `commit` config. --- # CLI > **Auto-trigger:** user runs or asks about `pkgist build`, `pkgist build:family`, `pkgist build:all`, `pkgist list`, `pkgist validate`; user asks about `--dry-run`, `--no-publish`, `--no-git`, `--concurrency`, `--config`, `--verbose`; "how do I release just one package", "how do I dry-run", "what does pkgist list show". > **Skip when:** per-package config (Package options); pipeline internals (Pipeline); git/publish in depth (Git workflow). ## Commands ### `build [pkg...]` Build one or more standalone packages by name. ```sh pkgist build @my-scope/utils pkgist build @my-scope/utils @my-scope/cache @my-scope/events pkgist build --all ``` Family packages can't be targeted with `build` — use `build:family`. ### `build:family ` Build every package in a family with a single shared version. ```sh pkgist build:family atom pkgist build:family localization ``` `` matches the family object's `name` field. `pkgist list` shows family names. ### `build:all` Build every standalone package and every family. ```sh pkgist build:all ``` Use for fleet-wide releases. For incremental, prefer targeted commands to avoid no-op bumps. ### `list` Show every registered package + family with current version + type + format info. ```sh pkgist list ``` ``` === Standalone Packages === @my-scope/utils v2.1.0 [typescript] [esm, cjs] root: /path/to/utils === Families === Family: state @my-scope/atom v6.0.7 [typescript] [esm, cjs] @my-scope/react-atom v6.0.7 [react] [esm, cjs] ``` ### `validate` Verify config parses + every `root` exists on disk. ```sh pkgist validate ``` Catches typos, missing dirs, malformed config. Cheap as a pre-commit / CI step. ## Common flags | Flag | Description | |---|---| | `--dry-run` | Print every step without writing to disk, git, or npm. Always run before real release | | `--no-publish` | Skip `npm publish`. Build + commit + tag still run | | `--no-git` | Skip git entirely, overriding per-package `commit` | | `--concurrency ` | Override `settings.concurrency`. Use `1` to serialize for debugging | | `--config ` | Use specific config file. Path relative to cwd | | `--verbose` | Debug-level log lines | ## Typical invocations | Scenario | Command | |---|---| | Sanity-check before release | `pkgist validate` | | Inventory current versions | `pkgist list` | | Preview release | `pkgist build:all --dry-run` | | Real release everything | `pkgist build:all` | | Hot-fix one package | `pkgist build @scope/the-pkg` | | Release synchronized group | `pkgist build:family atom` | | Verify build but don't ship | `pkgist build:all --no-publish --no-git` | | Debug a failing build | `pkgist build @scope/the-pkg --verbose --concurrency 1` | ## Exit codes - `0` — all targeted builds succeeded - non-zero — at least one package failed Builds run in parallel; one failure doesn't stop others. --- # Build pipeline > **Auto-trigger:** user asks "what does pkgist do step by step", "how does pkgist build a package", "what does the build output look like", "what's in the published package.json"; user wants to understand pipeline order; questions about source snapshots, generated `exports` map, why ESM-only gets `"type": "module"`; debugging build artifacts. > **Skip when:** per-package config (Package options); CLI (CLI); semver decisions (Versioning); git automation (Git workflow). ## The 10 steps ``` 1. Load source package.json → read current version 2. Resolve new version (auto-bump or explicit) 3. Create build output directory (buildDir///) 4. Snapshot source to sourcesDir// — full copy excluding .git, node_modules, dist (only if settings.sourcesDir is set) 5. Compile with tsdown → esm/ and cjs/ subdirectories 6. Clone extra files/directories listed in `clone` 7. Write clean package.json for the build (no devDeps, no scripts) 8. Update source package.json version in-place 9. Git: add . → commit → push → tag v → push tags (only if commit resolves to a non-empty string) 10. npm publish --access from build directory (only if publish !== false) ``` Build/clone/write/git/publish run in parallel across packages up to `concurrency`. Within a package, steps are sequential. ## Output structure — preserveModules: true (default) Each source file → its own output file: ``` builds/ └── utils/ └── 2.1.1/ ├── package.json ├── README.md ├── LICENSE ├── skills/ ├── llms.txt ├── llms-full.txt ├── esm/ │ ├── index.mjs / index.mjs.map / index.d.mts │ ├── array/chunk.mjs / .map / .d.mts │ └── string/trim.mjs / .d.mts └── cjs/ ├── index.cjs / .map / index.d.cts └── array/chunk.cjs / .d.cts ``` Generated `package.json`: ```json { "main": "./cjs/index.cjs", "module": "./esm/index.mjs", "types": "./esm/index.d.mts", "exports": { ".": { "import": { "types": "./esm/index.d.mts", "default": "./esm/index.mjs" }, "require": { "types": "./cjs/index.d.cts", "default": "./cjs/index.cjs" } } } } ``` Standard dual-publish shape. Bundlers, Node ESM, Node CJS, TypeScript all resolve correctly. ## Output structure — preserveModules: false Everything bundled per format: ``` builds/tiny-pkg/1.0.0/ ├── esm/index.js / .map / .d.ts └── cjs/index.js / .map / .d.ts ``` ```json { "main": "./cjs/index.js", "module": "./esm/index.js", "types": "./esm/index.d.ts" } ``` Trivial single-file packages only. ## ESM-only packages When `mainType: "esm"` or `formats: ["esm"]`: - `"type": "module"` in generated package.json - `require` condition omitted from exports - No `cjs/` directory - `main` points to ESM file ```json { "type": "module", "main": "./esm/index.mjs", "module": "./esm/index.mjs", "types": "./esm/index.d.mts", "exports": { ".": { "import": { "types": "./esm/index.d.mts", "default": "./esm/index.mjs" } } } } ``` ## What's in the published package.json Clean generation — NOT a verbatim copy: - **Kept**: `name`, `description`, `version`, `author`, `license`, `repository`, `homepage`, `bugs`, `keywords`, `dependencies`, `peerDependencies`, `optionalDependencies`, `peerDependenciesMeta`, `engines`, `sideEffects`, `bin`, `os`, `cpu`, `funding`, `files`, `publishConfig` - **Replaced**: `main`, `module`, `types`, `exports`, `type` - **Dropped**: `devDependencies`, `scripts`, `private`, `workspaces`, everything else ### bin normalization Leading `./` stripped from `bin` values. `{"my-cli": "./dist/cli.js"}` → `{"my-cli": "dist/cli.js"}`. ## Source snapshots (optional) If `settings.sourcesDir` is set, pkgist archives source per build (minus `.git`, `node_modules`, `dist`) into `//`. Useful for: - Reconstructing what shipped at any time - Diffing published versions without checking out tags - Recovering from edits between build and publish Omit `sourcesDir` to skip. ## Publish step `npm publish --access ` from the **build directory**. The clean `package.json` + compiled output + cloned files is what ships. `publish: false` → skip publish for that package. `--no-publish` → skip for the whole run. ## What's NOT in the pipeline - Running tests - Linting / formatting - Changelog generation - `node_modules` install Run these as pre-commit hooks or separate CI steps. pkgist trusts you. --- # Recipes > **Auto-trigger:** user asks "how do I configure X with pkgist", "show me an example pkgist config for Y", "how do I release a family"; user wants copy-paste-able pattern; setting up pkgist for new monorepo. > **Skip when:** detailed option reference (Package options); semver (Versioning); CLI flags (CLI); pipeline internals (Pipeline). ## Single TypeScript library, hands-off ```ts import { defineConfig } from "@mongez/pkgist"; export default defineConfig({ settings: { buildDir: "../builds" }, standalone: [ { name: "@scope/utils", root: "../utils", commit: true, clone: ["README.md", "LICENSE"], }, ], }); ``` Release: `pkgist build:all`. ## React component library ```ts { name: "@scope/ui", root: "../ui", type: "react", commit: true, clone: ["README.md", "LICENSE"] } ``` ## ESM-only Vite/build-tool plugin ```ts { name: "@scope/vite-plugin", root: "../vite-plugin", mainType: "esm", formats: ["esm"], commit: true, clone: ["README.md", "LICENSE"], } ``` ## Library + CLI entries ```ts { name: "@scope/agent-kit", root: "../agent-kit", entries: ["index.ts", "cli/index.ts"], commit: true, clone: ["README.md", "LICENSE", "bin"], } ``` `bin` field in source package.json preserved; leading `./` stripped automatically. ## Build-only (no publish) ```ts { name: "@scope/internal-tools", root: "../internal-tools", publish: false, commit: true } ``` ## Family of synchronized packages ```ts families: [ { name: "state", version: "patch", commit: true, packages: [ { name: "@scope/state-core", root: "../state-core" }, { name: "@scope/react-state", root: "../react-state", type: "react" }, { name: "@scope/svelte-state",root: "../svelte-state" }, ], }, ], ``` `pkgist build:family state` → all three land on the same new version. ## Mixed monorepo with shared constants ```ts const STANDARD_CLONE = ["README.md", "LICENSE", "skills", "llms.txt", "llms-full.txt"]; export default defineConfig({ settings: { buildDir: "../builds", concurrency: 8 }, standalone: [ { name: "@scope/utils", root: "../utils", commit: true, clone: STANDARD_CLONE }, { name: "@scope/ui", root: "../ui", commit: true, clone: STANDARD_CLONE, type: "react" }, { name: "@scope/vite-plug",root: "../vite-plug",commit: true, clone: STANDARD_CLONE, mainType: "esm", formats: ["esm"] }, ], families: [ { name: "state", commit: true, packages: [ { name: "@scope/state-core", root: "../state-core", clone: STANDARD_CLONE }, { name: "@scope/react-state", root: "../react-state", clone: STANDARD_CLONE, type: "react" }, ], }, ], }); ``` ## Per-release custom commit messages ```ts const RELEASE_COMMIT = "fix: prevent overflow on large arrays"; standalone: [ { name: "@scope/utils", root: "../utils", commit: RELEASE_COMMIT }, { name: "@scope/cache", root: "../cache", commit: RELEASE_COMMIT }, ] ``` Update `RELEASE_COMMIT` before each release run. Use targeted `pkgist build` to release only changed packages. ## Dry-run-first release flow ```sh pkgist validate # verify config + paths pkgist build:all --dry-run # preview every step # review for: correct versions, intended commit message, sane clone lists pkgist build:all # real run ``` ## Hotfix one package ```sh pkgist build @scope/the-pkg --dry-run pkgist build @scope/the-pkg ``` Others untouched. ## Build everything without publishing ```sh pkgist build:all --no-publish --no-git ``` For verifying the fleet compiles before committing to publish. ## CI-friendly: publish without git When CI tags separately: ```sh pkgist build:all --no-git ``` Or per-package `commit: false`. --- # @mongez/copper # @mongez/copper — Full reference > Complete API reference for `@mongez/copper` v2.0. This file is the concatenation of every per-feature skill, intended for single-fetch loading by AI agents. Install: `yarn add @mongez/copper` or `npm i @mongez/copper`. Every export comes from the package root: ```ts import { colors, createColors, type Colors, type ColorName, type ChainFormatter, type Formatter, spinner, type SpinnerHandle, type SpinnerOptions, progress, type ProgressHandle, type ProgressOptions, box, type BoxOptions, type BoxStyle, log, createLogger, type Logger, type LogLevel, type LoggerOptions, link, stripAnsi, symbols, type SymbolName, isColorSupported, detectColorSupport, } from "@mongez/copper"; ``` --- # Overview > **Auto-trigger:** code first imports anything from `@mongez/copper`; user asks "what is @mongez/copper / how do I install it / what does this package replace / how does it compare to chalk / ora / boxen"; package.json adds `"@mongez/copper"` as a dependency; v1 → v2 migration questions. > **Skip when:** a specific feature skill (colors, spinner, progress, log, box, utilities, recipes) already covers the concrete task — load that directly; questions about browser-only styling (use CSS); React component theming. `@mongez/copper` is a zero-dependency, TypeScript-first CLI toolkit. One install gives you colors, spinners, progress bars, boxed messages, a themed logger, and OSC-8 hyperlinks — without the maintenance burden of pulling in `chalk` + `ora` + `cli-progress` + `boxen` separately. ## Install ```sh yarn add @mongez/copper # or npm i @mongez/copper ``` ## Where features live | Need | Use | |---|---| | Color/style text | `colors.red("…")`, `colors.bold(…)` | | Animated loading | `spinner({ text }).start()` | | Known-total bar | `progress({ total })` | | Themed CLI logs | `log.info / warn / error / success` | | Boxed message | `box("text", { borderStyle })` | | Hyperlinks, ANSI strip, symbols | `link`, `stripAnsi`, `symbols` | ## Color-support detection `isColorSupported` is resolved at module-load. Priority: 1. `NO_COLOR` env or `--no-color` argv → off (per [no-color.org](https://no-color.org)). 2. `FORCE_COLOR=0` / `FORCE_COLOR=false` → off. 3. `FORCE_COLOR` (any other value) or `--color` argv → on. 4. Windows → on (cmd / pwsh / Windows Terminal all render ANSI on Win10+). 5. TTY stdout with `TERM !== "dumb"` → on. 6. Any `CI` env var → on. 7. Otherwise → off. `createColors(false)` short-circuits this — every formatter becomes the identity `String`. ## v2 breaking changes - `brown2*` → `brown*` (matches v1 README contract). - `limeGreen*` → `lime*` (matches v1 README contract). - `displayLoadingBar` / `displayThreeDotsAnimation` are kept but **return a stop handle** instead of leaking intervals — they're shims around the new `progress` / `spinner` APIs. Prefer the new ones. - `FORCE_COLOR=0` now correctly **disables** colors (v1 enabled them as long as `FORCE_COLOR` was *defined*). - The `tty` module is loaded lazily — importing `@mongez/copper` in a browser bundle no longer blows up. - New: `spinner`, `progress`, `box`, `log`, `createLogger`, `link`, `stripAnsi`, `symbols`, `detectColorSupport`. --- # Colors > **Auto-trigger:** code imports `colors`, `createColors`, `Colors`, `ColorName`, or `Formatter` from `@mongez/copper`; user asks "how do I color CLI text / replace chalk / detect NO_COLOR / force colors / 256-color terminal output / get a typed color name"; calls like `colors.red(x)`, `colors.bgGold(x)`, `colors.bold(colors.cyan(x))`. > **Skip when:** browser/CSS styling (this is ANSI only); `console.log` without `@mongez/copper`; React/JSX text styling. Chain colors and modifiers chalk-style. Each chain step builds a fresh callable formatter — lazy, stateless, reusable. ```ts import { colors, createColors, type ColorName, type ChainFormatter } from "@mongez/copper"; colors.red("error"); colors.red.bold("error"); colors.bgWhite.black.bold(" WARN "); colors.red.bold.underline("critical"); const danger = colors.red.bold.underline; danger("File not found"); ``` > Composition (`colors.red(colors.bold(x))`) produces identical ANSI output. The composition shape is the natural fit when the color name is decided at runtime: `colors[name](x)` where `name: ColorName`. ## Modifiers `bold` `dim` `italic` `underline` `strikethrough` `inverse` `hidden` `reset` ## Palette Foreground colors. Each row also has `*Bright`, `bg*`, and `bgBright*` variants: - Neutral: `black`, `gray`, `white`, `blackBright`, `whiteBright` - Slate: `slate`, `slateBright` - Red: `red`, `redBright` - Orange: `orange`, `orangeBright` - Yellow: `yellow`, `yellowBright` - Gold: `gold`, `goldBright` - Chocolate: `chocolate`, `chocolateBright` - Brown: `brown`, `brownBright` - Green: `green`, `greenBright` - Lime: `lime`, `limeBright` - Teal: `teal`, `tealBright` - Cyan: `cyan`, `cyanBright` - Blue: `blue`, `blueBright` - Magenta: `magenta`, `magentaBright` - Purple: `purple`, `purpleBright` - Pink: `pink`, `pinkBright` - Lavender: `lavender`, `lavenderBright` - Indigo: `indigo`, `indigoBright` The standard 4-bit colors use the 30-37 / 90-97 SGR sequences. The extended hues use 256-color sequences (`\x1b[38;5;Nm`). ## `createColors(enabled?)` ```ts const off = createColors(false); off.red("hi"); // "hi" off.isColorSupported; // false const on = createColors(true); on.red("hi"); // "\x1b[31mhi\x1b[39m" ``` ## Typed color names ```ts import { colors, type ColorName } from "@mongez/copper"; function paint(level: "ok" | "fail", text: string) { const c: ColorName = level === "ok" ? "green" : "red"; return colors[c](text); } ``` `ColorName` excludes `isColorSupported` and `createColors`. ## Replacing chalk Near-1:1 import swap thanks to chaining: ```diff - import chalk from "chalk"; + import { colors } from "@mongez/copper"; - chalk.red(text); + colors.red(text); - chalk.red.bold(text); + colors.red.bold(text); ``` For chalk's tagged-template syntax pair with `colorize-template`: ```ts import { createColorize } from "colorize-template"; import { colors } from "@mongez/copper"; const colorize = createColorize(colors); colorize`{red.bold Build} took {yellow ${ms}ms}`; ``` ## Composition safety Nested calls do not leak — copper rewrites internal closers to re-open the outer color. --- # Spinner > **Auto-trigger:** code imports `spinner`, `SpinnerHandle`, or `SpinnerOptions` from `@mongez/copper`; user asks "how do I show a spinner / loading indicator / ora replacement / busy indicator while awaiting an async task". > **Skip when:** progress-with-known-total scenarios — use the progress section; browser/React loading indicators. ```ts import { spinner } from "@mongez/copper"; const sp = spinner({ text: "Compiling…" }).start(); try { await build(); sp.succeed("Build complete"); } catch (err) { sp.fail("Build failed"); throw err; } ``` ## Options | Option | Default | Note | |---|---|---| | `text` | `""` | Trailing message; live-updatable via `.update(...)` | | `frames` | `symbols.spinner` | Array of strings, cycled | | `interval` | `80` | ms between frames | | `color` | `"cyan"` | Any `ColorName` | | `stream` | `process.stdout` | | ## Handle methods `start(text?)`, `update(text)`, `stop()`, `succeed(text?)`, `fail(text?)`, `warn(text?)`, `info(text?)`, `.isSpinning`. ## Non-TTY fallback In CI / piped streams, `.start()` prints the text once with a newline; finalizers print their colored marker + final text on a single line. The spinner's interval is `unref`'d, so a forgotten `.stop()` won't keep Node alive — but always pair `start()` with a finalizer in `try/finally`. --- # Progress > **Auto-trigger:** code imports `progress`, `ProgressHandle`, or `ProgressOptions` from `@mongez/copper`; user asks "how do I draw a progress bar / show percentage of N items processed / ETA in CLI"; replacing `cli-progress`, `progress`, `gauge`. > **Skip when:** unknown-total / indeterminate loading — use spinner; React/web progress UI. ```ts import { progress } from "@mongez/copper"; const bar = progress({ total: files.length }); for (const file of files) { await upload(file); bar.tick(); } bar.done(); ``` ## Options | Option | Default | Note | |---|---|---| | `total` | — (required) | Target | | `width` | `30` | Bar character width | | `complete` | `"█"` | Filled glyph | | `incomplete` | `"░"` | Empty glyph | | `color` | `"green"` | Applied to filled segment | | `format` | `":bar :percent :current/:total"` | Template tokens below | | `stream` | `process.stdout` | | ### Format tokens `:bar` `:current` `:total` `:percent` `:elapsed` `:eta`. ## Handle methods `tick(delta = 1)`, `update(value)`, `done()`, `stop()`, `.current`, `.total`, `.isComplete`. ## TTY vs non-TTY In TTY: redraws in place. Non-TTY: `tick`/`update` are silent; only `done()` writes a single rendered line. --- # Log > **Auto-trigger:** code imports `log`, `createLogger`, `Logger`, `LogLevel`, or `LoggerOptions` from `@mongez/copper`; user asks "how do I print colored info / warn / error logs in a CLI / silence info messages in quiet mode / log to stderr"; replacing `consola`, `signale`. > **Skip when:** pino/winston-style structured JSON for servers; browser console. ```ts import { log } from "@mongez/copper"; log.info("Starting server on", 4000); log.success("Build complete"); log.warn("Cache miss"); log.error(new Error("DB unreachable")); log.debug("payload", payload); ``` Each call writes one line prefixed with a colored level symbol — `ℹ` info, `✔` success, `⚠` warn, `✖` error, `•` debug. ## `createLogger(options)` | Option | Default | Note | |---|---|---| | `level` | `"debug"` | Suppress lower priorities. Order: `debug < info < success < warn < error` | | `stream` | `process.stdout` | | | `levels` | (built-ins) | Per-level `{ symbol, label, color }` overrides | ## Argument handling Args joined by space: - `string` → as-is - `Error` → `error.stack ?? error.message` - else → `JSON.stringify(value)` --- # Box > **Auto-trigger:** code imports `box`, `BoxOptions`, or `BoxStyle` from `@mongez/copper`; user asks "how do I draw a box around CLI text / banner / framed message / call-out / boxen replacement"; calls like `box("Deploy ok", { borderStyle: "round", borderColor: "green" })`. > **Skip when:** HTML/CSS bordered components; ASCII-art bigger than a small framed message. ```ts import { box } from "@mongez/copper"; console.log( box("Deploy successful", { borderStyle: "round", borderColor: "green", padding: 1, }), ); ``` ## Options | Option | Default | Note | |---|---|---| | `padding` | `1` | Internal blank lines + side spaces | | `margin` | `0` | Blank lines above/below | | `borderStyle` | `"round"` | `"single"` `"double"` `"round"` `"bold"` `"ascii"` | | `borderColor` | (none) | Any `ColorName` | | `align` | `"left"` | `"left"` `"center"` `"right"` | Border styles: - `single` `┌─┐ │ └─┘` - `double` `╔═╗ ║ ╚═╝` - `round` `╭─╮ │ ╰─╯` - `bold` `┏━┓ ┃ ┗━┛` - `ascii` `+-+ | +-+` Multi-line content is padded to the longest line. Width measurement strips ANSI first, so colored input lines up correctly. --- # Utilities > **Auto-trigger:** code imports `link`, `stripAnsi`, `symbols`, `SymbolName`, `isColorSupported`, or `detectColorSupport` from `@mongez/copper`; user asks "how do I make a clickable terminal link / strip ANSI from a string / get a check or cross symbol / detect color support / handle NO_COLOR". > **Skip when:** HTML anchor tags; emoji-only output with no `@mongez/copper` import. ## `link(text, url, options?)` OSC-8 hyperlink. Falls back to `text (url)` in unsupported terminals. ```ts console.log(`See ${link("the docs", "https://github.com/hassanzohdy/copper")}`); ``` Pass `{ fallback: "text-only" }` to suppress the trailing `(url)` in unsupported terminals. ## `stripAnsi(input)` Removes every ANSI escape — colors, modifiers, cursor moves, and OSC-8 hyperlinks. ```ts stripAnsi(colors.red("hi")); // "hi" stripAnsi(link("docs", "https://example.com")); // "docs" ``` ## `symbols` | Key | Fancy | ASCII fallback | |---|---|---| | `tick` | `✔` | `√` | | `cross` | `✖` | `×` | | `info` | `ℹ` | `i` | | `warning` | `⚠` | `‼` | | `arrow` | `→` | `->` | | `pointer` | `❯` | `>` | | `ellipsis` | `…` | `...` | | `bullet` | `•` | `*` | | `line` | `─` | `-` | | `spinner` | Braille array | `["-", "\\", "|", "/"]` | Fallback triggered on `win32` without `WT_SESSION` / `TERM_PROGRAM` / `TERM=xterm-256color`. ## `isColorSupported` / `detectColorSupport()` `isColorSupported` is the cached boolean. `detectColorSupport()` re-evaluates against current env/argv — useful after a CLI flag parser mutates `process.env`. --- # Recipes > **Auto-trigger:** user asks "show me a real example using @mongez/copper / how do I structure a CLI with @mongez/copper / build a polished CLI experience"; combining two or more `@mongez/copper` primitives in one flow. > **Skip when:** single-function lookups; React/web UI. ## Themed deploy script ```ts import { spinner, progress, box, log } from "@mongez/copper"; const auth = spinner({ text: "Authenticating…" }).start(); await login(); auth.succeed("Authenticated"); const bar = progress({ total: files.length, color: "lime" }); for (const f of files) { await upload(f); bar.tick(); } bar.done(); console.log(box(`${files.length} files deployed`, { borderStyle: "round", borderColor: "green", padding: 1, })); ``` ## Honoring `--quiet` ```ts const log = createLogger({ level: process.argv.includes("--quiet") ? "warn" : "debug", stream: process.stderr, }); ``` ## Capturing CLI output for tests ```ts const lines: string[] = []; const stream = { write(c: string) { lines.push(c); return true; } } as any; const log = createLogger({ stream }); log.info("started"); log.success("done"); lines.map(stripAnsi); // ["ℹ started\n", "✔ done\n"] ``` ## Clickable error link ```ts console.error(`${colors.red(symbols.cross)} ${msg}`); console.error(` ${colors.gray("See:")} ${link(code, `https://errors.example.com/${code}`)}`); ``` ## Startup banner ```ts console.log(box([ colors.bold(colors.cyan("my-cli")) + " " + colors.gray("v" + pkg.version), "", `Docs: ${link("read here", "https://example.com/docs")}`, ].join("\n"), { borderStyle: "double", borderColor: "cyan", padding: 1, align: "center", })); ``` ## Walking a directory with progress ```ts const sp = spinner({ text: "Scanning…" }).start(); const files = await listAll("./src"); sp.succeed(`Found ${files.length} files`); const bar = progress({ total: files.length }); for (const f of files) { await processFile(f); bar.tick(); } bar.done(); ``` ## Error-only stderr, info to stdout ```ts const out = createLogger({ stream: process.stdout, level: "info" }); const err = createLogger({ stream: process.stderr, level: "warn" }); ``` --- # @mongez/agent-kit # agent-kit — Full Reference > Authoring and distribution toolkit for AI coding agent artifacts. Treats `AGENTS.md` as a single source of truth and derives the tool-specific files each agent reads. Also syncs reusable skills bundled inside npm packages into per-agent skill directories with collision-free flat folder names. --- # README One file, every coding agent. `agent-kit` treats your project's `AGENTS.md` as the single source of truth and generates the tool-specific files that each AI coding agent reads — `CLAUDE.md`, `.gemini/GEMINI.md`, `.github/copilot-instructions.md`, `CONVENTIONS.md` — so you write your project instructions once and every agent stays in sync. It also syncs skills bundled inside npm packages into your project's agent skill directories with collision-free flat names. ## Install ```bash npm install -D @mongez/agent-kit # or yarn add -D @mongez/agent-kit, or pnpm add -D @mongez/agent-kit ``` > npm package: `@mongez/agent-kit`. CLI binary: `agent-kit` (no scope when invoking). ## Quick start ```bash npx agent-kit init # Scaffold AGENTS.md + derive (idempotent) npx agent-kit sync # Derive + sync skills npx agent-kit watch # Re-sync on change (dev loop) ``` Wire into postinstall: ```json { "scripts": { "postinstall": "agent-kit sync" } } ``` ## Derivation targets | Tool | Output | | --- | --- | | Claude Code | `CLAUDE.md` | | Gemini CLI | `.gemini/GEMINI.md` | | GitHub Copilot | `.github/copilot-instructions.md` | | Aider | `CONVENTIONS.md` | Tools that read root `AGENTS.md` natively (Codex, Cursor, Amp, Jules, Factory, Kilo, Windsurf, OpenCode, Goose) need no derivation. ## CLI reference ```bash agent-kit init # Scaffold AGENTS.md + derive agent-kit sync # Derive + sync skills agent-kit sync --derive-only # Skip skills export agent-kit sync --skills-only # Skip derivation agent-kit sync --target claude,cursor # Pick skill targets agent-kit sync --path @warlock.js # Add extra scan dirs (monorepo / dev-server) agent-kit sync --override # Replace user-authored dest folders agent-kit watch # Re-sync on changes agent-kit watch --path @warlock.js # Watch extra dirs too ``` Skill targets: `claude`, `copilot`, `cursor`, `codex`, `opencode`, `amp`, `goose`, `kiro`, `antigravity` (claude by default — or `agentKit.targets` from `package.json` if set). ## Programmatic API ```typescript import { deriveAll, syncSkills, findProjectRoot, scanForSkillPackages, deriveSlugForSkill, } from "@mongez/agent-kit"; const root = await findProjectRoot(); await deriveAll({ root }); await syncSkills({ root, targets: ["claude", "cursor"], scanPaths: ["@warlock.js"], override: false, }); ``` --- # Skill: overview > **Auto-trigger:** project contains `AGENTS.md`, `CLAUDE.md`, `.gemini/GEMINI.md`, `.github/copilot-instructions.md`, or `CONVENTIONS.md`; folder `.claude/skills/-*/` or `.agent-kit-managed` sentinel exists; user asks "what is agent-kit", "how does AGENTS.md derivation work", "how do skills get into `.claude/skills/`", or "why is my skill folder named `-`"; `package.json` has `agentKit` config block or `postinstall: agent-kit sync`; `import { deriveAll, syncSkills, findProjectRoot } from "@mongez/agent-kit"`. > **Skip when:** user is invoking, scripting, or debugging a specific CLI command / flag — load `mongez-agent-kit-cli-usage` instead; user is authoring a `SKILL.md` inside an npm package they publish — load `mongez-agent-kit-authoring-skills` instead; runtime AI features (model calls, embeddings) belong in `@warlock.js/ai*`, not here. `agent-kit` solves two distinct but related problems for projects that work with AI coding agents: 1. **One `AGENTS.md`, every agent.** `AGENTS.md` is the open standard — Codex, Cursor, Amp, Jules, Factory, Kilo, Windsurf, OpenCode read it natively. Claude Code, Gemini CLI, GitHub Copilot, and Aider want their own file. agent-kit derives them so they never drift. 2. **Skills — your own, organized your way, plus skills from packages.** Drop a single `skills/` folder at your **project root**, grouped into nested category folders (`skills/backend/auth/SKILL.md`) as deep as you like. Claude Code only reads a flat `.claude/skills/`, so `agent-kit sync` flattens each nested path into a unique top-level name (`backend/auth` → `backend-auth`) — you organize for humans, agent-kit handles the flat requirement. The same walk also covers every package in `node_modules/` (plus any `--path` root), so **any npm package can ship skills too**, copied in with **flat, collision-free folder names** like `.claude/skills/warlock-js-core-add-connector/SKILL.md`. ## Key principles - **Folder name = identity.** Claude Code routes by folder name; the SKILL.md frontmatter `name:` field is purely cosmetic display polish. agent-kit derives the destination folder as `[-skill-path]` — collisions impossible by construction across packages. - **SKILL.md content is copied verbatim.** agent-kit never reads or rewrites frontmatter. - **Sentinel-based prune.** Only folders with `.agent-kit-managed` get blown away on re-sync. User-authored skills are safe. - **Stateless.** Every sync re-derives from disk truth. No lockfile, no cache. # Skill: cli-usage > **Auto-trigger:** command line invokes `agent-kit init`, `agent-kit sync`, or `agent-kit watch` (or `npx agent-kit ...`); `package.json` has `"postinstall": "agent-kit sync"` or any `agent-kit` script; flags `--target`, `--path`, `-p`, `--override`, `--skills-only`, `--derive-only`, `--cwd` appear; user asks "how do I wire agent-kit into CI / postinstall / monorepo", "what does `--path` do", "how do I run agent-kit on every install", or "how do I sync skills for cursor/claude/copilot"; code imports `deriveAll`, `syncSkills`, `findProjectRoot`, `scanForSkillPackages`, or `deriveSlugForSkill` from `@mongez/agent-kit`. > **Skip when:** general "what is agent-kit / mental model" questions — load `mongez-agent-kit-overview` instead; authoring a `SKILL.md` to ship from an npm package — load `mongez-agent-kit-authoring-skills` instead; tasks unrelated to invoking the agent-kit binary or its API. Three commands. All are idempotent — running them twice in a row is a no-op the second time. ## `agent-kit init` Scaffold a starter `AGENTS.md` (only if it does not exist) and derive the per-tool files from it. Flags: - `--cwd ` — start from a different working directory. Behavior: - If `AGENTS.md` exists → leave it alone. - If `AGENTS.md` is missing → write a starter template. - Always derives `CLAUDE.md`, `.gemini/GEMINI.md`, `.github/copilot-instructions.md`, `CONVENTIONS.md`. ## `agent-kit sync` Re-derive the per-tool files from `AGENTS.md` and export skills from installed packages. Flags: - `--cwd ` — working directory override. - `--target ` — comma-separated skill targets. Defaults to `claude` (or whatever is set in `agentKit.targets`). - `--derive-only` — skip skills export. - `--skills-only` — skip derivation. - `--path ` / `-p` — comma-separated extra dirs to scan, each treated like a `node_modules/`. Use for monorepos and local dev setups. Packages found in scan paths override same-named entries in `node_modules/`. - `--override` — replace user-authored destination folders (skipped by default). This is the command to wire into your project's `postinstall`. ## Project-level config (`agentKit` in package.json) Configure defaults and filters from the project root's `package.json`: ```json { "agentKit": { "targets": ["claude", "cursor"], "pick": { "@warlock.js/core": true, "@my-org/lib": ["only-this-skill"] }, "omit": { "@warlock.js/core": ["add-connector"] } } } ``` - `targets` — default skill-sync targets. CLI `--target` overrides this completely (no merge). Empty array `[]` is honored as "no targets" + a warning. - **`pick`** (allowlist) — when set, ONLY listed packages are included. - `pick[pkg] = true` → include all of the package's skills. - `pick[pkg] = ["skill-name"]` → include only those specific skills. - Empty `pick: {}` or pick names that don't match installed packages → result is empty + warning. - **`omit`** (denylist) — runs AFTER `pick` when both are set. - `omit[pkg] = true` → skip the entire package's skills. - `omit[pkg] = ["skill-name"]` → skip specific skills from that package (matched against the source folder name, not the flat slug). - Entries for packages not installed are silently ignored. - Common pattern: `pick` an allowlist of packages, then `omit` a noisy skill from one of them. ## `agent-kit watch` Watch `AGENTS.md`, the project's local `skills/**/SKILL.md`, `node_modules/**/skills/**/SKILL.md`, and any `--path` skill globs; re-derive and re-sync on change. Debounced 150ms. Flags: - `--cwd ` — working directory override. - `--path ` / `-p` — extra dirs whose `**/skills/**/SKILL.md` should also be watched. - `--override` — replace user-authored destination folders on each re-sync. --- # Skill: authoring-skills > **Auto-trigger:** editing a `skills/**/SKILL.md` inside an npm package the user maintains; user asks "how do I ship a skill with my package", "how do consumers pick up my skill", "what should go in SKILL.md frontmatter", or "how should I write `when_to_use` / `description`"; `package.json` `files` field needs `"skills"` added; multi-skill package needs a "front-door" / `-overview` orientation skill; user is structuring nested skills (`skills/backend/auth/SKILL.md`) under a category folder. > **Skip when:** user is invoking the agent-kit CLI or wiring `postinstall` — load `mongez-agent-kit-cli-usage` instead; user just wants the mental model of agent-kit — load `mongez-agent-kit-overview` instead; project-local skills inside an app the user is NOT publishing to npm (no special authoring concerns — just drop a `skills/` folder). There are two reasons to write skills, and they share the exact same folder layout. **(1) Your project's own skills** — drop a single `skills/` folder at your project root, organize it into nested category folders, and `agent-kit sync` mirrors it into `.claude/skills/` (and friends), flattening the nesting. This is the common case — no publishing required. **(2) Skills shipped from a package** — if you maintain a library that benefits from a coding-agent skill, ship it inside your package; anyone using `agent-kit sync` downstream receives it automatically. ## Folder layout Place skill folders inside `skills/` — at your project root, or at your package root if you're shipping them. Each skill is a directory containing a `SKILL.md`. ``` my-package/ └── skills/ ├── using-the-thing/ │ ├── SKILL.md │ └── examples/example.ts └── another-skill/ └── SKILL.md ``` ### Nested organization agent-kit walks `skills/` recursively, so you can group skills under category folders: ``` my-app/ └── skills/ ├── backend/ │ ├── auth/SKILL.md ← name "backend/auth" │ └── db/SKILL.md ← name "backend/db" └── frontend/ └── page-builder/SKILL.md ← name "frontend/page-builder" ``` A directory containing `SKILL.md` is treated as a leaf — we don't recurse into it (matches warlock-style "root + subskills" convention). ## No declaration needed — `skills/` is discovered automatically Drop a `skills/` folder at your package root. agent-kit walks it recursively for any directory containing a `SKILL.md`. No `package.json` config required. ## Destination naming Claude Code only discovers skills at the top level of `.claude/skills/` (no nested folders). agent-kit exports every skill with a flat folder name: ``` .claude/skills/ my-org-my-package-using-the-thing/ SKILL.md examples/example.ts .agent-kit-managed ← sentinel, do not commit edits ``` The folder name is **always derived** as `[-skill-path]`: - Single-skill packages (root `skills/SKILL.md`): `` — e.g. `@warlock.js/ai` → `warlock-js-ai` - Multi-skill: `-` — e.g. `@my-org/pkg/skills/using-the-thing` → `my-org-pkg-using-the-thing` - Nested: `-` — e.g. `skills/backend/auth` → `-backend-auth` The slug strips the leading `@`, replaces `/` and `.` with `-`, lowercases. You don't pick a globally-unique name — agent-kit guarantees uniqueness via package prefixing. ## SKILL.md `name:` is optional Per the Claude Code Skills docs: *"name — Display name for the skill. If omitted, uses the directory name."* So you have two choices: - **Omit `name:` from frontmatter** — Claude uses the folder name (the auto-derived slug). Simplest. - **Set `name:` to a custom display label** — e.g. `name: Using the thing` for a prettier label in Claude's UI. Routing still happens by folder name; this is purely cosmetic. agent-kit **never reads or modifies** the SKILL.md content during sync. Your source file is copied verbatim into the destination folder. ## Ship the skills folder Make sure your package's `files` field (or absent `.npmignore`) includes the `skills/` directory: ```json { "files": ["dist", "skills", "README.md"] } ``` ## Writing a good SKILL.md ```markdown --- description: One sentence telling an agent when to read this skill. --- # Using the thing ## How to use Concrete steps, with code examples where it helps. ## Pitfalls Common mistakes and how to avoid them. ``` The `description` field is the most important line — it determines whether an agent picks up the skill in the first place. Make it specific.